diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml
index 553b4d40f..6553e28f5 100644
--- a/.github/workflows/ci-windows.yml
+++ b/.github/workflows/ci-windows.yml
@@ -228,6 +228,10 @@ jobs:
bun test src/features/file-explorer/lib/java-clipboard-class.test.ts src/features/file-explorer/lib/paste-java-class-from-clipboard.test.ts
node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-file-watcher.json -- src/features/file-system/services/file-watcher-listener.test.ts src/features/file-system/stores/file-watcher.store.test.ts
+ - name: Test Git gutter changes and inline review lifecycle
+ shell: pwsh
+ run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-gutter.json -- src/features/git/utils/git-gutter-changes.test.ts src/features/editor/engines/monaco/git-gutter-peek-session.test.ts
+
- name: Test editor tab scrolling and lifecycle
shell: pwsh
run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-tabs.json -- src/features/tabs
diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv
index 1a5bd70e4..241d81460 100644
--- a/docs/development/platform-parity-matrix.csv
+++ b/docs/development/platform-parity-matrix.csv
@@ -116,3 +116,6 @@ editor-unicode-text-classification,编辑器,文本编辑,中文 properties 与
ide-api-plugins,插件与扩展,IDE API / MCP,插件调用已授权项目的环境、Maven 与运行 API,已实现,待验证,已实现,待验证,IDE API,双端插件枚举显式授权的项目,读取环境并调用已允许的操作;撤销项目权限后拒绝调用,Windows 未声明 permissions.ide 的 Worker 无法访问。,复用 MCP 的同一应用功能和参数校验,不扩张语言包权限;原生运行验收待完成。,macos/Sources/LitheModuleAPI/Plugins/IDECapabilities.swift; macos/Sources/Lithe/Application/Composition/IdeCapabilityRegistry.swift,Plugins/win/SDK/ide-capabilities.ts; windows/tauri/src/extensions/ui/services/ide-extension-host.ts; windows/tauri/src/extensions/ui/services/ide-extension-host.test.ts
java-service-devtools-update,Run/Debug,Java 服务更新,运行中的 Spring Boot DevTools 服务手动保存并编译更新,已实现,待验证,已实现,待验证,Run/Debug,在双端运行 Spring Boot HTTP 服务,修改方法体并更新,检查响应、PID、DevTools 日志与断点暂停状态;覆盖编译失败、JVM 拒绝结构修改、连续点击、停止/重启、依赖路径变化。对比运行前后安装目录清单/哈希。Linux 仅完成共享 Core 和前端测试,原生验证待完成。,,macos/Sources/Lithe/Views/Run/RunView.swift,windows/tauri/src/features/run/components/run-pane.tsx
java-service-hot-code-replace,Run/Debug,Java 服务更新,本地 Java Debug 服务手动编译并热替换代码,已实现,待验证,已实现,待验证,Run/Debug,在双端运行 Spring Boot HTTP 服务,修改方法体并更新,检查响应、PID、DevTools 日志与断点暂停状态;覆盖编译失败、JVM 拒绝结构修改、连续点击、停止/重启、依赖路径变化。对比运行前后安装目录清单/哈希。Linux 仅完成共享 Core 和前端测试,原生验证待完成。,,macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift,windows/tauri/src/features/debugger/components/java-service-update.tsx
+editor-git-gutter-peek,编辑器,Git 变更,编辑器 Git 行变更标记、点击预览与变更导航,已实现,待验证,已实现,待验证,Editor / Git,在 Windows 打开已跟踪文件,新增、修改、删除内容并保存,点击标记查看差异、切换前后变更、按 Escape 关闭;确认多仓库同名文件互不影响。切换浅色、深色及自定义主色,检查面板边框、焦点和 Git 语义色;关闭 Git 标记设置后标记消失。macOS 检查对应行变更入口。Linux 单元测试不替代目标平台运行验证。,,macos/Sources/Lithe/Views/Editor/CodeEditorView.swift,windows/tauri/src/features/editor/engines/monaco/git-gutter-controller.ts; windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-widget.ts; windows/tauri/src/features/git/utils/git-gutter-changes.test.ts
+editor-git-gutter-stage,编辑器,Git 变更,从编辑器行变更入口暂存单处修改,已实现,待验证,已实现,待验证,Editor / Git,保存含多处增删改的已跟踪文件,单独暂存后一处修改,检查 index 只包含选中修改,邻近与前方未暂存修改保留;未保存或只读时禁用暂存。Windows 与 macOS 均需实机验证。,,macos/Sources/Lithe/Views/Editor/CodeEditorView.swift,windows/tauri/src/features/git/services/git-gutter-actions.ts; windows/tauri/src/features/git/utils/git-gutter-changes.test.ts
+editor-git-gutter-setting,编辑器,Git 变更,设置中开关编辑器 Git 变更标记,平台专属,不适用,已实现,待验证,Editor / Git,Windows 默认显示 Git 变更标记;在 Git 设置中关闭时清除标记和预览,重新启用后恢复,重启后保留选择。此项记录 Windows 新增的独立开关。,,macos/Sources/Lithe/Views/Editor/CodeEditorView.swift,windows/tauri/src/features/settings/components/tabs/git-settings.tsx; windows/tauri/src/features/settings/config/default-settings.ts
diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md
index 3533caaa1..4305bb633 100644
--- a/docs/development/platform-parity-matrix.md
+++ b/docs/development/platform-parity-matrix.md
@@ -4,9 +4,9 @@
- 最后复核:2026-09-29
- 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。)
-- 功能项:117
-- macOS:实现:✅ 103 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 0 已验证,🔍 106 待验证,— 11 不适用
-- Windows:实现:✅ 99 已实现,🟡 11 部分实现,❌ 5 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 110 待验证,— 7 不适用
+- 功能项:120
+- macOS:实现:✅ 105 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 5 平台专属;验证:✔️ 0 已验证,🔍 108 待验证,— 12 不适用
+- Windows:实现:✅ 102 已实现,🟡 11 部分实现,❌ 5 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 113 待验证,— 7 不适用
## 实现状态定义
@@ -72,7 +72,7 @@
-编辑器 · 16 个能力点
+编辑器 · 19 个能力点
| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 |
| --- | --- | --- | --- | --- | --- | --- |
@@ -92,6 +92,9 @@
| 文本编辑 | **按指定编码重新打开文本文件**
editor-file-encoding-reopen | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift`、`macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift`、`macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/FileSystem/MacDocumentEncoding.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/command-palette/components/encoding-picker.tsx`、`windows/tauri/src/features/editor/services/document-encoding-workflow.ts`、`windows/tauri/crates/project/src/document_file.rs` | Editor | 在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。 | |
| 文本编辑 | **按指定编码保存文本文件**
editor-file-encoding-save | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift`、`macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift`、`macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/FileSystem/MacDocumentEncoding.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/command-palette/components/encoding-picker.tsx`、`windows/tauri/src/features/editor/stores/editor-app.store.ts`、`windows/tauri/crates/project/src/document_file.rs` | Editor | 在 macOS 和 Windows 实机验证 UTF-8、UTF-8 BOM 与 GBK/GB18030 的自动识别,并分别验证 Shift JIS、Windows-1252 的指定编码重新打开、编码转换保存、脏文件选择和外部修改保护。 | |
| 文本编辑 | **中文 properties 与未知扩展名文本的一致打开和恢复**
editor-unicode-text-classification | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Core/Rust/RustTextContentPolicy.swift`、`macos/Tests/LitheTests/TextContentPolicyTests.swift`、`frontend/editor/src/language-contributions.ts` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/file-system/controllers/workspace-session-restore.ts`、`windows/tauri/src/features/file-system/controllers/workspace-session-restore.test.ts`、`frontend/editor/src/language-contributions.ts` | Editor | 双端打开高中文比例的 .properties 和未知扩展名 UTF-8 文件,编辑保存后恢复会话并验证内容及编码;验证 properties 高亮、emoji 跨边界、NUL 和控制字符拒绝、读取错误独立报告,并执行 text-content-v1.json 共享规则样例。 | |
+| Git 变更 | **编辑器 Git 行变更标记、点击预览与变更导航**
editor-git-gutter-peek | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor/CodeEditorView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor/engines/monaco/git-gutter-controller.ts`、`windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-widget.ts`、`windows/tauri/src/features/git/utils/git-gutter-changes.test.ts` | Editor / Git | 在 Windows 打开已跟踪文件,新增、修改、删除内容并保存,点击标记查看差异、切换前后变更、按 Escape 关闭;确认多仓库同名文件互不影响。切换浅色、深色及自定义主色,检查面板边框、焦点和 Git 语义色;关闭 Git 标记设置后标记消失。macOS 检查对应行变更入口。Linux 单元测试不替代目标平台运行验证。 | |
+| Git 变更 | **从编辑器行变更入口暂存单处修改**
editor-git-gutter-stage | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor/CodeEditorView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git/services/git-gutter-actions.ts`、`windows/tauri/src/features/git/utils/git-gutter-changes.test.ts` | Editor / Git | 保存含多处增删改的已跟踪文件,单独暂存后一处修改,检查 index 只包含选中修改,邻近与前方未暂存修改保留;未保存或只读时禁用暂存。Windows 与 macOS 均需实机验证。 | |
+| Git 变更 | **设置中开关编辑器 Git 变更标记**
editor-git-gutter-setting | 🧩 平台专属
— 不适用
`macos/Sources/Lithe/Views/Editor/CodeEditorView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/settings/components/tabs/git-settings.tsx`、`windows/tauri/src/features/settings/config/default-settings.ts` | Editor / Git | Windows 默认显示 Git 变更标记;在 Git 设置中关闭时清除标记和预览,重新启用后恢复,重启后保留选择。此项记录 Windows 新增的独立开关。 | |
diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json
index ef781746f..ad707092a 100644
--- a/shared/platform-feature-matrix.json
+++ b/shared/platform-feature-matrix.json
@@ -3025,6 +3025,76 @@
},
"owner": "Run/Debug",
"verification": "在双端运行 Spring Boot HTTP 服务,修改方法体并更新,检查响应、PID、DevTools 日志与断点暂停状态;覆盖编译失败、JVM 拒绝结构修改、连续点击、停止/重启、依赖路径变化。对比运行前后安装目录清单/哈希。Linux 仅完成共享 Core 和前端测试,原生验证待完成。"
+ },
+ {
+ "id": "editor-git-gutter-peek",
+ "area": "编辑器",
+ "group": "Git 变更",
+ "capability": "编辑器 Git 行变更标记、点击预览与变更导航",
+ "macos": {
+ "implementationStatus": "implemented",
+ "verificationStatus": "pending",
+ "evidence": [
+ "macos/Sources/Lithe/Views/Editor/CodeEditorView.swift"
+ ]
+ },
+ "windows": {
+ "implementationStatus": "implemented",
+ "verificationStatus": "pending",
+ "evidence": [
+ "windows/tauri/src/features/editor/engines/monaco/git-gutter-controller.ts",
+ "windows/tauri/src/features/editor/engines/monaco/git-gutter-peek-widget.ts",
+ "windows/tauri/src/features/git/utils/git-gutter-changes.test.ts"
+ ]
+ },
+ "owner": "Editor / Git",
+ "verification": "在 Windows 打开已跟踪文件,新增、修改、删除内容并保存,点击标记查看差异、切换前后变更、按 Escape 关闭;确认多仓库同名文件互不影响。切换浅色、深色及自定义主色,检查面板边框、焦点和 Git 语义色;关闭 Git 标记设置后标记消失。macOS 检查对应行变更入口。Linux 单元测试不替代目标平台运行验证。"
+ },
+ {
+ "id": "editor-git-gutter-stage",
+ "area": "编辑器",
+ "group": "Git 变更",
+ "capability": "从编辑器行变更入口暂存单处修改",
+ "macos": {
+ "implementationStatus": "implemented",
+ "verificationStatus": "pending",
+ "evidence": [
+ "macos/Sources/Lithe/Views/Editor/CodeEditorView.swift"
+ ]
+ },
+ "windows": {
+ "implementationStatus": "implemented",
+ "verificationStatus": "pending",
+ "evidence": [
+ "windows/tauri/src/features/git/services/git-gutter-actions.ts",
+ "windows/tauri/src/features/git/utils/git-gutter-changes.test.ts"
+ ]
+ },
+ "owner": "Editor / Git",
+ "verification": "保存含多处增删改的已跟踪文件,单独暂存后一处修改,检查 index 只包含选中修改,邻近与前方未暂存修改保留;未保存或只读时禁用暂存。Windows 与 macOS 均需实机验证。"
+ },
+ {
+ "id": "editor-git-gutter-setting",
+ "area": "编辑器",
+ "group": "Git 变更",
+ "capability": "设置中开关编辑器 Git 变更标记",
+ "macos": {
+ "implementationStatus": "platform-specific",
+ "verificationStatus": "not-applicable",
+ "evidence": [
+ "macos/Sources/Lithe/Views/Editor/CodeEditorView.swift"
+ ]
+ },
+ "windows": {
+ "implementationStatus": "implemented",
+ "verificationStatus": "pending",
+ "evidence": [
+ "windows/tauri/src/features/settings/components/tabs/git-settings.tsx",
+ "windows/tauri/src/features/settings/config/default-settings.ts"
+ ]
+ },
+ "owner": "Editor / Git",
+ "verification": "Windows 默认显示 Git 变更标记;在 Git 设置中关闭时清除标记和预览,重新启用后恢复,重启后保留选择。此项记录 Windows 新增的独立开关。"
}
]
}
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..3fd46e5df 100644
--- a/windows/tauri/src/features/editor/styles/monaco-editor.css
+++ b/windows/tauri/src/features/editor/styles/monaco-editor.css
@@ -516,3 +516,99 @@
.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);
+}
+
+.monaco-editor-shell .monaco-editor .lithe-git-gutter-modified {
+ background: var(--git-modified);
+}
+
+/* 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);
+ 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(--primary);
+ border-bottom: 1px solid var(--primary);
+ background: var(--background);
+ 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) 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) {
+ 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;
+}
+
+.monaco-editor-shell .monaco-editor .lithe-git-peek-button:focus-visible {
+ outline: 1px solid var(--primary);
+ outline-offset: -1px;
+}
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..deda99bea
--- /dev/null
+++ b/windows/tauri/src/features/git/hooks/use-git-gutter-base.ts
@@ -0,0 +1,91 @@
+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,
+ isGitGutterFileUntracked,
+ 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;
+ if (isGitGutterFileUntracked(status?.files ?? [], resolved.repoPath, resolved.filePath)) {
+ 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..1c612f3a3
--- /dev/null
+++ b/windows/tauri/src/features/git/utils/git-gutter-changes.test.ts
@@ -0,0 +1,253 @@
+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,
+ isGitGutterFileUntracked,
+ type GitGutterChange,
+ 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: GitGutterChange[] = [
+ { 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"] },
+ ];
+ 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 @@");
+ });
+});
+
+
+describe("Git gutter repository identity", () => {
+ test("an untracked sibling path does not hide the tracked file's markers", () => {
+ const files = [{
+ path: "a/src/app.ts", repositoryPath: "C:/workspace/a",
+ repositoryRelativePath: "src/app.ts", status: "untracked" as const, staged: false,
+ }];
+ expect(isGitGutterFileUntracked(files, "C:/workspace/b", "src/app.ts")).toBe(false);
+ expect(isGitGutterFileUntracked(files, "C:/workspace/a/", "src/app.ts")).toBe(true);
+ expect(isGitGutterFileUntracked(files, "C:/workspace/a", "src/other.ts")).toBe(false);
+ });
+
+ test("single-repository status retains undecorated path support", () => {
+ expect(isGitGutterFileUntracked([
+ { path: "src/app.ts", status: "untracked", staged: false },
+ ], "C:/workspace/a", "src/app.ts")).toBe(true);
+ });
+});
+
+describe("Git gutter isolated patch coordinates", () => {
+ test("earlier unstaged deletions do not make the selected patch start negative", () => {
+ const base = Array.from({ length: 10 }, (_, index) => String(index + 1));
+ // Exercise an isolated range directly: Monaco may coalesce nearby edits,
+ // but patch coordinates must remain valid for any selected range.
+ const change: GitGutterChange = {
+ kind: "modified", originalStart: 10, originalEnd: 11,
+ modifiedStart: 2, modifiedEnd: 3, originalLines: ["10"], modifiedLines: ["TEN"],
+ };
+ const hunk = gitGutterChangeHunk("f", base, change);
+ expect(hunk.lines[0].content).toBe("@@ -7,4 +7,4 @@");
+ expect(hunk.lines.slice(1).map((line) => line.content)).toEqual(["7", "8", "9", "10", "TEN"]);
+ });
+
+ test("earlier unstaged insertions do not shift the selected patch", () => {
+ const base = ["a", "b", "c", "d", "e", "f"];
+ const changes = changesOf(base, ["new", ...base.slice(0, -1), "F"]);
+ expect(changes).toHaveLength(2);
+ expect(gitGutterChangeHunk("f", base, changes[1]).lines[0].content).toBe("@@ -3,4 +3,4 @@");
+ });
+});
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..b04a3aac8
--- /dev/null
+++ b/windows/tauri/src/features/git/utils/git-gutter-changes.ts
@@ -0,0 +1,288 @@
+import type { ReviewRow } from "@lithe/editor/diff-review";
+import type { GitDiff, GitDiffLine, GitFile, GitHunk } from "../types/git.types";
+import { normalizeRepositoryPath } from "../api/git-repository-path";
+import { parseDiffHunkRange } from "./git-diff-helpers";
+
+/** Aggregated workspace status must match both repository and relative path. */
+export function isGitGutterFileUntracked(
+ files: readonly GitFile[],
+ repoPath: string,
+ filePath: string,
+): boolean {
+ return files.some((file) =>
+ file.status === "untracked" &&
+ (!file.repositoryPath ||
+ normalizeRepositoryPath(file.repositoryPath) === normalizeRepositoryPath(repoPath)) &&
+ (file.repositoryRelativePath ?? file.path) === filePath,
+ );
+}
+
+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;
+ // This patch applies only this change to the index. Earlier unstaged
+ // insertions/deletions must not shift its new-side coordinates.
+ const firstNewLine = contextStart + 1;
+ 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 d1ee83df0..b0912a855 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...",
@@ -4513,6 +4516,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.rollbackHunk": "Rollback Change Block",
"git.diff.rollbackHunkConfirm":
@@ -6519,6 +6532,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": "正在下载...",
@@ -8873,6 +8888,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.rollbackHunk": "回滚修改块",
"git.diff.rollbackHunkConfirm": "回滚 “{path}” 中的这个修改块吗?该块未暂存的修改将被丢弃,此操作无法撤销。",