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..f0d2171f0f --- /dev/null +++ b/frontend/app/view/gitdiff/gitdiff-model.ts @@ -0,0 +1,318 @@ +// 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 { isSameFileDiff } 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; + 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; + 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.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.refresh(), + }, + ]; + }); + } + + 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.isCurrentTarget(cwd, route)) { + return; + } + globalStore.set(this.statusAtom, status); + globalStore.set(this.errorAtom, null); + this.reconcileSelection(status); + } catch (e) { + if (this.isCurrentTarget(cwd, route)) { + globalStore.set(this.errorAtom, String(e?.message ?? e)); + } + } finally { + this.refreshInFlight = false; + if (this.isCurrentTarget(cwd, route)) { + globalStore.set(this.loadingAtom, false); + } + } + if (this.refreshQueued) { + this.refreshQueued = false; + await this.refresh(); + } + } + + // 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) { + 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); + // always refetch: edits that keep the same status and +/- counts would otherwise leave a stale diff + 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 curDiff = globalStore.get(this.fileDiffAtom); + if (curDiff?.path !== file.path) { + globalStore.set(this.fileDiffAtom, { path: file.path, 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; + } + const newDiff: GitFileDiffState = { + path: file.path, + 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, + 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..2c244dba89 --- /dev/null +++ b/frontend/app/view/gitdiff/gitdiff-util.ts @@ -0,0 +1,35 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.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 { + 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..a5fae28590 --- /dev/null +++ b/frontend/app/view/gitdiff/gitdiff.tsx @@ -0,0 +1,203 @@ +// 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; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.target !== e.currentTarget || (e.key !== "Enter" && e.key !== " ")) { + return; + } + e.preventDefault(); + onSelect(); + }; + 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..9aaf32ff9b --- /dev/null +++ b/frontend/preview/previews/gitdiff.preview.test.ts @@ -0,0 +1,53 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { isSameFileDiff, 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("treats diffs as different when contents change even if stats would not", () => { + const base = { path: "a.ts", loading: false, original: "a\nb\n", modified: "a\nB\n" }; + + expect(isSameFileDiff(base, { ...base })).toBe(true); + expect(isSameFileDiff(base, { ...base, modified: "a\nC\n" })).toBe(false); + expect(isSameFileDiff(base, { ...base, path: "b.ts" })).toBe(false); + expect(isSameFileDiff(base, null)).toBe(false); + expect(isSameFileDiff(null, null)).toBe(true); + }); + + 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..54f9185df1 --- /dev/null +++ b/pkg/util/gitutil/gitutil.go @@ -0,0 +1,494 @@ +// 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. + // literal pathspecs stop client-supplied paths containing glob characters from matching other files. + cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0", "GIT_TERMINAL_PROMPT=0", "GIT_LITERAL_PATHSPECS=1") + 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 +} + +func isRelOutside(rel string) bool { + return rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// resolveExistingPath resolves symlinks in the longest existing prefix of p. Components past that prefix +// do not exist yet (e.g. the directory of a deleted file), so they cannot be symlinks. +func resolveExistingPath(p string) (string, error) { + cur := p + var missing []string + for { + resolved, err := filepath.EvalSymlinks(cur) + if err == nil { + return filepath.Join(append([]string{resolved}, missing...)...), nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + if _, lerr := os.Lstat(cur); lerr == nil { + // cur exists but cannot be resolved, i.e. a dangling symlink + return "", err + } + parent := filepath.Dir(cur) + if parent == cur { + return "", err + } + missing = append([]string{filepath.Base(cur)}, missing...) + cur = parent + } +} + +// validateRepoPath ensures a repo-relative path (as supplied by the client) cannot escape the repo root, +// either lexically or through a symlinked parent directory. The final component is not resolved so callers +// can operate on a symlink itself. +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 || isRelOutside(rel) { + return "", fmt.Errorf("path %q is outside the repository", relPath) + } + physicalRoot, err := filepath.EvalSymlinks(repoRoot) + if err != nil { + return "", fmt.Errorf("cannot resolve repository root %q: %w", repoRoot, err) + } + physicalParent, err := resolveExistingPath(filepath.Dir(fullPath)) + if err != nil { + return "", fmt.Errorf("cannot resolve parent of %q: %w", relPath, err) + } + physicalRel, err := filepath.Rel(physicalRoot, physicalParent) + if err != nil || isRelOutside(physicalRel) { + 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.Lstat(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 verifyUntracked(ctx context.Context, repoRoot string, relPath string) error { + statusOut, err := runGit(ctx, repoRoot, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", relPath) + if err != nil { + return err + } + entries := parsePorcelainZ(statusOut) + if len(entries) != 1 || entries[0].Path != filepath.ToSlash(relPath) || entries[0].Status != GitStatus_Untracked { + return fmt.Errorf("refusing to delete %q: file is not untracked", relPath) + } + return 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 { + // the client's status may be stale, and deleting a file git tracks would lose its uncommitted changes + if err := verifyUntracked(ctx, repoRoot, data.Path); err != nil { + return err + } + 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..01c06ee1da --- /dev/null +++ b/pkg/util/gitutil/gitutil_test.go @@ -0,0 +1,452 @@ +// 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") + if err := os.Remove(filepath.Join(dir, "del.txt")); err != nil { + t.Fatal(err) + } + 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 := t.TempDir() + 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) + _, 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") + if err := os.WriteFile(outside, []byte("keep me"), 0644); err != nil { + t.Fatal(err) + } + 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) + } + if err := os.MkdirAll(filepath.Join(dir, "somedir"), 0755); err != nil { + t.Fatal(err) + } + err = RevertFile(ctx, wshrpc.CommandRemoteGitRevertFileData{RepoRoot: dir, Path: "somedir", Status: GitStatus_Untracked}) + if err == nil { + t.Fatal("expected directory revert to be rejected") + } +} + +// makeSymlinkEscape creates "link" inside the repo pointing at a directory outside it that holds victim.txt +func makeSymlinkEscape(t *testing.T, dir string) string { + t.Helper() + outsideDir := t.TempDir() + victim := filepath.Join(outsideDir, "victim.txt") + if err := os.WriteFile(victim, []byte("keep me"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outsideDir, filepath.Join(dir, "link")); err != nil { + t.Skipf("symlinks not supported: %v", err) + } + return victim +} + +func TestValidateRepoPathRejectsSymlinkedParent(t *testing.T) { + dir := makeRepo(t) + makeSymlinkEscape(t, dir) + for _, bad := range []string{"link/victim.txt", "link/missing/x.txt"} { + if _, err := validateRepoPath(dir, bad); err == nil { + t.Errorf("expected %q to be rejected", bad) + } + } + if _, err := validateRepoPath(dir, "link"); err != nil { + t.Errorf("the symlink itself lives in the repo and should be accepted: %v", err) + } + if _, err := validateRepoPath(dir, "gone/dir/file.txt"); err != nil { + t.Errorf("paths under missing directories should be accepted: %v", err) + } +} + +func TestGetFileDiffRejectsSymlinkEscape(t *testing.T) { + dir := makeRepo(t) + makeSymlinkEscape(t, dir) + _, err := GetFileDiff(context.Background(), wshrpc.CommandRemoteGitFileDiffData{RepoRoot: dir, Path: "link/victim.txt", Status: GitStatus_Untracked}) + if err == nil { + t.Fatal("expected symlinked parent escape to be rejected") + } +} + +func TestRevertFileRejectsSymlinkEscape(t *testing.T) { + dir := makeRepo(t) + victim := makeSymlinkEscape(t, dir) + err := RevertFile(context.Background(), wshrpc.CommandRemoteGitRevertFileData{RepoRoot: dir, Path: "link/victim.txt", Status: GitStatus_Untracked}) + if err == nil { + t.Fatal("expected symlinked parent escape to be rejected") + } + if _, statErr := os.Stat(victim); statErr != nil { + t.Fatalf("file outside repo was deleted: %v", statErr) + } +} + +func TestRevertFileRefusesToDeleteTrackedFile(t *testing.T) { + dir := makeMixedRepo(t) + ctx := context.Background() + for _, p := range []string{"mod.txt", "sub/keep.txt"} { + err := RevertFile(ctx, wshrpc.CommandRemoteGitRevertFileData{RepoRoot: dir, Path: p, Status: GitStatus_Untracked}) + if err == nil { + t.Errorf("%s: expected stale untracked status to be rejected", p) + } + if !fileExists(dir, p) { + t.Errorf("%s: tracked file was deleted", p) + } + } + if readFile(t, dir, "mod.txt") != "a\nB\nc\n" { + t.Errorf("uncommitted changes to mod.txt were lost") + } +} + +func TestRevertFileDeletedDirectory(t *testing.T) { + dir := makeRepo(t) + writeFile(t, dir, "gone/dir/file.txt", "x\n") + gitCmd(t, dir, "add", ".") + gitCmd(t, dir, "commit", "-q", "-m", "init") + if err := os.RemoveAll(filepath.Join(dir, "gone")); err != nil { + t.Fatal(err) + } + ctx := context.Background() + data := wshrpc.CommandRemoteGitFileDiffData{RepoRoot: dir, Path: "gone/dir/file.txt", Status: GitStatus_Deleted} + if _, err := GetFileDiff(ctx, data); err != nil { + t.Fatalf("diff of file in deleted directory: %v", err) + } + if err := RevertFile(ctx, wshrpc.CommandRemoteGitRevertFileData{RepoRoot: dir, Path: "gone/dir/file.txt", Status: GitStatus_Deleted}); err != nil { + t.Fatal(err) + } + if readFile(t, dir, "gone/dir/file.txt") != "x\n" { + t.Errorf("deleted file was not restored") + } +} 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"` +}