From ba17204529b826e4216dfe09b9405e25f9cdc1d1 Mon Sep 17 00:00:00 2001 From: jln <85513960+jln13x@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:47:14 +0300 Subject: [PATCH] feat: add worktree source control review --- .../mobile/src/features/review/reviewModel.ts | 2 +- apps/server/src/git/GitWorkflowService.ts | 16 + apps/server/src/vcs/GitVcsDriver.ts | 4 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 94 ++- apps/server/src/vcs/GitVcsDriverCore.ts | 341 ++++++--- apps/server/src/ws.ts | 21 + apps/web/src/components/DiffPanel.tsx | 101 +-- apps/web/src/components/Sidebar.logic.test.ts | 57 ++ apps/web/src/components/Sidebar.logic.ts | 25 + apps/web/src/components/Sidebar.tsx | 158 ++++- .../src/components/WorktreeSourceControl.tsx | 662 ++++++++++++++++++ .../src/components/diffs/diffViewStyles.ts | 97 +++ .../components/settings/SettingsPanels.tsx | 10 +- apps/web/src/hooks/useHandleNewThread.ts | 12 + apps/web/src/reviewCommentContext.test.ts | 23 + apps/web/src/reviewCommentContext.ts | 8 + apps/web/src/routeTree.gen.ts | 23 + ...hat.worktree.$environmentId.$projectId.tsx | 199 ++++++ docs/personal-fork-changes.md | 14 + packages/client-runtime/src/state/vcs.ts | 18 + packages/contracts/src/git.ts | 8 + packages/contracts/src/review.ts | 8 +- packages/contracts/src/rpc.ts | 22 + packages/contracts/src/settings.test.ts | 1 + packages/contracts/src/settings.ts | 4 + 25 files changed, 1712 insertions(+), 216 deletions(-) create mode 100644 apps/web/src/components/WorktreeSourceControl.tsx create mode 100644 apps/web/src/components/diffs/diffViewStyles.ts create mode 100644 apps/web/src/routes/_chat.worktree.$environmentId.$projectId.tsx diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 9459d41872d..ea20549bd2b 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -5,7 +5,7 @@ import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import * as Order from "effect/Order"; -export type ReviewSectionKind = "turn" | "working-tree" | "branch-range"; +export type ReviewSectionKind = "turn" | ReviewDiffPreviewSource["kind"]; const DIRTY_WORKTREE_SECTION_ID = "git:working-tree"; const DIRTY_WORKTREE_TITLE = "Dirty worktree"; diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 043497f2381..d649ef144ae 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -18,6 +18,7 @@ import { type GitPreparePullRequestThreadResult, type GitPullRequestRefInput, type VcsPullResult, + type VcsPathsInput, type VcsRemoveWorktreeInput, type GitResolvePullRequestResult, type GitRunStackedActionInput, @@ -49,6 +50,9 @@ export class GitWorkflowService extends Context.Service< readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly stagePaths: (input: VcsPathsInput) => Effect.Effect; + readonly unstagePaths: (input: VcsPathsInput) => Effect.Effect; + readonly discardPaths: (input: VcsPathsInput) => Effect.Effect; readonly runStackedAction: ( input: GitRunStackedActionInput, options?: GitManager.GitRunStackedActionOptions, @@ -277,6 +281,18 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe( Effect.andThen(git.pullCurrentBranch(cwd)), ), + stagePaths: (input) => + ensureGitCommand("GitWorkflowService.stagePaths", input.cwd).pipe( + Effect.andThen(git.stagePaths(input)), + ), + unstagePaths: (input) => + ensureGitCommand("GitWorkflowService.unstagePaths", input.cwd).pipe( + Effect.andThen(git.unstagePaths(input)), + ), + discardPaths: (input) => + ensureGitCommand("GitWorkflowService.discardPaths", input.cwd).pipe( + Effect.andThen(git.discardPaths(input)), + ), runStackedAction: (input, options) => ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( Effect.andThen(gitManager.runStackedAction(input, options)), diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 269a02d8f5c..2ba9f82c5b6 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -25,6 +25,7 @@ import { type VcsListRefsInput, type VcsListRefsResult, type VcsPullResult, + type VcsPathsInput, type VcsRemoveWorktreeInput, type VcsStatusInput, type VcsStatusResult, @@ -245,6 +246,9 @@ export class GitVcsDriver extends Context.Service< input: VcsListRefsInput, ) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly stagePaths: (input: VcsPathsInput) => Effect.Effect; + readonly unstagePaths: (input: VcsPathsInput) => Effect.Effect; + readonly discardPaths: (input: VcsPathsInput) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 9c46633cdbf..358c14a8473 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -133,7 +133,10 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () yield* driver.listRefs({ cwd }); assert.deepStrictEqual(commands, [ - { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, + { + args: ["status", "--porcelain=2", "--branch", "--untracked-files=all"], + lcAll: "C", + }, { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, { args: ["branch", "--no-color", "--no-column"], lcAll: "C" }, ]); @@ -386,6 +389,30 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("separates staged and unstaged review patches", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "README.md", "# staged\n"); + yield* driver.stagePaths({ cwd, paths: ["README.md"] }); + yield* writeTextFile(cwd, "README.md", "# unstaged\n"); + + const preview = yield* driver.getReviewDiffPreview({ + cwd, + ignoreWhitespace: false, + includeIndexSections: true, + }); + const staged = preview.sources.find((source) => source.kind === "staged")?.diff ?? ""; + const unstaged = preview.sources.find((source) => source.kind === "unstaged")?.diff ?? ""; + + assert.include(staged, "+# staged"); + assert.notInclude(staged, "+# unstaged"); + assert.include(unstaged, "-# staged"); + assert.include(unstaged, "+# unstaged"); + }), + ); + it.effect("honors whitespace filtering for worktree and branch previews", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -586,6 +613,71 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { status.workingTree.files.map((file) => file.path), "feature.ts", ); + assert.deepInclude( + status.workingTree.files.find((file) => file.path === "feature.ts") ?? {}, + { indexStatus: ".", worktreeStatus: "?" }, + ); + }), + ); + + it.effect("reports a staged rename once under its destination path", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + yield* writeTextFile(cwd, "old/name.ts", "export const value = 1;\n"); + yield* git(cwd, ["add", "old/name.ts"]); + yield* git(cwd, ["commit", "-m", "add nested source"]); + yield* fileSystem.makeDirectory(pathService.join(cwd, "new")); + yield* git(cwd, ["mv", "old/name.ts", "new/name.ts"]); + + const status = yield* driver.statusDetailsLocal(cwd); + + assert.deepStrictEqual( + status.workingTree.files.map((file) => file.path), + ["new/name.ts"], + ); + assert.deepInclude(status.workingTree.files[0] ?? {}, { + indexStatus: "R", + worktreeStatus: ".", + }); + }), + ); + + it.effect("stages, unstages, and discards explicit worktree paths", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + yield* writeTextFile(cwd, "README.md", "# changed\n"); + + yield* driver.stagePaths({ cwd, paths: ["README.md"] }); + const staged = yield* driver.statusDetailsLocal(cwd); + assert.deepInclude( + staged.workingTree.files.find((file) => file.path === "README.md") ?? {}, + { indexStatus: "M", worktreeStatus: "." }, + ); + + yield* driver.unstagePaths({ cwd, paths: ["README.md"] }); + const unstaged = yield* driver.statusDetailsLocal(cwd); + assert.deepInclude( + unstaged.workingTree.files.find((file) => file.path === "README.md") ?? {}, + { indexStatus: ".", worktreeStatus: "M" }, + ); + + yield* driver.discardPaths({ cwd, paths: ["README.md"] }); + assert.equal( + yield* fileSystem.readFileString(pathService.join(cwd, "README.md")), + "# test\n", + ); + + yield* writeTextFile(cwd, "scratch.txt", "temporary\n"); + yield* driver.discardPaths({ cwd, paths: ["scratch.txt"] }); + assert.equal(yield* fileSystem.exists(pathService.join(cwd, "scratch.txt")), false); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index eada5f81c85..99d99eee289 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -136,8 +136,17 @@ function parseNumstatEntries( const added = Number.parseInt(addedRaw ?? "0", 10); const deleted = Number.parseInt(deletedRaw ?? "0", 10); const renameArrowIndex = rawPath.indexOf(" => "); + const renameBraceStart = rawPath.lastIndexOf("{", renameArrowIndex); + const renameBraceEnd = rawPath.indexOf("}", renameArrowIndex); const normalizedPath = - renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; + renameArrowIndex < 0 + ? rawPath + : renameBraceStart >= 0 && renameBraceEnd > renameArrowIndex + ? `${rawPath.slice(0, renameBraceStart)}${rawPath.slice( + renameArrowIndex + " => ".length, + renameBraceEnd, + )}${rawPath.slice(renameBraceEnd + 1)}`.trim() + : rawPath.slice(renameArrowIndex + " => ".length).trim(); entries.push({ path: normalizedPath.length > 0 ? normalizedPath : rawPath, insertions: Number.isFinite(added) ? added : 0, @@ -147,26 +156,44 @@ function parseNumstatEntries( return entries; } -function parsePorcelainPath(line: string): string | null { - if (line.startsWith("? ") || line.startsWith("! ")) { - const simple = line.slice(2).trim(); - return simple.length > 0 ? simple : null; - } +interface PorcelainChange { + readonly path: string; + readonly indexStatus: string; + readonly worktreeStatus: string; +} - if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { - return null; +function parsePorcelainChange(line: string): PorcelainChange | null { + if (line.startsWith("? ")) { + const filePath = line.slice(2).trim(); + return filePath.length > 0 ? { path: filePath, indexStatus: ".", worktreeStatus: "?" } : null; } + if (line.startsWith("! ")) return null; + + const parts = line.split(" "); + const recordType = parts[0]; + const xy = parts[1]; + if (!xy || xy.length !== 2) return null; + + const pathStartIndex = recordType === "1" ? 8 : recordType === "2" ? 9 : 10; + if (recordType !== "1" && recordType !== "2" && recordType !== "u") return null; + const rawPath = parts.slice(pathStartIndex).join(" ").trim(); + if (rawPath.length === 0) return null; - const tabIndex = line.indexOf("\t"); - if (tabIndex >= 0) { - const fromTab = line.slice(tabIndex + 1); - const [filePath] = fromTab.split("\t"); - return filePath?.trim().length ? filePath.trim() : null; + if (recordType === "2") { + const [filePath] = rawPath.split("\t", 1); + if (!filePath?.trim()) return null; + return { + path: filePath.trim(), + indexStatus: xy[0]!, + worktreeStatus: xy[1]!, + }; } - const parts = line.trim().split(/\s+/g); - const filePath = parts.at(-1) ?? ""; - return filePath.length > 0 ? filePath : null; + return { + path: rawPath, + indexStatus: xy[0]!, + worktreeStatus: xy[1]!, + }; } function parseBranchLine(line: string): { name: string; current: boolean } | null { @@ -1364,7 +1391,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const statusResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.status", cwd, - ["status", "--porcelain=2", "--branch"], + ["status", "--porcelain=2", "--branch", "--untracked-files=all"], { allowNonZeroExit: true, }, @@ -1387,7 +1414,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.statusDetails.status", cwd, - args: ["status", "--porcelain=2", "--branch"], + args: ["status", "--porcelain=2", "--branch", "--untracked-files=all"], }), detail: "Git status failed.", exitCode: statusResult.exitCode, @@ -1429,7 +1456,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* let behindCount = 0; let aheadOfDefaultCount = 0; let hasWorkingTreeChanges = false; - const changedFilesWithoutNumstat = new Set(); + const porcelainChanges = new Map(); for (const line of statusStdout.split(/\r?\n/g)) { if (line.startsWith("# branch.head ")) { @@ -1451,8 +1478,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } if (line.trim().length > 0 && !line.startsWith("#")) { hasWorkingTreeChanges = true; - const pathValue = parsePorcelainPath(line); - if (pathValue) changedFilesWithoutNumstat.add(pathValue); + const change = parsePorcelainChange(line); + if (change) porcelainChanges.set(change.path, change); } } @@ -1493,13 +1520,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* .map(([filePath, stat]) => { insertions += stat.insertions; deletions += stat.deletions; - return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; + const change = porcelainChanges.get(filePath); + return { + path: filePath, + ...(change + ? { indexStatus: change.indexStatus, worktreeStatus: change.worktreeStatus } + : {}), + insertions: stat.insertions, + deletions: stat.deletions, + }; }) .toSorted((a, b) => a.path.localeCompare(b.path)); - for (const filePath of changedFilesWithoutNumstat) { + for (const [filePath, change] of porcelainChanges) { if (fileStatMap.has(filePath)) continue; - files.push({ path: filePath, insertions: 0, deletions: 0 }); + files.push({ + path: filePath, + indexStatus: change.indexStatus, + worktreeStatus: change.worktreeStatus, + insertions: 0, + deletions: 0, + }); } files.sort((a, b) => a.path.localeCompare(b.path)); @@ -1821,6 +1862,85 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); + const stagePaths: GitVcsDriver.GitVcsDriver["Service"]["stagePaths"] = Effect.fn("stagePaths")( + function* (input) { + yield* runGit("GitVcsDriver.stagePaths", input.cwd, ["add", "-A", "--", ...input.paths]); + }, + ); + + const unstagePaths: GitVcsDriver.GitVcsDriver["Service"]["unstagePaths"] = Effect.fn( + "unstagePaths", + )(function* (input) { + const head = yield* executeGit( + "GitVcsDriver.unstagePaths.resolveHead", + input.cwd, + ["rev-parse", "--verify", "--quiet", "HEAD"], + { allowNonZeroExit: true }, + ); + if (head.exitCode === 0) { + yield* runGit("GitVcsDriver.unstagePaths", input.cwd, [ + "restore", + "--staged", + "--", + ...input.paths, + ]); + return; + } + yield* runGit("GitVcsDriver.unstagePaths.unborn", input.cwd, [ + "rm", + "--cached", + "--force", + "--ignore-unmatch", + "--", + ...input.paths, + ]); + }); + + const discardPaths: GitVcsDriver.GitVcsDriver["Service"]["discardPaths"] = Effect.fn( + "discardPaths", + )(function* (input) { + const details = yield* readStatusDetailsLocal(input.cwd); + const changedFiles = new Map(details.workingTree.files.map((file) => [file.path, file])); + const trackedPaths: string[] = []; + const untrackedPaths: string[] = []; + + for (const requestedPath of new Set(input.paths)) { + const change = changedFiles.get(requestedPath); + if (!change || change.worktreeStatus === undefined || change.worktreeStatus === ".") { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.discardPaths", + cwd: input.cwd, + args: ["restore", "--worktree", "--", requestedPath], + }), + detail: `Cannot discard ${requestedPath} because it has no current worktree change.`, + }); + } + if (change.worktreeStatus === "?") { + untrackedPaths.push(requestedPath); + } else { + trackedPaths.push(requestedPath); + } + } + + if (trackedPaths.length > 0) { + yield* runGit("GitVcsDriver.discardPaths.restore", input.cwd, [ + "restore", + "--worktree", + "--", + ...trackedPaths, + ]); + } + if (untrackedPaths.length > 0) { + yield* runGit("GitVcsDriver.discardPaths.clean", input.cwd, [ + "clean", + "-f", + "--", + ...untrackedPaths, + ]); + } + }); + const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn( "readRangeContext", )(function* (cwd, baseRef) { @@ -1868,6 +1988,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const readWorkingTreeReviewDiff = Effect.fn("readWorkingTreeReviewDiff")(function* ( cwd: string, ignoreWhitespace: boolean | undefined, + comparison: "head" | "index" = "head", ) { const tempDirectory = yield* fileSystem .makeTempDirectory({ prefix: "t3code-review-index-" }) @@ -1887,21 +2008,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const env = { GIT_INDEX_FILE: indexPath } satisfies NodeJS.ProcessEnv; return yield* Effect.gen(function* () { - const headResult = yield* executeGit( - "GitVcsDriver.readWorkingTreeReviewDiff.resolveHead", - cwd, - ["rev-parse", "--verify", "--quiet", "HEAD"], - { allowNonZeroExit: true }, - ); - const hasHead = headResult.exitCode === 0; - const baseline = hasHead - ? "HEAD" - : (yield* executeGit( - "GitVcsDriver.readWorkingTreeReviewDiff.resolveEmptyTree", - cwd, - ["hash-object", "-t", "tree", "--stdin"], - { stdin: "" }, - )).stdout.trim(); + const baseline = + comparison === "head" + ? yield* executeGit( + "GitVcsDriver.readWorkingTreeReviewDiff.resolveHead", + cwd, + ["rev-parse", "--verify", "--quiet", "HEAD"], + { allowNonZeroExit: true }, + ).pipe( + Effect.flatMap((headResult) => + headResult.exitCode === 0 + ? Effect.succeed("HEAD") + : executeGit( + "GitVcsDriver.readWorkingTreeReviewDiff.resolveEmptyTree", + cwd, + ["hash-object", "-t", "tree", "--stdin"], + { stdin: "" }, + ).pipe(Effect.map((result) => result.stdout.trim())), + ), + ) + : null; const gitIndexResult = yield* executeGit( "GitVcsDriver.readWorkingTreeReviewDiff.resolveIndex", cwd, @@ -1948,7 +2074,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--minimal", "--find-renames", ...(ignoreWhitespace ? ["--ignore-all-space"] : []), - baseline, + ...(baseline ? [baseline] : []), "--", ], { @@ -1985,45 +2111,57 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ) : null); - const dirtyResult = yield* readWorkingTreeReviewDiff(input.cwd, input.ignoreWhitespace).pipe( - Effect.orElseSucceed(() => ({ - exitCode: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - })), + const emptyDiffResult = () => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + const recoverDiff = , E>( + effect: Effect.Effect, + ) => effect.pipe(Effect.orElseSucceed(emptyDiffResult)); + const diffArgs = [ + "--patch", + "--minimal", + "--find-renames", + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + ]; + const [dirtyResult, baseResult, stagedResult, unstagedResult] = yield* Effect.all( + [ + recoverDiff(readWorkingTreeReviewDiff(input.cwd, input.ignoreWhitespace)), + baseRef && branch + ? recoverDiff( + executeGit( + "GitVcsDriver.getReviewDiffPreview.base", + input.cwd, + ["diff", ...diffArgs, `${baseRef}...HEAD`], + { + maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ), + ) + : Effect.succeed(null), + input.includeIndexSections + ? recoverDiff( + executeGit( + "GitVcsDriver.getReviewDiffPreview.staged", + input.cwd, + ["diff", "--cached", ...diffArgs, "--"], + { + maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ), + ) + : Effect.succeed(null), + input.includeIndexSections + ? recoverDiff(readWorkingTreeReviewDiff(input.cwd, input.ignoreWhitespace, "index")) + : Effect.succeed(null), + ], + { concurrency: "unbounded" }, ); - const dirtyDiff = dirtyResult.stdout; - - const baseResult = - baseRef && branch - ? yield* executeGit( - "GitVcsDriver.getReviewDiffPreview.base", - input.cwd, - [ - "diff", - "--patch", - "--minimal", - "--find-renames", - ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), - `${baseRef}...HEAD`, - ], - { - maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, - appendTruncationMarker: true, - }, - ).pipe( - Effect.orElseSucceed(() => ({ - exitCode: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - })), - ) - : null; - const baseDiff = baseResult?.stdout ?? ""; const hashDiff = (diff: string) => crypto.digest("SHA-256", new TextEncoder().encode(diff)).pipe( Effect.map(Encoding.encodeHex), @@ -2038,33 +2176,57 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ), ); - const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ - hashDiff(dirtyDiff), - hashDiff(baseDiff), - ]); - - const sources: ReviewDiffPreviewSource[] = [ + const sourceDrafts: Array> = [ { id: "working-tree", kind: "working-tree", title: "Dirty worktree", baseRef: "HEAD", headRef: null, - diff: dirtyDiff, - diffHash: dirtyDiffHash, + diff: dirtyResult.stdout, truncated: dirtyResult.stdoutTruncated, }, + ...(stagedResult + ? [ + { + id: "staged", + kind: "staged" as const, + title: "Staged changes", + baseRef: "HEAD", + headRef: null, + diff: stagedResult.stdout, + truncated: stagedResult.stdoutTruncated, + }, + ] + : []), + ...(unstagedResult + ? [ + { + id: "unstaged", + kind: "unstaged" as const, + title: "Unstaged changes", + baseRef: null, + headRef: null, + diff: unstagedResult.stdout, + truncated: unstagedResult.stdoutTruncated, + }, + ] + : []), { id: "branch-range", kind: "branch-range", title: baseRef ? `Against ${baseRef}` : "Against base branch", baseRef, headRef: branch ?? "HEAD", - diff: baseDiff, - diffHash: baseDiffHash, + diff: baseResult?.stdout ?? "", truncated: baseResult?.stdoutTruncated ?? false, }, ]; + const sources = yield* Effect.forEach( + sourceDrafts, + (source) => hashDiff(source.diff).pipe(Effect.map((diffHash) => ({ ...source, diffHash }))), + { concurrency: "unbounded" }, + ); return { cwd: input.cwd, @@ -2752,6 +2914,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* commit, pushCurrentBranch, pullCurrentBranch, + stagePaths, + unstagePaths, + discardPaths, readRangeContext, getReviewDiffPreview, readConfigValue, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 89d7b3cb99f..8ab94103047 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -331,6 +331,9 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], + [WS_METHODS.vcsStagePaths, AuthOrchestrationOperateScope], + [WS_METHODS.vcsUnstagePaths, AuthOrchestrationOperateScope], + [WS_METHODS.vcsDiscardPaths, AuthOrchestrationOperateScope], [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope], [WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope], [WS_METHODS.gitPreparePullRequestThread, AuthOrchestrationOperateScope], @@ -1896,6 +1899,24 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "git" }, ), + [WS_METHODS.vcsStagePaths]: (input) => + observeRpcEffect( + WS_METHODS.vcsStagePaths, + gitWorkflow.stagePaths(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsUnstagePaths]: (input) => + observeRpcEffect( + WS_METHODS.vcsUnstagePaths, + gitWorkflow.unstagePaths(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsDiscardPaths]: (input) => + observeRpcEffect( + WS_METHODS.vcsDiscardPaths, + gitWorkflow.discardPaths(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), [WS_METHODS.gitRunStackedAction]: (input) => observeRpcStream( WS_METHODS.gitRunStackedAction, diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 1fe5473f89f..1ea9194d034 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -40,6 +40,7 @@ import { useClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; +import { DIFF_VIEW_UNSAFE_CSS } from "./diffs/diffViewStyles"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Switch } from "./ui/switch"; import { @@ -78,104 +79,6 @@ interface CollapsedDiffFilesState { const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); -const DIFF_PANEL_UNSAFE_CSS = ` -[data-diffs-header], -[data-diff], -[data-file], -[data-error-wrapper], -[data-virtualizer-buffer] { - --diffs-header-font-family: var(--font-sans) !important; - --diffs-font-family: var(--font-mono) !important; - --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; - --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; - --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; - --diffs-token-light-bg: transparent; - --diffs-token-dark-bg: transparent; - - --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground)); - --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground)); - --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); - --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); - - --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--success)); - --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success)); - --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); - --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); - - --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--destructive)); - --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, var(--destructive)); - --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); - --diffs-bg-deletion-emphasis-override: color-mix( - in srgb, - var(--background) 80%, - var(--destructive) - ); - - background-color: var(--diffs-bg) !important; -} - -[data-file-info] { - background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; - border-block-color: var(--border) !important; - color: var(--foreground) !important; -} - -[data-diffs-header] { - position: sticky !important; - top: 0; - z-index: 4; - background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; - border-bottom: 1px solid var(--border) !important; - align-items: center !important; - font-family: var(--font-sans) !important; - font-size: 12px !important; - line-height: 1 !important; - min-height: 32px !important; - padding-block: 6px !important; -} - -[data-diffs-header] [data-header-content] { - align-items: center !important; - line-height: 1 !important; -} - -[data-diffs-header] [data-metadata] { - align-items: center !important; - line-height: 1 !important; - font-variant-numeric: tabular-nums; -} - -[data-diffs-header] [data-additions-count], -[data-diffs-header] [data-deletions-count] { - font-family: var(--font-mono) !important; - font-size: 11px !important; - font-variant-numeric: tabular-nums; - line-height: 1 !important; -} - -[data-diffs-header] [data-change-icon], -[data-diffs-header] [data-rename-icon] { - display: block; - flex-shrink: 0; -} - -[data-title] { - cursor: pointer; - transition: - color 120ms ease, - text-decoration-color 120ms ease; - text-decoration: underline; - text-decoration-color: transparent; - text-underline-offset: 2px; - font-family: var(--font-sans) !important; -} - -[data-title]:hover { - color: color-mix(in srgb, var(--foreground) 84%, var(--primary)) !important; - text-decoration-color: currentColor; -} -`; - interface DiffPanelProps { mode?: DiffPanelMode; composerDraftTarget: ScopedThreadRef | DraftId; @@ -889,7 +792,7 @@ export default function DiffPanel({ overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), themeType: resolvedTheme as DiffThemeType, - unsafeCSS: DIFF_PANEL_UNSAFE_CSS, + unsafeCSS: DIFF_VIEW_UNSAFE_CSS, stickyHeaders: true, layout: { paddingTop: 8, paddingBottom: 8, gap: 8 }, }} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0a81bff5e80..f4023ad1aab 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -14,6 +14,7 @@ import { } from "../types"; import { archiveSelectedThreadEntries, + buildSidebarWorktreeSourceControlSearch, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, getFallbackThreadIdAfterDelete, @@ -29,6 +30,7 @@ import { orderSidebarThreadsByWorktree, resolveSidebarWorktreeThreadGroups, resolveSidebarWorktreeNewThreadOptions, + resolveSidebarWorktreePrimaryAction, resolveAdjacentThreadId, resolveProjectStatusIndicator, resolveProjectTitleClassName, @@ -278,6 +280,61 @@ describe("resolveSidebarWorktreeNewThreadOptions", () => { }); }); +describe("resolveSidebarWorktreePrimaryAction", () => { + it("opens source control when the fork feature is enabled", () => { + expect( + resolveSidebarWorktreePrimaryAction({ + enableSidebarWorktreeNavigation: true, + enableWorktreeSourceControl: true, + }), + ).toBe("source_control"); + }); + + it("restores new-chat behavior when source control is disabled", () => { + expect( + resolveSidebarWorktreePrimaryAction({ + enableSidebarWorktreeNavigation: true, + enableWorktreeSourceControl: false, + }), + ).toBe("new_chat"); + }); + + it("keeps the label non-actionable when worktree navigation is disabled", () => { + expect( + resolveSidebarWorktreePrimaryAction({ + enableSidebarWorktreeNavigation: false, + enableWorktreeSourceControl: true, + }), + ).toBeNull(); + }); +}); + +describe("buildSidebarWorktreeSourceControlSearch", () => { + it("carries the selected worktree branch and path into source control", () => { + expect( + buildSidebarWorktreeSourceControlSearch({ + branch: "feature/review", + worktreePath: "/repo/.t3/worktrees/review", + projectCwd: "/repo", + }), + ).toEqual({ + branch: "feature/review", + cwd: "/repo/.t3/worktrees/review", + scope: "unstaged", + }); + }); + + it("falls back to the project checkout without inventing a branch", () => { + expect( + buildSidebarWorktreeSourceControlSearch({ + branch: null, + worktreePath: null, + projectCwd: "/repo", + }), + ).toEqual({ cwd: "/repo", scope: "unstaged" }); + }); +}); + describe("orderSidebarThreadsByWorktree", () => { it("matches the visual grouped-row order for interleaved worktrees", () => { const first = { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4ae82de64ce..ce0fe8e94c2 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -18,6 +18,31 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; // nearby thread usually reuses an already-hot subscription. export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; export type SidebarNewThreadEnvMode = "local" | "worktree"; +export type SidebarWorktreePrimaryAction = "source_control" | "new_chat" | null; + +export function resolveSidebarWorktreePrimaryAction(input: { + readonly enableSidebarWorktreeNavigation: boolean; + readonly enableWorktreeSourceControl: boolean; +}): SidebarWorktreePrimaryAction { + if (!input.enableSidebarWorktreeNavigation) return null; + return input.enableWorktreeSourceControl ? "source_control" : "new_chat"; +} + +export function buildSidebarWorktreeSourceControlSearch(input: { + readonly branch: string | null; + readonly worktreePath: string | null; + readonly projectCwd: string; +}): { + readonly cwd: string; + readonly branch?: string; + readonly scope: "unstaged"; +} { + return { + cwd: input.worktreePath ?? input.projectCwd, + ...(input.branch ? { branch: input.branch } : {}), + scope: "unstaged", + }; +} export interface SidebarWorktreeNewThreadOptions { readonly branch: string | null; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 605a099496b..d85f19332b4 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -92,6 +92,7 @@ import { } from "../keybindings"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { startNewThreadInProjectFromContext } from "../lib/chatThreadActions"; +import { normalizeProjectPathForComparison } from "../lib/projectPaths"; import { isTerminalFocused } from "../lib/terminalFocus"; import { sortThreads } from "../lib/threadSort"; import { cn, isMacPlatform, newDraftId, newThreadId } from "../lib/utils"; @@ -154,6 +155,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; import { openDiscoveredPort } from "./preview/openDiscoveredPort"; import { archiveSelectedThreadEntries, + buildSidebarWorktreeSourceControlSearch, buildMultiSelectThreadContextMenuItems, getSidebarThreadIdsToPrewarm, isContextMenuPointerDown, @@ -164,6 +166,7 @@ import { resolveProjectStatusIndicator, resolveProjectTitleClassName, resolveSidebarStageBadgeLabel, + resolveSidebarWorktreePrimaryAction, resolveSidebarWorktreeNewThreadOptions, resolveSidebarWorktreeThreadGroups, resolveThreadRowClassName, @@ -928,6 +931,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr interface SidebarProjectThreadListProps { enableNativeMacSidebar: boolean; enableSidebarWorktreeNavigation: boolean; + enableWorktreeSourceControl: boolean; projectKey: string; projectExpanded: boolean; hasOverflowingThreads: boolean; @@ -984,6 +988,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( const { enableNativeMacSidebar, enableSidebarWorktreeNavigation, + enableWorktreeSourceControl, projectKey, projectExpanded, hasOverflowingThreads, @@ -1022,6 +1027,25 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( expandThreadListForProject, collapseThreadListForProject, } = props; + const navigate = useNavigate(); + const { isMobile, setOpenMobile } = useSidebar(); + const activeWorktreeRoute = useParams({ + strict: false, + select: (params) => + params.environmentId && params.projectId + ? { environmentId: params.environmentId, projectId: params.projectId } + : null, + }); + const activeWorktreeCwd = useLocation({ + select: (location) => { + const search = location.search as Record; + return typeof search.cwd === "string" ? search.cwd : null; + }, + }); + const worktreePrimaryAction = resolveSidebarWorktreePrimaryAction({ + enableSidebarWorktreeNavigation, + enableWorktreeSourceControl, + }); const showMoreButtonRender = useMemo(() => + + startThreadInWorktreeGroup(group)} + /> + } + > + + + New Chat Here + + + ) : ( +
startThreadInWorktreeGroup(group)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + startThreadInWorktreeGroup(group); + }} + onContextMenu={(event) => { + event.preventDefault(); + handleWorktreeGroupMenu(group, { + x: event.clientX, + y: event.clientY, + }); + }} + > + {labelContent} +
+ ) ) : (
{labelContent} @@ -1861,6 +1965,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const enableSidebarWorktreeNavigation = usePrimarySettings( (settings) => settings.enableSidebarWorktreeNavigation, ); + const enableWorktreeSourceControl = usePrimarySettings( + (settings) => settings.enableWorktreeSourceControl, + ); const enableNativeMacSidebar = usePrimarySettings((settings) => settings.enableNativeMacSidebar); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); @@ -3097,6 +3204,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec void; + readonly composerDraftTarget: DraftId; + readonly onContinueInChat: (branch: string | null) => void; +} + +const EMPTY_REVIEW_COMMENTS: ReadonlyArray = []; + +function isStaged(file: WorktreeFile): boolean { + return file.indexStatus !== undefined && file.indexStatus !== "."; +} + +function isUnstaged(file: WorktreeFile): boolean { + return file.worktreeStatus === undefined || file.worktreeStatus !== "."; +} + +function fileStatus(file: WorktreeFile, scope: WorktreeChangeScope): string { + return scope === "staged" + ? (file.indexStatus ?? "M") + : file.worktreeStatus === "?" + ? "U" + : (file.worktreeStatus ?? "M"); +} + +function statusClassName(status: string): string { + if (status === "A" || status === "U" || status === "?") return "text-emerald-500"; + if (status === "D") return "text-rose-500"; + if (status === "R" || status === "C") return "text-sky-500"; + return "text-amber-500"; +} + +function splitFilePath(filePath: string): { readonly name: string; readonly parent: string } { + const normalized = filePath.replaceAll("\\", "/"); + const separator = normalized.lastIndexOf("/"); + return separator < 0 + ? { name: normalized, parent: "" } + : { name: normalized.slice(separator + 1), parent: normalized.slice(0, separator) }; +} + +interface ChangeSectionProps { + readonly title: string; + readonly scope: WorktreeChangeScope; + readonly files: readonly WorktreeFile[]; + readonly selectedScope: WorktreeChangeScope; + readonly selectedPath: string | null; + readonly pending: { readonly kind: MutationKind; readonly path: string | null } | null; + readonly onSelect: (scope: WorktreeChangeScope, path: string | null) => void; + readonly onStage: (paths: readonly string[]) => void; + readonly onUnstage: (paths: readonly string[]) => void; + readonly onDiscard: (path: string) => void; +} + +const ChangeSection = memo(function ChangeSection({ + title, + scope, + files, + selectedScope, + selectedPath, + pending, + onSelect, + onStage, + onUnstage, + onDiscard, +}: ChangeSectionProps) { + const paths = useMemo(() => files.map((file) => file.path), [files]); + if (files.length === 0) return null; + + const sectionSelected = selectedScope === scope && selectedPath === null; + return ( +
+
+ + + (scope === "staged" ? onUnstage(paths) : onStage(paths))} + /> + } + > + {scope === "staged" ? ( + + ) : ( + + )} + + + {scope === "staged" ? "Unstage All" : "Stage All"} + + +
+
    + {files.map((file) => { + const pathParts = splitFilePath(file.path); + const status = fileStatus(file, scope); + const selected = selectedScope === scope && selectedPath === file.path; + const isPending = pending?.path === file.path; + return ( +
  • + +
    + {scope === "unstaged" ? ( + + onDiscard(file.path)} + /> + } + > + + + Discard Changes… + + ) : null} + + + scope === "staged" ? onUnstage([file.path]) : onStage([file.path]) + } + /> + } + > + {scope === "staged" ? ( + + ) : ( + + )} + + + {scope === "staged" ? "Unstage Changes" : "Stage Changes"} + + +
    +
  • + ); + })} +
+
+ ); +}); + +function formatMutationError(error: unknown): string { + return error instanceof Error ? error.message : "The source control action failed."; +} + +export function WorktreeSourceControl({ + environmentId, + cwd, + projectName, + worktreeName, + selectedScope, + selectedPath, + onSelectionChange, + composerDraftTarget, + onContinueInChat, +}: WorktreeSourceControlProps) { + const { resolvedTheme } = useTheme(); + const settings = useClientSettings(); + const [diffRenderMode, setDiffRenderMode] = useState<"stacked" | "split">("stacked"); + const [wordWrap, setWordWrap] = useState(settings.wordWrap); + const [ignoreWhitespace, setIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); + const [pending, setPending] = useState<{ + readonly kind: MutationKind; + readonly path: string | null; + } | null>(null); + + const status = useEnvironmentQuery(vcsEnvironment.status({ environmentId, input: { cwd } })); + const preview = useEnvironmentQuery( + reviewEnvironment.diffPreview({ + environmentId, + cacheKey: JSON.stringify({ + generation: status.data?.localGeneration ?? null, + ignoreWhitespace, + }), + input: { cwd, ignoreWhitespace, includeIndexSections: true }, + }), + ); + const stagePaths = useAtomCommand(vcsEnvironment.stagePaths, { reportFailure: false }); + const unstagePaths = useAtomCommand(vcsEnvironment.unstagePaths, { reportFailure: false }); + const discardPaths = useAtomCommand(vcsEnvironment.discardPaths, { reportFailure: false }); + const refreshStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); + const reviewComments = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.reviewComments ?? EMPTY_REVIEW_COMMENTS, + ); + const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); + + const files = status.data?.workingTree.files ?? []; + const stagedFiles = useMemo(() => files.filter(isStaged), [files]); + const unstagedFiles = useMemo(() => files.filter(isUnstaged), [files]); + const selectedSource = preview.data?.sources.find((source) => source.kind === selectedScope); + const renderablePatch = useMemo( + () => + getRenderablePatch(selectedSource?.diff, `worktree-source-control:${selectedScope}`, { + compactPartialHunkOffsets: true, + }), + [selectedScope, selectedSource?.diff], + ); + const visibleDiffs = useMemo(() => { + if (!renderablePatch || renderablePatch.kind !== "files") return []; + const sorted = renderablePatch.files.toSorted((left, right) => + resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, { + numeric: true, + sensitivity: "base", + }), + ); + return selectedPath + ? sorted.filter((fileDiff) => resolveFileDiffPath(fileDiff) === selectedPath) + : sorted; + }, [renderablePatch, selectedPath]); + const codeViewFiles = useMemo( + () => + visibleDiffs.map((fileDiff) => { + const canRender = canRenderFileDiff(fileDiff); + return { + canRender, + fileDiff, + filePath: resolveFileDiffPath(fileDiff), + fileKey: buildFileDiffRenderKey(fileDiff), + collapsed: !canRender, + }; + }), + [visibleDiffs], + ); + const reviewSectionId = `worktree:${cwd}:${selectedScope}`; + const reviewSectionTitle = + selectedScope === "staged" ? "Staged worktree changes" : "Unstaged worktree changes"; + + const runMutation = useCallback( + async (kind: Exclude, paths: readonly string[]) => { + if (paths.length === 0 || pending !== null) return false; + const path = paths.length === 1 ? paths[0]! : null; + setPending({ kind, path }); + const command = + kind === "stage" ? stagePaths : kind === "unstage" ? unstagePaths : discardPaths; + const result = await command({ environmentId, input: { cwd, paths: [...paths] } }); + setPending(null); + if (result._tag === "Success") return true; + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: + kind === "stage" + ? "Couldn’t stage changes" + : kind === "unstage" + ? "Couldn’t unstage changes" + : "Couldn’t discard changes", + description: formatMutationError(squashAtomCommandFailure(result)), + }), + ); + } + return false; + }, + [cwd, discardPaths, environmentId, pending, stagePaths, unstagePaths], + ); + + const handleStage = useCallback( + (paths: readonly string[]) => { + void runMutation("stage", paths).then((succeeded) => { + if (succeeded) onSelectionChange("staged", paths.length === 1 ? paths[0]! : null); + }); + }, + [onSelectionChange, runMutation], + ); + const handleUnstage = useCallback( + (paths: readonly string[]) => { + void runMutation("unstage", paths).then((succeeded) => { + if (succeeded) onSelectionChange("unstaged", paths.length === 1 ? paths[0]! : null); + }); + }, + [onSelectionChange, runMutation], + ); + const handleDiscard = useCallback( + (filePath: string) => { + void (async () => { + const file = files.find((candidate) => candidate.path === filePath); + const isUntracked = file?.worktreeStatus === "?"; + const confirmed = await ensureLocalApi().dialogs.confirm( + [ + isUntracked + ? `Delete untracked file “${filePath}”?` + : `Discard changes in “${filePath}”?`, + isUntracked + ? "This file is not tracked by Git and cannot be restored." + : "This will restore the worktree copy to its staged or committed version.", + ].join("\n\n"), + ); + if (!confirmed) return; + const succeeded = await runMutation("discard", [filePath]); + if (succeeded) onSelectionChange("unstaged", null); + })(); + }, + [files, onSelectionChange, runMutation], + ); + const handleRefresh = useCallback(() => { + if (pending !== null) return; + setPending({ kind: "refresh", path: null }); + void refreshStatus({ environmentId, input: { cwd } }).then((result) => { + setPending(null); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn’t refresh source control", + description: formatMutationError(squashAtomCommandFailure(result)), + }), + ); + } + }); + }, [cwd, environmentId, pending, refreshStatus]); + + const activeFiles = selectedScope === "staged" ? stagedFiles : unstagedFiles; + const selectionLabel = + selectedPath ?? `${selectedScope === "staged" ? "Staged" : "Changes"} · ${activeFiles.length}`; + const isLoading = status.isPending || preview.isPending; + const noChanges = status.data !== null && files.length === 0; + + return ( +
+
+
+
+
+
+
+ {worktreeName} + {status.data?.refName ? ( + + {status.data.refName} + + ) : null} +
+

{projectName}

+
+
+
+ + {stagedFiles.length} staged · {unstagedFiles.length} changed + + + + } + > + + + Refresh + +
+
+ +
+ + +
+
+ + {selectionLabel} + + + + { + const next = value[0]; + if (next === "stacked" || next === "split") setDiffRenderMode(next); + }} + > + + + + + + + + + setWordWrap(Boolean(pressed))} + /> + } + > + + + {wordWrap ? "Disable Wrap" : "Enable Wrap"} + + + setIgnoreWhitespace(Boolean(pressed))} + /> + } + > + + + + {ignoreWhitespace ? "Show Whitespace" : "Hide Whitespace"} + + +
+ +
+ {preview.error ? ( +
+ {preview.error} +
+ ) : isLoading && codeViewFiles.length === 0 ? ( +
+ Loading changes… +
+ ) : codeViewFiles.length > 0 ? ( + null} + options={{ + diffStyle: diffRenderMode === "split" ? "split" : "unified", + lineDiffType: "none", + overflow: wordWrap ? "wrap" : "scroll", + theme: resolveDiffThemeName(resolvedTheme), + themeType: resolvedTheme, + unsafeCSS: DIFF_VIEW_UNSAFE_CSS, + stickyHeaders: true, + layout: { paddingTop: 8, paddingBottom: 8, gap: 8 }, + }} + /> + ) : ( +
+
+

+ {activeFiles.length === 0 + ? "No changes in this section" + : "Select a changed file"} +

+

+ {activeFiles.length === 0 + ? "Choose the other section to continue reviewing." + : "Choose a file or the section heading to inspect its diff."} +

+
+ )} +
+ {reviewComments.length > 0 ? ( +
+ removeReviewComment(composerDraftTarget, commentId)} + /> + +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/components/diffs/diffViewStyles.ts b/apps/web/src/components/diffs/diffViewStyles.ts new file mode 100644 index 00000000000..2a060f7ec98 --- /dev/null +++ b/apps/web/src/components/diffs/diffViewStyles.ts @@ -0,0 +1,97 @@ +export const DIFF_VIEW_UNSAFE_CSS = ` +[data-diffs-header], +[data-diff], +[data-file], +[data-error-wrapper], +[data-virtualizer-buffer] { + --diffs-header-font-family: var(--font-sans) !important; + --diffs-font-family: var(--font-mono) !important; + --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; + --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; + --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; + --diffs-token-light-bg: transparent; + --diffs-token-dark-bg: transparent; + + --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground)); + --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground)); + --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); + --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); + + --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--success)); + --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success)); + --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); + --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); + + --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--destructive)); + --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, var(--destructive)); + --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); + --diffs-bg-deletion-emphasis-override: color-mix( + in srgb, + var(--background) 80%, + var(--destructive) + ); + + background-color: var(--diffs-bg) !important; +} + +[data-file-info] { + background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; + border-block-color: var(--border) !important; + color: var(--foreground) !important; +} + +[data-diffs-header] { + position: sticky !important; + top: 0; + z-index: 4; + background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; + border-bottom: 1px solid var(--border) !important; + align-items: center !important; + font-family: var(--font-sans) !important; + font-size: 12px !important; + line-height: 1 !important; + min-height: 32px !important; + padding-block: 6px !important; +} + +[data-diffs-header] [data-header-content] { + align-items: center !important; + line-height: 1 !important; +} + +[data-diffs-header] [data-metadata] { + align-items: center !important; + line-height: 1 !important; + font-variant-numeric: tabular-nums; +} + +[data-diffs-header] [data-additions-count], +[data-diffs-header] [data-deletions-count] { + font-family: var(--font-mono) !important; + font-size: 11px !important; + font-variant-numeric: tabular-nums; + line-height: 1 !important; +} + +[data-diffs-header] [data-change-icon], +[data-diffs-header] [data-rename-icon] { + display: block; + flex-shrink: 0; +} + +[data-title] { + cursor: pointer; + transition: + color 120ms ease, + text-decoration-color 120ms ease; + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 2px; + font-family: var(--font-sans) !important; +} + +[data-title]:hover { + color: color-mix(in srgb, var(--foreground) 84%, var(--primary)) !important; + text-decoration-color: currentColor; +} +`; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9e11a2e0d9d..31ff27f8105 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -116,6 +116,7 @@ type PersonalFeatureFlagName = Extract< | "enableNativeMacSidebar" | "enableMacosCompletionNotifications" | "enableSidebarWorktreeNavigation" + | "enableWorktreeSourceControl" | "enableCheckoutAwareThreadCreation" | "enableCompletionSounds" | "enableForkPullRequests" @@ -149,7 +150,12 @@ const PERSONAL_FEATURE_SETTINGS = [ { key: "enableSidebarWorktreeNavigation", title: "Sidebar worktree navigation", - description: "Open a new chat directly from a sidebar worktree group.", + description: "Expose checkout-level actions from sidebar worktree groups.", + }, + { + key: "enableWorktreeSourceControl", + title: "Worktree source control", + description: "Open a staged and unstaged change viewer when selecting a worktree.", }, { key: "enableCheckoutAwareThreadCreation", @@ -538,6 +544,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.enableNativeMacSidebar, settings.enableMacosCompletionNotifications, settings.enableSidebarWorktreeNavigation, + settings.enableWorktreeSourceControl, settings.enableCheckoutAwareThreadCreation, settings.enableForkPullRequests, settings.enableProviderSkillDiscovery, @@ -580,6 +587,7 @@ export function useSettingsRestore(onRestored?: () => void) { enableMacosCompletionNotifications: DEFAULT_UNIFIED_SETTINGS.enableMacosCompletionNotifications, enableSidebarWorktreeNavigation: DEFAULT_UNIFIED_SETTINGS.enableSidebarWorktreeNavigation, + enableWorktreeSourceControl: DEFAULT_UNIFIED_SETTINGS.enableWorktreeSourceControl, enableCheckoutAwareThreadCreation: DEFAULT_UNIFIED_SETTINGS.enableCheckoutAwareThreadCreation, enableForkPullRequests: DEFAULT_UNIFIED_SETTINGS.enableForkPullRequests, enableProviderSkillDiscovery: DEFAULT_UNIFIED_SETTINGS.enableProviderSkillDiscovery, diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index a34b51f3288..d05b61659a0 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -13,6 +13,7 @@ import { useCallback, useMemo } from "react"; import { resolveMainCheckoutTarget } from "../components/BranchToolbar.logic"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; import { + type DraftId, type DraftThreadEnvMode, type DraftThreadState, markPromotedDraftThreadByRef, @@ -53,6 +54,8 @@ export function useNewThreadHandler() { envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; replace?: boolean; + navigate?: boolean; + onDraftReady?: (draftId: DraftId) => void; }, ): Promise => { const { @@ -117,6 +120,10 @@ export function useNewThreadHandler() { threadId: reusableStoredDraftThread.threadId, }, ); + options?.onDraftReady?.(reusableStoredDraftThread.draftId); + if (options?.navigate === false) { + return; + } if ( currentRouteTarget?.kind === "draft" && currentRouteTarget.draftId === reusableStoredDraftThread.draftId @@ -160,6 +167,7 @@ export function useNewThreadHandler() { ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), }); + options?.onDraftReady?.(currentRouteTarget.draftId); return Promise.resolve(); } @@ -183,6 +191,10 @@ export function useNewThreadHandler() { runtimeMode: DEFAULT_RUNTIME_MODE, }); applyStickyState(draftId); + options?.onDraftReady?.(draftId); + if (options?.navigate === false) { + return; + } await router.navigate({ to: "/draft/$draftId", diff --git a/apps/web/src/reviewCommentContext.test.ts b/apps/web/src/reviewCommentContext.test.ts index 819c6768ab6..2e0872122b8 100644 --- a/apps/web/src/reviewCommentContext.test.ts +++ b/apps/web/src/reviewCommentContext.test.ts @@ -8,11 +8,34 @@ import { buildReviewCommentRenderablePatch, formatReviewCommentContext, inferReviewCommentFenceLanguage, + mergeReviewComments, parseReviewCommentMessageSegments, restoreDiffReviewCommentRange, } from "./reviewCommentContext"; describe("review comment context parsing", () => { + it("merges isolated review comments without dropping unrelated draft comments", () => { + const existing = buildFileReviewComment({ + id: "existing", + filePath: "src/existing.ts", + startLine: 1, + endLine: 1, + text: "Keep this.", + contents: "existing", + }); + const updated = { ...existing, text: "Update this." }; + const incoming = buildFileReviewComment({ + id: "incoming", + filePath: "src/incoming.ts", + startLine: 1, + endLine: 1, + text: "Review this.", + contents: "incoming", + }); + + expect(mergeReviewComments([existing], [updated, incoming])).toEqual([updated, incoming]); + }); + it("extracts comment metadata, user text, and fenced diff without raw wrapper text", () => { const segments = parseReviewCommentMessageSegments( [ diff --git a/apps/web/src/reviewCommentContext.ts b/apps/web/src/reviewCommentContext.ts index f3613cee3e8..3afaa8e6b27 100644 --- a/apps/web/src/reviewCommentContext.ts +++ b/apps/web/src/reviewCommentContext.ts @@ -27,6 +27,14 @@ export interface ReviewCommentContext { readonly fenceLanguage?: string | undefined; } +export function mergeReviewComments( + existing: ReadonlyArray, + incoming: ReadonlyArray, +): ReviewCommentContext[] { + const incomingIds = new Set(incoming.map((comment) => comment.id)); + return [...existing.filter((comment) => !incomingIds.has(comment.id)), ...incoming]; +} + interface DiffReviewLine { readonly change: "context" | "add" | "delete"; readonly oldLineNumber: number | null; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index de106c2b346..a6ae459843e 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -25,6 +25,7 @@ import { Route as SettingsArchivedRouteImport } from './routes/settings.archived import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' +import { Route as ChatWorktreeEnvironmentIdProjectIdRouteImport } from './routes/_chat.worktree.$environmentId.$projectId' const SettingsRoute = SettingsRouteImport.update({ id: '/settings', @@ -106,6 +107,12 @@ const ChatEnvironmentIdThreadIdRoute = path: '/$environmentId/$threadId', getParentRoute: () => ChatRoute, } as any) +const ChatWorktreeEnvironmentIdProjectIdRoute = + ChatWorktreeEnvironmentIdProjectIdRouteImport.update({ + id: '/worktree/$environmentId/$projectId', + path: '/worktree/$environmentId/$projectId', + getParentRoute: () => ChatRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute @@ -123,6 +130,7 @@ export interface FileRoutesByFullPath { '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/worktree/$environmentId/$projectId': typeof ChatWorktreeEnvironmentIdProjectIdRoute } export interface FileRoutesByTo { '/connect': typeof ConnectRoute @@ -140,6 +148,7 @@ export interface FileRoutesByTo { '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/worktree/$environmentId/$projectId': typeof ChatWorktreeEnvironmentIdProjectIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -159,6 +168,7 @@ export interface FileRoutesById { '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute + '/_chat/worktree/$environmentId/$projectId': typeof ChatWorktreeEnvironmentIdProjectIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -178,6 +188,7 @@ export interface FileRouteTypes { | '/settings/source-control' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/worktree/$environmentId/$projectId' fileRoutesByTo: FileRoutesByTo to: | '/connect' @@ -195,6 +206,7 @@ export interface FileRouteTypes { | '/' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/worktree/$environmentId/$projectId' id: | '__root__' | '/_chat' @@ -213,6 +225,7 @@ export interface FileRouteTypes { | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' + | '/_chat/worktree/$environmentId/$projectId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -337,6 +350,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatEnvironmentIdThreadIdRouteImport parentRoute: typeof ChatRoute } + '/_chat/worktree/$environmentId/$projectId': { + id: '/_chat/worktree/$environmentId/$projectId' + path: '/worktree/$environmentId/$projectId' + fullPath: '/worktree/$environmentId/$projectId' + preLoaderRoute: typeof ChatWorktreeEnvironmentIdProjectIdRouteImport + parentRoute: typeof ChatRoute + } } } @@ -344,12 +364,15 @@ interface ChatRouteChildren { ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute + ChatWorktreeEnvironmentIdProjectIdRoute: typeof ChatWorktreeEnvironmentIdProjectIdRoute } const ChatRouteChildren: ChatRouteChildren = { ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, + ChatWorktreeEnvironmentIdProjectIdRoute: + ChatWorktreeEnvironmentIdProjectIdRoute, } const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) diff --git a/apps/web/src/routes/_chat.worktree.$environmentId.$projectId.tsx b/apps/web/src/routes/_chat.worktree.$environmentId.$projectId.tsx new file mode 100644 index 00000000000..389d164af52 --- /dev/null +++ b/apps/web/src/routes/_chat.worktree.$environmentId.$projectId.tsx @@ -0,0 +1,199 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; + +import type { WorktreeChangeScope } from "~/components/WorktreeSourceControl"; +import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { SidebarInset } from "~/components/ui/sidebar"; +import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; +import { usePrimarySettings } from "~/hooks/useSettings"; +import { newDraftId } from "~/lib/utils"; +import { mergeReviewComments } from "~/reviewCommentContext"; +import { useProject } from "~/state/entities"; + +const WorktreeSourceControl = lazy(() => + import("~/components/WorktreeSourceControl").then((module) => ({ + default: module.WorktreeSourceControl, + })), +); + +export interface WorktreeSourceControlSearch { + readonly cwd: string; + readonly branch?: string; + readonly scope: WorktreeChangeScope; + readonly file?: string; +} + +function validateWorktreeSearch(search: Record): WorktreeSourceControlSearch { + const cwd = typeof search.cwd === "string" ? search.cwd.trim() : ""; + const branch = typeof search.branch === "string" ? search.branch.trim() : ""; + const scope = search.scope === "staged" ? "staged" : "unstaged"; + const file = typeof search.file === "string" ? search.file.trim() : ""; + return { + cwd, + ...(branch ? { branch } : {}), + scope, + ...(file ? { file } : {}), + }; +} + +function worktreeDisplayName(cwd: string): string { + const normalized = cwd.replaceAll("\\", "/").replace(/\/+$/, ""); + return normalized.split("/").at(-1) || cwd; +} + +interface WorktreeSourceControlSessionProps { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly projectName: string; + readonly projectWorkspaceRoot: string; + readonly cwd: string; + readonly initialBranch: string | null; + readonly selectedScope: WorktreeChangeScope; + readonly selectedPath: string | null; + readonly onSelectionChange: (scope: WorktreeChangeScope, file: string | null) => void; +} + +function WorktreeSourceControlSession({ + environmentId, + projectId, + projectName, + projectWorkspaceRoot, + cwd, + initialBranch, + selectedScope, + selectedPath, + onSelectionChange, +}: WorktreeSourceControlSessionProps) { + const navigate = useNavigate(); + const prepareDraft = useNewThreadHandler(); + const [reviewDraftTarget] = useState(newDraftId); + const continuingRef = useRef(false); + + useEffect( + () => () => { + useComposerDraftStore.getState().clearDraftThread(reviewDraftTarget); + }, + [reviewDraftTarget], + ); + + const handleContinueInChat = useCallback( + async (resolvedBranch: string | null) => { + if (continuingRef.current) return; + continuingRef.current = true; + try { + let chatDraftTarget: DraftId | null = null; + await prepareDraft(scopeProjectRef(environmentId, projectId), { + branch: resolvedBranch ?? initialBranch, + worktreePath: cwd, + envMode: cwd === projectWorkspaceRoot ? "local" : "worktree", + startFromOrigin: false, + navigate: false, + onDraftReady: (draftId) => { + chatDraftTarget = draftId; + }, + }); + if (chatDraftTarget === null) return; + const draftStore = useComposerDraftStore.getState(); + const reviewComments = draftStore.getComposerDraft(reviewDraftTarget)?.reviewComments ?? []; + const existingComments = draftStore.getComposerDraft(chatDraftTarget)?.reviewComments ?? []; + draftStore.setReviewComments( + chatDraftTarget, + mergeReviewComments(existingComments, reviewComments), + ); + draftStore.clearDraftThread(reviewDraftTarget); + await navigate({ + to: "/draft/$draftId", + params: { draftId: chatDraftTarget }, + }); + } finally { + continuingRef.current = false; + } + }, + [ + cwd, + environmentId, + initialBranch, + navigate, + prepareDraft, + projectId, + projectWorkspaceRoot, + reviewDraftTarget, + ], + ); + + return ( + void handleContinueInChat(branch)} + /> + ); +} + +function WorktreeSourceControlRouteView() { + const navigate = useNavigate(); + const { environmentId: rawEnvironmentId, projectId: rawProjectId } = Route.useParams(); + const search = Route.useSearch(); + const environmentId = rawEnvironmentId as EnvironmentId; + const projectId = rawProjectId as ProjectId; + const project = useProject(scopeProjectRef(environmentId, projectId)); + const enabled = usePrimarySettings((settings) => settings.enableWorktreeSourceControl); + + useEffect(() => { + if (enabled && search.cwd) return; + void navigate({ to: "/", replace: true }); + }, [enabled, navigate, search.cwd]); + + const handleSelectionChange = useCallback( + (scope: WorktreeChangeScope, file: string | null) => { + void navigate({ + to: "/worktree/$environmentId/$projectId", + params: { environmentId: rawEnvironmentId, projectId: rawProjectId }, + search: { + cwd: search.cwd, + ...(search.branch ? { branch: search.branch } : {}), + scope, + ...(file ? { file } : {}), + }, + replace: true, + }); + }, + [navigate, rawEnvironmentId, rawProjectId, search.branch, search.cwd], + ); + + if (!enabled || !search.cwd || !project) return null; + + const reviewScopeKey = `${environmentId}:${projectId}:${search.cwd}:${search.branch ?? ""}`; + + return ( + + + + + + ); +} + +export const Route = createFileRoute("/_chat/worktree/$environmentId/$projectId")({ + validateSearch: validateWorktreeSearch, + component: WorktreeSourceControlRouteView, +}); diff --git a/docs/personal-fork-changes.md b/docs/personal-fork-changes.md index dc759f569a0..0e8dcaed51e 100644 --- a/docs/personal-fork-changes.md +++ b/docs/personal-fork-changes.md @@ -8,6 +8,7 @@ Turning a flag off preserves upstream behavior. | Native macOS sidebar | `enableNativeMacSidebar` | On | | macOS completion notifications | `enableMacosCompletionNotifications` | On | | Sidebar worktree navigation | `enableSidebarWorktreeNavigation` | On | +| Worktree source control | `enableWorktreeSourceControl` | On | | Checkout-aware thread creation | `enableCheckoutAwareThreadCreation` | On | | Completion and attention sounds | `enableCompletionSounds` | On | | Fork-aware pull requests | `enableForkPullRequests` | On | @@ -45,6 +46,19 @@ Upstream PRs integrated into the fork are listed in - With sidebar worktree navigation enabled, right-clicking a worktree label opens an actions menu for starting a chat in that checkout or renaming its branch. +## Worktree source control + +- With worktree source control enabled, selecting a sidebar worktree opens a checkout-scoped + source control surface instead of creating a chat. The surface separates staged and unstaged + files, renders the selected diff, and exposes file-level stage, unstage, and confirmed discard + actions. Selecting diff lines adds comments to an isolated review draft, so opening the viewer + does not retarget an existing chat. Pending comments remain visible in a review tray and merge + into the project's reusable draft only after Continue in chat, preserving the selected checkout's + branch and worktree path. New chats remain available from the worktree row action and context + menu. +- Disabling the flag restores the previous worktree-label behavior, where selecting the label + immediately creates a chat in that checkout. + ## Projectless standalone chats - Creating a standalone chat opens a local draft immediately, matching project-thread creation; diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index f8f7a8c5217..f2b2a05c036 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -192,6 +192,24 @@ export function createVcsEnvironmentAtoms( scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, }), + stagePaths: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:stage-paths", + tag: WS_METHODS.vcsStagePaths, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + unstagePaths: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:unstage-paths", + tag: WS_METHODS.vcsUnstagePaths, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), + discardPaths: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:discard-paths", + tag: WS_METHODS.vcsDiscardPaths, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + }), createWorktree: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:create-worktree", tag: WS_METHODS.vcsCreateWorktree, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index c94e31adefd..3aea720f846 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -104,6 +104,12 @@ export const VcsStatusInput = Schema.Struct({ }); export type VcsStatusInput = typeof VcsStatusInput.Type; +export const VcsPathsInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + paths: Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), +}); +export type VcsPathsInput = typeof VcsPathsInput.Type; + export const VcsPullInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, }); @@ -221,6 +227,8 @@ const VcsStatusLocalShape = { files: Schema.Array( Schema.Struct({ path: TrimmedNonEmptyStringSchema, + indexStatus: Schema.optional(TrimmedNonEmptyStringSchema), + worktreeStatus: Schema.optional(TrimmedNonEmptyStringSchema), insertions: NonNegativeInt, deletions: NonNegativeInt, }), diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts index a6b879a0c7f..4c7cbc37720 100644 --- a/packages/contracts/src/review.ts +++ b/packages/contracts/src/review.ts @@ -7,10 +7,16 @@ export const ReviewDiffPreviewInput = Schema.Struct({ cwd: TrimmedNonEmptyString, baseRef: Schema.optional(TrimmedNonEmptyString), ignoreWhitespace: Schema.optionalKey(Schema.Boolean), + includeIndexSections: Schema.optionalKey(Schema.Boolean), }); export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type; -export const ReviewDiffPreviewSourceKind = Schema.Literals(["working-tree", "branch-range"]); +export const ReviewDiffPreviewSourceKind = Schema.Literals([ + "working-tree", + "staged", + "unstaged", + "branch-range", +]); export type ReviewDiffPreviewSourceKind = typeof ReviewDiffPreviewSourceKind.Type; export const ReviewDiffPreviewSource = Schema.Struct({ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 2c08105261f..18c54036203 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -38,6 +38,7 @@ import { GitResolvePullRequestResult, GitRunStackedActionInput, VcsStatusInput, + VcsPathsInput, VcsStatusResult, VcsStatusStreamEvent, } from "./git.ts"; @@ -179,6 +180,9 @@ export const WS_METHODS = { // VCS methods vcsPull: "vcs.pull", vcsRefreshStatus: "vcs.refreshStatus", + vcsStagePaths: "vcs.stagePaths", + vcsUnstagePaths: "vcs.unstagePaths", + vcsDiscardPaths: "vcs.discardPaths", vcsListRefs: "vcs.listRefs", vcsCreateWorktree: "vcs.createWorktree", vcsRemoveWorktree: "vcs.removeWorktree", @@ -453,6 +457,21 @@ export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), }); +export const WsVcsStagePathsRpc = Rpc.make(WS_METHODS.vcsStagePaths, { + payload: VcsPathsInput, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + +export const WsVcsUnstagePathsRpc = Rpc.make(WS_METHODS.vcsUnstagePaths, { + payload: VcsPathsInput, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + +export const WsVcsDiscardPathsRpc = Rpc.make(WS_METHODS.vcsDiscardPaths, { + payload: VcsPathsInput, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { payload: GitRunStackedActionInput, success: GitActionProgressEvent, @@ -766,6 +785,9 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeVcsStatusRpc, WsVcsPullRpc, WsVcsRefreshStatusRpc, + WsVcsStagePathsRpc, + WsVcsUnstagePathsRpc, + WsVcsDiscardPathsRpc, WsGitRunStackedActionRpc, WsGitResolvePullRequestRpc, WsGitPreparePullRequestThreadRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index b04697251c9..a084abd4232 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -49,6 +49,7 @@ describe("personal fork feature flags", () => { "enableNativeMacSidebar", "enableMacosCompletionNotifications", "enableSidebarWorktreeNavigation", + "enableWorktreeSourceControl", "enableCheckoutAwareThreadCreation", "enableForkPullRequests", "enableProviderSkillDiscovery", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 15e2a7d20da..02565584a46 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -389,6 +389,9 @@ export const ServerSettings = Schema.Struct({ enableSidebarWorktreeNavigation: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + enableWorktreeSourceControl: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + ), enableCheckoutAwareThreadCreation: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), @@ -545,6 +548,7 @@ export const ServerSettingsPatch = Schema.Struct({ enableNativeMacSidebar: Schema.optionalKey(Schema.Boolean), enableMacosCompletionNotifications: Schema.optionalKey(Schema.Boolean), enableSidebarWorktreeNavigation: Schema.optionalKey(Schema.Boolean), + enableWorktreeSourceControl: Schema.optionalKey(Schema.Boolean), enableCheckoutAwareThreadCreation: Schema.optionalKey(Schema.Boolean), enableForkPullRequests: Schema.optionalKey(Schema.Boolean), enableProviderSkillDiscovery: Schema.optionalKey(Schema.Boolean),