From 0d1210997369f13be2a795afc42c4c49f7bed50c Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 1 Oct 2026 20:15:13 +0800 Subject: [PATCH] feat(windows): add Git gutter with inline diff peek for editor Adds visual Git change markers in the editor gutter with click-to-peek inline diff viewer for Windows. **New components:** - GitGutterController: manages gutter decorations, peek widget lifecycle, and Monaco integration - GitGutterPeekWidget: inline diff viewer with prev/next navigation, displays HEAD vs working tree - git-gutter-changes: computes line-level diff markers (added/modified/deleted) from Git status and file diff - use-git-gutter-base: React hook providing gutter data and navigation actions **Settings:** - New toggle "git.showGutterChanges" (default true) in Git Settings panel - Search index updated for "gutter" and "inline diff" keywords **Behavior:** - Green bar: added lines; Blue bar: modified lines; Red triangle: deleted lines - Click marker opens inline diff at that change - Up/Down arrows navigate between changes - Widget closes on Escape, editor blur, or file change - Peek models disposed on widget close to prevent leaks **Not included (for follow-up):** - Revert/stage actions in peek widget (depends on PR #1015 merge) - macOS implementation (Windows-only for now) Refs #681 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../editor/components/monaco-editor.tsx | 104 +++++++ .../engines/monaco/git-gutter-controller.ts | 212 ++++++++++++++ .../monaco/git-gutter-peek-session.test.ts | 84 ++++++ .../engines/monaco/git-gutter-peek-session.ts | 102 +++++++ .../engines/monaco/git-gutter-peek-widget.ts | 208 ++++++++++++++ .../engines/monaco/monaco-diff-internals.d.ts | 25 ++ .../editor/engines/monaco/monaco-line-diff.ts | 35 +++ .../features/editor/styles/monaco-editor.css | 92 ++++++ .../features/git/hooks/use-git-gutter-base.ts | 93 ++++++ .../git/services/git-gutter-actions.ts | 44 +++ .../git/utils/git-gutter-changes.test.ts | 210 ++++++++++++++ .../features/git/utils/git-gutter-changes.ts | 271 ++++++++++++++++++ .../settings/components/tabs/git-settings.tsx | 13 + .../settings/config/default-settings.ts | 1 + .../features/settings/config/search-index.ts | 2 +- .../features/settings/types/settings.types.ts | 2 + windows/tauri/src/i18n/locale.ts | 25 ++ 17 files changed, 1522 insertions(+), 1 deletion(-) create mode 100644 windows/tauri/src/features/editor/engines/monaco/git-gutter-controller.ts create mode 100644 windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-session.test.ts create mode 100644 windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-session.ts create mode 100644 windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-widget.ts create mode 100644 windows/tauri/src/features/editor/engines/monaco/monaco-diff-internals.d.ts create mode 100644 windows/tauri/src/features/editor/engines/monaco/monaco-line-diff.ts create mode 100644 windows/tauri/src/features/git/hooks/use-git-gutter-base.ts create mode 100644 windows/tauri/src/features/git/services/git-gutter-actions.ts create mode 100644 windows/tauri/src/features/git/utils/git-gutter-changes.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-gutter-changes.ts diff --git a/windows/tauri/src/features/editor/components/monaco-editor.tsx b/windows/tauri/src/features/editor/components/monaco-editor.tsx index 6f9e17408..02c87c723 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -35,6 +35,16 @@ import { useInlineEditToolbarStore } from "@/features/editor/stores/inline-edit- import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; import { useGitBlame } from "@/features/git/hooks/use-git-blame"; +import { useGitGutterBase, type GitGutterBase } from "@/features/git/hooks/use-git-gutter-base"; +import { + openGitGutterFullDiff, + stageGitGutterChange, +} from "@/features/git/services/git-gutter-actions"; +import { + createGitGutterController, + type GitGutterController, + type GitGutterControllerOptions, +} from "../engines/monaco/git-gutter-controller"; import { keymapRegistry } from "@/features/keymaps/utils/registry"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { openMavenRunPane } from "@/features/maven/actions/maven-tool-window-actions"; @@ -151,6 +161,50 @@ import { toast } from "sonner"; registerMonacoLspProviders(); registerMonacoCodeLensProvider(); +interface GitGutterRenderContext { + base: GitGutterBase | null; + canStage: boolean; + languageId: string; + t: (key: string, values?: Record) => string; +} + +/** Binds the long-lived gutter controller to the editor's latest render values. */ +function gitGutterControllerOptions( + context: () => GitGutterRenderContext | null, +): GitGutterControllerOptions { + const translate = (key: string, values?: Record) => + context()?.t(key, values) ?? key; + return { + language: () => context()?.languageId ?? "plaintext", + labels: (peek) => ({ + title: translate("git.gutter.title", { index: peek.index + 1, total: peek.total }), + previous: translate("git.gutter.previous"), + next: translate("git.gutter.next"), + stage: translate("git.gutter.stage"), + stageUnavailable: translate("git.gutter.stageUnavailable"), + openDiff: translate("git.gutter.openDiff"), + close: translate("git.gutter.close"), + }), + markerTooltip: (kind) => translate(`git.gutter.${kind}`), + canStage: () => !!context()?.base && !!context()?.canStage, + stage: async (change, baseLines) => { + const base = context()?.base; + if (!base) return false; + const staged = await stageGitGutterChange(base, baseLines, change); + if (!staged) toast.error(translate("git.operationFailed")); + return staged; + }, + openDiff: () => { + const base = context()?.base; + if (!base) return; + void openGitGutterFullDiff(base, translate("git.diff.uncommitted")).catch((error) => { + console.error("Failed to open Git diff:", error); + toast.error(translate("git.operationFailed")); + }); + }, + }; +} + const EMPTY_DIAGNOSTICS: Diagnostic[] = []; const INACTIVE_CURSOR_POSITION: Position = { line: 0, column: 0, offset: 0 }; @@ -337,6 +391,9 @@ export function MonacoEditor({ // Latest Run-marker decorations, reapplied when the Monaco instance is recreated. const runMarkerDecorationSpecsRef = useRef([]); const renderInlineGitBlameRef = useRef<() => void>(() => {}); + const gitGutterControllerRef = useRef(null); + // Latest render values read by the long-lived gutter controller callbacks. + const gitGutterContextRef = useRef(null); const mouseSelectingRef = useRef(false); const mouseGestureStartRef = useRef(null); const suppressNextCursorSelectionSyncRef = useRef(false); @@ -374,6 +431,16 @@ export function MonacoEditor({ return current && current.type === "editor" ? (current.content ?? "") : ""; }, [contentRevision, editorBufferId]); const filePath = editorBuffer?.path ?? ""; + // Narrow selectors: staging needs saved text, and the gutter base reloads + // when the saved text changes. Neither re-renders on every keystroke. + const bufferIsDirty = useBufferStore((state) => { + const current = getBufferById(state.buffers, editorBufferId); + return current?.type === "editor" ? current.isDirty : false; + }); + const bufferSavedContent = useBufferStore((state) => { + const current = getBufferById(state.buffers, editorBufferId); + return current?.type === "editor" ? current.savedContent : ""; + }); const editorLanguage = editorBuffer?.language; const editorLanguageOverride = editorBuffer?.languageOverride; const documentUri = editorBuffer?.lspDocument?.documentUri; @@ -439,6 +506,7 @@ export function MonacoEditor({ javaMarkerRefreshRevision(state.lspStatus), ); const inlineGitBlameEnabled = useSettingsStore((state) => state.settings.enableInlineGitBlame); + const gitGutterEnabled = useSettingsStore((state) => state.settings.enableGitGutter); const workspaceId = useActiveWorkspaceId(); const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); const javaTestScope = useMemo( @@ -481,6 +549,20 @@ export function MonacoEditor({ : undefined, ); + const gitGutterActive = + gitGutterEnabled && enableExpensiveServices && !isPreviewMode && !editorBuffer?.isVirtual; + const gitGutterBase = useGitGutterBase( + gitGutterActive && filePath ? filePath : undefined, + () => bufferSavedContent, + bufferSavedContent, + ); + gitGutterContextRef.current = { + base: gitGutterBase, + canStage: !bufferIsDirty && !readOnly, + languageId: monacoLanguageId, + t, + }; + const renderInlineGitBlame = useCallback(() => { const editor = editorRef.current; const model = modelRef.current; @@ -964,6 +1046,16 @@ export function MonacoEditor({ runMarkerDecorationSpecsRef.current, ); runMarkerDecorationsRef.current = runMarkerDecorationCollection; + // The gutter controller lives exactly as long as this editor and model, + // so recreating the editor (file switch, option change) also disposes any + // open inline review. Its base arrives through `setBase`. + const gitGutterController = createGitGutterController( + editor, + model, + gitGutterControllerOptions(() => gitGutterContextRef.current), + ); + gitGutterControllerRef.current = gitGutterController; + gitGutterController.setBase(gitGutterContextRef.current?.base?.lines ?? null); previousContentRef.current = content; pendingLocalContentSnapshotsRef.current = []; if (filePath && fileOpenBenchmark.has(filePath)) { @@ -1488,6 +1580,10 @@ export function MonacoEditor({ } implementationMarkerOwnerRef.current = null; implementationMarkersRef.current = []; + gitGutterController.dispose(); + if (gitGutterControllerRef.current === gitGutterController) { + gitGutterControllerRef.current = null; + } runMarkerDecorationCollection.clear(); if (runMarkerDecorationsRef.current === runMarkerDecorationCollection) { runMarkerDecorationsRef.current = null; @@ -2266,6 +2362,14 @@ export function MonacoEditor({ scheduleInlineGitBlameRender(); }, [renderInlineGitBlame, scheduleInlineGitBlameRender]); + useEffect(() => { + gitGutterControllerRef.current?.setBase(gitGutterBase?.lines ?? null); + }, [gitGutterBase]); + + useEffect(() => { + gitGutterControllerRef.current?.refreshPeek(); + }, [bufferIsDirty, readOnly]); + useEffect(() => { const editor = editorRef.current; const model = modelRef.current; diff --git a/windows/tauri/src/features/editor/engines/monaco/git-gutter-controller.ts b/windows/tauri/src/features/editor/engines/monaco/git-gutter-controller.ts new file mode 100644 index 000000000..ace8b70cd --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/git-gutter-controller.ts @@ -0,0 +1,212 @@ +import { editor as monacoEditor, KeyCode } from "monaco-editor"; +import type * as Monaco from "monaco-editor"; +import { + computeGitGutterChanges, + gitGutterChangeIndexAtLine, + gitGutterMarkerLines, + gitGutterPeekAnchorLine, + gitGutterPeekRows, + trimFinalEmptyLine, + type GitGutterChange, +} from "@/features/git/utils/git-gutter-changes"; +import { GitGutterPeekSession, type GitGutterPeek } from "./git-gutter-peek-session"; +import { + createGitGutterPeekWidget, + type GitGutterPeekLabels, + type GitGutterPeekWidget, +} from "./git-gutter-peek-widget"; +import { monacoLineDiff } from "./monaco-line-diff"; + +export const GIT_GUTTER_MARKER_CLASS = "lithe-git-gutter"; + +/** Pause after typing before the gutter is recomputed against the Git base. */ +const GIT_GUTTER_RECOMPUTE_DELAY_MS = 250; + +export interface GitGutterControllerOptions { + language(): string; + labels(peek: GitGutterPeek): GitGutterPeekLabels; + markerTooltip(kind: GitGutterChange["kind"]): string; + /** Staging writes the change into the index, so it needs saved, writable text. */ + canStage(): boolean; + stage(change: GitGutterChange, base: readonly string[]): Promise; + openDiff(): void; +} + +export interface GitGutterController { + /** The index text to compare against, or null when the file has no Git base. */ + setBase(base: readonly string[] | null): void; + /** Re-evaluates button state, for example after the document was saved. */ + refreshPeek(): void; + dispose(): void; +} + +/** + * Git change markers in the line-decoration gutter and their inline review. + * Markers compare the live editor text with the Git index, so unsaved edits + * show up as changes. Any edit closes an open review; a review only acts on + * the change list computed for the current model version. + */ +export function createGitGutterController( + editor: Monaco.editor.IStandaloneCodeEditor, + model: Monaco.editor.ITextModel, + options: GitGutterControllerOptions, +): GitGutterController { + const decorations = editor.createDecorationsCollection(); + const session = new GitGutterPeekSession(); + let base: readonly string[] | null = null; + let widget: GitGutterPeekWidget | null = null; + let timer: ReturnType | null = null; + let staging = false; + let disposed = false; + + const closePeek = () => { + session.close(); + widget?.dispose(); + widget = null; + }; + + const editorLines = () => trimFinalEmptyLine(model.getLinesContent()); + + const render = (peek: GitGutterPeek | null) => { + if (!peek) { + closePeek(); + return; + } + widget ??= createGitGutterPeekWidget(editor, { + previous: () => navigate("previous"), + next: () => navigate("next"), + stage: () => void stage(), + openDiff: () => options.openDiff(), + close: () => { + closePeek(); + editor.focus(); + }, + }); + widget.show({ + rows: gitGutterPeekRows(session.changeList, peek.index, editorLines()), + anchorLine: gitGutterPeekAnchorLine(peek.change), + language: options.language(), + labels: options.labels(peek), + canNavigate: peek.total > 1, + canStage: base !== null && options.canStage(), + staging, + }); + }; + + const navigate = (direction: "previous" | "next") => { + if (disposed) return; + render(session.navigate(direction, model.getVersionId())); + }; + + const stage = async () => { + const peek = session.peek; + const snapshot = base; + const version = model.getVersionId(); + if (disposed || staging || !peek || !snapshot || !options.canStage()) return; + if (!session.isCurrent(peek, version)) return; + staging = true; + render(peek); + try { + // A successful write emits a Git change event; the refreshed base then + // drops this change from the list and closes the review. + await options.stage(peek.change, snapshot); + } finally { + staging = false; + if (!disposed && session.peek) render(session.peek); + } + }; + + const recompute = () => { + timer = null; + if (disposed || model.isDisposed()) return; + const changes = base ? computeGitGutterChanges(base, editorLines(), monacoLineDiff) : null; + const lineCount = model.getLineCount(); + decorations.set( + (changes ?? []).map((change) => { + const { start, end } = gitGutterMarkerLines(change, lineCount); + return { + range: { startLineNumber: start, startColumn: 1, endLineNumber: end, endColumn: 1 }, + options: { + isWholeLine: true, + linesDecorationsClassName: `${GIT_GUTTER_MARKER_CLASS} ${GIT_GUTTER_MARKER_CLASS}-${change.kind}`, + linesDecorationsTooltip: options.markerTooltip(change.kind), + stickiness: monacoEditor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + }, + }; + }), + ); + render(session.setChanges(changes ?? [], model.getVersionId())); + }; + + const schedule = (delay: number) => { + if (timer !== null) clearTimeout(timer); + timer = setTimeout(recompute, delay); + }; + + const disposables = [ + model.onDidChangeContent(() => { + // The review describes text that no longer exists; markers keep + // following Monaco's tracked ranges until the recompute lands. + closePeek(); + session.invalidate(); + schedule(GIT_GUTTER_RECOMPUTE_DELAY_MS); + }), + editor.onDidChangeModel(() => { + closePeek(); + session.invalidate(); + decorations.clear(); + }), + editor.onMouseDown((event) => { + if ( + event.target.type !== monacoEditor.MouseTargetType.GUTTER_LINE_DECORATIONS || + !event.target.position || + !event.event.leftButton || + !event.target.element?.classList.contains(GIT_GUTTER_MARKER_CLASS) + ) { + return; + } + event.event.preventDefault(); + event.event.stopPropagation(); + const version = model.getVersionId(); + const index = gitGutterChangeIndexAtLine( + session.changeList, + event.target.position.lineNumber, + model.getLineCount(), + ); + if (index < 0) return; + if (session.peek?.index === index) { + closePeek(); + return; + } + render(session.open(index, version)); + // Monaco keeps focus where it was on gutter clicks; take it so Escape + // closes the review, as in VS Code. + editor.focus(); + }), + editor.onKeyDown((event) => { + if (event.keyCode !== KeyCode.Escape || !session.peek) return; + event.preventDefault(); + event.stopPropagation(); + closePeek(); + }), + ]; + + return { + setBase(next) { + if (disposed) return; + base = next; + schedule(0); + }, + refreshPeek() { + if (!disposed && session.peek) render(session.peek); + }, + dispose() { + if (disposed) return; + disposed = true; + if (timer !== null) clearTimeout(timer); + disposables.forEach((disposable) => disposable.dispose()); + closePeek(); + decorations.clear(); + }, + }; +} diff --git a/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-session.test.ts b/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-session.test.ts new file mode 100644 index 000000000..9364930bd --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-session.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { computeGitGutterChanges } from "@/features/git/utils/git-gutter-changes"; +import { GitGutterPeekSession } from "./git-gutter-peek-session"; +import { monacoLineDiff } from "./monaco-line-diff"; + +const BASE = ["a", "b", "c", "d", "e", "f"]; +const changesOf = (editor: string[]) => computeGitGutterChanges(BASE, editor, monacoLineDiff) ?? []; +const THREE_CHANGES = ["a", "B", "c", "D", "e", "F"]; + +describe("Git gutter inline review lifecycle", () => { + test("opens a change and navigates with wrap-around", () => { + const session = new GitGutterPeekSession(); + session.setChanges(changesOf(THREE_CHANGES), 7); + + expect(session.open(1, 7)?.index).toBe(1); + expect(session.navigate("next", 7)?.index).toBe(2); + expect(session.navigate("next", 7)?.index).toBe(0); + expect(session.navigate("previous", 7)?.index).toBe(2); + expect(session.peek?.total).toBe(3); + }); + + test("refuses to open a change list computed for another model version", () => { + const session = new GitGutterPeekSession(); + session.setChanges(changesOf(THREE_CHANGES), 7); + expect(session.open(0, 8)).toBeNull(); + expect(session.peek).toBeNull(); + }); + + test("an edit closes the review and invalidates the previous change list", () => { + const session = new GitGutterPeekSession(); + session.setChanges(changesOf(THREE_CHANGES), 7); + const peek = session.open(0, 7)!; + + session.invalidate(); + + expect(session.peek).toBeNull(); + expect(session.isCurrent(peek, 7)).toBe(false); + expect(session.navigate("next", 7)).toBeNull(); + }); + + test("a stale review cannot act after a newer review replaces it", () => { + const session = new GitGutterPeekSession(); + session.setChanges(changesOf(THREE_CHANGES), 7); + const first = session.open(0, 7)!; + const second = session.open(0, 7)!; + + expect(session.isCurrent(first, 7)).toBe(false); + expect(session.isCurrent(second, 7)).toBe(true); + expect(session.isCurrent(second, 8)).toBe(false); + }); + + test("a Git refresh keeps an unchanged review open and re-indexes it", () => { + const session = new GitGutterPeekSession(); + session.setChanges(changesOf(THREE_CHANGES), 7); + session.open(2, 7); + + // The first change was staged: the refreshed list no longer contains it. + const staged = computeGitGutterChanges(["a", "B", "c", "d", "e", "f"], THREE_CHANGES, monacoLineDiff)!; + const peek = session.setChanges(staged, 7); + + expect(peek?.index).toBe(1); + expect(peek?.total).toBe(2); + expect(peek?.change.modifiedLines).toEqual(["F"]); + }); + + test("a refresh that removes the open change closes the review", () => { + const session = new GitGutterPeekSession(); + session.setChanges(changesOf(THREE_CHANGES), 7); + session.open(0, 7); + + const afterStage = computeGitGutterChanges(["a", "B", "c", "d", "e", "f"], THREE_CHANGES, monacoLineDiff)!; + expect(session.setChanges(afterStage, 7)).toBeNull(); + expect(session.peek).toBeNull(); + }); + + test("closing, as on a file switch, clears the open review", () => { + const session = new GitGutterPeekSession(); + session.setChanges(changesOf(THREE_CHANGES), 7); + const peek = session.open(1, 7)!; + session.close(); + expect(session.peek).toBeNull(); + expect(session.isCurrent(peek, 7)).toBe(false); + }); +}); diff --git a/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-session.ts b/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-session.ts new file mode 100644 index 000000000..10debba53 --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-session.ts @@ -0,0 +1,102 @@ +import { + adjacentGitGutterChangeIndex, + gitGutterChangeKey, + type GitGutterChange, +} from "@/features/git/utils/git-gutter-changes"; + +/** The change an open inline review shows, bound to the text it was computed from. */ +export interface GitGutterPeek { + index: number; + total: number; + change: GitGutterChange; + key: string; + /** Monaco model version the change list was computed for. */ + version: number; + /** Increments on every open, so a late action from a replaced review is ignored. */ + generation: number; +} + +/** + * Owns which change list is current and which change is open. It has no + * Monaco dependency: the editor adapter reports content and model changes and + * renders whatever `peek` is. + */ +export class GitGutterPeekSession { + private changes: GitGutterChange[] = []; + private version = -1; + private generation = 0; + private current: GitGutterPeek | null = null; + + get peek(): GitGutterPeek | null { + return this.current; + } + + get changeList(): readonly GitGutterChange[] { + return this.changes; + } + + /** + * Publishes a change list computed for `version`. An open review stays open + * only when the same change, with the same content, is still in the list. + */ + setChanges(changes: GitGutterChange[], version: number): GitGutterPeek | null { + this.changes = changes; + this.version = version; + const open = this.current; + if (!open) return null; + // A Git refresh (save, stage, external change) recomputes the list for + // the same editor text; the review follows its change or closes when the + // change is gone, for example after it was staged. + const index = changes.findIndex((change) => gitGutterChangeKey(change) === open.key); + this.current = index < 0 ? null : { ...open, index, total: changes.length, version }; + return this.current; + } + + open(index: number, version: number): GitGutterPeek | null { + if (version !== this.version) return null; + const change = this.changes[index]; + if (!change) return null; + this.current = { + index, + total: this.changes.length, + change, + key: gitGutterChangeKey(change), + version, + generation: ++this.generation, + }; + return this.current; + } + + navigate(direction: "previous" | "next", version: number): GitGutterPeek | null { + const from = this.current?.index ?? -1; + const index = + from < 0 + ? direction === "next" + ? 0 + : this.changes.length - 1 + : adjacentGitGutterChangeIndex(this.changes.length, from, direction); + return index < 0 ? null : this.open(index, version); + } + + /** Editing, switching models or disposing makes the open review stale. */ + close(): void { + this.current = null; + } + + /** Text edits invalidate the published list until it is recomputed. */ + invalidate(): void { + this.current = null; + this.changes = []; + this.version = -1; + } + + /** True only for the review that is open now, over the text it was computed from. */ + isCurrent(peek: GitGutterPeek, version: number): boolean { + return ( + this.current !== null && + this.current.generation === peek.generation && + this.current.version === version && + this.version === version + ); + } +} diff --git a/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-widget.ts b/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-widget.ts new file mode 100644 index 000000000..44d9bafb8 --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-widget.ts @@ -0,0 +1,208 @@ +import { editor as monacoEditor } from "monaco-editor"; +import type * as Monaco from "monaco-editor"; +import { mountDiffReview, type ReviewRow } from "@lithe/editor/diff-review"; + +const { EditorOption } = monacoEditor; + +const WIDGET_ID = "lithe.gitGutterPeek"; +const HEADER_HEIGHT = 28; +const MIN_BODY_LINES = 3; +const MAX_BODY_LINES = 20; +/** The review never covers more than this share of the editor viewport. */ +const MAX_VIEWPORT_SHARE = 0.6; + +export interface GitGutterPeekLabels { + title: string; + previous: string; + next: string; + stage: string; + stageUnavailable: string; + openDiff: string; + close: string; +} + +export interface GitGutterPeekView { + rows: ReviewRow[]; + /** Editor line below which the review opens; 0 places it above line 1. */ + anchorLine: number; + language: string; + labels: GitGutterPeekLabels; + canNavigate: boolean; + canStage: boolean; + staging: boolean; +} + +export interface GitGutterPeekHandlers { + previous(): void; + next(): void; + stage(): void; + openDiff(): void; + close(): void; +} + +export interface GitGutterPeekWidget { + show(view: GitGutterPeekView): void; + dispose(): void; +} + +function iconButton(codicon: string, onClick: () => void): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "lithe-git-peek-button"; + const icon = document.createElement("span"); + icon.className = `codicon codicon-${codicon}`; + icon.setAttribute("aria-hidden", "true"); + button.append(icon); + // Keep focus in the editor; the review is a transient inspection surface. + button.addEventListener("mousedown", (event) => event.preventDefault()); + button.addEventListener("click", onClick); + return button; +} + +function labelButton(button: HTMLButtonElement, label: string) { + button.title = label; + button.setAttribute("aria-label", label); +} + +/** + * An inline, read-only review of one Git change, embedded between editor + * lines like VS Code's dirty-diff peek. A view zone reserves the space and an + * overlay widget renders into it, so Monaco moves it while scrolling without + * React state updates. The diff uses the shared review renderer with its own + * models, which `dispose` releases. + */ +export function createGitGutterPeekWidget( + editor: Monaco.editor.ICodeEditor, + handlers: GitGutterPeekHandlers, +): GitGutterPeekWidget { + const root = document.createElement("div"); + root.className = "lithe-git-peek"; + root.setAttribute("role", "region"); + root.style.top = "-1000px"; + + const header = document.createElement("div"); + header.className = "lithe-git-peek-header"; + header.style.height = `${HEADER_HEIGHT}px`; + const title = document.createElement("span"); + title.className = "lithe-git-peek-title"; + const actions = document.createElement("div"); + actions.className = "lithe-git-peek-actions"; + const stage = iconButton("add", handlers.stage); + const previous = iconButton("arrow-up", handlers.previous); + const next = iconButton("arrow-down", handlers.next); + const openDiff = iconButton("go-to-file", handlers.openDiff); + const close = iconButton("close", handlers.close); + actions.append(stage, next, previous, openDiff, close); + header.append(title, actions); + + const body = document.createElement("div"); + body.className = "lithe-git-peek-body"; + root.append(header, body); + root.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + handlers.close(); + }); + + const review = mountDiffReview(body); + const overlay: Monaco.editor.IOverlayWidget = { + getId: () => WIDGET_ID, + getDomNode: () => root, + // Positioned by the view zone callbacks below, like Monaco's ZoneWidget. + getPosition: () => null, + }; + editor.addOverlayWidget(overlay); + + let zoneId: string | null = null; + let height = 0; + let anchorLine = -1; + let disposed = false; + + const layoutWidth = () => { + const info = editor.getLayoutInfo(); + root.style.width = `${Math.max(0, info.width - info.minimap.minimapWidth - info.verticalScrollbarWidth)}px`; + }; + layoutWidth(); + const layoutListener = editor.onDidLayoutChange(layoutWidth); + + const removeZone = () => { + if (zoneId === null) return; + const id = zoneId; + zoneId = null; + editor.changeViewZones((accessor) => accessor.removeZone(id)); + }; + + return { + show(view) { + if (disposed) return; + title.textContent = view.labels.title; + root.setAttribute("aria-label", view.labels.title); + labelButton(previous, view.labels.previous); + labelButton(next, view.labels.next); + labelButton(openDiff, view.labels.openDiff); + labelButton(close, view.labels.close); + labelButton(stage, view.canStage ? view.labels.stage : view.labels.stageUnavailable); + previous.disabled = !view.canNavigate; + next.disabled = !view.canNavigate; + stage.disabled = !view.canStage || view.staging; + + const lineHeight = editor.getOption(EditorOption.lineHeight); + const viewportLines = Math.floor( + (editor.getLayoutInfo().height * MAX_VIEWPORT_SHARE - HEADER_HEIGHT) / lineHeight, + ); + const bodyLines = Math.max( + MIN_BODY_LINES, + Math.min(view.rows.length, MAX_BODY_LINES, Math.max(MIN_BODY_LINES, viewportLines)), + ); + const nextHeight = HEADER_HEIGHT + bodyLines * lineHeight + 2; + body.style.height = `${nextHeight - HEADER_HEIGHT - 2}px`; + root.style.height = `${nextHeight}px`; + + if (zoneId === null || height !== nextHeight || view.anchorLine !== anchorLine) { + removeZone(); + height = nextHeight; + anchorLine = view.anchorLine; + editor.changeViewZones((accessor) => { + zoneId = accessor.addZone({ + afterLineNumber: view.anchorLine, + heightInPx: nextHeight, + domNode: document.createElement("div"), + onDomNodeTop: (top) => { + root.style.top = `${top}px`; + }, + }); + }); + editor.revealLinesInCenterIfOutsideViewport( + Math.max(1, view.anchorLine), + Math.max(1, view.anchorLine + 1), + ); + } + + review.configure({ + fontFamily: editor.getOption(EditorOption.fontFamily), + fontSize: editor.getOption(EditorOption.fontSize), + lineHeight, + renderIndicators: true, + scrollbar: { alwaysConsumeMouseWheel: false }, + }); + void review.update({ + rows: view.rows, + language: view.language, + sideBySide: false, + collapse: false, + overview: false, + highlightWords: true, + }); + }, + dispose() { + if (disposed) return; + disposed = true; + layoutListener.dispose(); + removeZone(); + editor.removeOverlayWidget(overlay); + review.dispose(); + root.remove(); + }, + }; +} diff --git a/windows/tauri/src/features/editor/engines/monaco/monaco-diff-internals.d.ts b/windows/tauri/src/features/editor/engines/monaco/monaco-diff-internals.d.ts new file mode 100644 index 000000000..088adba2e --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/monaco-diff-internals.d.ts @@ -0,0 +1,25 @@ +// Narrow declaration for Monaco 0.55.1's synchronous line diff. The public API +// only exposes it through a diff editor; the gutter needs the raw line ranges. +// Re-check this shape whenever the pinned Monaco version changes. +declare module "monaco-editor/esm/vs/editor/common/diff/linesDiffComputers.js" { + interface LineRange { + readonly startLineNumber: number; + readonly endLineNumberExclusive: number; + } + interface LineRangeMapping { + readonly original: LineRange; + readonly modified: LineRange; + } + interface LinesDiff { + readonly changes: readonly LineRangeMapping[]; + readonly hitTimeout: boolean; + } + interface LinesDiffComputer { + computeDiff( + originalLines: string[], + modifiedLines: string[], + options: { ignoreTrimWhitespace: boolean; maxComputationTimeMs: number; computeMoves: boolean }, + ): LinesDiff; + } + export const linesDiffComputers: { getDefault(): LinesDiffComputer }; +} diff --git a/windows/tauri/src/features/editor/engines/monaco/monaco-line-diff.ts b/windows/tauri/src/features/editor/engines/monaco/monaco-line-diff.ts new file mode 100644 index 000000000..0ee704935 --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/monaco-line-diff.ts @@ -0,0 +1,35 @@ +import { linesDiffComputers } from "monaco-editor/esm/vs/editor/common/diff/linesDiffComputers.js"; +import type { GitGutterLineDiff } from "@/features/git/utils/git-gutter-changes"; + +/** + * Time budget for one gutter comparison. It runs on the UI thread after the + * user pauses typing; a file too large to compare in this time shows no + * markers instead of blocking input. + */ +const GIT_GUTTER_DIFF_BUDGET_MS = 150; + +/** Monaco's own line diff, the same algorithm its diff editor uses. */ +export const monacoLineDiff: GitGutterLineDiff = (original, modified) => { + // Monaco's computer assumes at least one line on each side, as in a model. + if (original.length === 0 || modified.length === 0) { + if (original.length === modified.length) return []; + return [{ + originalStart: 1, + originalEnd: original.length + 1, + modifiedStart: 1, + modifiedEnd: modified.length + 1, + }]; + } + const result = linesDiffComputers.getDefault().computeDiff([...original], [...modified], { + ignoreTrimWhitespace: false, + maxComputationTimeMs: GIT_GUTTER_DIFF_BUDGET_MS, + computeMoves: false, + }); + if (result.hitTimeout) return null; + return result.changes.map((change) => ({ + originalStart: change.original.startLineNumber, + originalEnd: change.original.endLineNumberExclusive, + modifiedStart: change.modified.startLineNumber, + modifiedEnd: change.modified.endLineNumberExclusive, + })); +}; diff --git a/windows/tauri/src/features/editor/styles/monaco-editor.css b/windows/tauri/src/features/editor/styles/monaco-editor.css index 5a019a46a..3fa91f9fd 100644 --- a/windows/tauri/src/features/editor/styles/monaco-editor.css +++ b/windows/tauri/src/features/editor/styles/monaco-editor.css @@ -516,3 +516,95 @@ .monaco-vim-statusbar:empty { display: none !important; } + +/* Git change markers in the line-decoration gutter (#681). */ +.monaco-editor-shell .monaco-editor .lithe-git-gutter { + width: 3px !important; + margin-left: 3px; + cursor: pointer; +} + +.monaco-editor-shell .monaco-editor .lithe-git-gutter-added { + background: var(--git-added, #2ea043); +} + +.monaco-editor-shell .monaco-editor .lithe-git-gutter-modified { + background: var(--git-modified, #0078d4); +} + +/* A deletion owns no line; draw a wedge at the top of the following line. */ +.monaco-editor-shell .monaco-editor .lithe-git-gutter-deleted { + width: 0 !important; + height: 0 !important; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 6px solid var(--git-deleted, #f85149); + margin-top: -4px; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek { + position: absolute; + left: 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + border-top: 1px solid var(--git-modified, #0078d4); + border-bottom: 1px solid var(--git-modified, #0078d4); + background: var(--background, #0f1117); + color: var(--foreground); + z-index: 5; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-header { + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0 0.5rem; + background: color-mix(in srgb, var(--surface, #1a1d24) 90%, var(--background)); + font-family: var(--app-font-family); + font-size: var(--ui-text-sm); +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-actions { + display: flex; + flex: none; + gap: 0.125rem; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 0; + border-radius: var(--radius-sm, 3px); + background: transparent; + color: inherit; + cursor: pointer; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-button:hover:not(:disabled), +.monaco-editor-shell .monaco-editor .lithe-git-peek-button:focus-visible { + background: color-mix(in srgb, var(--foreground) 12%, transparent); + outline: none; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-button:disabled { + opacity: 0.4; + cursor: default; +} + +.monaco-editor-shell .monaco-editor .lithe-git-peek-body { + position: relative; + flex: 1; + min-height: 0; +} diff --git a/windows/tauri/src/features/git/hooks/use-git-gutter-base.ts b/windows/tauri/src/features/git/hooks/use-git-gutter-base.ts new file mode 100644 index 000000000..65caa3ded --- /dev/null +++ b/windows/tauri/src/features/git/hooks/use-git-gutter-base.ts @@ -0,0 +1,93 @@ +import { useEffect, useRef, useState } from "react"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { getFullContextFileDiff } from "../api/git-diff-api"; +import { resolveRepositoryForFile } from "../api/git-repo-api"; +import { isGitChangeRelevant, subscribeToGitChanges } from "../events/git-events"; +import { useGitStore } from "../stores/git.store"; +import { + gitGutterBaseFromDiff, + splitGitGutterLines, + type GitGutterBaseSource, +} from "../utils/git-gutter-changes"; + +/** The Git index version of one editor file, which gutter markers compare against. */ +export interface GitGutterBase { + repoPath: string; + /** Repository-relative path with `/` separators, as hunk patches require. */ + filePath: string; + lines: string[]; +} + +/** + * Resolves the comparison base for a file. A null result means the file has + * no markers: it is untracked, outside a repository, binary, or the read failed. + */ +export async function loadGitGutterBase( + rootPath: string, + filePath: string, + savedContent: () => string, +): Promise { + const resolved = await resolveRepositoryForFile(rootPath, filePath); + if (!resolved) return null; + const status = useGitStore.getState().workspaceGitStatus; + const untracked = status?.files.some( + (file) => + file.status === "untracked" && + (file.repositoryRelativePath ?? file.path) === resolved.filePath, + ); + if (untracked) return null; + + const source: GitGutterBaseSource | null = gitGutterBaseFromDiff( + await getFullContextFileDiff(resolved.repoPath, resolved.filePath, false), + ); + if (!source) return null; + return { + ...resolved, + // No index-to-worktree difference: the saved file is the index version. + lines: source.kind === "lines" ? source.lines : splitGitGutterLines(savedContent()), + }; +} + +/** + * Loads the gutter base for the active editor and reloads it on Git changes + * (save, stage, external edits). Results for a previous file or an older + * request never replace the current one. + */ +export function useGitGutterBase( + filePath: string | undefined, + savedContent: () => string, + savedRevision: unknown, +): GitGutterBase | null { + const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); + const [state, setState] = useState<{ key: string; base: GitGutterBase | null } | null>(null); + const [refresh, setRefresh] = useState(0); + const key = filePath && rootFolderPath ? JSON.stringify([rootFolderPath, filePath]) : null; + // Read at request time; `savedRevision` is the dependency that says it changed. + const savedContentRef = useRef(savedContent); + savedContentRef.current = savedContent; + + useEffect(() => { + if (!filePath || !rootFolderPath) return; + return subscribeToGitChanges((change) => { + if (isGitChangeRelevant(change, rootFolderPath, filePath)) setRefresh((value) => value + 1); + }); + }, [filePath, rootFolderPath]); + + useEffect(() => { + if (!key || !filePath || !rootFolderPath) return; + let cancelled = false; + void loadGitGutterBase(rootFolderPath, filePath, () => savedContentRef.current()) + .catch((error) => { + console.error("Failed to load Git gutter base:", error); + return null; + }) + .then((base) => { + if (!cancelled) setState({ key, base }); + }); + return () => { + cancelled = true; + }; + }, [key, filePath, rootFolderPath, refresh, savedRevision]); + + return state && state.key === key ? state.base : null; +} diff --git a/windows/tauri/src/features/git/services/git-gutter-actions.ts b/windows/tauri/src/features/git/services/git-gutter-actions.ts new file mode 100644 index 000000000..58787ddea --- /dev/null +++ b/windows/tauri/src/features/git/services/git-gutter-actions.ts @@ -0,0 +1,44 @@ +import { activateMainEditorPane } from "@/features/editor/stores/buffer-pane-sync"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; +import { getWorkingTreePathDiff } from "../api/git-diff-api"; +import { stageHunk } from "../api/git-status-api"; +import type { GitGutterBase } from "../hooks/use-git-gutter-base"; +import { gitGutterChangeHunk, type GitGutterChange } from "../utils/git-gutter-changes"; +import { createSingleFileWorkingTreeDiff } from "../utils/working-tree-multi-diff"; + +/** + * Stages one gutter change through the existing hunk API. The patch is built + * against the index text the change was computed from, so Git rejects it + * instead of staging the wrong lines if the index moved in the meantime. + */ +export function stageGitGutterChange( + base: GitGutterBase, + baseLines: readonly string[], + change: GitGutterChange, +): Promise { + return stageHunk(base.repoPath, gitGutterChangeHunk(base.filePath, baseLines, change)); +} + +/** + * Opens the same single-file working-tree Diff tab as the Changes panel + * (HEAD to working tree, full context, per-hunk staging). + */ +export async function openGitGutterFullDiff( + base: GitGutterBase, + title: string, +): Promise { + const diff = await getWorkingTreePathDiff(base.repoPath, base.filePath, false, undefined, true); + if (!diff || (diff.lines.length === 0 && !diff.is_image && !diff.is_binary)) return null; + const fileKey = `unstaged:${base.filePath}`; + const multiDiff = createSingleFileWorkingTreeDiff({ + repoPath: base.repoPath, + fileKey, + diff, + title, + target: { repoPath: base.repoPath, filePath: base.filePath, untracked: false }, + }); + activateMainEditorPane(); + return useBufferStore + .getState() + .actions.openBuffer("diff://working-tree/all-files", title, "", false, undefined, true, true, multiDiff); +} diff --git a/windows/tauri/src/features/git/utils/git-gutter-changes.test.ts b/windows/tauri/src/features/git/utils/git-gutter-changes.test.ts new file mode 100644 index 000000000..f6939a195 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-gutter-changes.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from "bun:test"; +import { monacoLineDiff } from "@/features/editor/engines/monaco/monaco-line-diff"; +import type { GitDiff, GitDiffLine } from "../types/git.types"; +import { + adjacentGitGutterChangeIndex, + computeGitGutterChanges, + gitGutterBaseFromDiff, + gitGutterChangeHunk, + gitGutterChangeIndexAtLine, + gitGutterMarkerLines, + gitGutterPeekAnchorLine, + gitGutterPeekRows, + splitGitGutterLines, +} from "./git-gutter-changes"; + +const changesOf = (base: string[], editor: string[]) => + computeGitGutterChanges(base, editor, monacoLineDiff) ?? []; + +const fullContextDiff = (lines: GitDiffLine[], header: string): GitDiff => ({ + file_path: "src/app.ts", + is_new: false, + is_deleted: false, + is_renamed: false, + is_full_context: true, + lines: [{ line_type: "header", content: header }, ...lines], +}); + +describe("Git gutter change locations", () => { + test("classifies added, modified and deleted lines against the base", () => { + const changes = changesOf( + ["a", "b", "c", "d", "e"], + ["a", "B", "c", "e", "f"], + ); + + expect(changes.map((change) => change.kind)).toEqual(["modified", "deleted", "added"]); + expect(changes[0]).toMatchObject({ originalLines: ["b"], modifiedLines: ["B"], modifiedStart: 2 }); + expect(changes[1]).toMatchObject({ originalLines: ["d"], modifiedLines: [], modifiedStart: 4 }); + expect(changes[2]).toMatchObject({ originalLines: [], modifiedLines: ["f"], modifiedStart: 5 }); + }); + + test("marks a deletion on the following line, or the last line at end of file", () => { + const [middle] = changesOf(["a", "b", "c"], ["a", "c"]); + const [end] = changesOf(["a", "b", "c"], ["a", "b"]); + + expect(gitGutterMarkerLines(middle, 2)).toEqual({ start: 2, end: 2 }); + expect(gitGutterMarkerLines(end, 2)).toEqual({ start: 2, end: 2 }); + expect(gitGutterPeekAnchorLine(middle)).toBe(1); + expect(gitGutterPeekAnchorLine(end)).toBe(2); + }); + + test("resolves a clicked line to the change that owns it", () => { + const changes = changesOf(["a", "b", "c", "d"], ["a", "x", "y", "d", "z"]); + expect(gitGutterChangeIndexAtLine(changes, 3, 5)).toBe(0); + expect(gitGutterChangeIndexAtLine(changes, 5, 5)).toBe(1); + expect(gitGutterChangeIndexAtLine(changes, 4, 5)).toBe(-1); + }); + + test("selects a deletion through the line that follows it", () => { + // "b" is deleted, so its marker sits on "c" (line 2); "x" is a separate addition. + const changes = changesOf(["a", "b", "c"], ["a", "c", "x"]); + expect(changes.map((change) => change.kind)).toEqual(["deleted", "added"]); + expect(gitGutterChangeIndexAtLine(changes, 2, 3)).toBe(0); + expect(gitGutterChangeIndexAtLine(changes, 3, 3)).toBe(1); + }); + + test("a modification wins over a deletion marked on the same line", () => { + const changes = [ + { kind: "deleted", originalStart: 2, originalEnd: 3, modifiedStart: 2, modifiedEnd: 2, + originalLines: ["b"], modifiedLines: [] }, + { kind: "modified", originalStart: 3, originalEnd: 4, modifiedStart: 2, modifiedEnd: 3, + originalLines: ["c"], modifiedLines: ["C"] }, + ] as const; + expect(gitGutterChangeIndexAtLine([...changes], 2, 3)).toBe(1); + }); + + test("wraps previous and next navigation around the change list", () => { + expect(adjacentGitGutterChangeIndex(3, 2, "next")).toBe(0); + expect(adjacentGitGutterChangeIndex(3, 0, "previous")).toBe(2); + expect(adjacentGitGutterChangeIndex(3, 1, "next")).toBe(2); + expect(adjacentGitGutterChangeIndex(0, 0, "next")).toBe(-1); + }); + + test("compares an empty base or editor without calling the line diff", () => { + expect(changesOf([], ["a", "b"])).toEqual([ + expect.objectContaining({ kind: "added", modifiedStart: 1, modifiedEnd: 3 }), + ]); + expect(changesOf(["a"], [])).toEqual([ + expect.objectContaining({ kind: "deleted", originalStart: 1, originalEnd: 2 }), + ]); + expect(changesOf([], [])).toEqual([]); + }); + + test("shows no markers when the comparison exceeds its budget", () => { + expect(computeGitGutterChanges(["a"], ["b"], () => null)).toBeNull(); + }); +}); + +describe("Git gutter comparison base", () => { + test("rebuilds the index text from a full-context patch", () => { + const diff = fullContextDiff( + [ + { line_type: "context", content: "a", old_line_number: 1, new_line_number: 1 }, + { line_type: "removed", content: "b", old_line_number: 2 }, + { line_type: "added", content: "B", new_line_number: 2 }, + { line_type: "context", content: "c", old_line_number: 3, new_line_number: 3 }, + ], + "@@ -1,3 +1,3 @@", + ); + expect(gitGutterBaseFromDiff(diff)).toEqual({ kind: "lines", lines: ["a", "b", "c"] }); + }); + + test("treats an empty patch as unchanged and rejects partial or binary patches", () => { + expect(gitGutterBaseFromDiff({ ...fullContextDiff([], "@@ -1 +1 @@"), lines: [] })).toEqual({ + kind: "unchanged", + }); + const sparse = { ...fullContextDiff([], "@@ -10,2 +10,2 @@"), is_full_context: false }; + expect(gitGutterBaseFromDiff(sparse)).toBeNull(); + expect(gitGutterBaseFromDiff({ ...fullContextDiff([], "@@ -1 +1 @@"), is_binary: true })).toBeNull(); + expect(gitGutterBaseFromDiff(null)).toBeNull(); + }); + + test("rejects a patch whose old side does not cover the whole index file", () => { + const diff = fullContextDiff( + [{ line_type: "context", content: "a", old_line_number: 1, new_line_number: 1 }], + "@@ -1,2 +1,2 @@", + ); + expect(gitGutterBaseFromDiff(diff)).toBeNull(); + }); + + test("splits text like an editor, ignoring one trailing line break", () => { + expect(splitGitGutterLines("a\r\nb\n")).toEqual(["a", "b"]); + expect(splitGitGutterLines("a\rb")).toEqual(["a", "b"]); + expect(splitGitGutterLines("")).toEqual([]); + }); +}); + +describe("Git gutter inline review rows", () => { + test("shows the change with real line numbers and context up to the neighbours", () => { + const base = ["a", "b", "c", "d", "e", "f"]; + const editor = ["a", "X", "c", "d", "e", "F"]; + const changes = changesOf(base, editor); + const rows = gitGutterPeekRows(changes, 0, editor); + + expect(rows.map((row) => [row.kind, row.oldLine, row.newLine, row.left ?? row.right])).toEqual([ + ["context", 1, 1, "a"], + ["removal", 2, null, "b"], + ["addition", null, 2, "X"], + ["context", 3, 3, "c"], + ["context", 4, 4, "d"], + ]); + // The next change starts at editor line 6; its context stops before it. + const next = gitGutterPeekRows(changes, 1, editor); + expect(next.map((row) => row.newLine ?? row.oldLine)).toEqual([4, 5, 6, 6]); + }); + + test("keeps old line numbers aligned after an earlier insertion", () => { + const base = ["a", "b", "c"]; + const editor = ["new", "a", "b", "C"]; + const changes = changesOf(base, editor); + const rows = gitGutterPeekRows(changes, 1, editor); + expect(rows.find((row) => row.kind === "context" && row.newLine === 3)?.oldLine).toBe(2); + }); +}); + +describe("Git gutter staging patch", () => { + test("builds a hunk whose context comes from the index text", () => { + const base = ["1", "2", "3", "4", "5", "6", "7", "8"]; + const [change] = changesOf(base, ["1", "2", "3", "4", "FIVE", "6", "7", "8"]); + const hunk = gitGutterChangeHunk("src/app.ts", base, change); + + expect(hunk.file_path).toBe("src/app.ts"); + expect(hunk.lines.map((line) => `${line.line_type}:${line.content}`)).toEqual([ + "header:@@ -2,7 +2,7 @@", + "context:2", + "context:3", + "context:4", + "removed:5", + "added:FIVE", + "context:6", + "context:7", + "context:8", + ]); + }); + + test("uses index context even when a neighbouring unstaged change is nearby", () => { + // Line 2 and line 4 both changed; staging the second must not stage "B". + const base = ["a", "b", "c", "d", "e"]; + const editor = ["a", "B", "c", "D", "e"]; + const changes = changesOf(base, editor); + const hunk = gitGutterChangeHunk("f.txt", base, changes[1]); + + expect(hunk.lines.map((line) => `${line.line_type}:${line.content}`)).toEqual([ + "header:@@ -1,5 +1,5 @@", + "context:a", + "context:b", + "context:c", + "removed:d", + "added:D", + "context:e", + ]); + }); + + test("names Git's empty ranges for an insertion at the start and a full deletion", () => { + const [insertion] = changesOf([], ["x"]); + expect(gitGutterChangeHunk("f", [], insertion).lines[0].content).toBe("@@ -0,0 +1,1 @@"); + + const [deletion] = changesOf(["x"], []); + expect(gitGutterChangeHunk("f", ["x"], deletion).lines[0].content).toBe("@@ -1,1 +0,0 @@"); + }); +}); diff --git a/windows/tauri/src/features/git/utils/git-gutter-changes.ts b/windows/tauri/src/features/git/utils/git-gutter-changes.ts new file mode 100644 index 000000000..5dd1fdbd2 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-gutter-changes.ts @@ -0,0 +1,271 @@ +import type { ReviewRow } from "@lithe/editor/diff-review"; +import type { GitDiff, GitDiffLine, GitHunk } from "../types/git.types"; +import { parseDiffHunkRange } from "./git-diff-helpers"; + +export type GitGutterChangeKind = "added" | "modified" | "deleted"; + +/** One-based, end-exclusive line ranges, as Monaco's line diff reports them. */ +export interface GitGutterLineRange { + originalStart: number; + originalEnd: number; + modifiedStart: number; + modifiedEnd: number; +} + +/** One contiguous change between the Git base text and the editor text. */ +export interface GitGutterChange extends GitGutterLineRange { + kind: GitGutterChangeKind; + originalLines: string[]; + modifiedLines: string[]; +} + +/** Returns null when the comparison could not finish within its budget. */ +export type GitGutterLineDiff = ( + original: readonly string[], + modified: readonly string[], +) => GitGutterLineRange[] | null; + +/** + * Where the editor's comparison base comes from. `unchanged` means Git found + * no difference between the index and the file on disk, so the saved disk + * text is the base. + */ +export type GitGutterBaseSource = { kind: "lines"; lines: string[] } | { kind: "unchanged" }; + +/** Context kept around a staged gutter change. Matches Git's default `--unified=3`. */ +const GITGUTTER_HUNK_CONTEXT_LINES = 3; + +/** + * Splits text the way an editor counts lines. A trailing line break does not + * start another line, so `"a\n"` and `"a"` both compare as `["a"]`; the Git + * diff parser drops end-of-file newline markers in the same way. + */ +export function splitGitGutterLines(text: string): string[] { + if (text === "") return []; + const lines = text.split(/\r\n|\r|\n/); + if (lines[lines.length - 1] === "") lines.pop(); + return lines; +} + +/** Drops the empty line Monaco reports after a final line break. */ +export function trimFinalEmptyLine(lines: readonly string[]): string[] { + return lines.length > 0 && lines[lines.length - 1] === "" ? lines.slice(0, -1) : [...lines]; +} + +/** + * Rebuilds the index text from a full-context index-to-worktree patch. The + * old side of such a patch is the complete index version: every context and + * removed line, in order. Sparse, truncated or binary patches cannot supply + * that text and return null instead of a partial base. + */ +export function gitGutterBaseFromDiff(diff: GitDiff | null): GitGutterBaseSource | null { + // A null diff means the read failed or the file is outside a repository; + // an empty patch (no lines) is how Git reports an unchanged file. + if (!diff || diff.is_binary || diff.is_image || diff.is_truncated) return null; + const headers = diff.lines.filter((line) => line.line_type === "header"); + if (headers.length === 0) return diff.lines.length === 0 ? { kind: "unchanged" } : null; + if (headers.length > 1 || !diff.is_full_context || diff.lines[0]?.line_type !== "header") { + return null; + } + const range = parseDiffHunkRange(headers[0].content); + if (!range) return null; + const lines = diff.lines + .filter((line) => line.line_type === "context" || line.line_type === "removed") + .map((line) => line.content); + const expectedStart = range.oldCount === 0 ? 0 : 1; + if (range.oldStart !== expectedStart || lines.length !== range.oldCount) return null; + return { kind: "lines", lines }; +} + +export function computeGitGutterChanges( + original: readonly string[], + modified: readonly string[], + lineDiff: GitGutterLineDiff, +): GitGutterChange[] | null { + const ranges = lineDiff(original, modified); + if (!ranges) return null; + return ranges.map((range) => ({ + ...range, + kind: + range.originalStart === range.originalEnd + ? "added" + : range.modifiedStart === range.modifiedEnd + ? "deleted" + : "modified", + originalLines: original.slice(range.originalStart - 1, range.originalEnd - 1), + modifiedLines: modified.slice(range.modifiedStart - 1, range.modifiedEnd - 1), + })); +} + +/** + * Editor lines that carry a change's gutter marker. Deleted lines have no + * editor line of their own, so they mark the line that now follows them, or + * the last line when the deletion is at the end of the file (as on macOS). + */ +export function gitGutterMarkerLines( + change: GitGutterChange, + lineCount: number, +): { start: number; end: number } { + const lastLine = Math.max(1, lineCount); + if (change.kind === "deleted") { + const line = Math.min(Math.max(1, change.modifiedStart), lastLine); + return { start: line, end: line }; + } + return { + start: Math.min(change.modifiedStart, lastLine), + end: Math.min(change.modifiedEnd - 1, lastLine), + }; +} + +/** The editor line below which a change's inline review opens. Zero means above line 1. */ +export function gitGutterPeekAnchorLine(change: GitGutterChange): number { + return change.kind === "deleted" ? change.modifiedStart - 1 : change.modifiedEnd - 1; +} + +export function gitGutterChangeIndexAtLine( + changes: readonly GitGutterChange[], + line: number, + lineCount: number, +): number { + // A deletion can share its marker line with the start of the next change; + // the change that owns real editor lines wins that click. + let deletion = -1; + for (let index = 0; index < changes.length; index++) { + const { start, end } = gitGutterMarkerLines(changes[index], lineCount); + if (line < start || line > end) continue; + if (changes[index].kind !== "deleted") return index; + if (deletion < 0) deletion = index; + } + return deletion; +} + +/** Previous and next wrap around, as VS Code's and IDEA's change navigation do. */ +export function adjacentGitGutterChangeIndex( + total: number, + index: number, + direction: "previous" | "next", +): number { + if (total <= 0) return -1; + return (index + (direction === "next" ? 1 : total - 1)) % total; +} + +/** Unchanged lines shown above and below a change in its inline review. */ +const GIT_GUTTER_PEEK_CONTEXT_LINES = 2; + +/** + * Rows for one change's read-only inline review: the change plus a little + * unchanged context, with real line numbers on both sides. Context stops at + * the neighbouring changes so it never shows another change as unchanged. + */ +export function gitGutterPeekRows( + changes: readonly GitGutterChange[], + index: number, + editorLines: readonly string[], + contextLines = GIT_GUTTER_PEEK_CONTEXT_LINES, +): ReviewRow[] { + const change = changes[index]; + if (!change) return []; + const previousEnd = index > 0 ? changes[index - 1].modifiedEnd : 1; + const nextStart = + index + 1 < changes.length ? changes[index + 1].modifiedStart : editorLines.length + 1; + const context = (line: number, offset: number): ReviewRow => { + const text = editorLines[line - 1] ?? ""; + return { + id: `context-${line}`, + oldLine: line + offset, + newLine: line, + left: text, + right: text, + kind: "context", + hunkID: null, + }; + }; + + const rows: ReviewRow[] = []; + const offsetBefore = change.originalStart - change.modifiedStart; + for ( + let line = Math.max(previousEnd, change.modifiedStart - contextLines); + line < change.modifiedStart; + line++ + ) { + rows.push(context(line, offsetBefore)); + } + change.originalLines.forEach((text, offset) => + rows.push({ + id: `removed-${offset}`, + oldLine: change.originalStart + offset, + newLine: null, + left: text, + right: null, + kind: "removal", + hunkID: null, + }), + ); + change.modifiedLines.forEach((text, offset) => + rows.push({ + id: `added-${offset}`, + oldLine: null, + newLine: change.modifiedStart + offset, + left: null, + right: text, + kind: "addition", + hunkID: null, + }), + ); + const offsetAfter = change.originalEnd - change.modifiedEnd; + const contextEnd = Math.min(nextStart, change.modifiedEnd + contextLines); + for (let line = change.modifiedEnd; line < contextEnd; line++) { + rows.push(context(line, offsetAfter)); + } + return rows; +} + +/** Identity of a change's content, used to keep a review open across refreshes. */ +export function gitGutterChangeKey(change: GitGutterChange): string { + return JSON.stringify([ + change.originalStart, + change.originalEnd, + change.modifiedStart, + change.modifiedEnd, + change.originalLines, + change.modifiedLines, + ]); +} + +/** + * Builds a patch that stages exactly one change against the index. Context + * comes from the base (index) text, never from the editor, so a neighbouring + * unstaged change inside the context window stays out of the index. + */ +export function gitGutterChangeHunk( + filePath: string, + base: readonly string[], + change: GitGutterChange, +): GitHunk { + const context = GITGUTTER_HUNK_CONTEXT_LINES; + const removedStart = change.originalStart - 1; + const removedEnd = change.originalEnd - 1; + const contextStart = Math.max(0, removedStart - context); + const contextEnd = Math.min(base.length, removedEnd + context); + const leading = removedStart - contextStart; + const trailing = contextEnd - removedEnd; + const contextLine = (content: string): GitDiffLine => ({ line_type: "context", content }); + + const oldCount = contextEnd - contextStart; + const newCount = leading + change.modifiedLines.length + trailing; + // Git names the line before an empty range, or 0 at the start of the file. + const oldStart = oldCount === 0 ? contextStart : contextStart + 1; + const firstNewLine = change.modifiedStart - leading; + const newStart = newCount === 0 ? firstNewLine - 1 : firstNewLine; + + return { + file_path: filePath, + lines: [ + { line_type: "header", content: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@` }, + ...base.slice(contextStart, removedStart).map(contextLine), + ...change.originalLines.map((content): GitDiffLine => ({ line_type: "removed", content })), + ...change.modifiedLines.map((content): GitDiffLine => ({ line_type: "added", content })), + ...base.slice(removedEnd, contextEnd).map(contextLine), + ], + }; +} diff --git a/windows/tauri/src/features/settings/components/tabs/git-settings.tsx b/windows/tauri/src/features/settings/components/tabs/git-settings.tsx index 36e705710..eaca978c4 100644 --- a/windows/tauri/src/features/settings/components/tabs/git-settings.tsx +++ b/windows/tauri/src/features/settings/components/tabs/git-settings.tsx @@ -22,6 +22,7 @@ export const GitSettings = () => { confirmBeforeDiscard: state.settings.confirmBeforeDiscard, coreFeatures: state.settings.coreFeatures, enableInlineGitBlame: state.settings.enableInlineGitBlame, + enableGitGutter: state.settings.enableGitGutter, gitChangesFolderView: state.settings.gitChangesFolderView, gitDefaultDiffView: state.settings.gitDefaultDiffView, openDiffOnClick: state.settings.openDiffOnClick, @@ -251,6 +252,18 @@ export const GitSettings = () => { size="sm" /> + updateSetting("enableGitGutter", getDefaultSetting("enableGitGutter"))} + canReset={settings.enableGitGutter !== getDefaultSetting("enableGitGutter")} + > + updateSetting("enableGitGutter", checked)} + size="sm" + /> + ); diff --git a/windows/tauri/src/features/settings/config/default-settings.ts b/windows/tauri/src/features/settings/config/default-settings.ts index 5b25230b5..786f0041a 100644 --- a/windows/tauri/src/features/settings/config/default-settings.ts +++ b/windows/tauri/src/features/settings/config/default-settings.ts @@ -210,6 +210,7 @@ export const defaultSettings: Settings = { gitSidebarTabOrder: ["changes", "history"], githubSidebarSectionOrder: ["pull-requests", "issues", "actions"], enableInlineGitBlame: true, + enableGitGutter: true, }; export const getDefaultSetting = (key: K): Settings[K] => diff --git a/windows/tauri/src/features/settings/config/search-index.ts b/windows/tauri/src/features/settings/config/search-index.ts index 069ceb714..e07a6bb1b 100644 --- a/windows/tauri/src/features/settings/config/search-index.ts +++ b/windows/tauri/src/features/settings/config/search-index.ts @@ -410,7 +410,7 @@ export const settingsSearchIndex: SettingSearchRecord[] = [ tab: "git", section: "Editor", label: "Enable Git Gutter", - description: "Show Git gutter indicators in the editor", + description: "Mark changed lines in the editor gutter and review a change inline", keywords: ["git", "gutter", "editor", "modified", "added", "deleted"], }, diff --git a/windows/tauri/src/features/settings/types/settings.types.ts b/windows/tauri/src/features/settings/types/settings.types.ts index 9e5901418..6d4f6a6af 100644 --- a/windows/tauri/src/features/settings/types/settings.types.ts +++ b/windows/tauri/src/features/settings/types/settings.types.ts @@ -207,4 +207,6 @@ export interface Settings { gitSidebarTabOrder: Array<"changes" | "history">; githubSidebarSectionOrder: Array<"pull-requests" | "issues" | "actions">; enableInlineGitBlame: boolean; + /** Git change markers in the editor gutter; clicking one opens its inline review. */ + enableGitGutter: boolean; } diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 221eb26d8..760244afc 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -2057,6 +2057,9 @@ const catalogs = { "settings.git.inlineBlame": "Enable Inline Blame", "settings.git.inlineBlameDescription": "Show inline Git blame metadata for the current line in the editor", + "settings.git.gutter": "Enable Git Gutter", + "settings.git.gutterDescription": + "Mark changed lines in the editor gutter; click a marker to review the change inline", "settings.general.version": "Version", "settings.general.versionDescription": "Check for updates and install the latest app version.", "settings.general.downloading": "Downloading...", @@ -4506,6 +4509,16 @@ const catalogs = { "git.diff.statusModified": "MODIFIED", "git.diff.statusRenamed": "RENAMED", "git.diff.stage": "Stage", + "git.gutter.title": "Git Local Changes (Working Tree) - Change {index} of {total}", + "git.gutter.previous": "Previous Change", + "git.gutter.next": "Next Change", + "git.gutter.stage": "Stage Change", + "git.gutter.stageUnavailable": "Save the file to stage this change", + "git.gutter.openDiff": "Open Full Diff", + "git.gutter.close": "Close", + "git.gutter.added": "Added lines - click to review the change", + "git.gutter.modified": "Modified lines - click to review the change", + "git.gutter.deleted": "Deleted lines - click to review the change", "git.diff.unstage": "Unstage", "git.diff.uncommitted": "Uncommitted Changes", "welcome.openProjectOrRemote": "Open project or remote connection", @@ -6509,6 +6522,8 @@ const catalogs = { "settings.git.editor": "编辑器", "settings.git.inlineBlame": "启用行内 Blame", "settings.git.inlineBlameDescription": "在编辑器中显示当前行的 Git Blame 元数据", + "settings.git.gutter": "启用 Git 变更标记", + "settings.git.gutterDescription": "在编辑器行号栏标记有改动的行,点击标记可在行内查看这一处差异", "settings.general.version": "版本", "settings.general.versionDescription": "检查更新并安装最新应用版本。", "settings.general.downloading": "正在下载...", @@ -8856,6 +8871,16 @@ const catalogs = { "git.diff.statusModified": "修改", "git.diff.statusRenamed": "重命名", "git.diff.stage": "暂存", + "git.gutter.title": "Git 本地更改(工作树)- 第 {index} 个更改(共 {total} 个)", + "git.gutter.previous": "上一处更改", + "git.gutter.next": "下一处更改", + "git.gutter.stage": "暂存更改", + "git.gutter.stageUnavailable": "保存文件后才能暂存此更改", + "git.gutter.openDiff": "打开完整 Diff", + "git.gutter.close": "关闭", + "git.gutter.added": "新增的行 - 点击查看此更改", + "git.gutter.modified": "修改的行 - 点击查看此更改", + "git.gutter.deleted": "删除的行 - 点击查看此更改", "git.diff.unstage": "取消暂存", "git.diff.uncommitted": "未提交的更改", "welcome.openProjectOrRemote": "打开项目或远程连接",