From 218fee390951c28b1929a0c84a64bc2fe86fc451 Mon Sep 17 00:00:00 2001 From: Frank O'Brien Date: Mon, 6 Apr 2026 14:18:52 -0700 Subject: [PATCH] git diff pane + diff qol --- apps/server/src/git/Layers/GitCore.test.ts | 77 +++ apps/server/src/git/Layers/GitCore.ts | 87 ++++ apps/server/src/git/Services/GitCore.ts | 6 + apps/server/src/ws.ts | 4 + apps/web/src/components/ChatView.tsx | 10 +- apps/web/src/components/DiffPanel.tsx | 535 ++++++++++++++++----- apps/web/src/diffRouteSearch.test.ts | 39 ++ apps/web/src/diffRouteSearch.ts | 19 +- apps/web/src/lib/gitReactQuery.test.ts | 15 + apps/web/src/lib/gitReactQuery.ts | 19 + apps/web/src/wsNativeApi.test.ts | 11 + apps/web/src/wsNativeApi.ts | 1 + apps/web/src/wsRpcClient.ts | 3 + packages/contracts/src/git.ts | 10 + packages/contracts/src/ipc.ts | 3 + packages/contracts/src/rpc.ts | 10 + 16 files changed, 722 insertions(+), 127 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 5ff2714b61..5de6c4253a 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -1594,6 +1594,83 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + describe("readWorkingTreeDiff", () => { + it.effect("returns an empty diff for a clean repo", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const diff = yield* (yield* GitCore).readWorkingTreeDiff(tmp); + expect(diff).toEqual({ diff: "" }); + }), + ); + + it.effect("returns a patch for tracked modifications", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + + yield* writeTextFile(path.join(tmp, "README.md"), "# test\ntracked change\n"); + + const diff = yield* (yield* GitCore).readWorkingTreeDiff(tmp); + expect(diff.diff).toContain("diff --git a/README.md b/README.md"); + expect(diff.diff).toContain("tracked change"); + }), + ); + + it.effect("returns a new-file patch for untracked files", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + + yield* writeTextFile(path.join(tmp, "notes.txt"), "hello\n"); + + const diff = yield* (yield* GitCore).readWorkingTreeDiff(tmp); + expect(diff.diff).toContain("diff --git a/notes.txt b/notes.txt"); + expect(diff.diff).toContain("new file mode"); + expect(diff.diff).toContain("+++ b/notes.txt"); + }), + ); + + it.effect("includes combined staged and unstaged changes against HEAD", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + + yield* writeTextFile(path.join(tmp, "README.md"), "# test\nstaged change\n"); + yield* git(tmp, ["add", "README.md"]); + yield* writeTextFile( + path.join(tmp, "README.md"), + "# test\nstaged change\nunstaged change\n", + ); + + const diff = yield* (yield* GitCore).readWorkingTreeDiff(tmp); + expect(diff.diff).toContain("diff --git a/README.md b/README.md"); + expect(diff.diff).toContain("staged change"); + expect(diff.diff).toContain("unstaged change"); + }), + ); + + it.effect("includes staged and untracked changes before the first commit", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const core = yield* GitCore; + yield* core.initRepo({ cwd: tmp }); + yield* git(tmp, ["config", "user.email", "test@test.com"]); + yield* git(tmp, ["config", "user.name", "Test"]); + + yield* writeTextFile(path.join(tmp, "staged.txt"), "staged\n"); + yield* writeTextFile(path.join(tmp, "untracked.txt"), "untracked\n"); + yield* git(tmp, ["add", "staged.txt"]); + + const diff = yield* core.readWorkingTreeDiff(tmp); + expect(diff.diff).toContain("diff --git a/staged.txt b/staged.txt"); + expect(diff.diff).toContain("diff --git a/untracked.txt b/untracked.txt"); + expect(diff.diff).toContain("+++ b/staged.txt"); + expect(diff.diff).toContain("+++ b/untracked.txt"); + }), + ); + }); + it.effect("computes ahead count against base branch when no upstream is configured", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 911a601955..4f785da434 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -40,6 +40,7 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; +const LARGE_DIFF_MAX_OUTPUT_BYTES = 5_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; @@ -187,6 +188,13 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul }; } +function parseNullSeparatedLines(stdout: string): string[] { + return stdout + .split("\u0000") + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + function filterBranchesForListQuery( branches: ReadonlyArray, query?: string, @@ -1337,6 +1345,84 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { })), ); + const readWorkingTreeDiff: GitCoreShape["readWorkingTreeDiff"] = Effect.fn("readWorkingTreeDiff")( + function* (cwd) { + const headResult = yield* executeGit( + "GitCore.readWorkingTreeDiff.verifyHead", + cwd, + ["rev-parse", "--verify", "HEAD"], + { + allowNonZeroExit: true, + }, + ); + const headExists = headResult.code === 0; + + const trackedPatchSegments = headExists + ? [ + yield* runGitStdoutWithOptions( + "GitCore.readWorkingTreeDiff.trackedPatch", + cwd, + ["diff", "HEAD", "--patch", "--minimal"], + { + maxOutputBytes: LARGE_DIFF_MAX_OUTPUT_BYTES, + }, + ), + ] + : yield* Effect.all( + [ + runGitStdoutWithOptions( + "GitCore.readWorkingTreeDiff.cachedRootPatch", + cwd, + ["diff", "--cached", "--patch", "--minimal", "--root"], + { + maxOutputBytes: LARGE_DIFF_MAX_OUTPUT_BYTES, + }, + ), + runGitStdoutWithOptions( + "GitCore.readWorkingTreeDiff.workingTreePatch", + cwd, + ["diff", "--patch", "--minimal"], + { + maxOutputBytes: LARGE_DIFF_MAX_OUTPUT_BYTES, + }, + ), + ], + { concurrency: "unbounded" }, + ); + + const untrackedPaths = parseNullSeparatedLines( + yield* runGitStdout("GitCore.readWorkingTreeDiff.untrackedFiles", cwd, [ + "ls-files", + "--others", + "--exclude-standard", + "-z", + ]), + ).toSorted((left, right) => left.localeCompare(right)); + + const untrackedPatches = yield* Effect.forEach( + untrackedPaths, + (relativePath) => + runGitStdoutWithOptions( + "GitCore.readWorkingTreeDiff.untrackedPatch", + cwd, + ["diff", "--no-index", "--patch", "--minimal", "--", "/dev/null", relativePath], + { + allowNonZeroExit: true, + maxOutputBytes: LARGE_DIFF_MAX_OUTPUT_BYTES, + }, + ).pipe(Effect.map((patch) => patch.trim())), + { concurrency: "unbounded" }, + ); + + const diff = [...trackedPatchSegments, ...untrackedPatches] + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) + .join("\n\n"); + + return { diff }; + }, + ); + const prepareCommitContext: GitCoreShape["prepareCommitContext"] = Effect.fn( "prepareCommitContext", )(function* (cwd, filePaths) { @@ -2127,6 +2213,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return { execute, status, + readWorkingTreeDiff, statusDetails, statusDetailsLocal, prepareCommitContext, diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 015efa8bbd..861baa656e 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -15,6 +15,7 @@ import type { GitCreateBranchResult, GitCreateWorktreeInput, GitCreateWorktreeResult, + GitDiffResult, GitInitInput, GitListBranchesInput, GitListBranchesResult, @@ -153,6 +154,11 @@ export interface GitCoreShape { */ readonly status: (input: GitStatusInput) => Effect.Effect; + /** + * Read a patch for all working tree changes, including untracked files. + */ + readonly readWorkingTreeDiff: (cwd: string) => Effect.Effect; + /** * Read detailed working tree / branch status for a repository. */ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ca096bff33..adc20bd621 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -589,6 +589,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( ), { "rpc.aggregate": "git" }, ), + [WS_METHODS.gitDiff]: (input) => + observeRpcEffect(WS_METHODS.gitDiff, git.readWorkingTreeDiff(input.cwd), { + "rpc.aggregate": "git", + }), [WS_METHODS.gitRunStackedAction]: (input) => observeRpcStream( WS_METHODS.gitRunStackedAction, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5d160f4bda..434721fd2e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3877,8 +3877,14 @@ export default function ChatView({ threadId }: ChatViewProps) { search: (previous) => { const rest = stripDiffSearchParams(previous); return filePath - ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } - : { ...rest, diff: "1", diffTurnId: turnId }; + ? { + ...rest, + diff: "1", + diffScope: "session", + diffTurnId: turnId, + diffFilePath: filePath, + } + : { ...rest, diff: "1", diffScope: "session", diffTurnId: turnId }; }, }); }, diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ff216baed7..97519ad686 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -4,9 +4,12 @@ import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { ThreadId, type TurnId } from "@t3tools/contracts"; import { + ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, Columns2Icon, + GitBranchIcon, + HistoryIcon, Rows3Icon, TextWrapIcon, } from "lucide-react"; @@ -18,13 +21,11 @@ import { useRef, useState, } from "react"; -import { openInPreferredEditor } from "../editorPreferences"; import { useGitStatus } from "~/lib/gitStatusState"; +import { gitDiffQueryOptions } from "~/lib/gitReactQuery"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; -import { readNativeApi } from "../nativeApi"; -import { resolvePathLinkTarget } from "../terminal-links"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { parseDiffRouteSearch, stripDiffSearchParams, type DiffScope } from "../diffRouteSearch"; import { useTheme } from "../hooks/useTheme"; import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; @@ -33,11 +34,19 @@ import { useStore } from "../store"; import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; +import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; +const DIFF_SCOPE_STORAGE_KEY = "t3code:diff-panel-scope"; +const DEFAULT_DIFF_SCOPE: DiffScope = "git"; + +function getWorkingTreeCollapsedStorageKey(cwd: string): string { + return `t3code:diff-panel:git-collapsed:${cwd}`; +} + const DIFF_PANEL_UNSAFE_CSS = ` [data-diffs-header], [data-diff], @@ -153,10 +162,58 @@ function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { return raw; } +function splitFileDiffPath(filePath: string): { directory: string | null; fileName: string } { + const normalizedPath = filePath.replace(/\/+$/, ""); + const lastSlashIndex = normalizedPath.lastIndexOf("/"); + if (lastSlashIndex === -1) { + return { directory: null, fileName: normalizedPath }; + } + return { + directory: normalizedPath.slice(0, lastSlashIndex + 1), + fileName: normalizedPath.slice(lastSlashIndex + 1), + }; +} + function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; } +function getFileDiffLineStats(fileDiff: FileDiffMetadata) { + return fileDiff.hunks.reduce( + (totals, hunk) => ({ + additions: totals.additions + hunk.additionLines, + deletions: totals.deletions + hunk.deletionLines, + }), + { additions: 0, deletions: 0 }, + ); +} + +function getFileDiffChangeLabel(fileDiff: FileDiffMetadata): string { + switch (fileDiff.type) { + case "new": + return "Added"; + case "deleted": + return "Removed"; + case "rename-pure": + return "Renamed"; + case "rename-changed": + return "Renamed and modified"; + default: + return "Modified"; + } +} + +function getFileDiffNameClasses(fileDiff: FileDiffMetadata): string { + switch (fileDiff.type) { + case "new": + return "text-emerald-600 dark:text-emerald-400"; + case "deleted": + return "text-rose-600 dark:text-rose-400"; + default: + return "text-foreground"; + } +} + interface DiffPanelProps { mode?: DiffPanelMode; } @@ -167,6 +224,13 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const navigate = useNavigate(); const { resolvedTheme } = useTheme(); const settings = useSettings(); + const [rememberedDiffScope, setRememberedDiffScope] = useState(() => { + if (typeof window === "undefined") { + return DEFAULT_DIFF_SCOPE; + } + const storedValue = window.localStorage.getItem(DIFF_SCOPE_STORAGE_KEY); + return storedValue === "session" || storedValue === "git" ? storedValue : DEFAULT_DIFF_SCOPE; + }); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const patchViewportRef = useRef(null); @@ -174,12 +238,15 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const previousDiffOpenRef = useRef(false); const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); + const [collapsedFileKeys, setCollapsedFileKeys] = useState>(() => new Set()); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); const diffOpen = diffSearch.diff === "1"; + const diffScope = diffSearch.diffScope ?? rememberedDiffScope; + const isSessionDiffScope = diffScope === "session"; const activeThreadId = routeThreadId; const activeThread = useStore((store) => activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, @@ -266,24 +333,41 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, toTurnCount: activeCheckpointRange?.toTurnCount ?? null, cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, - enabled: isGitRepo, + enabled: isGitRepo && isSessionDiffScope, }), ); + const gitDiffQuery = useQuery({ + ...gitDiffQueryOptions(activeCwd ?? null), + enabled: isGitRepo && !isSessionDiffScope && activeCwd !== null, + }); const selectedTurnCheckpointDiff = selectedTurn ? activeCheckpointDiffQuery.data?.diff : undefined; const conversationCheckpointDiff = selectedTurn ? undefined : activeCheckpointDiffQuery.data?.diff; - const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isLoading; const checkpointDiffError = activeCheckpointDiffQuery.error instanceof Error ? activeCheckpointDiffQuery.error.message : activeCheckpointDiffQuery.error ? "Failed to load checkpoint diff." : null; + const gitDiffError = + gitDiffQuery.error instanceof Error + ? gitDiffQuery.error.message + : gitDiffQuery.error + ? "Failed to load git diff." + : null; - const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; + const selectedPatch = isSessionDiffScope + ? selectedTurn + ? selectedTurnCheckpointDiff + : conversationCheckpointDiff + : gitDiffQuery.data?.diff; + const activeDiffError = isSessionDiffScope ? checkpointDiffError : gitDiffError; + const isLoadingSelectedPatch = isSessionDiffScope + ? activeCheckpointDiffQuery.isLoading + : gitDiffQuery.isLoading; const hasResolvedPatch = typeof selectedPatch === "string"; const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; const renderablePatch = useMemo( @@ -301,6 +385,24 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }), ); }, [renderablePatch]); + const totalDiffLineStats = useMemo( + () => + renderableFiles.reduce( + (totals, fileDiff) => { + const lineStats = getFileDiffLineStats(fileDiff); + return { + additions: totals.additions + lineStats.additions, + deletions: totals.deletions + lineStats.deletions, + }; + }, + { additions: 0, deletions: 0 }, + ), + [renderableFiles], + ); + + const allDiffCardsCollapsed = + renderableFiles.length > 0 && + renderableFiles.every((fileDiff) => collapsedFileKeys.has(buildFileDiffRenderKey(fileDiff))); useEffect(() => { if (diffOpen && !previousDiffOpenRef.current) { @@ -310,25 +412,78 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }, [diffOpen, settings.diffWordWrap]); useEffect(() => { - if (!selectedFilePath || !patchViewportRef.current) { + if (!isSessionDiffScope) { + return; + } + setCollapsedFileKeys(new Set()); + }, [isSessionDiffScope, selectedPatch]); + + useEffect(() => { + if (isSessionDiffScope || typeof window === "undefined") { + return; + } + if (!activeCwd) { + setCollapsedFileKeys(new Set()); + return; + } + + const rawValue = window.localStorage.getItem(getWorkingTreeCollapsedStorageKey(activeCwd)); + if (!rawValue) { + setCollapsedFileKeys(new Set()); + return; + } + + try { + const parsed = JSON.parse(rawValue); + if (!Array.isArray(parsed)) { + setCollapsedFileKeys(new Set()); + return; + } + setCollapsedFileKeys( + new Set(parsed.filter((value): value is string => typeof value === "string")), + ); + } catch { + setCollapsedFileKeys(new Set()); + } + }, [activeCwd, isSessionDiffScope]); + + useEffect(() => { + setRememberedDiffScope(diffScope); + if (typeof window !== "undefined") { + window.localStorage.setItem(DIFF_SCOPE_STORAGE_KEY, diffScope); + } + }, [diffScope]); + + useEffect(() => { + if (isSessionDiffScope || typeof window === "undefined" || !activeCwd) { + return; + } + window.localStorage.setItem( + getWorkingTreeCollapsedStorageKey(activeCwd), + JSON.stringify([...collapsedFileKeys]), + ); + }, [activeCwd, collapsedFileKeys, isSessionDiffScope]); + + useEffect(() => { + if (!isSessionDiffScope || !selectedFilePath || !patchViewportRef.current) { return; } const target = Array.from( patchViewportRef.current.querySelectorAll("[data-diff-file-path]"), ).find((element) => element.dataset.diffFilePath === selectedFilePath); target?.scrollIntoView({ block: "nearest" }); - }, [selectedFilePath, renderableFiles]); - - const openDiffFileInEditor = useCallback( - (filePath: string) => { - const api = readNativeApi(); - if (!api) return; - const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; - void openInPreferredEditor(api, targetPath).catch((error) => { - console.warn("Failed to open diff file in editor.", error); + }, [isSessionDiffScope, selectedFilePath, renderableFiles]); + + const selectDiffScope = useCallback( + (nextScope: DiffScope) => { + if (!activeThread) return; + void navigate({ + to: "/$threadId", + params: { threadId: activeThread.id }, + search: (previous) => ({ ...previous, diff: "1", diffScope: nextScope }), }); }, - [activeCwd], + [activeThread, navigate], ); const selectTurn = (turnId: TurnId) => { @@ -338,7 +493,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { params: { threadId: activeThread.id }, search: (previous) => { const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1", diffTurnId: turnId }; + return { ...rest, diff: "1", diffScope: "session", diffTurnId: turnId }; }, }); }; @@ -349,7 +504,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { params: { threadId: activeThread.id }, search: (previous) => { const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; + return { ...rest, diff: "1", diffScope: "session" }; }, }); }; @@ -380,6 +535,30 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { element.scrollBy({ left: event.deltaY, behavior: "auto" }); }, []); + const toggleFileCollapsed = useCallback((fileKey: string) => { + setCollapsedFileKeys((previous) => { + const next = new Set(previous); + if (next.has(fileKey)) { + next.delete(fileKey); + } else { + next.add(fileKey); + } + return next; + }); + }, []); + + const toggleAllDiffCards = useCallback(() => { + setCollapsedFileKeys((previous) => { + const shouldExpandAll = renderableFiles.every((fileDiff) => + previous.has(buildFileDiffRenderKey(fileDiff)), + ); + if (shouldExpandAll) { + return new Set(); + } + return new Set(renderableFiles.map((fileDiff) => buildFileDiffRenderKey(fileDiff))); + }); + }, [renderableFiles]); + useEffect(() => { const element = turnStripRef.current; if (!element) return; @@ -414,99 +593,137 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); }, [selectedTurn?.turnId, selectedTurnId]); - const headerRow = ( - <> -
- {canScrollTurnStripLeft && ( -
+ const headerLead = isSessionDiffScope ? ( +
+ {canScrollTurnStripLeft && ( +
+ )} + {canScrollTurnStripRight && ( +
+ )} + + +
- -
+ {orderedTurnDiffSummaries.map((summary) => ( - {orderedTurnDiffSummaries.map((summary) => ( - - ))} + ))} +
+
+ ) : ( +
+
+
+ Working Tree +
+
+ + +{totalDiffLineStats.additions} + +
+
+ ); + + const headerRow = ( + <> + {headerLead}
+ { + const next = value[0]; + if (next === "session" || next === "git") { + selectDiffScope(next); + } + }} + > + + + + + + + + { + toggleAllDiffCards(); + }} + > + {allDiffCardsCollapsed ? ( + + ) : ( + + )} + Turn diffs are unavailable because this project is not a git repository.
- ) : orderedTurnDiffSummaries.length === 0 ? ( + ) : isSessionDiffScope && orderedTurnDiffSummaries.length === 0 ? (
No completed turns yet.
@@ -562,20 +796,26 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ref={patchViewportRef} className="diff-panel-viewport min-h-0 min-w-0 flex-1 overflow-hidden" > - {checkpointDiffError && !renderablePatch && ( + {activeDiffError && !renderablePatch && (
-

{checkpointDiffError}

+

{activeDiffError}

)} {!renderablePatch ? ( - isLoadingCheckpointDiff ? ( - + isLoadingSelectedPatch ? ( + ) : (

{hasNoNetChanges - ? "No net changes in this selection." - : "No patch available for this selection."} + ? isSessionDiffScope + ? "No net changes in this selection." + : "Git reports no working tree changes." + : !isSessionDiffScope + ? "Git reports no working tree changes." + : "No patch available for this selection."}

) @@ -589,36 +829,87 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { > {renderableFiles.map((fileDiff) => { const filePath = resolveFileDiffPath(fileDiff); + const { directory, fileName } = splitFileDiffPath(filePath); const fileKey = buildFileDiffRenderKey(fileDiff); const themedFileKey = `${fileKey}:${resolvedTheme}`; + const diffBodyId = `diff-card-${themedFileKey.replace(/[^a-zA-Z0-9_-]/g, "-")}`; + const isCollapsed = collapsedFileKeys.has(fileKey); + const lineStats = getFileDiffLineStats(fileDiff); + const changeLabel = getFileDiffChangeLabel(fileDiff); return ( -
{ - const nativeEvent = event.nativeEvent as MouseEvent; - const composedPath = nativeEvent.composedPath?.() ?? []; - const clickedHeader = composedPath.some((node) => { - if (!(node instanceof Element)) return false; - return node.hasAttribute("data-title"); - }); - if (!clickedHeader) return; - openDiffFileInEditor(filePath); - }} + className="diff-render-file mb-1.5 overflow-hidden rounded-md border border-border/70 bg-card/70 shadow-xs first:mt-2 last:mb-0" > - -
+
+ +
+
+ + +{lineStats.additions} + + + -{lineStats.deletions} + +
+
+
+ {!isCollapsed && ( +
+ +
+ )} + ); })} diff --git a/apps/web/src/diffRouteSearch.test.ts b/apps/web/src/diffRouteSearch.test.ts index ef00874bd2..b850b276c2 100644 --- a/apps/web/src/diffRouteSearch.test.ts +++ b/apps/web/src/diffRouteSearch.test.ts @@ -6,12 +6,14 @@ describe("parseDiffRouteSearch", () => { it("parses valid diff search values", () => { const parsed = parseDiffRouteSearch({ diff: "1", + diffScope: "git", diffTurnId: "turn-1", diffFilePath: "src/app.ts", }); expect(parsed).toEqual({ diff: "1", + diffScope: "git", diffTurnId: "turn-1", diffFilePath: "src/app.ts", }); @@ -71,4 +73,41 @@ describe("parseDiffRouteSearch", () => { diff: "1", }); }); + + it("leaves diff scope unset when absent", () => { + expect( + parseDiffRouteSearch({ + diff: "1", + }), + ).toEqual({ + diff: "1", + }); + }); + + it("drops invalid diff scope values", () => { + expect( + parseDiffRouteSearch({ + diff: "1", + diffScope: "workspace", + }), + ).toEqual({ + diff: "1", + }); + }); + + it("preserves turn and file state in git diff scope", () => { + expect( + parseDiffRouteSearch({ + diff: "1", + diffScope: "git", + diffTurnId: "turn-1", + diffFilePath: "src/app.ts", + }), + ).toEqual({ + diff: "1", + diffScope: "git", + diffTurnId: "turn-1", + diffFilePath: "src/app.ts", + }); + }); }); diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts index d7de23e348..4c31dc21d6 100644 --- a/apps/web/src/diffRouteSearch.ts +++ b/apps/web/src/diffRouteSearch.ts @@ -1,7 +1,10 @@ import { TurnId } from "@t3tools/contracts"; +export type DiffScope = "session" | "git"; + export interface DiffRouteSearch { diff?: "1" | undefined; + diffScope?: DiffScope | undefined; diffTurnId?: TurnId | undefined; diffFilePath?: string | undefined; } @@ -20,19 +23,29 @@ function normalizeSearchString(value: unknown): string | undefined { export function stripDiffSearchParams>( params: T, -): Omit { - const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; - return rest as Omit; +): Omit { + const { + diff: _diff, + diffScope: _diffScope, + diffTurnId: _diffTurnId, + diffFilePath: _diffFilePath, + ...rest + } = params; + return rest as Omit; } export function parseDiffRouteSearch(search: Record): DiffRouteSearch { const diff = isDiffOpenValue(search.diff) ? "1" : undefined; + const diffScopeRaw = diff ? normalizeSearchString(search.diffScope) : undefined; + const diffScope: DiffScope | undefined = + diffScopeRaw === "git" || diffScopeRaw === "session" ? diffScopeRaw : undefined; const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; const diffTurnId = diffTurnIdRaw ? TurnId.makeUnsafe(diffTurnIdRaw) : undefined; const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; return { ...(diff ? { diff } : {}), + ...(diffScope ? { diffScope } : {}), ...(diffTurnId ? { diffTurnId } : {}), ...(diffFilePath ? { diffFilePath } : {}), }; diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index 254b93eb6d..4c90057e0d 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -14,9 +14,11 @@ import type { GitListBranchesResult } from "@t3tools/contracts"; import { gitBranchSearchInfiniteQueryOptions, + gitDiffQueryOptions, gitMutationKeys, gitPreparePullRequestThreadMutationOptions, gitPullMutationOptions, + gitQueryKeys, gitRunStackedActionMutationOptions, invalidateGitQueries, } from "./gitReactQuery"; @@ -34,6 +36,12 @@ const BRANCH_SEARCH_RESULT: InfiniteData = { pageParams: [0], }; +describe("gitQueryKeys", () => { + it("scopes diff keys by cwd", () => { + expect(gitQueryKeys.diff("/repo/a")).not.toEqual(gitQueryKeys.diff("/repo/b")); + }); +}); + describe("gitMutationKeys", () => { it("scopes stacked action keys by cwd", () => { expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual( @@ -116,3 +124,10 @@ describe("invalidateGitQueries", () => { ).toBe(false); }); }); + +describe("git query options", () => { + it("attaches cwd-scoped query key for diff", () => { + const options = gitDiffQueryOptions("/repo/a"); + expect(options.queryKey).toEqual(gitQueryKeys.diff("/repo/a")); + }); +}); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index a2611ebe25..8b0cf8c31c 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -12,12 +12,15 @@ import { import { ensureNativeApi } from "../nativeApi"; import { getWsRpcClient } from "../wsRpcClient"; +const GIT_STATUS_STALE_TIME_MS = 5_000; +const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; const GIT_BRANCHES_STALE_TIME_MS = 15_000; const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; const GIT_BRANCHES_PAGE_SIZE = 100; export const gitQueryKeys = { all: ["git"] as const, + diff: (cwd: string | null) => ["git", "diff", cwd] as const, branches: (cwd: string | null) => ["git", "branches", cwd] as const, branchSearch: (cwd: string | null, query: string) => ["git", "branches", cwd, "search", query] as const, @@ -49,6 +52,22 @@ function invalidateGitBranchQueries(queryClient: QueryClient, cwd: string | null return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }); } +export function gitDiffQueryOptions(cwd: string | null) { + return queryOptions({ + queryKey: gitQueryKeys.diff(cwd), + queryFn: async () => { + const api = ensureNativeApi(); + if (!cwd) throw new Error("Git diff is unavailable."); + return api.git.diff({ cwd }); + }, + enabled: cwd !== null, + staleTime: GIT_STATUS_STALE_TIME_MS, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: GIT_STATUS_REFETCH_INTERVAL_MS, + }); +} + export function gitBranchSearchInfiniteQueryOptions(input: { cwd: string | null; query: string; diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index ae56f85991..795daed818 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -56,6 +56,7 @@ const rpcClientMock = { }, git: { pull: vi.fn(), + diff: vi.fn(), refreshStatus: vi.fn(), onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => registerListener(gitStatusListeners, listener), @@ -342,6 +343,16 @@ describe("wsNativeApi", () => { }); }); + it("forwards git diff requests directly to the RPC client", async () => { + rpcClientMock.git.diff.mockResolvedValue({ diff: "patch" }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.git.diff({ cwd: "/repo" }); + + expect(rpcClientMock.git.diff).toHaveBeenCalledWith({ cwd: "/repo" }); + }); + it("forwards full-thread diff requests to the orchestration RPC", async () => { rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); const { createWsNativeApi } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 3cfb976e09..8827a8db29 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -69,6 +69,7 @@ export function createWsNativeApi(): NativeApi { }, git: { pull: rpcClient.git.pull, + diff: rpcClient.git.diff, refreshStatus: rpcClient.git.refreshStatus, onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), listBranches: rpcClient.git.listBranches, diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 997b83d2d7..92babc9549 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -1,5 +1,6 @@ import { type GitActionProgressEvent, + type GitDiffResult, type GitRunStackedActionInput, type GitRunStackedActionResult, type GitStatusResult, @@ -67,6 +68,7 @@ export interface WsRpcClient { }; readonly git: { readonly pull: RpcUnaryMethod; + readonly diff: (input: { cwd: string }) => Promise; readonly refreshStatus: RpcUnaryMethod; readonly onStatus: ( input: RpcInput, @@ -157,6 +159,7 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { }, git: { pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), + diff: (input) => transport.request((client) => client[WS_METHODS.gitDiff](input)), refreshStatus: (input) => transport.request((client) => client[WS_METHODS.gitRefreshStatus](input)), onStatus: (input, listener, options) => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 345208acf9..71efa179a1 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -115,6 +115,11 @@ export const GitPullInput = Schema.Struct({ }); export type GitPullInput = typeof GitPullInput.Type; +export const GitDiffInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, +}); +export type GitDiffInput = typeof GitDiffInput.Type; + export const GitRunStackedActionInput = Schema.Struct({ actionId: TrimmedNonEmptyStringSchema, cwd: TrimmedNonEmptyStringSchema, @@ -320,6 +325,11 @@ export const GitPullResult = Schema.Struct({ }); export type GitPullResult = typeof GitPullResult.Type; +export const GitDiffResult = Schema.Struct({ + diff: Schema.String, +}); +export type GitDiffResult = typeof GitDiffResult.Type; + // RPC / domain errors export class GitCommandError extends Schema.TaggedErrorClass()("GitCommandError", { operation: Schema.String, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 630ccd8249..91092064e6 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -2,6 +2,8 @@ import type { GitCheckoutInput, GitCheckoutResult, GitCreateBranchInput, + GitDiffInput, + GitDiffResult, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullRequestRefInput, @@ -167,6 +169,7 @@ export interface NativeApi { onResubscribe?: () => void; }, ) => () => void; + diff: (input: GitDiffInput) => Promise; }; contextMenu: { show: ( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index a3d10299df..738a5e67e8 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -12,6 +12,8 @@ import { GitCreateBranchResult, GitCreateWorktreeInput, GitCreateWorktreeResult, + GitDiffInput, + GitDiffResult, GitInitInput, GitListBranchesInput, GitListBranchesResult, @@ -86,6 +88,7 @@ export const WS_METHODS = { // Git methods gitPull: "git.pull", + gitDiff: "git.diff", gitRefreshStatus: "git.refreshStatus", gitRunStackedAction: "git.runStackedAction", gitListBranches: "git.listBranches", @@ -179,6 +182,12 @@ export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { error: GitCommandError, }); +export const WsGitDiffRpc = Rpc.make(WS_METHODS.gitDiff, { + payload: GitDiffInput, + success: GitDiffResult, + error: GitCommandError, +}); + export const WsGitRefreshStatusRpc = Rpc.make(WS_METHODS.gitRefreshStatus, { payload: GitStatusInput, success: GitStatusResult, @@ -345,6 +354,7 @@ export const WsRpcGroup = RpcGroup.make( WsShellOpenInEditorRpc, WsSubscribeGitStatusRpc, WsGitPullRpc, + WsGitDiffRpc, WsGitRefreshStatusRpc, WsGitRunStackedActionRpc, WsGitResolvePullRequestRpc,