From 00e717d8fdba4bf58f8f53ea9934be893a677cd9 Mon Sep 17 00:00:00 2001
From: arookieofc <2128194521@qq.com>
Date: Tue, 3 Nov 2026 10:18:39 +0800
Subject: [PATCH] feat(vcs): diff preview in the Git window's Local Changes tab
(#714)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Local Changes tab of the Git tool window showed only the changes tree; a file's diff was visible only after opening a diff tab.
It now previews the selected file's working-tree diff beside the tree when the window is docked at the bottom and below it when docked on a side. The preview follows the selection (arrow keys included), shows a placeholder for no or multiple selection, and can be turned off with View Options › Show Diff Preview (persisted; the selection is kept when toggling).
The preview reuses the diff editor, whose toolbar did not fit a side-docked window: the editor is now a size container, the whitespace and highlighting pickers move to the settings menu below 640px and the separators go below 380px, buttons no longer shrink and the differences counter truncates.
Fixes #710.
Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com>
---
src/features/vcs/CommitView.tsx | 44 +++++-
src/features/vcs/DiffEditor.module.css | 21 ++-
src/features/vcs/GitLog.tsx | 4 +-
src/features/vcs/__tests__/diffFind.test.tsx | 19 +++
.../vcs/__tests__/localChanges.test.tsx | 130 ++++++++++++++++++
src/features/vcs/commitStore.ts | 12 ++
src/platform/locales/text/zh.json | 1 +
7 files changed, 226 insertions(+), 5 deletions(-)
create mode 100644 src/features/vcs/__tests__/localChanges.test.tsx
diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx
index f9019a0a..b809529d 100644
--- a/src/features/vcs/CommitView.tsx
+++ b/src/features/vcs/CommitView.tsx
@@ -32,6 +32,7 @@ import {
T,
} from "@/ui";
import { editorService } from "@/workbench/contributions";
+import { useToolWindowLayout } from "@/workbench/toolwindows/store";
import {
aggregateCheck,
buildChangeTree,
@@ -47,10 +48,11 @@ import { applyStash, commitChanges, dropStash, showUnstashDialog, stashChanges }
import { CommitOptionsPopup } from "./CommitOptions";
import { DEFAULT_LIST_NAME, useChangelists, withListDefaults } from "./changelists";
import { moveToChangelist } from "./changelistsUi";
-import { messageHistory, setAmend, setGroupByDir, useCommit, type CommitTab } from "./commitStore";
+import { messageHistory, setAmend, setDiffPreview, setGroupByDir, useCommit, type CommitTab } from "./commitStore";
import { pushAfterCommit } from "./commitUi";
import { stashTabOf, stashTabs, type ContentTab } from "./gitTabs";
-import { openDiff, withChain, workingDiffData } from "./diffInput";
+import { diffInput, openDiff, withChain, workingDiffData } from "./diffInput";
+import { DiffEditor } from "./DiffEditor";
import { KIND_COLOR, KIND_LABEL, fileCount, formatTime, stashLabel, stripSlash } from "./model";
import { call, commands } from "@/platform/ipc";
import type { FileStatus } from "@/bindings";
@@ -344,6 +346,37 @@ function ChangesPanel() {
);
}
+/**
+ * The Git window's Local Changes tab: a diff preview beside the tree when
+ * bottom-docked, below it when side-docked (off via View Options › Show Diff Preview).
+ */
+export function LocalChangesView() {
+ const preview = useCommit((s) => s.diffPreview);
+ const path = useCommit((s) => (s.selection.length === 1 ? s.selection[0] : null));
+ const anchor = useToolWindowLayout((s) => s.placements.git?.anchor ?? "bottom");
+ return (
+
+
+
+
+ {preview && (
+ <>
+
+
+ {path ? (
+
+ ) : (
+
+ Select a file to preview its changes
+
+ )}
+
+ >
+ )}
+
+ );
+}
+
/**
* The change tree with its toolbar. `commit`: the Commit tool window's (inclusion
* checkboxes); `local`: the Git tool window's Local Changes tab of the modal commit
@@ -353,6 +386,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
const status = useVcs((s) => s.status);
const root = useVcs((s) => s.root) ?? "";
const groupByDir = useCommit((s) => s.groupByDir);
+ const diffPreview = useCommit((s) => s.diffPreview);
const lists = useChangelists((s) => s.state);
const checks = useCommitChecks();
const checkboxes = mode !== "local";
@@ -438,6 +472,12 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
showContextMenu(bottomLeft(viewRef.current), [
{ kind: "header", label: "Group By" },
{ label: "Directory", checked: groupByDir, run: () => setGroupByDir(!groupByDir) },
+ ...(mode === "local"
+ ? [
+ { kind: "separator" } as const,
+ { label: "Show Diff Preview", checked: diffPreview, run: () => setDiffPreview(!diffPreview) },
+ ]
+ : []),
])
}
/>
diff --git a/src/features/vcs/DiffEditor.module.css b/src/features/vcs/DiffEditor.module.css
index acf34543..5fc7db47 100644
--- a/src/features/vcs/DiffEditor.module.css
+++ b/src/features/vcs/DiffEditor.module.css
@@ -1,5 +1,6 @@
.root {
position: relative;
+ container: diff-editor / inline-size;
flex: 1;
height: 100%;
min-height: 0;
@@ -16,19 +17,37 @@
padding: 0 6px;
border-bottom: 1px solid var(--border);
}
+.toolbar > * {
+ flex-shrink: 0;
+}
.sep {
width: 1px;
height: 14px;
margin: 0 4px;
background: var(--border-strong);
}
-.count {
+.toolbar > .count {
+ flex-shrink: 1;
+ min-width: 0;
margin-left: auto;
padding: 0 6px;
+ overflow: hidden;
+ text-overflow: ellipsis;
color: var(--fg-subtle);
font-size: var(--fs-small);
white-space: nowrap;
}
+/* Narrow (a side-docked preview): the whitespace / highlighting pickers stay in the gear menu. */
+@container diff-editor (max-width: 640px) {
+ .select {
+ display: none;
+ }
+}
+@container diff-editor (max-width: 380px) {
+ .toolbar > .sep {
+ display: none;
+ }
+}
.labels {
flex: none;
display: flex;
diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx
index ba4c431f..440a9b03 100644
--- a/src/features/vcs/GitLog.tsx
+++ b/src/features/vcs/GitLog.tsx
@@ -93,7 +93,7 @@ import styles from "./GitLog.module.css";
import { tx } from "@/platform/i18n";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { gitTabs, shownTab } from "./gitTabs";
-import { ChangesTree, StashContent, tabItems, useStashTabCounts } from "./CommitView";
+import { LocalChangesView, StashContent, tabItems, useStashTabCounts } from "./CommitView";
import { WorktreesPanel } from "./gitMenuDialogs";
const ROW = 26;
@@ -148,7 +148,7 @@ export function GitToolWindow() {
aria-labelledby={`openidea-git-tab-${shown}`}
>
{shown === "changes" ? (
-
+
) : shown === "shelf" || shown === "stashes" ? (
) : shown === "console" ? (
diff --git a/src/features/vcs/__tests__/diffFind.test.tsx b/src/features/vcs/__tests__/diffFind.test.tsx
index e7cdde2d..a42649bc 100644
--- a/src/features/vcs/__tests__/diffFind.test.tsx
+++ b/src/features/vcs/__tests__/diffFind.test.tsx
@@ -40,6 +40,7 @@ vi.mock("@/editor/api", async (importOriginal) => ({
}));
import { DiffEditor } from "../DiffEditor";
+import type { DiffData } from "../diffInput";
const diff = (right: object) =>
({
@@ -74,6 +75,24 @@ const press = async (index: number, code: string, shift = false) => {
afterEach(() => useKeymap.setState({ overrides: {} }));
describe("Find in the diff viewer", () => {
+ it("preserves external focus when loading and changing a working-tree preview", async () => {
+ const preview = (input: EditorInput) => (
+ <>
+
+
+ >
+ );
+ const { rerender } = render(preview(working));
+ const tree = screen.getByRole("button", { name: "Changes tree" });
+ tree.focus();
+ const oldPane = await pane(1);
+ expect(tree).toHaveFocus();
+ rerender(preview({ ...working, key: "diff:b.ts", data: { ...(working.data as DiffData), path: "/p/b.ts" } }));
+ await waitFor(() => expect(oldPane.dom.isConnected).toBe(false));
+ await pane(1);
+ expect(tree).toHaveFocus();
+ });
+
it("opens the editor's find bar from the keymap and closes it with Esc", async () => {
useKeymap.setState({ overrides: KEYMAP });
render();
diff --git a/src/features/vcs/__tests__/localChanges.test.tsx b/src/features/vcs/__tests__/localChanges.test.tsx
new file mode 100644
index 00000000..23b2f8a5
--- /dev/null
+++ b/src/features/vcs/__tests__/localChanges.test.tsx
@@ -0,0 +1,130 @@
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/platform/ipc", async (importOriginal) => {
+ const real = await importOriginal();
+ return { ...real, call: vi.fn() };
+});
+
+vi.mock("../DiffEditor", () => ({
+ DiffEditor: ({ input }: { input: { data: { path: string } } }) => (
+
+ ),
+}));
+
+const openDiff = vi.hoisted(() => vi.fn());
+vi.mock("../diffInput", async (original) => ({
+ ...(await original()),
+ openDiff,
+}));
+
+import { LocalChangesView } from "../CommitView";
+import { setDiffPreview, useCommit } from "../commitStore";
+import { useVcs } from "../store";
+import { useToolWindowLayout } from "@/workbench/toolwindows/store";
+
+describe("Local Changes diff preview (#710)", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ openDiff.mockClear();
+ useCommit.setState({ diffPreview: true, selection: [] });
+ useVcs.setState({ root: "/p", repo: "/p", roots: ["/p"], status: [] });
+ useToolWindowLayout.setState({ placements: {} });
+ });
+
+ it("places the preview beside the tree for default bottom docking", () => {
+ render();
+ expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "vertical");
+ });
+
+ it.each(["left", "right"] as const)("stacks the preview for %s docking and updates when moved", (anchor) => {
+ render();
+ act(() => useToolWindowLayout.setState({ placements: { git: { anchor } } }));
+ expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "horizontal");
+ act(() => useToolWindowLayout.setState({ placements: { git: { anchor: "bottom" } } }));
+ expect(screen.getByRole("separator")).toHaveAttribute("aria-orientation", "vertical");
+ });
+
+ it("previews the single selected file's working-tree diff", () => {
+ useCommit.setState({ selection: ["/p/src/a.ts"] });
+ render();
+ expect(screen.getByTestId("diff-preview").getAttribute("data-path")).toBe("/p/src/a.ts");
+ });
+
+ it("shows a placeholder until exactly one file is selected", () => {
+ render();
+ expect(screen.getByText("Select a file to preview its changes")).toBeTruthy();
+ useCommit.setState({ selection: ["/p/a.ts", "/p/b.ts"] });
+ expect(screen.getByText("Select a file to preview its changes")).toBeTruthy();
+ });
+
+ it("follows the selection", () => {
+ useCommit.setState({ selection: ["/p/a.ts"] });
+ render();
+ expect(screen.getByTestId("diff-preview").getAttribute("data-path")).toBe("/p/a.ts");
+ act(() => useCommit.setState({ selection: ["/p/b.ts"] }));
+ expect(screen.getByTestId("diff-preview").getAttribute("data-path")).toBe("/p/b.ts");
+ act(() => useCommit.setState({ selection: [] }));
+ expect(screen.queryByTestId("diff-preview")).toBeNull();
+ });
+
+ it("previews keyboard selection and opens the full diff on Enter and double click", () => {
+ useVcs.setState({
+ status: ["a.ts", "b.ts"].map((name) => ({
+ path: `/p/${name}`,
+ relPath: name,
+ staged: null,
+ unstaged: "modified" as const,
+ renamedFrom: null,
+ })),
+ });
+ render();
+ const tree = screen.getByRole("tree", { name: "Changes" });
+ tree.focus();
+ fireEvent.keyDown(tree, { key: "Home" });
+ fireEvent.keyDown(tree, { key: "ArrowDown" });
+ expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/a.ts");
+ fireEvent.keyDown(tree, { key: "ArrowDown" });
+ expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/b.ts");
+ expect(tree).toHaveFocus();
+ expect(openDiff).not.toHaveBeenCalled();
+ fireEvent.keyDown(tree, { key: "Enter" });
+ expect(openDiff).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/p/b.ts" }), { preview: true });
+ fireEvent.doubleClick(screen.getByText("a.ts"));
+ expect(openDiff).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/p/a.ts" }), { preview: true });
+ });
+
+ it("turns off via the View Options toggle, persists, and keeps the tree alone", () => {
+ useCommit.setState({ selection: ["/p/a.ts"] });
+ render();
+ act(() => setDiffPreview(false));
+ expect(useCommit.getState().diffPreview).toBe(false);
+ expect(screen.queryByTestId("diff-preview")).toBeNull();
+ expect(screen.getByText("No local changes")).toBeTruthy();
+ expect(localStorage.getItem("openidea.vcs.diffPreview")).toBe("false");
+ });
+
+ it("keeps the tree selection when the preview is toggled off and on", () => {
+ useVcs.setState({
+ status: ["a.ts", "b.ts"].map((name) => ({
+ path: `/p/${name}`,
+ relPath: name,
+ staged: null,
+ unstaged: "modified" as const,
+ renamedFrom: null,
+ })),
+ });
+ render();
+ const tree = screen.getByRole("tree", { name: "Changes" });
+ tree.focus();
+ fireEvent.keyDown(tree, { key: "Home" });
+ fireEvent.keyDown(tree, { key: "ArrowDown" });
+ fireEvent.keyDown(tree, { key: "ArrowDown" });
+ expect(screen.getByText("b.ts").closest('[role="treeitem"]')).toHaveAttribute("aria-selected", "true");
+ act(() => setDiffPreview(false));
+ expect(screen.getByText("b.ts").closest('[role="treeitem"]')).toHaveAttribute("aria-selected", "true");
+ act(() => setDiffPreview(true));
+ expect(screen.getByText("b.ts").closest('[role="treeitem"]')).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByTestId("diff-preview")).toHaveAttribute("data-path", "/p/b.ts");
+ });
+});
diff --git a/src/features/vcs/commitStore.ts b/src/features/vcs/commitStore.ts
index e609cec9..b1f78b72 100644
--- a/src/features/vcs/commitStore.ts
+++ b/src/features/vcs/commitStore.ts
@@ -14,6 +14,8 @@ interface CommitState {
/** Commit Options › Run Git hooks. */
runHooks: boolean;
groupByDir: boolean;
+ /** Whether the Git window's Local Changes tab previews the selection's diff. */
+ diffPreview: boolean;
/** User overrides of the inclusion checkboxes (absolute path → state). */
checks: Map;
/** Changed blocks left out of the commit (absolute path → `hunkKeys`) of `hunks` files. */
@@ -42,6 +44,7 @@ export const useCommit = create(() => ({
author: "",
runHooks: true,
groupByDir: readBool("openidea.vcs.groupByDir", false),
+ diffPreview: readBool("openidea.vcs.diffPreview", true),
checks: new Map(),
excludedHunks: new Map(),
selection: [],
@@ -71,6 +74,15 @@ export function setGroupByDir(v: boolean) {
}
}
+export function setDiffPreview(v: boolean) {
+ useCommit.setState({ diffPreview: v });
+ try {
+ localStorage.setItem("openidea.vcs.diffPreview", String(v));
+ } catch {
+ // ignore
+ }
+}
+
const historyKey = () => `openidea.vcs.commitMessages:${useVcs.getState().root ?? ""}`;
const MAX_HISTORY = 20;
diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json
index 5d0f755f..3a2d099a 100644
--- a/src/platform/locales/text/zh.json
+++ b/src/platform/locales/text/zh.json
@@ -1755,6 +1755,7 @@
"Restore Size": "恢复大小",
"Restore detected roots": "恢复检测到的根",
"Result": "结果",
+ "Select a file to preview its changes": "选择文件以预览其更改",
"Select a problem to see its details": "选择一个问题以查看详情",
"Select an item to preview": "选择一项以预览",
"Severity": "严重性",