diff --git a/package-lock.json b/package-lock.json index eeea392e2fe..2ecf1543cf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "diff-match-patch": "^1.0.5", "fast-deep-equal": "^3.1.3", "fastest-levenshtein": "^1.0.16", + "get-folder-size": "^5.0.0", "globby": "^14.0.2", "isbinaryfile": "^5.0.2", "mammoth": "^1.8.0", @@ -39,6 +40,7 @@ "os-name": "^6.0.0", "p-wait-for": "^5.0.2", "pdf-parse": "^1.1.1", + "pretty-bytes": "^6.1.1", "puppeteer-chromium-resolver": "^23.0.0", "puppeteer-core": "^23.4.0", "serialize-error": "^11.0.3", @@ -9429,6 +9431,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-folder-size": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/get-folder-size/-/get-folder-size-5.0.0.tgz", + "integrity": "sha512-+fgtvbL83tSDypEK+T411GDBQVQtxv+qtQgbV+HVa/TYubqDhNd5ghH/D6cOHY9iC5/88GtOZB7WI8PXy2A3bg==", + "license": "MIT", + "bin": { + "get-folder-size": "bin/get-folder-size.js" + }, + "engines": { + "node": ">=18.11.0" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -13428,6 +13442,18 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", diff --git a/package.json b/package.json index b87b640e0d8..2a86214aadc 100644 --- a/package.json +++ b/package.json @@ -314,6 +314,7 @@ "diff-match-patch": "^1.0.5", "fast-deep-equal": "^3.1.3", "fastest-levenshtein": "^1.0.16", + "get-folder-size": "^5.0.0", "globby": "^14.0.2", "isbinaryfile": "^5.0.2", "mammoth": "^1.8.0", @@ -322,6 +323,7 @@ "os-name": "^6.0.0", "p-wait-for": "^5.0.2", "pdf-parse": "^1.1.1", + "pretty-bytes": "^6.1.1", "puppeteer-chromium-resolver": "^23.0.0", "puppeteer-core": "^23.4.0", "serialize-error": "^11.0.3", @@ -352,17 +354,17 @@ "@vscode/test-cli": "^0.0.9", "@vscode/test-electron": "^2.4.0", "esbuild": "^0.24.0", - "mkdirp": "^3.0.1", - "rimraf": "^6.0.1", "eslint": "^8.57.0", "glob": "^11.0.1", "husky": "^9.1.7", "jest": "^29.7.0", "jest-simple-dot-reporter": "^1.0.5", "lint-staged": "^15.2.11", + "mkdirp": "^3.0.1", "mocha": "^11.1.0", "npm-run-all": "^4.1.5", "prettier": "^3.4.2", + "rimraf": "^6.0.1", "ts-jest": "^29.2.5", "typescript": "^5.4.5" }, diff --git a/src/__mocks__/get-folder-size.js b/src/__mocks__/get-folder-size.js new file mode 100644 index 00000000000..9757854f581 --- /dev/null +++ b/src/__mocks__/get-folder-size.js @@ -0,0 +1,6 @@ +module.exports = async function getFolderSize() { + return { + size: 1000, + errors: [], + } +} diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 33865601aaa..34d20120567 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -6,13 +6,14 @@ import delay from "delay" import fs from "fs/promises" import os from "os" import pWaitFor from "p-wait-for" +import getFolderSize from "get-folder-size" import * as path from "path" import { serializeError } from "serialize-error" import * as vscode from "vscode" import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api" import { ApiStream } from "../api/transform/stream" import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider" -import { CheckpointService } from "../services/checkpoints/CheckpointService" +import { CheckpointService, CheckpointServiceFactory } from "../services/checkpoints" import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown" import { extractTextFromFile, @@ -239,7 +240,8 @@ export class Cline { private async saveClineMessages() { try { - const filePath = path.join(await this.ensureTaskDirectoryExists(), GlobalFileNames.uiMessages) + const taskDir = await this.ensureTaskDirectoryExists() + const filePath = path.join(taskDir, GlobalFileNames.uiMessages) await fs.writeFile(filePath, JSON.stringify(this.clineMessages)) // combined as they are in ChatView const apiMetrics = getApiMetrics(combineApiRequests(combineCommandSequences(this.clineMessages.slice(1)))) @@ -251,6 +253,17 @@ export class Cline { (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), ) ] + + let taskDirSize = 0 + + try { + taskDirSize = await getFolderSize.loose(taskDir) + } catch (err) { + console.error( + `[saveClineMessages] failed to get task directory size (${taskDir}): ${err instanceof Error ? err.message : String(err)}`, + ) + } + await this.providerRef.deref()?.updateTaskHistory({ id: this.taskId, ts: lastRelevantMessage.ts, @@ -260,6 +273,7 @@ export class Cline { cacheWrites: apiMetrics.totalCacheWrites, cacheReads: apiMetrics.totalCacheReads, totalCost: apiMetrics.totalCost, + size: taskDirSize, }) } catch (error) { console.error("Failed to save cline messages:", error) @@ -2692,7 +2706,7 @@ export class Cline { } if (isCheckpointPossible) { - await this.checkpointSave() + await this.checkpointSave({ isFirst: false }) } /* @@ -2762,7 +2776,7 @@ export class Cline { const isFirstRequest = this.clineMessages.filter((m) => m.say === "api_req_started").length === 0 if (isFirstRequest) { - await this.checkpointSave() + await this.checkpointSave({ isFirst: true }) } // getting verbose details is an expensive operation, it uses globby to top-down build file structure of project which for large projects can take a few seconds @@ -3255,11 +3269,32 @@ export class Cline { // Checkpoints private async getCheckpointService() { + if (!this.checkpointsEnabled) { + throw new Error("Checkpoints are disabled") + } + if (!this.checkpointService) { - this.checkpointService = await CheckpointService.create({ - taskId: this.taskId, - baseDir: vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? "", - log: (message) => this.providerRef.deref()?.log(message), + const workspaceDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) + const shadowDir = this.providerRef.deref()?.context.globalStorageUri.fsPath + + if (!workspaceDir) { + this.providerRef.deref()?.log("[getCheckpointService] workspace folder not found") + throw new Error("Workspace directory not found") + } + + if (!shadowDir) { + this.providerRef.deref()?.log("[getCheckpointService] shadowDir not found") + throw new Error("Global storage directory not found") + } + + this.checkpointService = await CheckpointServiceFactory.create({ + strategy: "shadow", + options: { + taskId: this.taskId, + workspaceDir, + shadowDir, + log: (message) => this.providerRef.deref()?.log(message), + }, }) } @@ -3318,29 +3353,25 @@ export class Cline { } } - public async checkpointSave() { + public async checkpointSave({ isFirst }: { isFirst: boolean }) { if (!this.checkpointsEnabled) { return } try { - const isFirst = !this.checkpointService const service = await this.getCheckpointService() - const commit = await service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`) + const strategy = service.strategy + const version = service.version - if (commit?.commit) { - await this.providerRef - .deref() - ?.postMessageToWebview({ type: "currentCheckpointUpdated", text: service.currentCheckpoint }) + const commit = await service.saveCheckpoint(`Task: ${this.taskId}, Time: ${Date.now()}`) + const fromHash = service.baseHash + const toHash = isFirst ? commit?.commit || fromHash : commit?.commit - // Checkpoint metadata required by the UI. - const checkpoint = { - isFirst, - from: service.baseCommitHash, - to: commit.commit, - } + if (toHash) { + await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: toHash }) - await this.say("checkpoint_saved", commit.commit, undefined, undefined, checkpoint) + const checkpoint = { isFirst, from: fromHash, to: toHash, strategy, version } + await this.say("checkpoint_saved", toHash, undefined, undefined, checkpoint) } } catch (err) { this.providerRef.deref()?.log("[checkpointSave] disabling checkpoints for this task") @@ -3371,9 +3402,7 @@ export class Cline { const service = await this.getCheckpointService() await service.restoreCheckpoint(commitHash) - await this.providerRef - .deref() - ?.postMessageToWebview({ type: "currentCheckpointUpdated", text: service.currentCheckpoint }) + await this.providerRef.deref()?.postMessageToWebview({ type: "currentCheckpointUpdated", text: commitHash }) if (mode === "restore") { await this.overwriteApiConversationHistory( diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 031e7960f9d..eeefc7578cc 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2277,35 +2277,55 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.deleteTaskFromState(id) - // Delete the task files + // Delete the task files. const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath) + if (apiConversationHistoryFileExists) { await fs.unlink(apiConversationHistoryFilePath) } + const uiMessagesFileExists = await fileExistsAtPath(uiMessagesFilePath) + if (uiMessagesFileExists) { await fs.unlink(uiMessagesFilePath) } + const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json") + if (await fileExistsAtPath(legacyMessagesFilePath)) { await fs.unlink(legacyMessagesFilePath) } - await fs.rmdir(taskDirPath) // succeeds if the dir is empty const { checkpointsEnabled } = await this.getState() const baseDir = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) - const branch = `roo-code-checkpoints-${id}` + // Delete checkpoints branch. if (checkpointsEnabled && baseDir) { + const branchSummary = await simpleGit(baseDir) + .branch(["-D", `roo-code-checkpoints-${id}`]) + .catch(() => undefined) + + if (branchSummary) { + console.log(`[deleteTaskWithId${id}] deleted checkpoints branch`) + } + } + + // Delete checkpoints directory + const checkpointsDir = path.join(taskDirPath, "checkpoints") + + if (await fileExistsAtPath(checkpointsDir)) { try { - await simpleGit(baseDir).branch(["-D", branch]) - console.log(`[deleteTaskWithId] Deleted branch ${branch}`) - } catch (err) { + await fs.rm(checkpointsDir, { recursive: true, force: true }) + console.log(`[deleteTaskWithId${id}] removed checkpoints repo`) + } catch (error) { console.error( - `[deleteTaskWithId] Error deleting branch ${branch}: ${err instanceof Error ? err.message : String(err)}`, + `[deleteTaskWithId${id}] failed to remove checkpoints repo: ${error instanceof Error ? error.message : String(error)}`, ) } } + + // Succeeds if the dir is empty. + await fs.rmdir(taskDirPath) } async deleteTaskFromState(id: string) { @@ -2373,6 +2393,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { alwaysAllowMcp: alwaysAllowMcp ?? false, alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, uriScheme: vscode.env.uriScheme, + currentTaskItem: this.cline?.taskId + ? (taskHistory || []).find((item) => item.id === this.cline?.taskId) + : undefined, clineMessages: this.cline?.clineMessages || [], taskHistory: (taskHistory || []) .filter((item: HistoryItem) => item.ts && item.task) diff --git a/src/services/checkpoints/CheckpointServiceFactory.ts b/src/services/checkpoints/CheckpointServiceFactory.ts new file mode 100644 index 00000000000..ff39a1fe7e0 --- /dev/null +++ b/src/services/checkpoints/CheckpointServiceFactory.ts @@ -0,0 +1,29 @@ +import { LocalCheckpointService, LocalCheckpointServiceOptions } from "./LocalCheckpointService" +import { ShadowCheckpointService, ShadowCheckpointServiceOptions } from "./ShadowCheckpointService" + +export type CreateCheckpointServiceFactoryOptions = + | { + strategy: "local" + options: LocalCheckpointServiceOptions + } + | { + strategy: "shadow" + options: ShadowCheckpointServiceOptions + } + +type CheckpointServiceType = T extends { strategy: "local" } + ? LocalCheckpointService + : T extends { strategy: "shadow" } + ? ShadowCheckpointService + : never + +export class CheckpointServiceFactory { + public static create(options: T): CheckpointServiceType { + switch (options.strategy) { + case "local": + return LocalCheckpointService.create(options.options) as any + case "shadow": + return ShadowCheckpointService.create(options.options) as any + } + } +} diff --git a/src/services/checkpoints/CheckpointService.ts b/src/services/checkpoints/LocalCheckpointService.ts similarity index 81% rename from src/services/checkpoints/CheckpointService.ts rename to src/services/checkpoints/LocalCheckpointService.ts index d229452021d..ce5c6bd6eac 100644 --- a/src/services/checkpoints/CheckpointService.ts +++ b/src/services/checkpoints/LocalCheckpointService.ts @@ -4,12 +4,9 @@ import path from "path" import simpleGit, { SimpleGit, CleanOptions } from "simple-git" -export type CheckpointServiceOptions = { - taskId: string - git?: SimpleGit - baseDir: string - log?: (message: string) => void -} +import { CheckpointStrategy, CheckpointService, CheckpointServiceOptions } from "./types" + +export interface LocalCheckpointServiceOptions extends CheckpointServiceOptions {} /** * The CheckpointService provides a mechanism for storing a snapshot of the @@ -49,29 +46,26 @@ export type CheckpointServiceOptions = { * and it's not clear whether it's worth it. */ -export class CheckpointService { +export class LocalCheckpointService implements CheckpointService { private static readonly USER_NAME = "Roo Code" private static readonly USER_EMAIL = "support@roocode.com" private static readonly CHECKPOINT_BRANCH = "roo-code-checkpoints" private static readonly STASH_BRANCH = "roo-code-stash" - private _currentCheckpoint?: string - - public get currentCheckpoint() { - return this._currentCheckpoint - } + public readonly strategy: CheckpointStrategy = "local" + public readonly version = 1 - private set currentCheckpoint(value: string | undefined) { - this._currentCheckpoint = value + public get baseHash() { + return this._baseHash } constructor( public readonly taskId: string, - private readonly git: SimpleGit, - public readonly baseDir: string, - public readonly mainBranch: string, - public readonly baseCommitHash: string, - public readonly hiddenBranch: string, + public readonly git: SimpleGit, + public readonly workspaceDir: string, + private readonly mainBranch: string, + private _baseHash: string, + private readonly hiddenBranch: string, private readonly log: (message: string) => void, ) {} @@ -83,40 +77,27 @@ export class CheckpointService { } } - public async getDiff({ from, to }: { from?: string; to: string }) { + public async getDiff({ from, to }: { from?: string; to?: string }) { const result = [] if (!from) { - from = this.baseCommitHash + from = this.baseHash } const { files } = await this.git.diffSummary([`${from}..${to}`]) for (const file of files.filter((f) => !f.binary)) { const relPath = file.file - const absPath = path.join(this.baseDir, relPath) + const absPath = path.join(this.workspaceDir, relPath) + const before = await this.git.show([`${from}:${relPath}`]).catch(() => "") - // If modified both before and after will generate content. - // If added only after will generate content. - // If deleted only before will generate content. - let beforeContent = "" - let afterContent = "" - - try { - beforeContent = await this.git.show([`${from}:${relPath}`]) - } catch (err) { - // File doesn't exist in older commit. - } - - try { - afterContent = await this.git.show([`${to}:${relPath}`]) - } catch (err) { - // File doesn't exist in newer commit. - } + const after = to + ? await this.git.show([`${to}:${relPath}`]).catch(() => "") + : await fs.readFile(absPath, "utf8").catch(() => "") result.push({ paths: { relative: relPath, absolute: absPath }, - content: { before: beforeContent, after: afterContent }, + content: { before, after }, }) } @@ -201,7 +182,7 @@ export class CheckpointService { * - Create branch * - Change branch */ - const stashBranch = `${CheckpointService.STASH_BRANCH}-${Date.now()}` + const stashBranch = `${LocalCheckpointService.STASH_BRANCH}-${Date.now()}` await this.git.checkout(["-b", stashBranch]) this.log(`[saveCheckpoint] created and checked out ${stashBranch}`) @@ -322,7 +303,7 @@ export class CheckpointService { // If the cherry-pick resulted in an empty commit (e.g., only // deletions) then complete it with --allow-empty. // Otherwise, rethrow the error. - if (existsSync(path.join(this.baseDir, ".git/CHERRY_PICK_HEAD"))) { + if (existsSync(path.join(this.workspaceDir, ".git/CHERRY_PICK_HEAD"))) { await this.git.raw(["commit", "--allow-empty", "--no-edit"]) } else { throw err @@ -330,7 +311,6 @@ export class CheckpointService { } commit = await this.git.revparse(["HEAD"]) - this.currentCheckpoint = commit this.log(`[saveCheckpoint] cherry-pick commit = ${commit}`) } catch (err) { this.log( @@ -360,42 +340,42 @@ export class CheckpointService { await this.git.raw(["restore", "--source", commitHash, "--worktree", "--", "."]) const duration = Date.now() - startTime this.log(`[restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`) - this.currentCheckpoint = commitHash } - public static async create({ taskId, git, baseDir, log = console.log }: CheckpointServiceOptions) { - git = git || simpleGit({ baseDir }) - + public static async create({ taskId, workspaceDir, log = console.log }: LocalCheckpointServiceOptions) { + const git = simpleGit(workspaceDir) const version = await git.version() if (!version?.installed) { throw new Error(`Git is not installed. Please install Git if you wish to use checkpoints.`) } - if (!baseDir || !existsSync(baseDir)) { + if (!workspaceDir || !existsSync(workspaceDir)) { throw new Error(`Base directory is not set or does not exist.`) } - const { currentBranch, currentSha, hiddenBranch } = await CheckpointService.initRepo({ + const { currentBranch, currentSha, hiddenBranch } = await LocalCheckpointService.initRepo(git, { taskId, - git, - baseDir, + workspaceDir, log, }) log( - `[CheckpointService] taskId = ${taskId}, baseDir = ${baseDir}, currentBranch = ${currentBranch}, currentSha = ${currentSha}, hiddenBranch = ${hiddenBranch}`, + `[create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, currentBranch = ${currentBranch}, currentSha = ${currentSha}, hiddenBranch = ${hiddenBranch}`, ) - return new CheckpointService(taskId, git, baseDir, currentBranch, currentSha, hiddenBranch, log) + return new LocalCheckpointService(taskId, git, workspaceDir, currentBranch, currentSha, hiddenBranch, log) } - private static async initRepo({ taskId, git, baseDir, log }: Required) { - const isExistingRepo = existsSync(path.join(baseDir, ".git")) + private static async initRepo( + git: SimpleGit, + { taskId, workspaceDir, log }: Required, + ) { + const isExistingRepo = existsSync(path.join(workspaceDir, ".git")) if (!isExistingRepo) { await git.init() - log(`[initRepo] Initialized new Git repository at ${baseDir}`) + log(`[initRepo] Initialized new Git repository at ${workspaceDir}`) } const globalUserName = await git.getConfig("user.name", "global") @@ -410,21 +390,21 @@ export class CheckpointService { // config, and it should not override the global config. To address // this we remove the local user config if it matches the default // user name and email and there's a global config. - if (globalUserName.value && localUserName.value === CheckpointService.USER_NAME) { + if (globalUserName.value && localUserName.value === LocalCheckpointService.USER_NAME) { await git.raw(["config", "--unset", "--local", "user.name"]) } - if (globalUserEmail.value && localUserEmail.value === CheckpointService.USER_EMAIL) { + if (globalUserEmail.value && localUserEmail.value === LocalCheckpointService.USER_EMAIL) { await git.raw(["config", "--unset", "--local", "user.email"]) } // Only set user config if not already configured. if (!userName) { - await git.addConfig("user.name", CheckpointService.USER_NAME) + await git.addConfig("user.name", LocalCheckpointService.USER_NAME) } if (!userEmail) { - await git.addConfig("user.email", CheckpointService.USER_EMAIL) + await git.addConfig("user.email", LocalCheckpointService.USER_EMAIL) } if (!isExistingRepo) { @@ -433,7 +413,7 @@ export class CheckpointService { // However, using an empty commit causes problems when restoring // the checkpoint (i.e. the `git restore` command doesn't work // for empty commits). - await fs.writeFile(path.join(baseDir, ".gitkeep"), "") + await fs.writeFile(path.join(workspaceDir, ".gitkeep"), "") await git.add(".gitkeep") const commit = await git.commit("Initial commit") @@ -447,7 +427,7 @@ export class CheckpointService { const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"]) const currentSha = await git.revparse(["HEAD"]) - const hiddenBranch = `${CheckpointService.CHECKPOINT_BRANCH}-${taskId}` + const hiddenBranch = `${LocalCheckpointService.CHECKPOINT_BRANCH}-${taskId}` const branchSummary = await git.branch() if (!branchSummary.all.includes(hiddenBranch)) { diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts new file mode 100644 index 00000000000..18e7e69a53a --- /dev/null +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -0,0 +1,247 @@ +import fs from "fs/promises" +import os from "os" +import * as path from "path" +import { globby } from "globby" +import simpleGit, { SimpleGit } from "simple-git" + +import { GIT_DISABLED_SUFFIX, GIT_EXCLUDES } from "./constants" +import { CheckpointStrategy, CheckpointService, CheckpointServiceOptions } from "./types" + +export interface ShadowCheckpointServiceOptions extends CheckpointServiceOptions { + shadowDir: string +} + +export class ShadowCheckpointService implements CheckpointService { + public readonly strategy: CheckpointStrategy = "shadow" + public readonly version = 1 + + private _baseHash?: string + + public get baseHash() { + return this._baseHash + } + + private set baseHash(value: string | undefined) { + this._baseHash = value + } + + private readonly shadowGitDir: string + private shadowGitConfigWorktree?: string + + private constructor( + public readonly taskId: string, + public readonly git: SimpleGit, + public readonly shadowDir: string, + public readonly workspaceDir: string, + private readonly log: (message: string) => void, + ) { + this.shadowGitDir = path.join(this.shadowDir, "tasks", this.taskId, "checkpoints", ".git") + } + + private async initShadowGit() { + const fileExistsAtPath = (path: string) => + fs + .access(path) + .then(() => true) + .catch(() => false) + + if (await fileExistsAtPath(this.shadowGitDir)) { + this.log(`[initShadowGit] shadow git repo already exists at ${this.shadowGitDir}`) + const worktree = await this.getShadowGitConfigWorktree() + + if (worktree !== this.workspaceDir) { + throw new Error( + `Checkpoints can only be used in the original workspace: ${worktree} !== ${this.workspaceDir}`, + ) + } + + this.baseHash = await this.git.revparse(["--abbrev-ref", "HEAD"]) + } else { + this.log(`[initShadowGit] creating shadow git repo at ${this.workspaceDir}`) + + await this.git.init() + await this.git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace. + await this.git.addConfig("commit.gpgSign", "false") // Disable commit signing for shadow repo. + await this.git.addConfig("user.name", "Roo Code") + await this.git.addConfig("user.email", "noreply@example.com") + + let lfsPatterns: string[] = [] // Get LFS patterns from workspace if they exist. + + try { + const attributesPath = path.join(this.workspaceDir, ".gitattributes") + + if (await fileExistsAtPath(attributesPath)) { + lfsPatterns = (await fs.readFile(attributesPath, "utf8")) + .split("\n") + .filter((line) => line.includes("filter=lfs")) + .map((line) => line.split(" ")[0].trim()) + } + } catch (error) { + console.warn(`Failed to read .gitattributes: ${error instanceof Error ? error.message : String(error)}`) + } + + // Add basic excludes directly in git config, while respecting any + // .gitignore in the workspace. + // .git/info/exclude is local to the shadow git repo, so it's not + // shared with the main repo - and won't conflict with user's + // .gitignore. + await fs.mkdir(path.join(this.shadowGitDir, "info"), { recursive: true }) + const excludesPath = path.join(this.shadowGitDir, "info", "exclude") + await fs.writeFile(excludesPath, [...GIT_EXCLUDES, ...lfsPatterns].join("\n")) + await this.stageAll() + const { commit } = await this.git.commit("initial commit", { "--allow-empty": null }) + this.baseHash = commit + this.log(`[initShadowGit] base commit is ${commit}`) + } + } + + private async stageAll() { + await this.renameNestedGitRepos(true) + + try { + await this.git.add(".") + } catch (error) { + console.error(`Failed to add files to git: ${error instanceof Error ? error.message : String(error)}`) + } finally { + await this.renameNestedGitRepos(false) + } + } + + // Since we use git to track checkpoints, we need to temporarily disable + // nested git repos to work around git's requirement of using submodules for + // nested repos. + private async renameNestedGitRepos(disable: boolean) { + // Find all .git directories that are not at the root level. + const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), { + cwd: this.workspaceDir, + onlyDirectories: true, + ignore: [".git"], // Ignore root level .git. + dot: true, + markDirectories: false, + }) + + // For each nested .git directory, rename it based on operation. + for (const gitPath of gitPaths) { + const fullPath = path.join(this.workspaceDir, gitPath) + let newPath: string + + if (disable) { + newPath = fullPath + GIT_DISABLED_SUFFIX + } else { + newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) + ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) + : fullPath + } + + try { + await fs.rename(fullPath, newPath) + this.log(`${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`) + } catch (error) { + this.log( + `failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + } + + public async getShadowGitConfigWorktree() { + if (!this.shadowGitConfigWorktree) { + try { + this.shadowGitConfigWorktree = (await this.git.getConfig("core.worktree")).value || undefined + } catch (error) { + console.error( + `[getShadowGitConfigWorktree] failed to get core.worktree: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + return this.shadowGitConfigWorktree + } + + public async saveCheckpoint(message: string) { + try { + const startTime = Date.now() + await this.stageAll() + const result = await this.git.commit(message) + + if (result.commit) { + const duration = Date.now() - startTime + this.log(`[saveCheckpoint] saved checkpoint ${result.commit} in ${duration}ms`) + return result + } else { + return undefined + } + } catch (error) { + console.error( + `[saveCheckpoint] failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`, + ) + + throw error + } + } + + public async restoreCheckpoint(commitHash: string) { + const start = Date.now() + await this.git.clean("f", ["-d", "-f"]) + await this.git.reset(["--hard", commitHash]) + const duration = Date.now() - start + this.log(`[restoreCheckpoint] restored checkpoint ${commitHash} in ${duration}ms`) + } + + public async getDiff({ from, to }: { from?: string; to?: string }) { + const result = [] + + if (!from) { + from = (await this.git.raw(["rev-list", "--max-parents=0", "HEAD"])).trim() + } + + // Stage all changes so that untracked files appear in diff summary. + await this.stageAll() + + const { files } = to ? await this.git.diffSummary([`${from}..${to}`]) : await this.git.diffSummary([from]) + + const cwdPath = (await this.getShadowGitConfigWorktree()) || this.workspaceDir || "" + + for (const file of files) { + const relPath = file.file + const absPath = path.join(cwdPath, relPath) + const before = await this.git.show([`${from}:${relPath}`]).catch(() => "") + + const after = to + ? await this.git.show([`${to}:${relPath}`]).catch(() => "") + : await fs.readFile(relPath, "utf8").catch(() => "") + + result.push({ paths: { relative: relPath, absolute: absPath }, content: { before, after } }) + } + + return result + } + + public static async create({ taskId, shadowDir, workspaceDir, log = console.log }: ShadowCheckpointServiceOptions) { + try { + await simpleGit().version() + } catch (error) { + throw new Error("Git must be installed to use checkpoints.") + } + + const homedir = os.homedir() + const desktopPath = path.join(homedir, "Desktop") + const documentsPath = path.join(homedir, "Documents") + const downloadsPath = path.join(homedir, "Downloads") + const protectedPaths = [homedir, desktopPath, documentsPath, downloadsPath] + + if (protectedPaths.includes(workspaceDir)) { + throw new Error(`Cannot use checkpoints in ${workspaceDir}`) + } + + const checkpointsDir = path.join(shadowDir, "tasks", taskId, "checkpoints") + await fs.mkdir(checkpointsDir, { recursive: true }) + const gitDir = path.join(checkpointsDir, ".git") + const git = simpleGit(path.dirname(gitDir)) + + log(`[create] taskId = ${taskId}, workspaceDir = ${workspaceDir}, shadowDir = ${shadowDir}`) + const service = new ShadowCheckpointService(taskId, git, shadowDir, workspaceDir, log) + await service.initShadowGit() + return service + } +} diff --git a/src/services/checkpoints/__tests__/CheckpointService.test.ts b/src/services/checkpoints/__tests__/LocalCheckpointService.test.ts similarity index 70% rename from src/services/checkpoints/__tests__/CheckpointService.test.ts rename to src/services/checkpoints/__tests__/LocalCheckpointService.test.ts index c19ef0830f1..5ba2721b8c2 100644 --- a/src/services/checkpoints/__tests__/CheckpointService.test.ts +++ b/src/services/checkpoints/__tests__/LocalCheckpointService.test.ts @@ -1,64 +1,66 @@ -// npx jest src/services/checkpoints/__tests__/CheckpointService.test.ts +// npx jest src/services/checkpoints/__tests__/LocalCheckpointService.test.ts import fs from "fs/promises" import path from "path" import os from "os" -import { simpleGit, SimpleGit, SimpleGitTaskCallback } from "simple-git" +import { simpleGit, SimpleGit } from "simple-git" -import { CheckpointService } from "../CheckpointService" +import { CheckpointServiceFactory } from "../CheckpointServiceFactory" +import { LocalCheckpointService } from "../LocalCheckpointService" -describe("CheckpointService", () => { +describe("LocalCheckpointService", () => { const taskId = "test-task" - let git: SimpleGit let testFile: string - let service: CheckpointService + let service: LocalCheckpointService const initRepo = async ({ - baseDir, + workspaceDir, userName = "Roo Code", userEmail = "support@roocode.com", testFileName = "test.txt", textFileContent = "Hello, world!", }: { - baseDir: string + workspaceDir: string userName?: string userEmail?: string testFileName?: string textFileContent?: string }) => { // Create a temporary directory for testing. - await fs.mkdir(baseDir) + await fs.mkdir(workspaceDir) // Initialize git repo. - const git = simpleGit(baseDir) + const git = simpleGit(workspaceDir) await git.init() await git.addConfig("user.name", userName) await git.addConfig("user.email", userEmail) // Create test file. - const testFile = path.join(baseDir, testFileName) + const testFile = path.join(workspaceDir, testFileName) await fs.writeFile(testFile, textFileContent) // Create initial commit. await git.add(".") await git.commit("Initial commit")! - return { git, testFile } + return { testFile } } beforeEach(async () => { - const baseDir = path.join(os.tmpdir(), `checkpoint-service-test-${Date.now()}`) - const repo = await initRepo({ baseDir }) + const workspaceDir = path.join(os.tmpdir(), `checkpoint-service-test-${Date.now()}`) + const repo = await initRepo({ workspaceDir }) - git = repo.git testFile = repo.testFile - service = await CheckpointService.create({ taskId, git, baseDir, log: () => {} }) + service = await CheckpointServiceFactory.create({ + strategy: "local", + options: { taskId, workspaceDir, log: () => {} }, + }) }) afterEach(async () => { - await fs.rm(service.baseDir, { recursive: true, force: true }) + await fs.rm(service.workspaceDir, { recursive: true, force: true }) jest.restoreAllMocks() }) @@ -95,7 +97,7 @@ describe("CheckpointService", () => { }) it("handles new files in diff", async () => { - const newFile = path.join(service.baseDir, "new.txt") + const newFile = path.join(service.workspaceDir, "new.txt") await fs.writeFile(newFile, "New file content") const commit = await service.saveCheckpoint("Add new file") expect(commit?.commit).toBeTruthy() @@ -108,7 +110,7 @@ describe("CheckpointService", () => { }) it("handles deleted files in diff", async () => { - const fileToDelete = path.join(service.baseDir, "new.txt") + const fileToDelete = path.join(service.workspaceDir, "new.txt") await fs.writeFile(fileToDelete, "New file content") const commit1 = await service.saveCheckpoint("Add file") expect(commit1?.commit).toBeTruthy() @@ -130,14 +132,14 @@ describe("CheckpointService", () => { await fs.writeFile(testFile, "Ahoy, world!") const commit1 = await service.saveCheckpoint("First checkpoint") expect(commit1?.commit).toBeTruthy() - const details1 = await git.show([commit1!.commit]) + const details1 = await service.git.show([commit1!.commit]) expect(details1).toContain("-Hello, world!") expect(details1).toContain("+Ahoy, world!") await fs.writeFile(testFile, "Hola, world!") const commit2 = await service.saveCheckpoint("Second checkpoint") expect(commit2?.commit).toBeTruthy() - const details2 = await git.show([commit2!.commit]) + const details2 = await service.git.show([commit2!.commit]) expect(details2).toContain("-Hello, world!") expect(details2).toContain("+Hola, world!") @@ -150,30 +152,31 @@ describe("CheckpointService", () => { expect(await fs.readFile(testFile, "utf-8")).toBe("Hola, world!") // Switch back to initial commit. - await service.restoreCheckpoint(service.baseCommitHash) + expect(service.baseHash).toBeTruthy() + await service.restoreCheckpoint(service.baseHash!) expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!") }) it("preserves workspace and index state after saving checkpoint", async () => { // Create three files with different states: staged, unstaged, and mixed. - const unstagedFile = path.join(service.baseDir, "unstaged.txt") - const stagedFile = path.join(service.baseDir, "staged.txt") - const mixedFile = path.join(service.baseDir, "mixed.txt") + const unstagedFile = path.join(service.workspaceDir, "unstaged.txt") + const stagedFile = path.join(service.workspaceDir, "staged.txt") + const mixedFile = path.join(service.workspaceDir, "mixed.txt") await fs.writeFile(unstagedFile, "Initial unstaged") await fs.writeFile(stagedFile, "Initial staged") await fs.writeFile(mixedFile, "Initial mixed") - await git.add(["."]) - const result = await git.commit("Add initial files") + await service.git.add(["."]) + const result = await service.git.commit("Add initial files") expect(result?.commit).toBeTruthy() await fs.writeFile(unstagedFile, "Modified unstaged") await fs.writeFile(stagedFile, "Modified staged") - await git.add([stagedFile]) + await service.git.add([stagedFile]) await fs.writeFile(mixedFile, "Modified mixed - staged") - await git.add([mixedFile]) + await service.git.add([mixedFile]) await fs.writeFile(mixedFile, "Modified mixed - unstaged") // Save checkpoint. @@ -181,7 +184,7 @@ describe("CheckpointService", () => { expect(commit?.commit).toBeTruthy() // Verify workspace state is preserved. - const status = await git.status() + const status = await service.git.status() // All files should be modified. expect(status.modified).toContain("unstaged.txt") @@ -199,12 +202,12 @@ describe("CheckpointService", () => { expect(await fs.readFile(mixedFile, "utf-8")).toBe("Modified mixed - unstaged") // Verify staged changes (--cached shows only staged changes). - const stagedDiff = await git.diff(["--cached", "mixed.txt"]) + const stagedDiff = await service.git.diff(["--cached", "mixed.txt"]) expect(stagedDiff).toContain("-Initial mixed") expect(stagedDiff).toContain("+Modified mixed - staged") // Verify unstaged changes (shows working directory changes). - const unstagedDiff = await git.diff(["mixed.txt"]) + const unstagedDiff = await service.git.diff(["mixed.txt"]) expect(unstagedDiff).toContain("-Modified mixed - staged") expect(unstagedDiff).toContain("+Modified mixed - unstaged") }) @@ -223,7 +226,7 @@ describe("CheckpointService", () => { it("includes untracked files in checkpoints", async () => { // Create an untracked file. - const untrackedFile = path.join(service.baseDir, "untracked.txt") + const untrackedFile = path.join(service.workspaceDir, "untracked.txt") await fs.writeFile(untrackedFile, "I am untracked!") // Save a checkpoint with the untracked file. @@ -231,7 +234,7 @@ describe("CheckpointService", () => { expect(commit1?.commit).toBeTruthy() // Verify the untracked file was included in the checkpoint. - const details = await git.show([commit1!.commit]) + const details = await service.git.show([commit1!.commit]) expect(details).toContain("+I am untracked!") // Create another checkpoint with a different state. @@ -253,16 +256,19 @@ describe("CheckpointService", () => { it("throws if we're on the wrong branch", async () => { // Create and switch to a feature branch. - await git.checkoutBranch("feature", service.mainBranch) + const currentBranch = await service.git.revparse(["--abbrev-ref", "HEAD"]) + await service.git.checkoutBranch("feature", currentBranch) // Attempt to save checkpoint from feature branch. await expect(service.saveCheckpoint("test")).rejects.toThrow( - `Git branch mismatch: expected '${service.mainBranch}' but found 'feature'`, + `Git branch mismatch: expected '${currentBranch}' but found 'feature'`, ) // Attempt to restore checkpoint from feature branch. - await expect(service.restoreCheckpoint(service.baseCommitHash)).rejects.toThrow( - `Git branch mismatch: expected '${service.mainBranch}' but found 'feature'`, + expect(service.baseHash).toBeTruthy() + + await expect(service.restoreCheckpoint(service.baseHash!)).rejects.toThrow( + `Git branch mismatch: expected '${currentBranch}' but found 'feature'`, ) }) @@ -270,19 +276,19 @@ describe("CheckpointService", () => { await fs.writeFile(testFile, "Changed content") // Mock git commit to simulate failure. - jest.spyOn(git, "commit").mockRejectedValue(new Error("Simulated commit failure")) + jest.spyOn(service.git, "commit").mockRejectedValue(new Error("Simulated commit failure")) // Attempt to save checkpoint. await expect(service.saveCheckpoint("test")).rejects.toThrow("Simulated commit failure") // Verify files are unstaged. - const status = await git.status() + const status = await service.git.status() expect(status.staged).toHaveLength(0) }) it("handles file deletions correctly", async () => { await fs.writeFile(testFile, "I am tracked!") - const untrackedFile = path.join(service.baseDir, "new.txt") + const untrackedFile = path.join(service.workspaceDir, "new.txt") await fs.writeFile(untrackedFile, "I am untracked!") const commit1 = await service.saveCheckpoint("First checkpoint") expect(commit1?.commit).toBeTruthy() @@ -310,17 +316,16 @@ describe("CheckpointService", () => { describe("create", () => { it("initializes a git repository if one does not already exist", async () => { - const baseDir = path.join(os.tmpdir(), `checkpoint-service-test2-${Date.now()}`) - await fs.mkdir(baseDir) - const newTestFile = path.join(baseDir, "test.txt") + const workspaceDir = path.join(os.tmpdir(), `checkpoint-service-test2-${Date.now()}`) + await fs.mkdir(workspaceDir) + const newTestFile = path.join(workspaceDir, "test.txt") await fs.writeFile(newTestFile, "Hello, world!") - const newGit = simpleGit(baseDir) - const initSpy = jest.spyOn(newGit, "init") - const newService = await CheckpointService.create({ taskId, git: newGit, baseDir, log: () => {} }) - // Ensure the git repository was initialized. - expect(initSpy).toHaveBeenCalled() + const gitDir = path.join(workspaceDir, ".git") + await expect(fs.stat(gitDir)).rejects.toThrow() + const newService = await LocalCheckpointService.create({ taskId, workspaceDir, log: () => {} }) + expect(await fs.stat(gitDir)).toBeTruthy() // Save a checkpoint: Hello, world! const commit1 = await newService.saveCheckpoint("Hello, world!") @@ -328,7 +333,8 @@ describe("CheckpointService", () => { expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!") // Restore initial commit; the file should no longer exist. - await newService.restoreCheckpoint(newService.baseCommitHash) + expect(newService.baseHash).toBeTruthy() + await newService.restoreCheckpoint(newService.baseHash!) await expect(fs.access(newTestFile)).rejects.toThrow() // Restore to checkpoint 1; the file should now exist. @@ -350,67 +356,25 @@ describe("CheckpointService", () => { expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!") // Restore initial commit. - await newService.restoreCheckpoint(newService.baseCommitHash) + expect(newService.baseHash).toBeTruthy() + await newService.restoreCheckpoint(newService.baseHash!) await expect(fs.access(newTestFile)).rejects.toThrow() - await fs.rm(newService.baseDir, { recursive: true, force: true }) + await fs.rm(newService.workspaceDir, { recursive: true, force: true }) }) it("respects existing git user configuration", async () => { - const baseDir = path.join(os.tmpdir(), `checkpoint-service-test-config2-${Date.now()}`) + const workspaceDir = path.join(os.tmpdir(), `checkpoint-service-test-config2-${Date.now()}`) const userName = "Custom User" const userEmail = "custom@example.com" - const repo = await initRepo({ baseDir, userName, userEmail }) - const newGit = repo.git - - await CheckpointService.create({ taskId, git: newGit, baseDir, log: () => {} }) - - expect((await newGit.getConfig("user.name")).value).toBe(userName) - expect((await newGit.getConfig("user.email")).value).toBe(userEmail) - - await fs.rm(baseDir, { recursive: true, force: true }) - }) - - it("removes local git config if it matches default and global exists", async () => { - const baseDir = path.join(os.tmpdir(), `checkpoint-service-test-config2-${Date.now()}`) - const repo = await initRepo({ baseDir }) - const newGit = repo.git - - const originalGetConfig = newGit.getConfig.bind(newGit) - - jest.spyOn(newGit, "getConfig").mockImplementation( - ( - key: string, - scope?: "system" | "global" | "local" | "worktree", - callback?: SimpleGitTaskCallback, - ) => { - if (scope === "global") { - if (key === "user.email") { - return Promise.resolve({ value: "global@example.com" }) as any - } - if (key === "user.name") { - return Promise.resolve({ value: "Global User" }) as any - } - } - - return originalGetConfig(key, scope, callback) - }, - ) - - await CheckpointService.create({ taskId, git: newGit, baseDir, log: () => {} }) + await initRepo({ workspaceDir, userName, userEmail }) - // Verify local config was removed and global config is used. - const localName = await newGit.getConfig("user.name", "local") - const localEmail = await newGit.getConfig("user.email", "local") - const globalName = await newGit.getConfig("user.name", "global") - const globalEmail = await newGit.getConfig("user.email", "global") + const newService = await LocalCheckpointService.create({ taskId, workspaceDir, log: () => {} }) - expect(localName.value).toBeNull() // Local config should be removed. - expect(localEmail.value).toBeNull() - expect(globalName.value).toBe("Global User") // Global config should remain. - expect(globalEmail.value).toBe("global@example.com") + expect((await newService.git.getConfig("user.name")).value).toBe(userName) + expect((await newService.git.getConfig("user.email")).value).toBe(userEmail) - await fs.rm(baseDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) }) }) }) diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts new file mode 100644 index 00000000000..0e32e82fdfa --- /dev/null +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts @@ -0,0 +1,334 @@ +// npx jest src/services/checkpoints/__tests__/ShadowCheckpointService.test.ts + +import fs from "fs/promises" +import path from "path" +import os from "os" + +import { simpleGit, SimpleGit } from "simple-git" + +import { ShadowCheckpointService } from "../ShadowCheckpointService" +import { CheckpointServiceFactory } from "../CheckpointServiceFactory" + +jest.mock("globby", () => ({ + globby: jest.fn().mockResolvedValue([]), +})) + +describe("ShadowCheckpointService", () => { + const taskId = "test-task" + + let workspaceGit: SimpleGit + let shadowGit: SimpleGit + let testFile: string + let service: ShadowCheckpointService + + const initRepo = async ({ + workspaceDir, + userName = "Roo Code", + userEmail = "support@roocode.com", + testFileName = "test.txt", + textFileContent = "Hello, world!", + }: { + workspaceDir: string + userName?: string + userEmail?: string + testFileName?: string + textFileContent?: string + }) => { + // Create a temporary directory for testing. + await fs.mkdir(workspaceDir) + + // Initialize git repo. + const git = simpleGit(workspaceDir) + await git.init() + await git.addConfig("user.name", userName) + await git.addConfig("user.email", userEmail) + + // Create test file. + const testFile = path.join(workspaceDir, testFileName) + await fs.writeFile(testFile, textFileContent) + + // Create initial commit. + await git.add(".") + await git.commit("Initial commit")! + + return { git, testFile } + } + + beforeEach(async () => { + jest.mocked(require("globby").globby).mockClear().mockResolvedValue([]) + + const shadowDir = path.join(os.tmpdir(), `shadow-${Date.now()}`) + const workspaceDir = path.join(os.tmpdir(), `workspace-${Date.now()}`) + const repo = await initRepo({ workspaceDir }) + + testFile = repo.testFile + + service = await CheckpointServiceFactory.create({ + strategy: "shadow", + options: { taskId, shadowDir, workspaceDir, log: () => {} }, + }) + + workspaceGit = repo.git + shadowGit = service.git + }) + + afterEach(async () => { + await fs.rm(service.shadowDir, { recursive: true, force: true }) + await fs.rm(service.workspaceDir, { recursive: true, force: true }) + jest.restoreAllMocks() + }) + + describe("getDiff", () => { + it("returns the correct diff between commits", async () => { + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await fs.writeFile(testFile, "Goodbye, world!") + const commit2 = await service.saveCheckpoint("Second checkpoint") + expect(commit2?.commit).toBeTruthy() + + const diff1 = await service.getDiff({ to: commit1!.commit }) + expect(diff1).toHaveLength(1) + expect(diff1[0].paths.relative).toBe("test.txt") + expect(diff1[0].paths.absolute).toBe(testFile) + expect(diff1[0].content.before).toBe("Hello, world!") + expect(diff1[0].content.after).toBe("Ahoy, world!") + + const diff2 = await service.getDiff({ to: commit2!.commit }) + expect(diff2).toHaveLength(1) + expect(diff2[0].paths.relative).toBe("test.txt") + expect(diff2[0].paths.absolute).toBe(testFile) + expect(diff2[0].content.before).toBe("Hello, world!") + expect(diff2[0].content.after).toBe("Goodbye, world!") + + const diff12 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit }) + expect(diff12).toHaveLength(1) + expect(diff12[0].paths.relative).toBe("test.txt") + expect(diff12[0].paths.absolute).toBe(testFile) + expect(diff12[0].content.before).toBe("Ahoy, world!") + expect(diff12[0].content.after).toBe("Goodbye, world!") + }) + + it("handles new files in diff", async () => { + const newFile = path.join(service.workspaceDir, "new.txt") + await fs.writeFile(newFile, "New file content") + const commit = await service.saveCheckpoint("Add new file") + expect(commit?.commit).toBeTruthy() + + const changes = await service.getDiff({ to: commit!.commit }) + const change = changes.find((c) => c.paths.relative === "new.txt") + expect(change).toBeDefined() + expect(change?.content.before).toBe("") + expect(change?.content.after).toBe("New file content") + }) + + it("handles deleted files in diff", async () => { + const fileToDelete = path.join(service.workspaceDir, "new.txt") + await fs.writeFile(fileToDelete, "New file content") + const commit1 = await service.saveCheckpoint("Add file") + expect(commit1?.commit).toBeTruthy() + + await fs.unlink(fileToDelete) + const commit2 = await service.saveCheckpoint("Delete file") + expect(commit2?.commit).toBeTruthy() + + const changes = await service.getDiff({ from: commit1!.commit, to: commit2!.commit }) + const change = changes.find((c) => c.paths.relative === "new.txt") + expect(change).toBeDefined() + expect(change!.content.before).toBe("New file content") + expect(change!.content.after).toBe("") + }) + }) + + describe("saveCheckpoint", () => { + it("creates a checkpoint if there are pending changes", async () => { + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + const details1 = await service.getDiff({ to: commit1!.commit }) + expect(details1[0].content.before).toContain("Hello, world!") + expect(details1[0].content.after).toContain("Ahoy, world!") + + await fs.writeFile(testFile, "Hola, world!") + const commit2 = await service.saveCheckpoint("Second checkpoint") + expect(commit2?.commit).toBeTruthy() + const details2 = await service.getDiff({ from: commit1!.commit, to: commit2!.commit }) + expect(details2[0].content.before).toContain("Ahoy, world!") + expect(details2[0].content.after).toContain("Hola, world!") + + // Switch to checkpoint 1. + await service.restoreCheckpoint(commit1!.commit) + expect(await fs.readFile(testFile, "utf-8")).toBe("Ahoy, world!") + + // Switch to checkpoint 2. + await service.restoreCheckpoint(commit2!.commit) + expect(await fs.readFile(testFile, "utf-8")).toBe("Hola, world!") + + // Switch back to initial commit. + expect(service.baseHash).toBeTruthy() + await service.restoreCheckpoint(service.baseHash!) + expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!") + }) + + it("preserves workspace and index state after saving checkpoint", async () => { + // Create three files with different states: staged, unstaged, and mixed. + const unstagedFile = path.join(service.workspaceDir, "unstaged.txt") + const stagedFile = path.join(service.workspaceDir, "staged.txt") + const mixedFile = path.join(service.workspaceDir, "mixed.txt") + + await fs.writeFile(unstagedFile, "Initial unstaged") + await fs.writeFile(stagedFile, "Initial staged") + await fs.writeFile(mixedFile, "Initial mixed") + await workspaceGit.add(["."]) + const result = await workspaceGit.commit("Add initial files") + expect(result?.commit).toBeTruthy() + + await fs.writeFile(unstagedFile, "Modified unstaged") + + await fs.writeFile(stagedFile, "Modified staged") + await workspaceGit.add([stagedFile]) + + await fs.writeFile(mixedFile, "Modified mixed - staged") + await workspaceGit.add([mixedFile]) + await fs.writeFile(mixedFile, "Modified mixed - unstaged") + + // Save checkpoint. + const commit = await service.saveCheckpoint("Test checkpoint") + expect(commit?.commit).toBeTruthy() + + // Verify workspace state is preserved. + const status = await workspaceGit.status() + + // All files should be modified. + expect(status.modified).toContain("unstaged.txt") + expect(status.modified).toContain("staged.txt") + expect(status.modified).toContain("mixed.txt") + + // Only staged and mixed files should be staged. + expect(status.staged).not.toContain("unstaged.txt") + expect(status.staged).toContain("staged.txt") + expect(status.staged).toContain("mixed.txt") + + // Verify file contents. + expect(await fs.readFile(unstagedFile, "utf-8")).toBe("Modified unstaged") + expect(await fs.readFile(stagedFile, "utf-8")).toBe("Modified staged") + expect(await fs.readFile(mixedFile, "utf-8")).toBe("Modified mixed - unstaged") + + // Verify staged changes (--cached shows only staged changes). + const stagedDiff = await workspaceGit.diff(["--cached", "mixed.txt"]) + expect(stagedDiff).toContain("-Initial mixed") + expect(stagedDiff).toContain("+Modified mixed - staged") + + // Verify unstaged changes (shows working directory changes). + const unstagedDiff = await workspaceGit.diff(["mixed.txt"]) + expect(unstagedDiff).toContain("-Modified mixed - staged") + expect(unstagedDiff).toContain("+Modified mixed - unstaged") + }) + + it("does not create a checkpoint if there are no pending changes", async () => { + const commit0 = await service.saveCheckpoint("Zeroth checkpoint") + expect(commit0?.commit).toBeFalsy() + + await fs.writeFile(testFile, "Ahoy, world!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + const commit2 = await service.saveCheckpoint("Second checkpoint") + expect(commit2?.commit).toBeFalsy() + }) + + it("includes untracked files in checkpoints", async () => { + // Create an untracked file. + const untrackedFile = path.join(service.workspaceDir, "untracked.txt") + await fs.writeFile(untrackedFile, "I am untracked!") + + // Save a checkpoint with the untracked file. + const commit1 = await service.saveCheckpoint("Checkpoint with untracked file") + expect(commit1?.commit).toBeTruthy() + + // Verify the untracked file was included in the checkpoint. + const details = await service.getDiff({ to: commit1!.commit }) + expect(details[0].content.before).toContain("") + expect(details[0].content.after).toContain("I am untracked!") + + // Create another checkpoint with a different state. + await fs.writeFile(testFile, "Changed tracked file") + const commit2 = await service.saveCheckpoint("Second checkpoint") + expect(commit2?.commit).toBeTruthy() + + // Restore first checkpoint and verify untracked file is preserved. + await service.restoreCheckpoint(commit1!.commit) + expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!") + expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!") + + // Restore second checkpoint and verify untracked file remains (since + // restore preserves untracked files) + await service.restoreCheckpoint(commit2!.commit) + expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!") + expect(await fs.readFile(testFile, "utf-8")).toBe("Changed tracked file") + }) + + it("handles file deletions correctly", async () => { + await fs.writeFile(testFile, "I am tracked!") + const untrackedFile = path.join(service.workspaceDir, "new.txt") + await fs.writeFile(untrackedFile, "I am untracked!") + const commit1 = await service.saveCheckpoint("First checkpoint") + expect(commit1?.commit).toBeTruthy() + + await fs.unlink(testFile) + await fs.unlink(untrackedFile) + const commit2 = await service.saveCheckpoint("Second checkpoint") + expect(commit2?.commit).toBeTruthy() + + // Verify files are gone. + await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow() + await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow() + + // Restore first checkpoint. + await service.restoreCheckpoint(commit1!.commit) + expect(await fs.readFile(testFile, "utf-8")).toBe("I am tracked!") + expect(await fs.readFile(untrackedFile, "utf-8")).toBe("I am untracked!") + + // Restore second checkpoint. + await service.restoreCheckpoint(commit2!.commit) + await expect(fs.readFile(testFile, "utf-8")).rejects.toThrow() + await expect(fs.readFile(untrackedFile, "utf-8")).rejects.toThrow() + }) + }) + + describe("create", () => { + it("initializes a git repository if one does not already exist", async () => { + const shadowDir = path.join(os.tmpdir(), `shadow2-${Date.now()}`) + const workspaceDir = path.join(os.tmpdir(), `workspace2-${Date.now()}`) + await fs.mkdir(workspaceDir) + + const newTestFile = path.join(workspaceDir, "test.txt") + await fs.writeFile(newTestFile, "Hello, world!") + expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!") + + // Ensure the git repository was initialized. + const gitDir = path.join(shadowDir, "tasks", taskId, "checkpoints", ".git") + await expect(fs.stat(gitDir)).rejects.toThrow() + const newService = await ShadowCheckpointService.create({ taskId, shadowDir, workspaceDir, log: () => {} }) + expect(await fs.stat(gitDir)).toBeTruthy() + + // Save a new checkpoint: Ahoy, world! + await fs.writeFile(newTestFile, "Ahoy, world!") + const commit1 = await newService.saveCheckpoint("Ahoy, world!") + expect(commit1?.commit).toBeTruthy() + expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!") + + // Restore "Hello, world!" + await newService.restoreCheckpoint(newService.baseHash!) + expect(await fs.readFile(newTestFile, "utf-8")).toBe("Hello, world!") + + // Restore "Ahoy, world!" + await newService.restoreCheckpoint(commit1!.commit) + expect(await fs.readFile(newTestFile, "utf-8")).toBe("Ahoy, world!") + + await fs.rm(newService.shadowDir, { recursive: true, force: true }) + await fs.rm(newService.workspaceDir, { recursive: true, force: true }) + }) + }) +}) diff --git a/src/services/checkpoints/constants.ts b/src/services/checkpoints/constants.ts new file mode 100644 index 00000000000..f691587c841 --- /dev/null +++ b/src/services/checkpoints/constants.ts @@ -0,0 +1,89 @@ +export const GIT_DISABLED_SUFFIX = "_disabled" + +export const GIT_EXCLUDES = [ + ".git/", // Ignore the user's .git. + `.git${GIT_DISABLED_SUFFIX}/`, // Ignore the disabled nested git repos. + ".DS_Store", + "*.log", + "node_modules/", + "__pycache__/", + "env/", + "venv/", + "target/dependency/", + "build/dependencies/", + "dist/", + "out/", + "bundle/", + "vendor/", + "tmp/", + "temp/", + "deps/", + "pkg/", + "Pods/", + // Media files. + "*.jpg", + "*.jpeg", + "*.png", + "*.gif", + "*.bmp", + "*.ico", + // "*.svg", + "*.mp3", + "*.mp4", + "*.wav", + "*.avi", + "*.mov", + "*.wmv", + "*.webm", + "*.webp", + "*.m4a", + "*.flac", + // Build and dependency directories. + "build/", + "bin/", + "obj/", + ".gradle/", + ".idea/", + ".vscode/", + ".vs/", + "coverage/", + ".next/", + ".nuxt/", + // Cache and temporary files. + "*.cache", + "*.tmp", + "*.temp", + "*.swp", + "*.swo", + "*.pyc", + "*.pyo", + ".pytest_cache/", + ".eslintcache", + // Environment and config files. + ".env*", + "*.local", + "*.development", + "*.production", + // Large data files. + "*.zip", + "*.tar", + "*.gz", + "*.rar", + "*.7z", + "*.iso", + "*.bin", + "*.exe", + "*.dll", + "*.so", + "*.dylib", + // Database files. + "*.sqlite", + "*.db", + "*.sql", + // Log files. + "*.logs", + "*.error", + "npm-debug.log*", + "yarn-debug.log*", + "yarn-error.log*", +] diff --git a/src/services/checkpoints/index.ts b/src/services/checkpoints/index.ts new file mode 100644 index 00000000000..e62b6119ce7 --- /dev/null +++ b/src/services/checkpoints/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export * from "./CheckpointServiceFactory" diff --git a/src/services/checkpoints/types.ts b/src/services/checkpoints/types.ts new file mode 100644 index 00000000000..50843abbd32 --- /dev/null +++ b/src/services/checkpoints/types.ts @@ -0,0 +1,32 @@ +import { CommitResult } from "simple-git" + +export type CheckpointResult = Partial & Pick + +export type CheckpointDiff = { + paths: { + relative: string + absolute: string + } + content: { + before: string + after: string + } +} + +export type CheckpointStrategy = "local" | "shadow" + +export interface CheckpointService { + saveCheckpoint(message: string): Promise + restoreCheckpoint(commit: string): Promise + getDiff(range: { from?: string; to?: string }): Promise + workspaceDir: string + baseHash?: string + strategy: CheckpointStrategy + version: number +} + +export interface CheckpointServiceOptions { + taskId: string + workspaceDir: string + log?: (message: string) => void +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 29bc0ec3b62..76a029d487b 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -107,6 +107,7 @@ export interface ExtensionState { requestDelaySeconds: number rateLimitSeconds: number // Minimum time between successive requests (0 = disabled) uriScheme?: string + currentTaskItem?: HistoryItem allowedCommands?: string[] soundEnabled?: boolean soundVolume?: number diff --git a/src/shared/HistoryItem.ts b/src/shared/HistoryItem.ts index d4539f64411..ef242cb9679 100644 --- a/src/shared/HistoryItem.ts +++ b/src/shared/HistoryItem.ts @@ -7,4 +7,5 @@ export type HistoryItem = { cacheWrites?: number cacheReads?: number totalCost: number + size?: number } diff --git a/webview-ui/src/__mocks__/pretty-bytes.js b/webview-ui/src/__mocks__/pretty-bytes.js new file mode 100644 index 00000000000..61660edd9df --- /dev/null +++ b/webview-ui/src/__mocks__/pretty-bytes.js @@ -0,0 +1,12 @@ +module.exports = function prettyBytes(bytes) { + if (typeof bytes !== "number") { + throw new TypeError("Expected a number") + } + + // Simple mock implementation that returns formatted strings. + if (bytes === 0) return "0 B" + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` +} diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index bf359fe2cd9..b139c68f963 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -761,8 +761,8 @@ export const ChatRowContent = ({ ) default: diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index f51bf13b0e9..b35be0cd2a6 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -1,6 +1,8 @@ -import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import React, { memo, useEffect, useMemo, useRef, useState } from "react" import { useWindowSize } from "react-use" +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" +import prettyBytes from "pretty-bytes" + import { ClineMessage } from "../../../../src/shared/ExtensionMessage" import { useExtensionState } from "../../context/ExtensionStateContext" import { vscode } from "../../utils/vscode" @@ -8,6 +10,8 @@ import Thumbnails from "../common/Thumbnails" import { mentionRegexGlobal } from "../../../../src/shared/context-mentions" import { formatLargeNumber } from "../../utils/format" import { normalizeApiConfiguration } from "../settings/ApiOptions" +import { Button } from "../ui" +import { HistoryItem } from "../../../../src/shared/HistoryItem" interface TaskHeaderProps { task: ClineMessage @@ -32,7 +36,7 @@ const TaskHeader: React.FC = ({ contextTokens, onClose, }) => { - const { apiConfiguration } = useExtensionState() + const { apiConfiguration, currentTaskItem } = useExtensionState() const { selectedModelInfo } = useMemo(() => normalizeApiConfiguration(apiConfiguration), [apiConfiguration]) const [isTaskExpanded, setIsTaskExpanded] = useState(true) const [isTextExpanded, setIsTextExpanded] = useState(false) @@ -40,7 +44,6 @@ const TaskHeader: React.FC = ({ const textContainerRef = useRef(null) const textRef = useRef(null) const contextWindow = selectedModelInfo?.contextWindow || 1 - const contextPercentage = Math.round((contextTokens / contextWindow) * 100) /* When dealing with event listeners in React components that depend on state variables, we face a challenge. We want our listener to always use the most up-to-date version of a callback function that relies on current state, but we don't want to constantly add and remove event listeners as that function updates. This scenario often arises with resize listeners or other window events. Simply adding the listener in a useEffect with an empty dependency array risks using stale state, while including the callback in the dependencies can lead to unnecessary re-registrations of the listener. There are react hook libraries that provide a elegant solution to this problem by utilizing the useRef hook to maintain a reference to the latest callback function without triggering re-renders or effect re-runs. This approach ensures that our event listener always has access to the most current state while minimizing performance overhead and potential memory leaks from multiple listener registrations. @@ -250,14 +253,11 @@ const TaskHeader: React.FC = ({ See less )} + {task.images && task.images.length > 0 && } +
-
+
Tokens: @@ -275,83 +275,51 @@ const TaskHeader: React.FC = ({ {formatLargeNumber(tokensOut || 0)}
- {!isCostAvailable && } + {!isCostAvailable && }
-
- Context: - - {contextTokens - ? `${formatLargeNumber(contextTokens)} (${contextPercentage}%)` - : "-"} - -
+ {isTaskExpanded && contextWindow && ( +
+ +
+ )} {shouldShowPromptCacheInfo && (cacheReads !== undefined || cacheWrites !== undefined) && ( -
+
Cache: - + +{formatLargeNumber(cacheWrites || 0)} - + {formatLargeNumber(cacheReads || 0)}
)} + {isCostAvailable && ( -
-
- API Cost: +
+
+ API Cost: ${totalCost?.toFixed(4)}
- +
)}
)}
- {/* {apiProvider === "" && ( -
-
Credits Remaining:
-
- {formatPrice(Credits || 0)} - {(Credits || 0) < 1 && ( - <> - {" "} - - (get more?) - - - )} -
-
- )} */}
) } @@ -378,18 +346,44 @@ export const highlightMentions = (text?: string, withShadow = true) => { }) } -const ExportButton = () => ( - vscode.postMessage({ type: "exportCurrentTask" })} - style={ - { - // marginBottom: "-2px", - // marginRight: "-2.5px", - } - }> -
EXPORT
-
+const TaskActions = ({ item }: { item: HistoryItem | undefined }) => ( +
+ + {item?.size && ( + + )} +
+) + +const ContextWindowProgress = ({ contextWindow, contextTokens }: { contextWindow: number; contextTokens: number }) => ( + <> +
+ Context Window: +
+
+
{formatLargeNumber(contextTokens)}
+
+
+
+
+
+
{formatLargeNumber(contextWindow)}
+
+ ) export default memo(TaskHeader) diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx index b3e88013a81..aa52c2db888 100644 --- a/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx +++ b/webview-ui/src/components/chat/checkpoints/CheckpointMenu.tsx @@ -9,16 +9,20 @@ import { Checkpoint } from "./schema" type CheckpointMenuProps = { ts: number commitHash: string - checkpoint?: Checkpoint - currentCheckpointHash?: string + currentHash?: string + checkpoint: Checkpoint } -export const CheckpointMenu = ({ ts, commitHash, checkpoint, currentCheckpointHash }: CheckpointMenuProps) => { +export const CheckpointMenu = ({ ts, commitHash, currentHash, checkpoint }: CheckpointMenuProps) => { const [portalContainer, setPortalContainer] = useState() const [isOpen, setIsOpen] = useState(false) const [isConfirming, setIsConfirming] = useState(false) - const isCurrent = currentCheckpointHash === commitHash + const isCurrent = currentHash === commitHash + const isFirst = checkpoint.isFirst + + const isDiffAvailable = !isFirst + const isRestoreAvailable = !isFirst || !isCurrent const onCheckpointDiff = useCallback(() => { vscode.postMessage({ type: "checkpointDiff", payload: { ts, commitHash, mode: "checkpoint" } }) @@ -45,69 +49,75 @@ export const CheckpointMenu = ({ ts, commitHash, checkpoint, currentCheckpointHa return (
- {!checkpoint?.isFirst && ( + {isDiffAvailable && ( )} - { - setIsOpen(open) - setIsConfirming(false) - }}> - - - - -
- {!isCurrent && ( -
- -
- Restores your project's files back to a snapshot taken at this point. -
-
- )} -
-
- {!isConfirming ? ( - + + +
+ {!isCurrent && ( +
+ - ) : ( - <> - + ) : ( + <> + + + + )} + {isConfirming ? ( +
+ This action cannot be undone.
- - - - )} - {isConfirming ? ( -
This action cannot be undone.
- ) : ( -
- Restores your project's files back to a snapshot taken at this point and deletes - all messages after this point. + )}
- )} -
+
+ )}
-
- - + + + )}
) } diff --git a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx index d48bfaf7f7e..c15bbd102c9 100644 --- a/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx +++ b/webview-ui/src/components/chat/checkpoints/CheckpointSaved.tsx @@ -3,15 +3,17 @@ import { useMemo } from "react" import { CheckpointMenu } from "./CheckpointMenu" import { checkpointSchema } from "./schema" +const REQUIRED_VERSION = 1 + type CheckpointSavedProps = { ts: number commitHash: string + currentHash?: string checkpoint?: Record - currentCheckpointHash?: string } export const CheckpointSaved = ({ checkpoint, ...props }: CheckpointSavedProps) => { - const isCurrent = props.currentCheckpointHash === props.commitHash + const isCurrent = props.currentHash === props.commitHash const metadata = useMemo(() => { if (!checkpoint) { @@ -19,16 +21,23 @@ export const CheckpointSaved = ({ checkpoint, ...props }: CheckpointSavedProps) } const result = checkpointSchema.safeParse(checkpoint) - return result.success ? result.data : undefined + + if (!result.success || result.data.version < REQUIRED_VERSION) { + return undefined + } + + return result.data }, [checkpoint]) - const isFirst = !!metadata?.isFirst + if (!metadata) { + return null + } return (
- {isFirst ? "Initial Checkpoint" : "Checkpoint"} + {metadata.isFirst ? "Initial Checkpoint" : "Checkpoint"} {isCurrent && Current}
diff --git a/webview-ui/src/components/chat/checkpoints/schema.ts b/webview-ui/src/components/chat/checkpoints/schema.ts index 4acd32a6ab6..7f097966b80 100644 --- a/webview-ui/src/components/chat/checkpoints/schema.ts +++ b/webview-ui/src/components/chat/checkpoints/schema.ts @@ -4,6 +4,8 @@ export const checkpointSchema = z.object({ isFirst: z.boolean(), from: z.string(), to: z.string(), + strategy: z.enum(["local", "shadow"]), + version: z.number(), }) export type Checkpoint = z.infer diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 6d251b67b2b..38ca14df46c 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -1,12 +1,15 @@ +import React, { memo, useMemo, useState, useEffect } from "react" +import { Fzf } from "fzf" +import prettyBytes from "pretty-bytes" +import { Virtuoso } from "react-virtuoso" import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react" + import { useExtensionState } from "../../context/ExtensionStateContext" import { vscode } from "../../utils/vscode" -import { Virtuoso } from "react-virtuoso" -import React, { memo, useMemo, useState, useEffect } from "react" -import { Fzf } from "fzf" import { formatLargeNumber } from "../../utils/format" import { highlightFzfMatch } from "../../utils/highlight" import { useCopyToClipboard } from "../../utils/clipboard" +import { Button } from "../ui" type HistoryViewProps = { onDone: () => void @@ -19,7 +22,6 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { const [searchQuery, setSearchQuery] = useState("") const [sortOption, setSortOption] = useState("newest") const [lastNonRelevantSort, setLastNonRelevantSort] = useState("newest") - const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard() useEffect(() => { if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) { @@ -99,357 +101,329 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { }, [presentableTasks, searchQuery, fzf, sortOption]) return ( - <> - - {showCopyFeedback &&
Prompt Copied to Clipboard
} +
-
-

History

- Done -
-
-
- { - const newValue = (e.target as HTMLInputElement)?.value - setSearchQuery(newValue) - if (newValue && !searchQuery && sortOption !== "mostRelevant") { - setLastNonRelevantSort(sortOption) - setSortOption("mostRelevant") - } - }}> +

History

+ Done +
+
+
+ { + const newValue = (e.target as HTMLInputElement)?.value + setSearchQuery(newValue) + if (newValue && !searchQuery && sortOption !== "mostRelevant") { + setLastNonRelevantSort(sortOption) + setSortOption("mostRelevant") + } + }}> +
+ {searchQuery && (
- {searchQuery && ( -
setSearchQuery("")} - slot="end" - style={{ - display: "flex", - justifyContent: "center", - alignItems: "center", - height: "100%", - }} - /> - )} - - setSortOption((e.target as HTMLInputElement).value as SortOption)}> - Newest - Oldest - Most Expensive - Most Tokens - - Most Relevant - - -
+ className="input-icon-button codicon codicon-close" + aria-label="Clear search" + onClick={() => setSearchQuery("")} + slot="end" + style={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + height: "100%", + }} + /> + )} + + setSortOption((e.target as HTMLInputElement).value as SortOption)}> + Newest + Oldest + Most Expensive + Most Tokens + + Most Relevant + +
-
- ( -
- )), - }} - itemContent={(index, item) => ( +
+
+ ( +
+ )), + }} + itemContent={(index, item) => ( +
handleHistorySelect(item.id)}>
handleHistorySelect(item.id)}> + display: "flex", + flexDirection: "column", + gap: "8px", + padding: "12px 20px", + position: "relative", + }}>
+ + {formatDate(item.ts)} + +
+ +
+
+
+
- - {formatDate(item.ts)} - -
- - + + {formatLargeNumber(item.tokensOut || 0)} +
+ {!item.totalCost && }
-
-
+ + {!!item.cacheWrites && (
-
+ Cache: + + - - Tokens: - - - - {formatLargeNumber(item.tokensIn || 0)} - - - - {formatLargeNumber(item.tokensOut || 0)} - -
- {!item.totalCost && } -
- - {!!item.cacheWrites && ( -
+ +{formatLargeNumber(item.cacheWrites || 0)} + + - - Cache: - + fontSize: "12px", + fontWeight: "bold", + marginBottom: 0, + }} + /> + {formatLargeNumber(item.cacheReads || 0)} + +
+ )} + + {!!item.totalCost && ( +
+
- - +{formatLargeNumber(item.cacheWrites || 0)} + API Cost: - - - {formatLargeNumber(item.cacheReads || 0)} + + ${item.totalCost?.toFixed(4)}
- )} - {!!item.totalCost && ( -
-
- - API Cost: - - - ${item.totalCost?.toFixed(4)} - -
+
+
- )} -
+
+ )}
- )} - /> -
+
+ )} + />
- +
+ ) +} + +const CopyButton = ({ itemTask }: { itemTask: string }) => { + const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard() + + return ( + ) } const ExportButton = ({ itemId }: { itemId: string }) => ( - { e.stopPropagation() vscode.postMessage({ type: "exportTaskWithId", text: itemId }) }}> -
EXPORT
-
+ + ) export default memo(HistoryView) diff --git a/webview-ui/src/components/history/__tests__/HistoryView.test.tsx b/webview-ui/src/components/history/__tests__/HistoryView.test.tsx index d47c5e460b9..7408b268786 100644 --- a/webview-ui/src/components/history/__tests__/HistoryView.test.tsx +++ b/webview-ui/src/components/history/__tests__/HistoryView.test.tsx @@ -181,9 +181,6 @@ describe("HistoryView", () => { // Verify clipboard was called expect(navigator.clipboard.writeText).toHaveBeenCalledWith("Test task 1") - // Verify modal appears immediately after clipboard operation - expect(screen.getByText("Prompt Copied to Clipboard")).toBeInTheDocument() - // Advance timer to trigger the setTimeout for modal disappearance act(() => { jest.advanceTimersByTime(2000) @@ -239,7 +236,7 @@ describe("HistoryView", () => { const taskContainer = screen.getByTestId("virtuoso-item-2") fireEvent.mouseEnter(taskContainer) - const exportButton = within(taskContainer).getByText("EXPORT") + const exportButton = within(taskContainer).getByTestId("export") fireEvent.click(exportButton) // Verify vscode message was sent