feat(vcs): diff preview in the Git window's Local Changes tab (#714)

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>
This commit is contained in:
arookieofc
2026-11-03 10:18:39 +08:00
committed by GitHub
co-authored by a-rookie-of-C-language
parent 4f64ca3149
commit 00e717d8fd
7 changed files with 226 additions and 5 deletions
+42 -2
View File
@@ -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 (
<SplitGroup orientation={anchor === "bottom" ? "horizontal" : "vertical"} className={styles.split}>
<SplitPanel defaultSize="50%" minSize="20%">
<ChangesTree mode="local" />
</SplitPanel>
{preview && (
<>
<SplitHandle />
<SplitPanel defaultSize="50%" minSize="120px">
{path ? (
<DiffEditor input={diffInput(workingDiffData(path))} />
) : (
<EmptyState>
<T>Select a file to preview its changes</T>
</EmptyState>
)}
</SplitPanel>
</>
)}
</SplitGroup>
);
}
/**
* 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) },
]
: []),
])
}
/>
+20 -1
View File
@@ -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;
+2 -2
View File
@@ -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" ? (
<ChangesTree mode="local" />
<LocalChangesView />
) : shown === "shelf" || shown === "stashes" ? (
<StashContent tab={shown} />
) : shown === "console" ? (
@@ -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) => (
<>
<button>Changes tree</button>
<DiffEditor input={input} />
</>
);
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(<DiffEditor input={revisions} />);
@@ -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<typeof import("@/platform/ipc")>();
return { ...real, call: vi.fn() };
});
vi.mock("../DiffEditor", () => ({
DiffEditor: ({ input }: { input: { data: { path: string } } }) => (
<div data-testid="diff-preview" data-path={input.data.path} />
),
}));
const openDiff = vi.hoisted(() => vi.fn());
vi.mock("../diffInput", async (original) => ({
...(await original<typeof import("../diffInput")>()),
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(<LocalChangesView />);
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(<LocalChangesView />);
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(<LocalChangesView />);
expect(screen.getByTestId("diff-preview").getAttribute("data-path")).toBe("/p/src/a.ts");
});
it("shows a placeholder until exactly one file is selected", () => {
render(<LocalChangesView />);
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(<LocalChangesView />);
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(<LocalChangesView />);
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(<LocalChangesView />);
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(<LocalChangesView />);
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");
});
});
+12
View File
@@ -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<string, CheckState>;
/** Changed blocks left out of the commit (absolute path → `hunkKeys`) of `hunks` files. */
@@ -42,6 +44,7 @@ export const useCommit = create<CommitState>(() => ({
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;
+1
View File
@@ -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": "严重性",