mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
co-authored by
a-rookie-of-C-language
parent
4f64ca3149
commit
00e717d8fd
@@ -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) },
|
||||
]
|
||||
: []),
|
||||
])
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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": "严重性",
|
||||
|
||||
Reference in New Issue
Block a user