From 56e5b879c6544479adebb0cb981684dd3727397a Mon Sep 17 00:00:00 2001 From: bircni Date: Wed, 7 Oct 2026 21:14:07 +0200 Subject: [PATCH 1/2] Add Git Diff view for reviewing working tree changes Adds a "gitdiff" block, opened from the terminal context menu, that lists the changed files in the terminal's git repo and shows each file's diff against HEAD with the Monaco diff viewer. Files can be reverted from the list after confirmation. Git runs through new Remote* RPCs on the connection's wsh server, so the view works for local, SSH and WSL connections. Repo-relative paths from the client are validated so file reads and reverts cannot escape the repo. Diff models now keep the file's own extension in their URI so Monaco can infer the language, which also gives the AI file diff viewer syntax highlighting. --- frontend/app/block/blockregistry.ts | 2 + frontend/app/block/blockutil.tsx | 6 + frontend/app/monaco/monaco-react.tsx | 5 +- frontend/app/store/wshclientapi.ts | 18 + frontend/app/view/gitdiff/gitdiff-model.ts | 327 +++++++++++++ frontend/app/view/gitdiff/gitdiff-util.ts | 23 + frontend/app/view/gitdiff/gitdiff.tsx | 186 ++++++++ frontend/app/view/term/term-model.ts | 16 + .../preview/previews/gitdiff.preview-util.ts | 80 ++++ .../preview/previews/gitdiff.preview.test.ts | 51 +++ frontend/preview/previews/gitdiff.preview.tsx | 51 +++ frontend/types/gotypes.d.ts | 52 +++ pkg/util/gitutil/gitutil.go | 433 ++++++++++++++++++ pkg/util/gitutil/gitutil_test.go | 363 +++++++++++++++ pkg/waveobj/metaconsts.go | 1 + pkg/waveobj/wtypemeta.go | 1 + pkg/wshrpc/wshclient/wshclient.go | 18 + pkg/wshrpc/wshremote/git.go | 32 ++ pkg/wshrpc/wshrpctypes.go | 47 ++ 19 files changed, 1710 insertions(+), 2 deletions(-) create mode 100644 frontend/app/view/gitdiff/gitdiff-model.ts create mode 100644 frontend/app/view/gitdiff/gitdiff-util.ts create mode 100644 frontend/app/view/gitdiff/gitdiff.tsx create mode 100644 frontend/preview/previews/gitdiff.preview-util.ts create mode 100644 frontend/preview/previews/gitdiff.preview.test.ts create mode 100644 frontend/preview/previews/gitdiff.preview.tsx create mode 100644 pkg/util/gitutil/gitutil.go create mode 100644 pkg/util/gitutil/gitutil_test.go create mode 100644 pkg/wshrpc/wshremote/git.go diff --git a/frontend/app/block/blockregistry.ts b/frontend/app/block/blockregistry.ts index 5de7e05bd3..25dea018d1 100644 --- a/frontend/app/block/blockregistry.ts +++ b/frontend/app/block/blockregistry.ts @@ -4,6 +4,7 @@ import { BlockNodeModel } from "@/app/block/blocktypes"; import type { TabModel } from "@/app/store/tab-model"; import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff"; +import { GitDiffViewModel } from "@/app/view/gitdiff/gitdiff-model"; import { LauncherViewModel } from "@/app/view/launcher/launcher"; import { PreviewModel } from "@/app/view/preview/preview-model"; import { ProcessViewerViewModel } from "@/app/view/processviewer/processviewer"; @@ -33,6 +34,7 @@ BlockRegistry.set("help", HelpViewModel); BlockRegistry.set("launcher", LauncherViewModel); BlockRegistry.set("tsunami", TsunamiViewModel); BlockRegistry.set("aifilediff", AiFileDiffViewModel); +BlockRegistry.set("gitdiff", GitDiffViewModel); BlockRegistry.set("waveconfig", WaveConfigViewModel); BlockRegistry.set("processviewer", ProcessViewerViewModel); diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index 3ef4d39821..6f7dce649d 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -45,6 +45,9 @@ export function blockViewToIcon(view: string): string { if (view == "processviewer") { return "microchip"; } + if (view == "gitdiff") { + return "code-compare"; + } return "square"; } @@ -73,6 +76,9 @@ export function blockViewToName(view: string): string { if (view == "processviewer") { return "Processes"; } + if (view == "gitdiff") { + return "Git Diff"; + } return view; } diff --git a/frontend/app/monaco/monaco-react.tsx b/frontend/app/monaco/monaco-react.tsx index ea295b1855..6772d7eb9a 100644 --- a/frontend/app/monaco/monaco-react.tsx +++ b/frontend/app/monaco/monaco-react.tsx @@ -135,8 +135,9 @@ export function MonacoDiffViewer({ original, modified, language, path, options } const el = divRef.current; if (!el) return; - const origUri = monaco.Uri.parse(`wave://diff/${encodeURIComponent(path)}.orig`); - const modUri = monaco.Uri.parse(`wave://diff/${encodeURIComponent(path)}.mod`); + // keep the file's own extension last so monaco can infer the language when none is passed + const origUri = monaco.Uri.parse(`wave://diff/orig/${encodeURIComponent(path)}`); + const modUri = monaco.Uri.parse(`wave://diff/mod/${encodeURIComponent(path)}`); const originalModel = monaco.editor.createModel(original, language, origUri); const modifiedModel = monaco.editor.createModel(modified, language, modUri); diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 5dece08473..4bc06b9c31 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -738,6 +738,24 @@ export class RpcApiType { return client.wshRpcCall("remotegetinfo", null, opts); } + // command "remotegitfilediff" [call] + RemoteGitFileDiffCommand(client: WshClient, data: CommandRemoteGitFileDiffData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotegitfilediff", data, opts); + return client.wshRpcCall("remotegitfilediff", data, opts); + } + + // command "remotegitrevertfile" [call] + RemoteGitRevertFileCommand(client: WshClient, data: CommandRemoteGitRevertFileData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotegitrevertfile", data, opts); + return client.wshRpcCall("remotegitrevertfile", data, opts); + } + + // command "remotegitstatus" [call] + RemoteGitStatusCommand(client: WshClient, data: CommandRemoteGitStatusData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotegitstatus", data, opts); + return client.wshRpcCall("remotegitstatus", data, opts); + } + // command "remoteinstallrcfiles" [call] RemoteInstallRcFilesCommand(client: WshClient, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remoteinstallrcfiles", null, opts); diff --git a/frontend/app/view/gitdiff/gitdiff-model.ts b/frontend/app/view/gitdiff/gitdiff-model.ts new file mode 100644 index 0000000000..c1637f4c9e --- /dev/null +++ b/frontend/app/view/gitdiff/gitdiff-model.ts @@ -0,0 +1,327 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import { makeORef } from "@/app/store/wos"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import type { MetaKeyAtomFnType, SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; +import { base64ToString, isBlank, makeConnRoute } from "@/util/util"; +import * as jotai from "jotai"; +import { GitDiffView } from "./gitdiff"; +import { getFileSignature } from "./gitdiff-util"; + +export type GitDiffEnv = WaveEnvSubset<{ + rpc: { + RemoteGitStatusCommand: WaveEnv["rpc"]["RemoteGitStatusCommand"]; + RemoteGitFileDiffCommand: WaveEnv["rpc"]["RemoteGitFileDiffCommand"]; + RemoteGitRevertFileCommand: WaveEnv["rpc"]["RemoteGitRevertFileCommand"]; + SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; + }; + getConnStatusAtom: WaveEnv["getConnStatusAtom"]; + getBlockMetaKeyAtom: MetaKeyAtomFnType<"connection" | "file" | "editor:inlinediff">; + getSettingsKeyAtom: SettingsKeyAtomFnType<"editor:inlinediff">; +}>; + +export type GitFileDiffState = { + path: string; + signature: string; + loading: boolean; + original?: string; + modified?: string; + binary?: boolean; + toolarge?: boolean; + error?: string; +}; + +const PollIntervalMs = 5000; + +export class GitDiffViewModel implements ViewModel { + viewType = "gitdiff"; + blockId: string; + env: GitDiffEnv; + + viewIcon = jotai.atom("code-compare"); + viewName = jotai.atom("Git Diff"); + noPadding = jotai.atom(true); + + statusAtom = jotai.atom(null) as jotai.PrimitiveAtom; + loadingAtom = jotai.atom(true) as jotai.PrimitiveAtom; + errorAtom = jotai.atom(null) as jotai.PrimitiveAtom; + selectedPathAtom = jotai.atom(null) as jotai.PrimitiveAtom; + fileDiffAtom = jotai.atom(null) as jotai.PrimitiveAtom; + + connection: jotai.Atom; + connStatus: jotai.Atom; + cwdAtom: jotai.Atom; + inlineDiffAtom: jotai.Atom; + selectedFileAtom: jotai.Atom; + viewText: jotai.Atom; + endIconButtons: jotai.Atom; + + disposed = false; + refreshInFlight = false; + refreshQueued = false; + pollTimer: ReturnType = null; + diffEpoch = 0; + + constructor({ blockId, waveEnv }: ViewModelInitType) { + this.blockId = blockId; + this.env = waveEnv; + + this.connection = jotai.atom((get) => { + const connValue = get(this.env.getBlockMetaKeyAtom(blockId, "connection")); + if (isBlank(connValue)) { + return "local"; + } + return connValue; + }); + this.connStatus = jotai.atom((get) => { + const connName = get(this.env.getBlockMetaKeyAtom(blockId, "connection")); + return get(this.env.getConnStatusAtom(connName)); + }); + this.cwdAtom = jotai.atom((get) => get(this.env.getBlockMetaKeyAtom(blockId, "file"))); + this.inlineDiffAtom = jotai.atom((get) => { + const metaVal = get(this.env.getBlockMetaKeyAtom(blockId, "editor:inlinediff")); + if (metaVal != null) { + return metaVal; + } + return !!get(this.env.getSettingsKeyAtom("editor:inlinediff")); + }); + this.selectedFileAtom = jotai.atom((get) => { + const status = get(this.statusAtom); + const selectedPath = get(this.selectedPathAtom); + return status?.files?.find((f) => f.path === selectedPath) ?? null; + }); + this.viewText = jotai.atom((get) => { + const status = get(this.statusAtom); + if (status == null || status.notrepo) { + return []; + } + const repoName = status.reporoot?.split(/[\\/]/).filter(Boolean).pop() ?? ""; + const rtn: HeaderElem[] = []; + rtn.push({ elemtype: "text", text: status.branch ? `${repoName} · ${status.branch}` : repoName }); + const files = status.files ?? []; + const additions = files.reduce((acc, f) => acc + (f.additions ?? 0), 0); + const deletions = files.reduce((acc, f) => acc + (f.deletions ?? 0), 0); + const countText = `${files.length}${status.truncated ? "+" : ""} file${files.length === 1 ? "" : "s"}`; + rtn.push({ elemtype: "text", text: countText, className: "text-secondary" }); + if (additions > 0) { + rtn.push({ elemtype: "text", text: `+${additions}`, className: "text-success", noGrow: true }); + } + if (deletions > 0) { + rtn.push({ elemtype: "text", text: `−${deletions}`, className: "text-error", noGrow: true }); + } + return rtn; + }); + this.endIconButtons = jotai.atom((get) => { + const inlineDiff = get(this.inlineDiffAtom); + return [ + { + elemtype: "iconbutton", + icon: inlineDiff ? "table-columns" : "bars", + title: inlineDiff ? "Show Side-by-Side Diff" : "Show Inline Diff", + click: () => this.toggleInlineDiff(), + }, + { + elemtype: "iconbutton", + icon: "rotate-right", + title: "Refresh", + click: () => this.manualRefresh(), + }, + ]; + }); + } + + get viewComponent(): ViewComponent { + return GitDiffView; + } + + startPolling() { + this.stopPolling(); + this.refresh(); + this.pollTimer = setInterval(() => { + if (document.hidden) { + return; + } + this.refresh(); + }, PollIntervalMs); + } + + stopPolling() { + if (this.pollTimer == null) { + return; + } + clearInterval(this.pollTimer); + this.pollTimer = null; + } + + resetState() { + globalStore.set(this.statusAtom, null); + globalStore.set(this.errorAtom, null); + globalStore.set(this.selectedPathAtom, null); + globalStore.set(this.fileDiffAtom, null); + globalStore.set(this.loadingAtom, true); + } + + async refresh() { + if (this.disposed) { + return; + } + if (this.refreshInFlight) { + // an explicit refresh (e.g. after a revert) must not be dropped while a poll is in flight + this.refreshQueued = true; + return; + } + const connStatus = globalStore.get(this.connStatus); + if (!connStatus?.connected) { + globalStore.set(this.loadingAtom, false); + return; + } + const cwd = globalStore.get(this.cwdAtom); + const route = makeConnRoute(globalStore.get(this.connection)); + this.refreshInFlight = true; + try { + const status = await this.env.rpc.RemoteGitStatusCommand(TabRpcClient, { cwd }, { route }); + if (this.disposed) { + return; + } + globalStore.set(this.statusAtom, status); + globalStore.set(this.errorAtom, null); + this.reconcileSelection(status); + } catch (e) { + if (!this.disposed) { + globalStore.set(this.errorAtom, String(e?.message ?? e)); + } + } finally { + this.refreshInFlight = false; + if (!this.disposed) { + globalStore.set(this.loadingAtom, false); + } + } + if (this.refreshQueued) { + this.refreshQueued = false; + await this.refresh(); + } + } + + async manualRefresh() { + await this.refresh(); + const selectedFile = globalStore.get(this.selectedFileAtom); + if (selectedFile != null) { + this.loadFileDiff(selectedFile); + } + } + + reconcileSelection(status: GitStatusRtnData) { + const files = status?.files ?? []; + const selectedPath = globalStore.get(this.selectedPathAtom); + let selected = files.find((f) => f.path === selectedPath); + if (selected == null) { + selected = files[0]; + } + if (selected == null) { + globalStore.set(this.selectedPathAtom, null); + globalStore.set(this.fileDiffAtom, null); + return; + } + globalStore.set(this.selectedPathAtom, selected.path); + const curDiff = globalStore.get(this.fileDiffAtom); + if (curDiff?.path === selected.path && curDiff.signature === getFileSignature(selected)) { + return; + } + this.loadFileDiff(selected); + } + + selectFile(file: GitFileStatus) { + if (file == null) { + return; + } + globalStore.set(this.selectedPathAtom, file.path); + this.loadFileDiff(file); + } + + async loadFileDiff(file: GitFileStatus) { + const status = globalStore.get(this.statusAtom); + if (status?.reporoot == null) { + return; + } + const epoch = ++this.diffEpoch; + const signature = getFileSignature(file); + const curDiff = globalStore.get(this.fileDiffAtom); + if (curDiff?.path !== file.path) { + globalStore.set(this.fileDiffAtom, { path: file.path, signature, loading: true }); + } + const route = makeConnRoute(globalStore.get(this.connection)); + try { + const rtn = await this.env.rpc.RemoteGitFileDiffCommand( + TabRpcClient, + { reporoot: status.reporoot, path: file.path, origpath: file.origpath, status: file.status }, + { route } + ); + if (this.disposed || epoch !== this.diffEpoch) { + return; + } + globalStore.set(this.fileDiffAtom, { + path: file.path, + signature, + loading: false, + original: base64ToString(rtn?.originalcontents64 ?? ""), + modified: base64ToString(rtn?.modifiedcontents64 ?? ""), + binary: rtn?.binary, + toolarge: rtn?.toolarge, + }); + } catch (e) { + if (this.disposed || epoch !== this.diffEpoch) { + return; + } + globalStore.set(this.fileDiffAtom, { + path: file.path, + signature, + loading: false, + error: String(e?.message ?? e), + }); + } + } + + async revertFile(file: GitFileStatus) { + const status = globalStore.get(this.statusAtom); + if (file == null || status?.reporoot == null) { + return; + } + const message = + file.status === "untracked" + ? `Delete untracked file "${file.path}"?\n\nThis file is not tracked by git and cannot be recovered.` + : `Discard all changes to "${file.path}"?\n\nThis restores the file to HEAD (staged and unstaged changes) and cannot be undone.`; + if (!window.confirm(message)) { + return; + } + const route = makeConnRoute(globalStore.get(this.connection)); + try { + await this.env.rpc.RemoteGitRevertFileCommand( + TabRpcClient, + { reporoot: status.reporoot, path: file.path, origpath: file.origpath, status: file.status }, + { route } + ); + } catch (e) { + globalStore.set(this.errorAtom, `Revert failed: ${String(e?.message ?? e)}`); + return; + } + if (globalStore.get(this.selectedPathAtom) === file.path) { + globalStore.set(this.fileDiffAtom, null); + } + await this.refresh(); + } + + toggleInlineDiff() { + const inlineDiff = globalStore.get(this.inlineDiffAtom); + this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: makeORef("block", this.blockId), + meta: { "editor:inlinediff": !inlineDiff }, + }); + } + + dispose() { + this.disposed = true; + this.stopPolling(); + } +} diff --git a/frontend/app/view/gitdiff/gitdiff-util.ts b/frontend/app/view/gitdiff/gitdiff-util.ts new file mode 100644 index 0000000000..82815a6dc2 --- /dev/null +++ b/frontend/app/view/gitdiff/gitdiff-util.ts @@ -0,0 +1,23 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// a changed signature means the file's diff must be refetched; content edits that keep identical +/- counts +// are only picked up on reselect or manual refresh +export function getFileSignature(file: GitFileStatus): string { + return `${file.status}:${file.origpath ?? ""}:${file.additions ?? 0}:${file.deletions ?? 0}`; +} + +export function isWshOutdatedError(errMsg: string): boolean { + if (errMsg == null) { + return false; + } + return /command "remotegit\w+" not found/.test(errMsg); +} + +export function splitRepoPath(filePath: string): { dir: string; name: string } { + const idx = filePath.lastIndexOf("/"); + if (idx === -1) { + return { dir: "", name: filePath }; + } + return { dir: filePath.substring(0, idx), name: filePath.substring(idx + 1) }; +} diff --git a/frontend/app/view/gitdiff/gitdiff.tsx b/frontend/app/view/gitdiff/gitdiff.tsx new file mode 100644 index 0000000000..5e504ecc9e --- /dev/null +++ b/frontend/app/view/gitdiff/gitdiff.tsx @@ -0,0 +1,186 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { DiffViewer } from "@/app/view/codeeditor/diffviewer"; +import { cn } from "@/util/util"; +import * as jotai from "jotai"; +import * as React from "react"; +import type { GitDiffViewModel } from "./gitdiff-model"; +import { isWshOutdatedError, splitRepoPath } from "./gitdiff-util"; + +const StatusDecl: Record = { + modified: { letter: "M", className: "text-warning", label: "Modified" }, + added: { letter: "A", className: "text-success", label: "Added" }, + deleted: { letter: "D", className: "text-error", label: "Deleted" }, + renamed: { letter: "R", className: "text-accent", label: "Renamed" }, + untracked: { letter: "U", className: "text-success", label: "Untracked" }, + conflicted: { letter: "C", className: "text-error", label: "Conflicted" }, +}; + +function Placeholder({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +type FileRowProps = { + file: GitFileStatus; + selected: boolean; + onSelect: () => void; + onRevert: () => void; +}; + +function FileRow({ file, selected, onSelect, onRevert }: FileRowProps) { + const decl = StatusDecl[file.status] ?? StatusDecl.modified; + const { dir, name } = splitRepoPath(file.path); + const title = file.origpath ? `${file.origpath} → ${file.path}` : file.path; + return ( +
+ + {decl.letter} + +
+ {name} + {dir && {dir}} +
+
+ {file.binary ? ( + bin + ) : ( + <> + {file.additions > 0 && +{file.additions}} + {file.deletions > 0 && −{file.deletions}} + + )} +
+ +
+ ); +} + +function FileList({ model }: { model: GitDiffViewModel }) { + const status = jotai.useAtomValue(model.statusAtom); + const selectedPath = jotai.useAtomValue(model.selectedPathAtom); + const files = status?.files ?? []; + return ( +
+ {files.map((file) => ( + model.selectFile(file)} + onRevert={() => model.revertFile(file)} + /> + ))} + {status?.truncated && ( +
Showing the first {files.length} changed files
+ )} +
+ ); +} + +function DiffPane({ blockId, model }: { blockId: string; model: GitDiffViewModel }) { + const fileDiff = jotai.useAtomValue(model.fileDiffAtom); + if (fileDiff == null || fileDiff.loading) { + return Loading diff...; + } + if (fileDiff.error) { + return {fileDiff.error}; + } + if (fileDiff.binary) { + return Binary file — diff not shown; + } + if (fileDiff.toolarge) { + return File is too large to diff; + } + return ( + + ); +} + +export function GitDiffView({ blockId, model }: ViewComponentProps) { + const status = jotai.useAtomValue(model.statusAtom); + const loading = jotai.useAtomValue(model.loadingAtom); + const error = jotai.useAtomValue(model.errorAtom); + const connStatus = jotai.useAtomValue(model.connStatus); + const cwd = jotai.useAtomValue(model.cwdAtom); + const connection = jotai.useAtomValue(model.connection); + + React.useEffect(() => { + model.resetState(); + model.startPolling(); + return () => model.stopPolling(); + }, [cwd, connection]); + + if (!connStatus?.connected) { + return Not connected to {connection}; + } + if (cwd == null) { + return No directory set for this block; + } + if (status == null) { + if (isWshOutdatedError(error)) { + return Update wsh on this connection to use Git Diff; + } + if (error) { + return {error}; + } + if (loading) { + return Loading changes...; + } + return null; + } + if (status.notrepo) { + return ( + + +
Not a git repository
+
{cwd}
+
+ ); + } + const files = status.files ?? []; + return ( +
+ {error &&
{error}
} + {files.length === 0 ? ( + + +
No changes — working tree is clean
+
+ ) : ( +
+ +
+ +
+
+ )} +
+ ); +} diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index a256929e7d..42d4b567aa 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -980,6 +980,22 @@ export class TermViewModel implements ViewModel { createBlock(blockDef); }, }); + fullMenu.push({ + label: "Git Diff", + click: () => { + const blockData = globalStore.get(this.blockAtom); + const connection = blockData?.meta?.connection; + const cwd = blockData?.meta?.["cmd:cwd"]; + const meta: Record = { + view: "gitdiff", + file: cwd, + }; + if (connection) { + meta.connection = connection; + } + createBlockSplitHorizontally({ meta }, this.blockId, "after"); + }, + }); fullMenu.push({ type: "separator" }); } diff --git a/frontend/preview/previews/gitdiff.preview-util.ts b/frontend/preview/previews/gitdiff.preview-util.ts new file mode 100644 index 0000000000..db191e6220 --- /dev/null +++ b/frontend/preview/previews/gitdiff.preview-util.ts @@ -0,0 +1,80 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { stringToBase64 } from "@/util/util"; + +export const DefaultGitDiffCwd = "/home/user/projects/greeter"; + +type MockGitFile = { + status: GitFileStatus; + original: string; + modified: string; + binary?: boolean; +}; + +export const MockGitFiles: MockGitFile[] = [ + { + status: { path: "src/lib/greeting.ts", status: "modified", additions: 2, deletions: 1 }, + original: `export function greet(name: string) { + return "Hello " + name; +} +`, + modified: `export function greet(name: string) { + const normalizedName = name.trim() || "friend"; + return \`Hello, \${normalizedName}!\`; +} +`, + }, + { + status: { path: "src/lib/farewell.ts", status: "added", staged: true, additions: 3 }, + original: "", + modified: `export function farewell(name: string) { + return \`Goodbye, \${name}!\`; +} +`, + }, + { + status: { path: "docs/OLD_NOTES.md", status: "deleted", deletions: 2 }, + original: "# Notes\n\nTo be removed.\n", + modified: "", + }, + { + status: { path: "src/main.go", origpath: "main.go", status: "renamed", staged: true, additions: 1, deletions: 1 }, + original: 'package main\n\nfunc main() {\n\tprintln("hi")\n}\n', + modified: 'package main\n\nfunc main() {\n\tprintln("hello")\n}\n', + }, + { + status: { path: "scratch.py", status: "untracked", additions: 2 }, + original: "", + modified: 'print("scratch")\nprint("done")\n', + }, + { + status: { path: "assets/logo.png", status: "modified", binary: true }, + original: "", + modified: "", + binary: true, + }, +]; + +export function makeMockGitStatus(files: MockGitFile[] = MockGitFiles): GitStatusRtnData { + return { + reporoot: DefaultGitDiffCwd, + branch: "feature/greetings", + hashead: true, + files: files.map((f) => f.status), + }; +} + +export function makeMockGitFileDiff(path: string, files: MockGitFile[] = MockGitFiles): GitFileDiffRtnData { + const file = files.find((f) => f.status.path === path); + if (file == null) { + throw new Error(`file not found: ${path}`); + } + if (file.binary) { + return { originalcontents64: "", modifiedcontents64: "", binary: true }; + } + return { + originalcontents64: stringToBase64(file.original), + modifiedcontents64: stringToBase64(file.modified), + }; +} diff --git a/frontend/preview/previews/gitdiff.preview.test.ts b/frontend/preview/previews/gitdiff.preview.test.ts new file mode 100644 index 0000000000..fe1cca76cc --- /dev/null +++ b/frontend/preview/previews/gitdiff.preview.test.ts @@ -0,0 +1,51 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { getFileSignature, isWshOutdatedError, splitRepoPath } from "@/app/view/gitdiff/gitdiff-util"; +import { base64ToString } from "@/util/util"; +import { describe, expect, it } from "vitest"; +import { MockGitFiles, makeMockGitFileDiff, makeMockGitStatus } from "./gitdiff.preview-util"; + +describe("gitdiff preview helpers", () => { + it("builds a status listing every mock file", () => { + const status = makeMockGitStatus(); + + expect(status.files.map((f) => f.path)).toEqual(MockGitFiles.map((f) => f.status.path)); + expect(status.notrepo).toBeFalsy(); + }); + + it("encodes file contents for the diff rpc response", () => { + const rtn = makeMockGitFileDiff("src/lib/greeting.ts"); + + expect(base64ToString(rtn.originalcontents64)).toBe(MockGitFiles[0].original); + expect(base64ToString(rtn.modifiedcontents64)).toBe(MockGitFiles[0].modified); + }); + + it("flags binary files without contents", () => { + const rtn = makeMockGitFileDiff("assets/logo.png"); + + expect(rtn.binary).toBe(true); + expect(rtn.modifiedcontents64).toBe(""); + }); +}); + +describe("gitdiff util", () => { + it("changes the signature when stats or status change", () => { + const base: GitFileStatus = { path: "a.ts", status: "modified", additions: 1, deletions: 0 }; + + expect(getFileSignature(base)).toBe(getFileSignature({ ...base })); + expect(getFileSignature(base)).not.toBe(getFileSignature({ ...base, additions: 2 })); + expect(getFileSignature(base)).not.toBe(getFileSignature({ ...base, status: "deleted" })); + }); + + it("detects unknown-command errors from an outdated wsh", () => { + expect(isWshOutdatedError('command "remotegitstatus" not found')).toBe(true); + expect(isWshOutdatedError("git status: fatal: bad object")).toBe(false); + expect(isWshOutdatedError(null)).toBe(false); + }); + + it("splits repo paths into dir and name", () => { + expect(splitRepoPath("src/lib/a.ts")).toEqual({ dir: "src/lib", name: "a.ts" }); + expect(splitRepoPath("a.ts")).toEqual({ dir: "", name: "a.ts" }); + }); +}); diff --git a/frontend/preview/previews/gitdiff.preview.tsx b/frontend/preview/previews/gitdiff.preview.tsx new file mode 100644 index 0000000000..9c1c142adc --- /dev/null +++ b/frontend/preview/previews/gitdiff.preview.tsx @@ -0,0 +1,51 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Block } from "@/app/block/block"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import * as React from "react"; +import { makeMockNodeModel } from "../mock/mock-node-model"; +import { useRpcOverride } from "../mock/use-rpc-override"; +import { DefaultGitDiffCwd, MockGitFiles, makeMockGitFileDiff, makeMockGitStatus } from "./gitdiff.preview-util"; + +const PreviewNodeId = "preview-gitdiff-node"; + +export function GitDiffPreview() { + const env = useWaveEnv(); + const [blockId, setBlockId] = React.useState(null); + const filesRef = React.useRef([...MockGitFiles]); + + useRpcOverride("RemoteGitStatusCommand", async () => makeMockGitStatus(filesRef.current)); + useRpcOverride("RemoteGitFileDiffCommand", async (_client, data) => + makeMockGitFileDiff(data.path, filesRef.current) + ); + useRpcOverride("RemoteGitRevertFileCommand", async (_client, data) => { + filesRef.current = filesRef.current.filter((f) => f.status.path !== data.path); + }); + + React.useEffect(() => { + env.createBlock({ meta: { view: "gitdiff", file: DefaultGitDiffCwd } }, false, false).then((id) => + setBlockId(id) + ); + }, []); + + const nodeModel = React.useMemo( + () => (blockId != null ? makeMockNodeModel({ nodeId: PreviewNodeId, blockId }) : null), + [blockId] + ); + + if (blockId == null || nodeModel == null) { + return null; + } + + return ( +
+
full gitdiff block (mock WOS + mock git RPCs)
+
+
+ +
+
+
+ ); +} diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c5b870d7ed..68af7b1f93 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -546,6 +546,27 @@ declare global { streammeta: StreamMeta; }; + // wshrpc.CommandRemoteGitFileDiffData + type CommandRemoteGitFileDiffData = { + reporoot: string; + path: string; + origpath?: string; + status: string; + }; + + // wshrpc.CommandRemoteGitRevertFileData + type CommandRemoteGitRevertFileData = { + reporoot: string; + path: string; + origpath?: string; + status: string; + }; + + // wshrpc.CommandRemoteGitStatusData + type CommandRemoteGitStatusData = { + cwd: string; + }; + // wshrpc.CommandRemoteListEntriesData type CommandRemoteListEntriesData = { path: string; @@ -1024,6 +1045,35 @@ declare global { buildtime: string; }; + // wshrpc.GitFileDiffRtnData + type GitFileDiffRtnData = { + originalcontents64: string; + modifiedcontents64: string; + binary?: boolean; + toolarge?: boolean; + }; + + // wshrpc.GitFileStatus + type GitFileStatus = { + path: string; + origpath?: string; + status: string; + staged?: boolean; + additions?: number; + deletions?: number; + binary?: boolean; + }; + + // wshrpc.GitStatusRtnData + type GitStatusRtnData = { + notrepo?: boolean; + reporoot?: string; + branch?: string; + hashead?: boolean; + truncated?: boolean; + files: GitFileStatus[]; + }; + // waveobj.Job type Job = WaveObj & { connection: string; @@ -1150,6 +1200,7 @@ declare global { "editor:stickyscrollenabled"?: boolean; "editor:wordwrap"?: boolean; "editor:fontsize"?: number; + "editor:inlinediff"?: boolean; "graph:*"?: boolean; "graph:numpoints"?: number; "graph:metrics"?: string[]; @@ -1589,6 +1640,7 @@ declare global { "debug:panictype"?: string; "block:view"?: string; "block:controller"?: string; + "block:subblock"?: boolean; "ai:backendtype"?: string; "ai:local"?: boolean; "wsh:cmd"?: string; diff --git a/pkg/util/gitutil/gitutil.go b/pkg/util/gitutil/gitutil.go new file mode 100644 index 0000000000..2b49d4371d --- /dev/null +++ b/pkg/util/gitutil/gitutil.go @@ -0,0 +1,433 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gitutil + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "strconv" + "strings" + + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +const ( + GitStatus_Modified = "modified" + GitStatus_Added = "added" + GitStatus_Deleted = "deleted" + GitStatus_Renamed = "renamed" + GitStatus_Untracked = "untracked" + GitStatus_Conflicted = "conflicted" +) + +const ( + MaxDiffFileSize = 2 * 1024 * 1024 + MaxStatusFiles = 1000 + BinarySniffSize = 8000 +) + +var conflictCodes = map[string]bool{ + "DD": true, "AU": true, "UD": true, "UA": true, "DU": true, "AA": true, "UU": true, +} + +type porcelainEntry struct { + Path string + OrigPath string + Status string + Staged bool +} + +type numstatEntry struct { + Additions int + Deletions int + Binary bool +} + +func runGit(ctx context.Context, dir string, args ...string) ([]byte, error) { + fullArgs := append([]string{"-C", dir, "-c", "core.quotepath=off"}, args...) + cmd := exec.CommandContext(ctx, "git", fullArgs...) + // optional locks would make our background polling contend with the user's own git commands + cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0", "GIT_TERMINAL_PROMPT=0") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + return nil, fmt.Errorf("git executable not found in PATH") + } + errMsg := strings.TrimSpace(stderr.String()) + if errMsg == "" { + errMsg = err.Error() + } + return nil, fmt.Errorf("git %s: %s", args[0], errMsg) + } + return stdout.Bytes(), nil +} + +func classifyPorcelain(xy string) string { + if xy == "??" { + return GitStatus_Untracked + } + if conflictCodes[xy] { + return GitStatus_Conflicted + } + x, y := xy[0], xy[1] + if x == 'R' || y == 'R' { + return GitStatus_Renamed + } + if x == 'A' || x == 'C' { + return GitStatus_Added + } + if x == 'D' || y == 'D' { + return GitStatus_Deleted + } + return GitStatus_Modified +} + +// parsePorcelainZ parses `git status --porcelain=v1 -z` output. +// Renames and copies are encoded as "XY NEWPATH\0ORIGPATH\0". +func parsePorcelainZ(out []byte) []porcelainEntry { + var rtn []porcelainEntry + parts := strings.Split(string(out), "\x00") + for i := 0; i < len(parts); i++ { + rec := parts[i] + if len(rec) < 4 || rec[2] != ' ' { + continue + } + xy := rec[:2] + if xy == "!!" { + continue + } + entry := porcelainEntry{ + Path: rec[3:], + Status: classifyPorcelain(xy), + Staged: xy[0] != ' ' && xy[0] != '?', + } + if xy[0] == 'R' || xy[0] == 'C' || xy[1] == 'R' || xy[1] == 'C' { + if i+1 < len(parts) { + i++ + if entry.Status == GitStatus_Renamed { + entry.OrigPath = parts[i] + } + } + } + rtn = append(rtn, entry) + } + return rtn +} + +// parseNumstatZ parses `git diff --numstat -z` output, keyed by the (new) path. +// Renames are encoded as "ADD\tDEL\t\0ORIGPATH\0NEWPATH\0". +func parseNumstatZ(out []byte) map[string]numstatEntry { + rtn := make(map[string]numstatEntry) + parts := strings.Split(string(out), "\x00") + for i := 0; i < len(parts); i++ { + fields := strings.SplitN(parts[i], "\t", 3) + if len(fields) != 3 { + continue + } + filePath := fields[2] + if filePath == "" { + if i+2 >= len(parts) { + break + } + filePath = parts[i+2] + i += 2 + } + var entry numstatEntry + if fields[0] == "-" && fields[1] == "-" { + entry.Binary = true + } else { + entry.Additions, _ = strconv.Atoi(fields[0]) + entry.Deletions, _ = strconv.Atoi(fields[1]) + } + rtn[filePath] = entry + } + return rtn +} + +func isBinaryContent(data []byte) bool { + sniff := data + if len(sniff) > BinarySniffSize { + sniff = sniff[:BinarySniffSize] + } + return bytes.IndexByte(sniff, 0) != -1 +} + +func countLines(data []byte) int { + if len(data) == 0 { + return 0 + } + n := bytes.Count(data, []byte{'\n'}) + if data[len(data)-1] != '\n' { + n++ + } + return n +} + +// validateRepoPath ensures a repo-relative path (as supplied by the client) cannot escape the repo root. +func validateRepoPath(repoRoot string, relPath string) (string, error) { + if !filepath.IsAbs(repoRoot) { + return "", fmt.Errorf("repository root %q must be absolute", repoRoot) + } + if relPath == "" { + return "", fmt.Errorf("empty path") + } + if filepath.IsAbs(relPath) || path.IsAbs(relPath) || filepath.VolumeName(relPath) != "" { + return "", fmt.Errorf("path %q must be relative to the repository root", relPath) + } + cleaned := path.Clean(filepath.ToSlash(relPath)) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return "", fmt.Errorf("path %q is outside the repository", relPath) + } + fullPath := filepath.Join(repoRoot, filepath.FromSlash(cleaned)) + rel, err := filepath.Rel(repoRoot, fullPath) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("path %q is outside the repository", relPath) + } + return fullPath, nil +} + +func hasHead(ctx context.Context, repoRoot string) bool { + _, err := runGit(ctx, repoRoot, "rev-parse", "--verify", "--quiet", "HEAD") + return err == nil +} + +func getBranch(ctx context.Context, repoRoot string, headExists bool) string { + out, err := runGit(ctx, repoRoot, "symbolic-ref", "--short", "-q", "HEAD") + if err == nil { + return strings.TrimSpace(string(out)) + } + if !headExists { + return "" + } + out, err = runGit(ctx, repoRoot, "rev-parse", "--short", "HEAD") + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func GetRepoRoot(ctx context.Context, cwd string) (string, error) { + out, err := runGit(ctx, cwd, "rev-parse", "--show-toplevel") + if err != nil { + return "", err + } + root := strings.TrimSpace(string(out)) + if root == "" { + return "", fmt.Errorf("not a git repository") + } + return filepath.FromSlash(root), nil +} + +func GetStatus(ctx context.Context, cwd string) (*wshrpc.GitStatusRtnData, error) { + if _, err := os.Stat(cwd); err != nil { + return nil, fmt.Errorf("cannot access directory %q: %w", cwd, err) + } + if _, err := exec.LookPath("git"); err != nil { + return nil, fmt.Errorf("git executable not found in PATH") + } + repoRoot, err := GetRepoRoot(ctx, cwd) + if err != nil { + return &wshrpc.GitStatusRtnData{NotRepo: true}, nil + } + headExists := hasHead(ctx, repoRoot) + rtn := &wshrpc.GitStatusRtnData{ + RepoRoot: repoRoot, + Branch: getBranch(ctx, repoRoot, headExists), + HasHead: headExists, + } + statusOut, err := runGit(ctx, repoRoot, "status", "--porcelain=v1", "-z", "--untracked-files=all") + if err != nil { + return nil, err + } + entries := parsePorcelainZ(statusOut) + if len(entries) > MaxStatusFiles { + entries = entries[:MaxStatusFiles] + rtn.Truncated = true + } + var numstat map[string]numstatEntry + if headExists { + numstatOut, err := runGit(ctx, repoRoot, "diff", "--numstat", "-z", "HEAD") + if err == nil { + numstat = parseNumstatZ(numstatOut) + } + } + rtn.Files = make([]wshrpc.GitFileStatus, 0, len(entries)) + for _, entry := range entries { + fileStatus := wshrpc.GitFileStatus{ + Path: entry.Path, + OrigPath: entry.OrigPath, + Status: entry.Status, + Staged: entry.Staged, + } + if ns, ok := numstat[entry.Path]; ok { + fileStatus.Additions = ns.Additions + fileStatus.Deletions = ns.Deletions + fileStatus.Binary = ns.Binary + } else if entry.Status == GitStatus_Untracked || (entry.Status == GitStatus_Added && !headExists) { + fillNewFileStats(repoRoot, &fileStatus) + } + rtn.Files = append(rtn.Files, fileStatus) + } + return rtn, nil +} + +func fillNewFileStats(repoRoot string, fileStatus *wshrpc.GitFileStatus) { + fullPath, err := validateRepoPath(repoRoot, fileStatus.Path) + if err != nil { + return + } + finfo, err := os.Stat(fullPath) + if err != nil || !finfo.Mode().IsRegular() || finfo.Size() > MaxDiffFileSize { + return + } + data, err := os.ReadFile(fullPath) + if err != nil { + return + } + if isBinaryContent(data) { + fileStatus.Binary = true + return + } + fileStatus.Additions = countLines(data) +} + +func readHeadBlob(ctx context.Context, repoRoot string, relPath string) ([]byte, bool, error) { + spec := "HEAD:" + filepath.ToSlash(relPath) + sizeOut, err := runGit(ctx, repoRoot, "cat-file", "-s", spec) + if err != nil { + return nil, false, nil + } + size, err := strconv.ParseInt(strings.TrimSpace(string(sizeOut)), 10, 64) + if err != nil { + return nil, false, fmt.Errorf("cannot parse blob size for %q: %w", relPath, err) + } + if size > MaxDiffFileSize { + return nil, true, nil + } + // --filters applies eol/smudge conversion so the HEAD side matches what a checkout would produce + data, err := runGit(ctx, repoRoot, "cat-file", "--filters", spec) + if err != nil { + return nil, false, err + } + return data, false, nil +} + +func readWorktreeFile(fullPath string) ([]byte, bool, error) { + finfo, err := os.Lstat(fullPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + return nil, false, err + } + if finfo.Mode()&os.ModeSymlink != 0 { + // git stores a symlink's target as its blob contents, so diff against that rather than following it + target, err := os.Readlink(fullPath) + if err != nil { + return nil, false, err + } + return []byte(target), false, nil + } + if finfo.IsDir() { + return nil, false, fmt.Errorf("%q is a directory", fullPath) + } + if finfo.Size() > MaxDiffFileSize { + return nil, true, nil + } + data, err := os.ReadFile(fullPath) + if err != nil { + return nil, false, err + } + return data, false, nil +} + +func GetFileDiff(ctx context.Context, data wshrpc.CommandRemoteGitFileDiffData) (*wshrpc.GitFileDiffRtnData, error) { + repoRoot := data.RepoRoot + fullPath, err := validateRepoPath(repoRoot, data.Path) + if err != nil { + return nil, err + } + origRelPath := data.Path + if data.OrigPath != "" { + if _, err := validateRepoPath(repoRoot, data.OrigPath); err != nil { + return nil, err + } + origRelPath = data.OrigPath + } + rtn := &wshrpc.GitFileDiffRtnData{} + var original, modified []byte + if data.Status != GitStatus_Untracked && data.Status != GitStatus_Added { + var tooLarge bool + original, tooLarge, err = readHeadBlob(ctx, repoRoot, origRelPath) + if err != nil { + return nil, err + } + if tooLarge { + rtn.TooLarge = true + return rtn, nil + } + } + if data.Status != GitStatus_Deleted { + var tooLarge bool + modified, tooLarge, err = readWorktreeFile(fullPath) + if err != nil { + return nil, err + } + if tooLarge { + rtn.TooLarge = true + return rtn, nil + } + } + if isBinaryContent(original) || isBinaryContent(modified) { + rtn.Binary = true + return rtn, nil + } + rtn.OriginalContents64 = base64.StdEncoding.EncodeToString(original) + rtn.ModifiedContents64 = base64.StdEncoding.EncodeToString(modified) + return rtn, nil +} + +func RevertFile(ctx context.Context, data wshrpc.CommandRemoteGitRevertFileData) error { + repoRoot := data.RepoRoot + fullPath, err := validateRepoPath(repoRoot, data.Path) + if err != nil { + return err + } + if data.OrigPath != "" { + if _, err := validateRepoPath(repoRoot, data.OrigPath); err != nil { + return err + } + } + if data.Status == GitStatus_Untracked { + finfo, err := os.Lstat(fullPath) + if err != nil { + return err + } + if finfo.IsDir() { + return fmt.Errorf("refusing to delete directory %q", data.Path) + } + return os.Remove(fullPath) + } + if !hasHead(ctx, repoRoot) { + // with no commits yet, "reverting" a staged file means dropping it entirely + _, err := runGit(ctx, repoRoot, "rm", "-f", "-q", "--", data.Path) + return err + } + args := []string{"restore", "--source=HEAD", "--staged", "--worktree", "--", data.Path} + if data.OrigPath != "" { + args = append(args, data.OrigPath) + } + _, err = runGit(ctx, repoRoot, args...) + return err +} diff --git a/pkg/util/gitutil/gitutil_test.go b/pkg/util/gitutil/gitutil_test.go new file mode 100644 index 0000000000..df9af373fc --- /dev/null +++ b/pkg/util/gitutil/gitutil_test.go @@ -0,0 +1,363 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package gitutil + +import ( + "context" + "encoding/base64" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } +} + +func gitCmd(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL="+os.DevNull, + "GIT_CONFIG_SYSTEM="+os.DevNull, + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) + } +} + +func writeFile(t *testing.T, dir string, name string, contents string) { + t.Helper() + fullPath := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(contents), 0644); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, dir string, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name))) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func fileExists(dir string, name string) bool { + _, err := os.Lstat(filepath.Join(dir, filepath.FromSlash(name))) + return err == nil +} + +func makeRepo(t *testing.T) string { + t.Helper() + requireGit(t) + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + gitCmd(t, dir, "init", "-q", "-b", "main") + return dir +} + +// makeMixedRepo commits a baseline then produces one change of each kind in the working tree +func makeMixedRepo(t *testing.T) string { + t.Helper() + dir := makeRepo(t) + writeFile(t, dir, "mod.txt", "a\nb\n") + writeFile(t, dir, "del.txt", "gone\n") + writeFile(t, dir, "old.txt", "rename me\nline2\n") + writeFile(t, dir, "sub/keep.txt", "keep\n") + gitCmd(t, dir, "add", ".") + gitCmd(t, dir, "commit", "-q", "-m", "init") + + writeFile(t, dir, "mod.txt", "a\nB\nc\n") + os.Remove(filepath.Join(dir, "del.txt")) + gitCmd(t, dir, "mv", "old.txt", "new name.txt") + writeFile(t, dir, "added.txt", "new\n") + gitCmd(t, dir, "add", "added.txt") + writeFile(t, dir, "untracked ü.txt", "u1\nu2\nu3") + return dir +} + +func findFile(files []wshrpc.GitFileStatus, path string) *wshrpc.GitFileStatus { + for i := range files { + if files[i].Path == path { + return &files[i] + } + } + return nil +} + +func TestParsePorcelainZ(t *testing.T) { + out := []byte("A added.txt\x00 D d.txt\x00 M m.txt\x00R new name.txt\x00old.txt\x00?? untr ü.txt\x00UU conflict.txt\x00MM both.txt\x00") + entries := parsePorcelainZ(out) + want := []porcelainEntry{ + {Path: "added.txt", Status: GitStatus_Added, Staged: true}, + {Path: "d.txt", Status: GitStatus_Deleted}, + {Path: "m.txt", Status: GitStatus_Modified}, + {Path: "new name.txt", OrigPath: "old.txt", Status: GitStatus_Renamed, Staged: true}, + {Path: "untr ü.txt", Status: GitStatus_Untracked}, + {Path: "conflict.txt", Status: GitStatus_Conflicted, Staged: true}, + {Path: "both.txt", Status: GitStatus_Modified, Staged: true}, + } + if len(entries) != len(want) { + t.Fatalf("got %d entries, want %d: %+v", len(entries), len(want), entries) + } + for i := range want { + if entries[i] != want[i] { + t.Errorf("entry %d: got %+v, want %+v", i, entries[i], want[i]) + } + } +} + +func TestParseNumstatZ(t *testing.T) { + out := []byte("1\t0\tadded.txt\x002\t1\tm.txt\x000\t0\t\x00old.txt\x00new name.txt\x00-\t-\timg.png\x00") + stats := parseNumstatZ(out) + if stats["added.txt"] != (numstatEntry{Additions: 1}) { + t.Errorf("added.txt: %+v", stats["added.txt"]) + } + if stats["m.txt"] != (numstatEntry{Additions: 2, Deletions: 1}) { + t.Errorf("m.txt: %+v", stats["m.txt"]) + } + if _, ok := stats["new name.txt"]; !ok { + t.Errorf("rename should be keyed by new path: %+v", stats) + } + if !stats["img.png"].Binary { + t.Errorf("img.png should be binary: %+v", stats["img.png"]) + } +} + +func TestValidateRepoPath(t *testing.T) { + root := filepath.Join(string(filepath.Separator), "repo") + if filepath.VolumeName(os.TempDir()) != "" { + root = filepath.Join(filepath.VolumeName(os.TempDir())+string(filepath.Separator), "repo") + } + for _, bad := range []string{"", "..", "../x", "a/../../x", "/etc/passwd", ".", "a/.."} { + if _, err := validateRepoPath(root, bad); err == nil { + t.Errorf("expected %q to be rejected", bad) + } + } + for _, good := range []string{"a.txt", "sub/dir/a.txt", "a/../b.txt", "..foo"} { + if _, err := validateRepoPath(root, good); err != nil { + t.Errorf("expected %q to be accepted: %v", good, err) + } + } + if _, err := validateRepoPath("relative/root", "a.txt"); err == nil { + t.Errorf("expected relative repo root to be rejected") + } +} + +func TestGetStatusNotRepo(t *testing.T) { + requireGit(t) + dir := t.TempDir() + status, err := GetStatus(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if !status.NotRepo { + t.Errorf("expected NotRepo for %s", dir) + } +} + +func TestGetStatusMixed(t *testing.T) { + dir := makeMixedRepo(t) + status, err := GetStatus(context.Background(), filepath.Join(dir, "sub")) + if err != nil { + t.Fatal(err) + } + if status.NotRepo || status.RepoRoot != dir || status.Branch != "main" || !status.HasHead { + t.Fatalf("unexpected status header: %+v", status) + } + checks := []struct { + path string + status string + origPath string + additions int + deletions int + }{ + {"mod.txt", GitStatus_Modified, "", 2, 1}, + {"del.txt", GitStatus_Deleted, "", 0, 1}, + {"new name.txt", GitStatus_Renamed, "old.txt", 0, 0}, + {"added.txt", GitStatus_Added, "", 1, 0}, + {"untracked ü.txt", GitStatus_Untracked, "", 3, 0}, + } + if len(status.Files) != len(checks) { + t.Fatalf("got %d files, want %d: %+v", len(status.Files), len(checks), status.Files) + } + for _, c := range checks { + f := findFile(status.Files, c.path) + if f == nil { + t.Errorf("missing %q in %+v", c.path, status.Files) + continue + } + if f.Status != c.status || f.OrigPath != c.origPath || f.Additions != c.additions || f.Deletions != c.deletions { + t.Errorf("%s: got %+v", c.path, *f) + } + } +} + +func TestGetStatusUnbornHead(t *testing.T) { + dir := makeRepo(t) + writeFile(t, dir, "first.txt", "one\ntwo\n") + gitCmd(t, dir, "add", "first.txt") + status, err := GetStatus(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if status.HasHead || status.Branch != "main" { + t.Errorf("unexpected header: %+v", status) + } + f := findFile(status.Files, "first.txt") + if f == nil || f.Status != GitStatus_Added || f.Additions != 2 { + t.Fatalf("unexpected file status: %+v", status.Files) + } +} + +func decodeDiff(t *testing.T, rtn *wshrpc.GitFileDiffRtnData) (string, string) { + t.Helper() + orig, err := base64.StdEncoding.DecodeString(rtn.OriginalContents64) + if err != nil { + t.Fatal(err) + } + mod, err := base64.StdEncoding.DecodeString(rtn.ModifiedContents64) + if err != nil { + t.Fatal(err) + } + return string(orig), string(mod) +} + +func TestGetFileDiff(t *testing.T) { + dir := makeMixedRepo(t) + ctx := context.Background() + cases := []struct { + path string + origPath string + status string + wantOrig string + wantMod string + }{ + {"mod.txt", "", GitStatus_Modified, "a\nb\n", "a\nB\nc\n"}, + {"del.txt", "", GitStatus_Deleted, "gone\n", ""}, + {"new name.txt", "old.txt", GitStatus_Renamed, "rename me\nline2\n", "rename me\nline2\n"}, + {"added.txt", "", GitStatus_Added, "", "new\n"}, + {"untracked ü.txt", "", GitStatus_Untracked, "", "u1\nu2\nu3"}, + } + for _, c := range cases { + rtn, err := GetFileDiff(ctx, wshrpc.CommandRemoteGitFileDiffData{RepoRoot: dir, Path: c.path, OrigPath: c.origPath, Status: c.status}) + if err != nil { + t.Fatalf("%s: %v", c.path, err) + } + orig, mod := decodeDiff(t, rtn) + if orig != c.wantOrig || mod != c.wantMod { + t.Errorf("%s: got orig=%q mod=%q", c.path, orig, mod) + } + } +} + +func TestGetFileDiffBinaryAndTooLarge(t *testing.T) { + dir := makeRepo(t) + writeFile(t, dir, "bin.dat", "abc\x00def") + writeFile(t, dir, "big.txt", strings.Repeat("x", MaxDiffFileSize+1)) + ctx := context.Background() + rtn, err := GetFileDiff(ctx, wshrpc.CommandRemoteGitFileDiffData{RepoRoot: dir, Path: "bin.dat", Status: GitStatus_Untracked}) + if err != nil { + t.Fatal(err) + } + if !rtn.Binary || rtn.ModifiedContents64 != "" { + t.Errorf("expected binary result: %+v", rtn) + } + rtn, err = GetFileDiff(ctx, wshrpc.CommandRemoteGitFileDiffData{RepoRoot: dir, Path: "big.txt", Status: GitStatus_Untracked}) + if err != nil { + t.Fatal(err) + } + if !rtn.TooLarge || rtn.ModifiedContents64 != "" { + t.Errorf("expected too-large result: %+v", rtn) + } +} + +func TestGetFileDiffRejectsEscape(t *testing.T) { + dir := makeRepo(t) + outside := filepath.Join(filepath.Dir(dir), "outside-secret.txt") + os.WriteFile(outside, []byte("secret"), 0644) + defer os.Remove(outside) + _, err := GetFileDiff(context.Background(), wshrpc.CommandRemoteGitFileDiffData{RepoRoot: dir, Path: "../outside-secret.txt", Status: GitStatus_Untracked}) + if err == nil { + t.Fatal("expected path escape to be rejected") + } +} + +func TestRevertFile(t *testing.T) { + dir := makeMixedRepo(t) + ctx := context.Background() + reverts := []wshrpc.CommandRemoteGitRevertFileData{ + {RepoRoot: dir, Path: "mod.txt", Status: GitStatus_Modified}, + {RepoRoot: dir, Path: "del.txt", Status: GitStatus_Deleted}, + {RepoRoot: dir, Path: "new name.txt", OrigPath: "old.txt", Status: GitStatus_Renamed}, + {RepoRoot: dir, Path: "added.txt", Status: GitStatus_Added}, + {RepoRoot: dir, Path: "untracked ü.txt", Status: GitStatus_Untracked}, + } + for _, r := range reverts { + if err := RevertFile(ctx, r); err != nil { + t.Fatalf("revert %s: %v", r.Path, err) + } + } + status, err := GetStatus(ctx, dir) + if err != nil { + t.Fatal(err) + } + if len(status.Files) != 0 { + t.Errorf("expected clean tree after reverts, got %+v", status.Files) + } + if readFile(t, dir, "mod.txt") != "a\nb\n" || readFile(t, dir, "del.txt") != "gone\n" || readFile(t, dir, "old.txt") != "rename me\nline2\n" { + t.Errorf("reverted contents do not match HEAD") + } + if fileExists(dir, "new name.txt") || fileExists(dir, "added.txt") || fileExists(dir, "untracked ü.txt") { + t.Errorf("new files should have been removed") + } +} + +func TestRevertFileUnbornHead(t *testing.T) { + dir := makeRepo(t) + writeFile(t, dir, "first.txt", "one\n") + gitCmd(t, dir, "add", "first.txt") + if err := RevertFile(context.Background(), wshrpc.CommandRemoteGitRevertFileData{RepoRoot: dir, Path: "first.txt", Status: GitStatus_Added}); err != nil { + t.Fatal(err) + } + if fileExists(dir, "first.txt") { + t.Errorf("expected first.txt to be removed") + } +} + +func TestRevertFileRejectsEscapeAndDirs(t *testing.T) { + dir := makeRepo(t) + outside := filepath.Join(filepath.Dir(dir), "outside-victim.txt") + os.WriteFile(outside, []byte("keep me"), 0644) + defer os.Remove(outside) + ctx := context.Background() + err := RevertFile(ctx, wshrpc.CommandRemoteGitRevertFileData{RepoRoot: dir, Path: "../outside-victim.txt", Status: GitStatus_Untracked}) + if err == nil { + t.Fatal("expected path escape to be rejected") + } + if _, statErr := os.Stat(outside); statErr != nil { + t.Fatalf("file outside repo was deleted: %v", statErr) + } + os.MkdirAll(filepath.Join(dir, "somedir"), 0755) + err = RevertFile(ctx, wshrpc.CommandRemoteGitRevertFileData{RepoRoot: dir, Path: "somedir", Status: GitStatus_Untracked}) + if err == nil { + t.Fatal("expected directory revert to be rejected") + } +} diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 0ce08099d8..b4a72dc0be 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -82,6 +82,7 @@ const ( MetaKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled" MetaKey_EditorWordWrap = "editor:wordwrap" MetaKey_EditorFontSize = "editor:fontsize" + MetaKey_EditorInlineDiff = "editor:inlinediff" MetaKey_GraphClear = "graph:*" MetaKey_GraphNumPoints = "graph:numpoints" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index 2280b55d2d..03b32d0cf3 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -84,6 +84,7 @@ type MetaTSType struct { EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"` EditorWordWrap bool `json:"editor:wordwrap,omitempty"` EditorFontSize float64 `json:"editor:fontsize,omitempty"` + EditorInlineDiff bool `json:"editor:inlinediff,omitempty"` GraphClear bool `json:"graph:*,omitempty"` GraphNumPoints int `json:"graph:numpoints,omitempty"` diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index f2b426db52..7ced8a682b 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -736,6 +736,24 @@ func RemoteGetInfoCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (wshrpc.Remot return resp, err } +// command "remotegitfilediff", wshserver.RemoteGitFileDiffCommand +func RemoteGitFileDiffCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteGitFileDiffData, opts *wshrpc.RpcOpts) (*wshrpc.GitFileDiffRtnData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.GitFileDiffRtnData](w, "remotegitfilediff", data, opts) + return resp, err +} + +// command "remotegitrevertfile", wshserver.RemoteGitRevertFileCommand +func RemoteGitRevertFileCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteGitRevertFileData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "remotegitrevertfile", data, opts) + return err +} + +// command "remotegitstatus", wshserver.RemoteGitStatusCommand +func RemoteGitStatusCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteGitStatusData, opts *wshrpc.RpcOpts) (*wshrpc.GitStatusRtnData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.GitStatusRtnData](w, "remotegitstatus", data, opts) + return resp, err +} + // command "remoteinstallrcfiles", wshserver.RemoteInstallRcFilesCommand func RemoteInstallRcFilesCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "remoteinstallrcfiles", nil, opts) diff --git a/pkg/wshrpc/wshremote/git.go b/pkg/wshrpc/wshremote/git.go new file mode 100644 index 0000000000..8fdfbb4852 --- /dev/null +++ b/pkg/wshrpc/wshremote/git.go @@ -0,0 +1,32 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshremote + +import ( + "context" + "path/filepath" + + "github.com/wavetermdev/waveterm/pkg/util/gitutil" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +func (impl *ServerImpl) RemoteGitStatusCommand(ctx context.Context, data wshrpc.CommandRemoteGitStatusData) (*wshrpc.GitStatusRtnData, error) { + cwd := data.Cwd + if cwd == "" { + cwd = "~" + } + cwd = filepath.Clean(wavebase.ExpandHomeDirSafe(cwd)) + return gitutil.GetStatus(ctx, cwd) +} + +func (impl *ServerImpl) RemoteGitFileDiffCommand(ctx context.Context, data wshrpc.CommandRemoteGitFileDiffData) (*wshrpc.GitFileDiffRtnData, error) { + data.RepoRoot = filepath.Clean(wavebase.ExpandHomeDirSafe(data.RepoRoot)) + return gitutil.GetFileDiff(ctx, data) +} + +func (impl *ServerImpl) RemoteGitRevertFileCommand(ctx context.Context, data wshrpc.CommandRemoteGitRevertFileData) error { + data.RepoRoot = filepath.Clean(wavebase.ExpandHomeDirSafe(data.RepoRoot)) + return gitutil.RevertFile(ctx, data) +} diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index db2c709f71..7038833cb6 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -129,6 +129,9 @@ type WshRpcInterface interface { BadgeWatchPidCommand(ctx context.Context, data CommandBadgeWatchPidData) error RemoteProcessListCommand(ctx context.Context, data CommandRemoteProcessListData) (*ProcessListResponse, error) RemoteProcessSignalCommand(ctx context.Context, data CommandRemoteProcessSignalData) error + RemoteGitStatusCommand(ctx context.Context, data CommandRemoteGitStatusData) (*GitStatusRtnData, error) + RemoteGitFileDiffCommand(ctx context.Context, data CommandRemoteGitFileDiffData) (*GitFileDiffRtnData, error) + RemoteGitRevertFileCommand(ctx context.Context, data CommandRemoteGitRevertFileData) error // emain WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) @@ -932,3 +935,47 @@ type CommandRemoteProcessSignalData struct { Pid int32 `json:"pid"` Signal string `json:"signal"` } + +type CommandRemoteGitStatusData struct { + Cwd string `json:"cwd"` +} + +type GitFileStatus struct { + Path string `json:"path"` + OrigPath string `json:"origpath,omitempty"` + Status string `json:"status"` + Staged bool `json:"staged,omitempty"` + Additions int `json:"additions,omitempty"` + Deletions int `json:"deletions,omitempty"` + Binary bool `json:"binary,omitempty"` +} + +type GitStatusRtnData struct { + NotRepo bool `json:"notrepo,omitempty"` + RepoRoot string `json:"reporoot,omitempty"` + Branch string `json:"branch,omitempty"` + HasHead bool `json:"hashead,omitempty"` + Truncated bool `json:"truncated,omitempty"` + Files []GitFileStatus `json:"files"` +} + +type CommandRemoteGitFileDiffData struct { + RepoRoot string `json:"reporoot"` + Path string `json:"path"` + OrigPath string `json:"origpath,omitempty"` + Status string `json:"status"` +} + +type GitFileDiffRtnData struct { + OriginalContents64 string `json:"originalcontents64"` + ModifiedContents64 string `json:"modifiedcontents64"` + Binary bool `json:"binary,omitempty"` + TooLarge bool `json:"toolarge,omitempty"` +} + +type CommandRemoteGitRevertFileData struct { + RepoRoot string `json:"reporoot"` + Path string `json:"path"` + OrigPath string `json:"origpath,omitempty"` + Status string `json:"status"` +} From b15d163083db3af089e2ec38b4f5774cdf065fd4 Mon Sep 17 00:00:00 2001 From: bircni Date: Wed, 7 Oct 2026 22:04:22 +0200 Subject: [PATCH 2/2] Address review feedback on Git Diff view - Discard status results for a cwd/connection that changed mid-request - Refetch the selected diff on every poll so same-count edits are not stale - Make file rows and the revert button keyboard accessible - Resolve symlinked parents when validating repo paths - Re-check that a file is untracked before deleting it on revert - Use literal pathspecs for git commands and check test fixture errors --- frontend/app/view/gitdiff/gitdiff-model.ts | 45 +++----- frontend/app/view/gitdiff/gitdiff-util.ts | 20 +++- frontend/app/view/gitdiff/gitdiff.tsx | 25 +++- .../preview/previews/gitdiff.preview.test.ts | 16 +-- pkg/util/gitutil/gitutil.go | 71 +++++++++++- pkg/util/gitutil/gitutil_test.go | 109 ++++++++++++++++-- 6 files changed, 229 insertions(+), 57 deletions(-) diff --git a/frontend/app/view/gitdiff/gitdiff-model.ts b/frontend/app/view/gitdiff/gitdiff-model.ts index c1637f4c9e..f0d2171f0f 100644 --- a/frontend/app/view/gitdiff/gitdiff-model.ts +++ b/frontend/app/view/gitdiff/gitdiff-model.ts @@ -8,7 +8,7 @@ import type { MetaKeyAtomFnType, SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } import { base64ToString, isBlank, makeConnRoute } from "@/util/util"; import * as jotai from "jotai"; import { GitDiffView } from "./gitdiff"; -import { getFileSignature } from "./gitdiff-util"; +import { isSameFileDiff } from "./gitdiff-util"; export type GitDiffEnv = WaveEnvSubset<{ rpc: { @@ -24,7 +24,6 @@ export type GitDiffEnv = WaveEnvSubset<{ export type GitFileDiffState = { path: string; - signature: string; loading: boolean; original?: string; modified?: string; @@ -54,7 +53,6 @@ export class GitDiffViewModel implements ViewModel { connStatus: jotai.Atom; cwdAtom: jotai.Atom; inlineDiffAtom: jotai.Atom; - selectedFileAtom: jotai.Atom; viewText: jotai.Atom; endIconButtons: jotai.Atom; @@ -87,11 +85,6 @@ export class GitDiffViewModel implements ViewModel { } return !!get(this.env.getSettingsKeyAtom("editor:inlinediff")); }); - this.selectedFileAtom = jotai.atom((get) => { - const status = get(this.statusAtom); - const selectedPath = get(this.selectedPathAtom); - return status?.files?.find((f) => f.path === selectedPath) ?? null; - }); this.viewText = jotai.atom((get) => { const status = get(this.statusAtom); if (status == null || status.notrepo) { @@ -126,7 +119,7 @@ export class GitDiffViewModel implements ViewModel { elemtype: "iconbutton", icon: "rotate-right", title: "Refresh", - click: () => this.manualRefresh(), + click: () => this.refresh(), }, ]; }); @@ -182,19 +175,19 @@ export class GitDiffViewModel implements ViewModel { this.refreshInFlight = true; try { const status = await this.env.rpc.RemoteGitStatusCommand(TabRpcClient, { cwd }, { route }); - if (this.disposed) { + if (!this.isCurrentTarget(cwd, route)) { return; } globalStore.set(this.statusAtom, status); globalStore.set(this.errorAtom, null); this.reconcileSelection(status); } catch (e) { - if (!this.disposed) { + if (this.isCurrentTarget(cwd, route)) { globalStore.set(this.errorAtom, String(e?.message ?? e)); } } finally { this.refreshInFlight = false; - if (!this.disposed) { + if (this.isCurrentTarget(cwd, route)) { globalStore.set(this.loadingAtom, false); } } @@ -204,12 +197,12 @@ export class GitDiffViewModel implements ViewModel { } } - async manualRefresh() { - await this.refresh(); - const selectedFile = globalStore.get(this.selectedFileAtom); - if (selectedFile != null) { - this.loadFileDiff(selectedFile); + // cwd or connection can change while a status request is in flight; its result then belongs to another repo + isCurrentTarget(cwd: string, route: string): boolean { + if (this.disposed) { + return false; } + return globalStore.get(this.cwdAtom) === cwd && makeConnRoute(globalStore.get(this.connection)) === route; } reconcileSelection(status: GitStatusRtnData) { @@ -225,10 +218,7 @@ export class GitDiffViewModel implements ViewModel { return; } globalStore.set(this.selectedPathAtom, selected.path); - const curDiff = globalStore.get(this.fileDiffAtom); - if (curDiff?.path === selected.path && curDiff.signature === getFileSignature(selected)) { - return; - } + // always refetch: edits that keep the same status and +/- counts would otherwise leave a stale diff this.loadFileDiff(selected); } @@ -246,10 +236,9 @@ export class GitDiffViewModel implements ViewModel { return; } const epoch = ++this.diffEpoch; - const signature = getFileSignature(file); const curDiff = globalStore.get(this.fileDiffAtom); if (curDiff?.path !== file.path) { - globalStore.set(this.fileDiffAtom, { path: file.path, signature, loading: true }); + globalStore.set(this.fileDiffAtom, { path: file.path, loading: true }); } const route = makeConnRoute(globalStore.get(this.connection)); try { @@ -261,22 +250,24 @@ export class GitDiffViewModel implements ViewModel { if (this.disposed || epoch !== this.diffEpoch) { return; } - globalStore.set(this.fileDiffAtom, { + const newDiff: GitFileDiffState = { path: file.path, - signature, loading: false, original: base64ToString(rtn?.originalcontents64 ?? ""), modified: base64ToString(rtn?.modifiedcontents64 ?? ""), binary: rtn?.binary, toolarge: rtn?.toolarge, - }); + }; + if (isSameFileDiff(globalStore.get(this.fileDiffAtom), newDiff)) { + return; + } + globalStore.set(this.fileDiffAtom, newDiff); } catch (e) { if (this.disposed || epoch !== this.diffEpoch) { return; } globalStore.set(this.fileDiffAtom, { path: file.path, - signature, loading: false, error: String(e?.message ?? e), }); diff --git a/frontend/app/view/gitdiff/gitdiff-util.ts b/frontend/app/view/gitdiff/gitdiff-util.ts index 82815a6dc2..2c244dba89 100644 --- a/frontend/app/view/gitdiff/gitdiff-util.ts +++ b/frontend/app/view/gitdiff/gitdiff-util.ts @@ -1,10 +1,22 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -// a changed signature means the file's diff must be refetched; content edits that keep identical +/- counts -// are only picked up on reselect or manual refresh -export function getFileSignature(file: GitFileStatus): string { - return `${file.status}:${file.origpath ?? ""}:${file.additions ?? 0}:${file.deletions ?? 0}`; +import type { GitFileDiffState } from "./gitdiff-model"; + +// the selected diff is refetched on every poll, so skip identical results to avoid needless re-renders +export function isSameFileDiff(a: GitFileDiffState, b: GitFileDiffState): boolean { + if (a == null || b == null) { + return a == b; + } + return ( + a.path === b.path && + a.loading === b.loading && + a.original === b.original && + a.modified === b.modified && + !!a.binary === !!b.binary && + !!a.toolarge === !!b.toolarge && + a.error === b.error + ); } export function isWshOutdatedError(errMsg: string): boolean { diff --git a/frontend/app/view/gitdiff/gitdiff.tsx b/frontend/app/view/gitdiff/gitdiff.tsx index 5e504ecc9e..a5fae28590 100644 --- a/frontend/app/view/gitdiff/gitdiff.tsx +++ b/frontend/app/view/gitdiff/gitdiff.tsx @@ -19,7 +19,12 @@ const StatusDecl: Record +
{children}
); @@ -36,14 +41,25 @@ function FileRow({ file, selected, onSelect, onRevert }: FileRowProps) { const decl = StatusDecl[file.status] ?? StatusDecl.modified; const { dir, name } = splitRepoPath(file.path); const title = file.origpath ? `${file.origpath} → ${file.path}` : file.path; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.target !== e.currentTarget || (e.key !== "Enter" && e.key !== " ")) { + return; + } + e.preventDefault(); + onSelect(); + }; return (
{decl.letter} @@ -52,7 +68,7 @@ function FileRow({ file, selected, onSelect, onRevert }: FileRowProps) { {name} {dir && {dir}}
-
+
{file.binary ? ( bin ) : ( @@ -63,8 +79,9 @@ function FileRow({ file, selected, onSelect, onRevert }: FileRowProps) { )}