diff --git a/src/modules/editor/EditorPane.tsx b/src/modules/editor/EditorPane.tsx index 46d9fe276..a09ab9152 100644 --- a/src/modules/editor/EditorPane.tsx +++ b/src/modules/editor/EditorPane.tsx @@ -28,6 +28,13 @@ import { vimCompartment, wrapCompartment, } from "./lib/extensions"; +import { native } from "@/modules/ai/lib/native"; +import { + emptyGitChanges, + gitChangeGutter, + parseUnifiedDiff, + setGitChanges, +} from "./lib/gitGutter"; import { type LanguageResult, resolveLanguage } from "./lib/languageResolver"; import { useEditorThemeExt } from "./lib/useEditorThemeExt"; import { useDocument } from "./lib/useDocument"; @@ -157,6 +164,53 @@ export const EditorPane = forwardRef( if (doc.status === "ready") applyPendingGoto(); }, [doc.status, applyPendingGoto]); + // Git change gutter: recompute the file's diff on open and after each save, + // then push it into the CodeMirror state field the gutter reads. + const refreshGitGutterRef = useRef<() => void>(() => {}); + const refreshGitGutter = useCallback(async () => { + const view = cmRef.current?.view; + if (!view) return; + const p = pathRef.current; + const stale = () => !view.dom.isConnected || pathRef.current !== p; + try { + const norm = p.replace(/\\/g, "/"); + const slash = norm.lastIndexOf("/"); + const dir = slash > 0 ? norm.slice(0, slash) : norm; + const repo = await native.gitResolveRepo(dir); + if (stale()) return; + if (!repo) { + view.dispatch({ effects: setGitChanges.of(emptyGitChanges()) }); + return; + } + const root = repo.repoRoot.replace(/\\/g, "/").replace(/\/+$/, ""); + const rel = norm.startsWith(`${root}/`) + ? norm.slice(root.length + 1) + : norm; + const { diffText } = await native.gitDiff(repo.repoRoot, rel, false); + if (stale()) return; + view.dispatch({ effects: setGitChanges.of(parseUnifiedDiff(diffText)) }); + } catch { + /* ignore — the gutter just stays empty */ + } + }, []); + refreshGitGutterRef.current = () => void refreshGitGutter(); + + useEffect(() => { + // Single refresh trigger for open + reload. doc.status flips to "ready" on + // both; the CodeMirror view may not be mounted yet on first open, so retry + // on the next frame until it exists rather than firing a second trigger. + if (doc.status !== "ready") return; + let raf = 0; + const run = () => { + if (cmRef.current?.view) void refreshGitGutter(); + else raf = requestAnimationFrame(run); + }; + run(); + return () => { + if (raf) cancelAnimationFrame(raf); + }; + }, [doc.status, refreshGitGutter]); + const extensions = useMemo( () => [ // basicSetup is added before user extensions by @uiw/react-codemirror, @@ -174,11 +228,13 @@ export const EditorPane = forwardRef( void (async () => { await saveRef.current(); onSavedRef.current?.(); + refreshGitGutterRef.current(); })(); }, close: () => onCloseRef.current?.(), })), ...buildSharedExtensions(), + gitChangeGutter, languageCompartment.of([]), inlineCompletion({ getPrefs: () => { @@ -227,6 +283,7 @@ export const EditorPane = forwardRef( void (async () => { await saveRef.current(); onSavedRef.current?.(); + refreshGitGutterRef.current(); })(); return true; }, diff --git a/src/modules/editor/lib/gitGutter.test.ts b/src/modules/editor/lib/gitGutter.test.ts new file mode 100644 index 000000000..676a153b7 --- /dev/null +++ b/src/modules/editor/lib/gitGutter.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { parseUnifiedDiff } from "./gitGutter"; + +describe("parseUnifiedDiff", () => { + it("returns nothing for empty input", () => { + const c = parseUnifiedDiff(""); + expect(c.added.size + c.modified.size + c.deleted.size).toBe(0); + }); + + it("marks a pure insertion as added on the new-file line", () => { + // Insert one line after line 1 (context), so the new line 2 is added. + const diff = [ + "diff --git a/f b/f", + "--- a/f", + "+++ b/f", + "@@ -1,2 +1,3 @@", + " a", + "+inserted", + " b", + "", + ].join("\n"); + const c = parseUnifiedDiff(diff); + expect([...c.added]).toEqual([2]); + expect(c.modified.size).toBe(0); + expect(c.deleted.size).toBe(0); + }); + + it("marks a replaced line as modified", () => { + // Line 2 replaced: one deletion immediately followed by one addition. + const diff = [ + "@@ -1,3 +1,3 @@", + " a", + "-old", + "+new", + " c", + "", + ].join("\n"); + const c = parseUnifiedDiff(diff); + expect([...c.modified]).toEqual([2]); + expect(c.added.size).toBe(0); + }); + + it("marks a boundary for a pure deletion", () => { + // Delete line 2; the deletion boundary lands on the following new-file line. + const diff = ["@@ -1,3 +1,2 @@", " a", "-gone", " c", ""].join("\n"); + const c = parseUnifiedDiff(diff); + expect([...c.deleted]).toEqual([2]); + expect(c.added.size + c.modified.size).toBe(0); + }); + + it("does not treat added content beginning with '-' as a header", () => { + const diff = ["@@ -1,1 +1,2 @@", " a", "+- a bullet", ""].join("\n"); + const c = parseUnifiedDiff(diff); + expect([...c.added]).toEqual([2]); + }); +}); diff --git a/src/modules/editor/lib/gitGutter.ts b/src/modules/editor/lib/gitGutter.ts new file mode 100644 index 000000000..f1db025f8 --- /dev/null +++ b/src/modules/editor/lib/gitGutter.ts @@ -0,0 +1,126 @@ +import { type Extension, StateEffect, StateField } from "@codemirror/state"; +import { EditorView, gutter, GutterMarker } from "@codemirror/view"; + +// 1-based new-file line numbers, grouped by change kind. `deleted` marks lines +// that have a deletion immediately above/at them (shown as a boundary bar). +export type GitChanges = { + added: Set; + modified: Set; + deleted: Set; +}; + +export const emptyGitChanges = (): GitChanges => ({ + added: new Set(), + modified: new Set(), + deleted: new Set(), +}); + +/** + * Parse a `git diff` unified patch into per-line change kinds against the NEW + * (worktree) file, so a gutter can mark added / modified / deleted lines. + * Note: diff is worktree-vs-index (recomputed on save); good enough for the + * edit→save loop. For staged-line accuracy switch the source to `git diff HEAD`. + */ +export function parseUnifiedDiff(diffText: string): GitChanges { + const res = emptyGitChanges(); + if (!diffText) return res; + + let newLine = 0; + let pendingDel = 0; // deletions seen since the last addition/context line + for (const raw of diffText.split("\n")) { + if (raw === "") continue; + if (raw.startsWith("@@")) { + const m = /@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw); + if (m) newLine = Number.parseInt(m[1], 10); + pendingDel = 0; + continue; + } + // File headers / metadata — never part of a hunk body. + if ( + raw.startsWith("+++ ") || + raw.startsWith("--- ") || + raw.startsWith("diff ") || + raw.startsWith("index ") || + raw.startsWith("new file") || + raw.startsWith("deleted file") || + raw.startsWith("rename ") || + raw.startsWith("similarity ") || + raw.startsWith("\\") + ) { + continue; + } + + const c = raw[0]; + if (c === "+") { + if (pendingDel > 0) { + res.modified.add(newLine); + pendingDel--; + } else { + res.added.add(newLine); + } + newLine++; + } else if (c === "-") { + pendingDel++; + } else { + // context line: flush any unmatched deletions as a boundary marker here + if (pendingDel > 0) { + res.deleted.add(newLine); + pendingDel = 0; + } + newLine++; + } + } + if (pendingDel > 0) res.deleted.add(Math.max(1, newLine - 1)); + return res; +} + +export const setGitChanges = StateEffect.define(); + +const gitChangesField = StateField.define({ + create: emptyGitChanges, + update(value, tr) { + for (const e of tr.effects) if (e.is(setGitChanges)) return e.value; + return value; + }, +}); + +class ChangeMarker extends GutterMarker { + constructor(kind: "added" | "modified" | "deleted") { + super(); + this.elementClass = `cm-change-${kind}`; + } +} + +const addedMarker = new ChangeMarker("added"); +const modifiedMarker = new ChangeMarker("modified"); +const deletedMarker = new ChangeMarker("deleted"); + +const changeGutter = gutter({ + class: "cm-changeGutter", + lineMarker(view, line) { + const changes = view.state.field(gitChangesField, false); + if (!changes) return null; + const lineNo = view.state.doc.lineAt(line.from).number; + if (changes.modified.has(lineNo)) return modifiedMarker; + if (changes.added.has(lineNo)) return addedMarker; + if (changes.deleted.has(lineNo)) return deletedMarker; + return null; + }, + lineMarkerChange: (update) => + update.state.field(gitChangesField) !== + update.startState.field(gitChangesField), +}); + +const changeGutterTheme = EditorView.baseTheme({ + ".cm-changeGutter": { width: "3px", padding: "0" }, + ".cm-changeGutter .cm-gutterElement": { padding: "0" }, + ".cm-change-added": { backgroundColor: "#3fb950" }, + ".cm-change-modified": { backgroundColor: "#2f81f7" }, + ".cm-change-deleted": { boxShadow: "inset 0 -2px 0 0 #f85149" }, +}); + +export const gitChangeGutter: Extension = [ + gitChangesField, + changeGutter, + changeGutterTheme, +];