fix(vcs): maximize diffs and keep file navigation (#907)

Diff tabs, including the preview tab, get Maximize Editor / Restore Size in their toolbar (reusing Hide All Tool Windows). Show Diff from the Commit view or the Git log now carries the whole change list: F7 / Shift+F7 at the last or first difference shows a hint, and pressing again moves to the first or last difference of the neighbouring file, skipping binary, oversized and unreadable files. The layout is restored after a preview is replaced and keyboard focus stays in the diff across files. Compare branches stays single-file.

Fixes #904

Co-authored-by: kkMihai <59262001+kkMihai@users.noreply.github.com>
This commit is contained in:
kkmihai
2026-11-03 23:41:16 +08:00
committed by GitHub
co-authored by kkMihai
parent c2fa6f37ab
commit a3576fca90
14 changed files with 483 additions and 21 deletions
+94
View File
@@ -0,0 +1,94 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./fixtures";
async function openChain(page: Page) {
await page.evaluate(async () => {
const inputPath = "/src/features/vcs/diffInput.ts";
const { openDiff, withChain } = await import(inputPath);
localStorage.setItem("openidea.vcs.diffOptions", JSON.stringify({ mode: "split", collapse: false }));
const files = ["a.ts", "b.ts"].map((name) => ({
path: `/dev/acme-api/${name}`,
left: { type: "text", text: "same\nbefore\ntail\n", label: "HEAD" },
right: { type: "text", text: "same\nafter\ntail\n", label: "Working tree" },
}));
await openDiff(withChain(files, 0), { preview: true });
});
const toolbar = page.getByRole("toolbar", { name: "Diff", exact: true });
await expect(toolbar).toContainText("1 difference");
return toolbar;
}
test("maximizes a diff preview tab and restores its previous layout", async ({ workbench: page }) => {
const toolbar = await openChain(page);
const viewer = toolbar.locator("..");
const project = page.getByRole("tree", { name: "Project", exact: true });
await expect(project).toBeVisible();
const before = (await viewer.boundingBox())!;
await toolbar.getByRole("button", { name: "Maximize Editor", exact: true }).click();
await expect(project).toBeHidden();
await expect.poll(async () => (await viewer.boundingBox())!.width).toBeGreaterThan(before.width + 100);
await expect(toolbar).toContainText("1 difference");
await toolbar.getByRole("button", { name: "Restore Size", exact: true }).click();
await expect(project).toBeVisible();
await expect.poll(async () => Math.abs((await viewer.boundingBox())!.width - before.width)).toBeLessThan(2);
await expect(toolbar).toContainText("1 difference");
});
test("continues between changed files from toolbar buttons and the keymap", async ({ workbench: page }) => {
const toolbar = await openChain(page);
const selected = page.getByRole("tablist", { name: "Editor tabs" }).locator('[aria-selected="true"]');
await toolbar.getByRole("button", { name: "Next Difference", exact: true }).click();
await expect(toolbar).toContainText("1 of 1 differences");
await toolbar.getByRole("button", { name: "Next Difference", exact: true }).click();
await expect(page.getByRole("status").filter({ hasText: "Press again to go to the next file" })).toBeVisible();
await expect(selected).toContainText("a.ts");
await page.keyboard.press("F7");
await expect(selected).toContainText("b.ts");
await expect(toolbar).toContainText("1 of 1 differences");
await toolbar.getByRole("button", { name: "Previous Difference", exact: true }).click();
await expect(page.getByRole("status").filter({ hasText: "Press again to go to the previous file" })).toBeVisible();
await toolbar.getByRole("button", { name: "Previous Difference", exact: true }).click();
await expect(selected).toContainText("a.ts");
await expect(toolbar).toContainText("1 of 1 differences");
});
test("skips binary and unchanged siblings and restores after changing preview files", async ({ workbench: page }) => {
const toolbar = await openChain(page);
await page.evaluate(async () => {
const inputPath = "/src/features/vcs/diffInput.ts";
const layoutPath = "/src/workbench/toolwindows/store.ts";
const { openDiff, withChain } = await import(inputPath);
const { useToolWindowLayout } = await import(layoutPath);
useToolWindowLayout.setState({ maximized: "left" });
const files = [
["a.ts", "after\n"],
["binary.dat", "\0binary"],
["unchanged.ts", "before\n"],
["z.ts", "after\n"],
].map(([name, text]) => ({
path: `/dev/acme-api/${name}`,
left: { type: "text", text: "before\n", label: "HEAD" },
right: { type: "text", text, label: "Working tree" },
}));
await openDiff(withChain(files, 0), { preview: true });
});
await expect(toolbar).toContainText("1 difference");
await toolbar.getByRole("button", { name: "Maximize Editor", exact: true }).click();
const selected = page.getByRole("tablist", { name: "Editor tabs" }).locator('[aria-selected="true"]');
await page.keyboard.press("F7");
await page.keyboard.press("F7");
await expect(selected).toContainText("z.ts");
await expect(toolbar).toContainText("1 of 1 differences");
await page.keyboard.press("F7");
await expect(selected).toContainText("z.ts");
await page.keyboard.press("Shift+F7");
await page.keyboard.press("Shift+F7");
await expect(selected).toContainText("a.ts");
await toolbar.getByRole("button", { name: "Restore Size", exact: true }).click();
const maximized = await page.evaluate(async () => {
const layoutPath = "/src/workbench/toolwindows/store.ts";
return (await import(layoutPath)).useToolWindowLayout.getState().maximized;
});
expect(maximized).toBe("left");
await expect(page.getByRole("tree", { name: "Project", exact: true })).toBeVisible();
});
+2 -5
View File
@@ -37,6 +37,7 @@ import { useToolWindowLayout } from "@/workbench/toolwindows/store";
import {
aggregateCheck,
buildChangeTree,
changeGroupPaths,
checkOf,
filesOf,
ignoreTargets,
@@ -168,11 +169,7 @@ const bottomLeft = (el: HTMLElement) => {
/** Opens a file's diff; F7 continues through the other files of its group, in tree order. */
function openNode(n: ChangeNode, roots: readonly ChangeNode[]) {
if (n.type !== "file" || !n.path) return;
const group = roots.find((g) => g.files.some((f) => f.path === n.files[0]?.path)) ?? null;
const paths: string[] = [];
const walk = (ns: readonly ChangeNode[]) =>
ns.forEach((x) => (x.type === "file" ? x.path && paths.push(x.path) : walk(x.children ?? [])));
walk(group ? [group] : [n]);
const paths = changeGroupPaths(roots, n.path);
const items = paths.map((p) => workingDiffData(p));
void openDiff(withChain(items, Math.max(0, paths.indexOf(n.path))), { preview: true });
}
+40 -8
View File
@@ -1,6 +1,16 @@
/** Diff viewer editor tab (side-by-side / unified) built on @codemirror/merge. */
import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
type MouseEvent,
type ReactNode,
} from "react";
import clsx from "clsx";
import { EditorView, keymap } from "@codemirror/view";
import { Prec, Text, type Extension } from "@codemirror/state";
@@ -52,6 +62,7 @@ import {
type DiffSide,
} from "./diffInput";
import { diffAnnotations } from "./diffAnnotations";
import { changedChainNeighbour } from "./diffNavigation";
import { movedCodeHighlights } from "./movedCode";
import {
createDiffViewerContributions,
@@ -198,7 +209,7 @@ function isStrokeOf(e: KeyboardEvent, actionId: string): boolean {
return !!stroke && bindingsFor(actionId).some((b) => normalizeBinding(b) === stroke);
}
export function DiffEditor({ input }: { input: EditorInput }) {
export function DiffEditor({ input, toolbarActions }: { input: EditorInput; toolbarActions?: ReactNode }) {
const data = input.data as DiffData;
const { path, left, right } = data;
const [attached, setAttached] = useState<Attached | null>(null);
@@ -552,14 +563,35 @@ export function DiffEditor({ input }: { input: EditorInput }) {
if (at === undefined) return;
if (data.line != null) revealPos(v, at);
else v.dispatch({ selection: { anchor: at }, effects: EditorView.scrollIntoView(at, { y: "center" }) });
// A left-side line: that pane gets the focus, so navigation goes on from there.
if (v === viewsRef.current?.a) v.focus();
// Keep keyboard navigation in the revealed pane after moving to another file.
if (data.reveal || v === viewsRef.current?.a) v.focus();
updateChunkInfo();
}, [ready, statics, revealKey, data.line, data.lineSide, data.reveal, updateChunkInfo]);
// IDEA: past the last (first) difference F7 (Shift+F7) first hints, then moves along the chain.
const [navHint, setNavHint] = useState<string | null>(null);
const pendingFile = useRef<0 | 1 | -1>(0);
const navigation = useRef<object | null>(null);
useEffect(
() => () => {
navigation.current = null;
},
[data],
);
const openNeighbour = useCallback(
async (dir: 1 | -1) => {
if (navigation.current) return;
const request = {};
navigation.current = request;
try {
const neighbour = await changedChainNeighbour(data, dir, opts.ignore);
if (navigation.current === request && neighbour) await openDiff(neighbour, { preview: true });
} finally {
if (navigation.current === request) navigation.current = null;
}
},
[data, opts.ignore],
);
useEffect(() => {
if (!navHint) return;
const t = setTimeout(() => setNavHint(null), 2500);
@@ -583,7 +615,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
if (pendingFile.current === dir) {
pendingFile.current = 0;
setNavHint(null);
void openDiff(neighbour, { preview: true });
void openNeighbour(dir);
} else {
pendingFile.current = dir;
setNavHint(dir === 1 ? "Press again to go to the next file" : "Press again to go to the previous file");
@@ -602,7 +634,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
}
updateChunkInfo();
},
[updateChunkInfo, data],
[updateChunkInfo, data, openNeighbour],
);
// A file-backed side is the source; for a diff of revisions (Git Log, Compare) the
@@ -771,8 +803,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
return true;
};
const file = (dir: 1 | -1) => {
const neighbour = chainNeighbour(data, dir);
if (neighbour) void openDiff(neighbour, { preview: true });
void openNeighbour(dir);
};
const toolbarRef = useRef<HTMLDivElement | null>(null);
/** The viewer settings menu, under the toolbar's gear (`from`) or its start. */
@@ -1022,6 +1053,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
(chunkInfo.moves === 1 ? tx(" · 1 moved block") : tx` · ${chunkInfo.moves} moved blocks`)}
</span>
{contribs.map((c, i) => c.Toolbar && <c.Toolbar key={i} host={viewer} />)}
{toolbarActions}
<IconButton
icon="settings"
title="Settings"
@@ -0,0 +1,58 @@
import { beforeEach, expect, it, vi } from "vitest";
import { changedChainNeighbour } from "../diffNavigation";
import { withChain, type DiffData } from "../diffInput";
const probe = vi.hoisted(() => vi.fn());
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
commands: { fsProbe: probe },
}));
const diff = (name: string, text = "after\n"): DiffData => ({
path: `/repo/${name}`,
left: { type: "text", text: "before\n", label: "Before" },
right: { type: "text", text, label: "After" },
});
beforeEach(() => {
probe.mockReset();
probe.mockResolvedValue({ status: "ok", data: { binary: false, tooLarge: false } });
});
it.each([1, -1] as const)("skips unchanged and binary revisions in direction %s", async (dir) => {
const items = [diff("a"), diff("binary", "\0binary"), diff("unchanged", "before\n"), diff("z")];
const data = withChain(items, dir === 1 ? 0 : 3);
const next = await changedChainNeighbour(data, dir, "none");
expect(next).toMatchObject({ path: dir === 1 ? "/repo/z" : "/repo/a", reveal: dir === 1 ? "first" : "last" });
expect(await changedChainNeighbour(next!, dir, "none")).toBeNull();
});
it("skips binary, oversized and unreadable working files before loading their text", async () => {
probe.mockImplementation((path: string) => {
if (path.endsWith("unreadable")) return Promise.reject(new Error("gone"));
return Promise.resolve({
status: "ok",
data: { binary: path.endsWith("binary"), tooLarge: path.endsWith("large") },
});
});
const skipped = ["binary", "large", "unreadable"].map((name) => ({
...diff(name),
right: { type: "worktree" as const },
}));
const data = withChain([diff("a"), ...skipped, diff("z")], 0);
expect(await changedChainNeighbour(data, 1, "none")).toMatchObject({ path: "/repo/z" });
expect(probe).toHaveBeenCalledTimes(3);
});
it("uses the viewer whitespace policy and stops when no textual changes remain", async () => {
const data = withChain([diff("a"), diff("space", " before \n")], 0);
expect(await changedChainNeighbour(data, 1, "none")).toMatchObject({ path: "/repo/space" });
expect(await changedChainNeighbour(data, 1, "whitespace")).toBeNull();
expect(await changedChainNeighbour(diff("single"), 1, "none")).toBeNull();
});
it("normalizes line endings like the viewer when skipping unchanged files", async () => {
const crlf = diff("crlf", "before\r\n");
const data = withChain([diff("a"), crlf, diff("z")], 0);
expect(await changedChainNeighbour(data, 1, "none")).toMatchObject({ path: "/repo/z" });
});
@@ -1,13 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { EditorInput } from "@/workbench/contributions";
import { Chunk } from "@codemirror/merge";
import { Chunk, getChunks } from "@codemirror/merge";
import { Text } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { ContextMenuHost, iconMarkup } from "@/ui";
const probe = vi.fn();
const openFile = vi.fn();
const openInput = vi.fn();
const texts: Record<string, string> = { HEAD: "a\n", abc123: "a\n" };
let working = "a\n";
@@ -26,7 +27,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
vi.mock("@/workbench/contributions", async (importOriginal) => ({
...(await importOriginal<typeof import("@/workbench/contributions")>()),
editorService: () => ({ openFile }),
editorService: () => ({ openFile, openInput }),
}));
vi.mock("@/editor/api", async (importOriginal) => ({
@@ -43,6 +44,9 @@ vi.mock("@/editor/api", async (importOriginal) => ({
}));
import { DiffEditor, mapLine } from "../DiffEditor";
import { DiffEditorTab } from "../diff-editor-tab";
import { diffInput, withChain, type DiffData } from "../diffInput";
import { useToolWindowLayout } from "@/workbench/toolwindows/store";
/** Git Log opens a commit's file: both sides are revisions. */
const input = {
@@ -61,6 +65,7 @@ const paths = (html: string) => [...html.matchAll(/ d="([^"]+)"/g)].map((m) => m
beforeEach(() => {
probe.mockReset();
openFile.mockReset();
openInput.mockReset();
texts.HEAD = texts.abc123 = working = "a\n";
probe.mockResolvedValue({ status: "ok", data: { size: 3, binary: false, tooLarge: false } });
});
@@ -150,6 +155,97 @@ describe("Diff toolbar", () => {
});
});
it.each([null, "bottom"] as const)(
"maximizes a diff tab and restores the %s layout without rebuilding its editor",
async (maximized) => {
const layout = useToolWindowLayout.getState();
const visible = { ...layout.visible, left: "project", bottom: "git" };
useToolWindowLayout.setState({ visible, stashed: null, maximized });
const mounted = render(<DiffEditorTab input={input} />);
try {
await waitFor(() => expect(document.querySelectorAll(".cm-mergeViewEditor .cm-editor")).toHaveLength(2));
const dom = document.querySelectorAll<HTMLElement>(".cm-mergeViewEditor .cm-editor")[1]!;
const view = EditorView.findFromDOM(dom)!;
view.dispatch({ selection: { anchor: 1 } });
fireEvent.click(screen.getByRole("button", { name: "Maximize Editor" }));
expect(Object.values(useToolWindowLayout.getState().visible).every((id) => id === null)).toBe(true);
expect(screen.getByRole("button", { name: "Restore Size" })).toHaveAttribute("aria-pressed", "true");
expect(EditorView.findFromDOM(dom)).toBe(view);
expect(view.state.selection.main.head).toBe(1);
fireEvent.click(screen.getByRole("button", { name: "Restore Size" }));
expect(useToolWindowLayout.getState().visible).toEqual(visible);
expect(useToolWindowLayout.getState().maximized).toBe(maximized);
expect(EditorView.findFromDOM(dom)).toBe(view);
expect(view.state.selection.main.head).toBe(1);
} finally {
mounted.unmount();
useToolWindowLayout.setState(layout);
}
},
);
it("does not offer editor maximization in an embedded diff", async () => {
render(<DiffEditor input={input} />);
await screen.findByRole("button", { name: "Jump to Source" });
expect(screen.queryByRole("button", { name: "Maximize Editor" })).toBeNull();
});
it("restores the shared layout after the diff tab is remounted", async () => {
const layout = useToolWindowLayout.getState();
const visible = { ...layout.visible, left: "project", bottom: "git" };
useToolWindowLayout.setState({ visible, stashed: null, maximized: "bottom" });
const first = render(<DiffEditorTab input={input} />);
fireEvent.click(screen.getByRole("button", { name: "Maximize Editor" }));
first.unmount();
const second = render(<DiffEditorTab input={input} />);
try {
fireEvent.click(screen.getByRole("button", { name: "Restore Size" }));
expect(useToolWindowLayout.getState()).toMatchObject({ visible, maximized: "bottom" });
} finally {
second.unmount();
useToolWindowLayout.setState(layout);
}
});
it.each([
{
index: 0,
dir: 1 as const,
button: "Next Difference",
hint: "Press again to go to the next file",
path: "/p/b.ts",
reveal: "first",
},
{
index: 1,
dir: -1 as const,
button: "Previous Difference",
hint: "Press again to go to the previous file",
path: "/p/a.ts",
reveal: "last",
},
])("moves across files after the $button boundary hint", async ({ index, dir, button, hint, path, reveal }) => {
texts.HEAD = "same\nold\n";
texts.abc123 = "same\nnew\n";
const items: DiffData[] = ["/p/a.ts", "/p/b.ts"].map((path) => ({
path,
left: { type: "rev", rev: "HEAD" },
right: { type: "rev", rev: "abc123" },
}));
render(<DiffEditor input={diffInput(withChain(items, index))} />);
await waitFor(() => expect(document.querySelectorAll(".cm-mergeViewEditor .cm-editor")).toHaveLength(2));
const view = EditorView.findFromDOM(document.querySelectorAll<HTMLElement>(".cm-mergeViewEditor .cm-editor")[1]!)!;
const chunks = getChunks(view.state)!.chunks;
const chunk = dir === 1 ? chunks[chunks.length - 1]! : chunks[0]!;
view.dispatch({ selection: { anchor: chunk.fromB } });
fireEvent.click(screen.getByRole("button", { name: button }));
expect(await screen.findByText(hint)).toBeInTheDocument();
expect(openInput).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: button }));
await waitFor(() => expect(openInput).toHaveBeenCalledOnce());
expect(openInput.mock.calls[0]![0].data).toMatchObject({ path, reveal });
});
const doc = (s: string) => Text.of(s.split("\n"));
describe("mapLine", () => {
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { FileStatus } from "@/bindings";
import { getAction } from "@/platform/actions";
import { registerVcsActions } from "../actions";
import { useVcs } from "../store";
import { useCommit } from "../commitStore";
import { initialChangelists, useChangelists } from "../changelists";
import { chainNeighbour } from "../diffInput";
const opened = vi.hoisted(() => vi.fn());
vi.mock("@/workbench/contributions", async (original) => ({
...(await original<typeof import("@/workbench/contributions")>()),
editorService: () => ({ openInput: opened }),
projectViewSelection: () => [],
}));
vi.mock("../comparePaths", () => ({ showDirectoryWorkingDiff: async () => false }));
const status = (name: string): FileStatus => ({
path: `/repo/${name}`,
relPath: name,
staged: null,
unstaged: "modified",
renamedFrom: null,
});
const vcs = useVcs.getState();
const commit = useCommit.getState();
const lists = useChangelists.getState();
let unregister: () => void;
beforeEach(() => {
opened.mockClear();
const files = [status("z.ts"), status("b.ts"), status("a.ts"), status("other.ts")];
useVcs.setState({ root: "/repo", roots: ["/repo"], status: files, byPath: new Map(files.map((f) => [f.path, f])) });
useCommit.setState({ groupByDir: false });
const state = initialChangelists();
state.lists.push({ id: "other", name: "Other", comment: "" });
state.files = { "other.ts": "other" };
useChangelists.setState({ state });
unregister = registerVcsActions();
});
afterEach(() => {
unregister();
useVcs.setState(vcs);
useCommit.setState(commit);
useChangelists.setState(lists);
});
it("keeps sibling files in tree order when Show Diff opens a changed file", async () => {
await getAction("vcs.showDiff")!.run({ arg: "/repo/b.ts", ctx: { vcsAvailable: true } });
const data = opened.mock.calls[0]![0].data;
expect(data.path).toBe("/repo/b.ts");
expect(data.chain.map((d: { path: string }) => d.path)).toEqual(["/repo/a.ts", "/repo/b.ts", "/repo/z.ts"]);
expect(chainNeighbour(data, 1)).toMatchObject({ path: "/repo/z.ts", reveal: "first" });
expect(chainNeighbour(data, -1)).toMatchObject({ path: "/repo/a.ts", reveal: "last" });
});
it("keeps an explicit line on the opened file without carrying it into neighbouring requests", async () => {
await getAction("vcs.showDiff")!.run({ arg: { path: "/repo/b.ts", line: 12 }, ctx: { vcsAvailable: true } });
const data = opened.mock.calls[0]![0].data;
expect(data.line).toBe(12);
expect(chainNeighbour(chainNeighbour(data, 1)!, -1)!.line).toBeUndefined();
});
it("opens a file outside the changes list without unrelated siblings", async () => {
await getAction("vcs.showDiff")!.run({ arg: "/repo/clean.ts", ctx: { vcsAvailable: true } });
expect(opened.mock.calls[0]![0].data.path).toBe("/repo/clean.ts");
expect(opened.mock.calls[0]![0].data.chain).toBeUndefined();
});
it("follows directory order rather than global filename order", async () => {
const files = [status("a.ts"), status("src/z.ts"), status("src/nested/b.ts"), status("other.ts")];
useVcs.setState({ status: files, byPath: new Map(files.map((f) => [f.path, f])) });
useCommit.setState({ groupByDir: true });
await getAction("vcs.showDiff")!.run({ arg: "/repo/src/z.ts", ctx: { vcsAvailable: true } });
expect(opened.mock.calls[0]![0].data.chain.map((d: { path: string }) => d.path)).toEqual([
"/repo/src/nested/b.ts",
"/repo/src/z.ts",
"/repo/a.ts",
]);
});
it("keeps repository grouping in a changelist's chain", async () => {
const files = [status("z.ts"), status("module/b.ts"), status("module/a.ts")];
useVcs.setState({ status: files, roots: ["/repo", "/repo/module"] });
await getAction("vcs.showDiff")!.run({ arg: "/repo/module/b.ts", ctx: { vcsAvailable: true } });
expect(opened.mock.calls[0]![0].data.chain.map((d: { path: string }) => d.path)).toEqual([
"/repo/z.ts",
"/repo/module/a.ts",
"/repo/module/b.ts",
]);
});
+20 -3
View File
@@ -27,7 +27,9 @@ import {
import { setAmend, useCommit } from "./commitStore";
import { gotoConflict, resolveAtCaret, showConflictsDialog } from "./conflictsUi";
import { focusedDiffNavigator } from "./diffNavigator";
import { openDiff, workingDiffData } from "./diffInput";
import { openDiff, withChain, workingDiffData } from "./diffInput";
import { buildChangeTree, changeGroupPaths } from "./changes";
import { useChangelists } from "./changelists";
import { openDialog } from "./dialogs";
import { gotoChange, rollbackSelectedLines } from "./gutter";
import { openLocalHistory } from "./localHistoryKind";
@@ -45,7 +47,7 @@ import {
showRebaseDialog,
showWorktrees,
} from "./gitMenuDialogs";
import { refreshNow, repoName } from "./store";
import { refreshNow, repoName, useVcs } from "./store";
import { tx } from "@/platform/i18n";
import { addToIgnore } from "./ignore";
@@ -325,7 +327,22 @@ export function registerVcsActions(): () => void {
return;
}
if (await showDirectoryWorkingDiff(paths)) return;
for (const p of paths.slice(0, 5)) await openDiff(workingDiffData(p, line), { preview: paths.length === 1 });
const state = useVcs.getState();
const tree = buildChangeTree(
state.status,
state.root ?? "",
useCommit.getState().groupByDir,
useChangelists.getState().state,
state.roots,
);
for (const path of paths.slice(0, 5)) {
const siblings = changeGroupPaths(tree, path);
const items = siblings.map((path) => workingDiffData(path));
await openDiff(
{ ...withChain(items, Math.max(0, siblings.indexOf(path))), line },
{ preview: paths.length === 1 },
);
}
},
},
{
+14
View File
@@ -268,6 +268,20 @@ export function buildChangeTree(
return roots;
}
export function changeGroupPaths(roots: readonly ChangeNode[], path: string): string[] {
const group = roots.find((node) => node.files.some((file) => stripSlash(file.path) === path));
if (!group) return [path];
const paths: string[] = [];
const walk = (nodes: readonly ChangeNode[]) => {
for (const node of nodes) {
if (node.type === "file" && node.path) paths.push(node.path);
else walk(node.children ?? []);
}
};
walk([group]);
return paths;
}
export interface CommitPlan {
stage: string[];
unstage: string[];
+24
View File
@@ -0,0 +1,24 @@
import type { EditorInput } from "@/workbench/contributions";
import { hideAllToolWindows, useToolWindowLayout } from "@/workbench/toolwindows/store";
import { shortcutLabel } from "@/platform/keymap";
import { IconButton } from "@/ui";
import { DiffEditor } from "./DiffEditor";
export function DiffEditorTab({ input }: { input: EditorInput }) {
const maximized = useToolWindowLayout((state) => !!state.stashed && !Object.values(state.visible).some(Boolean));
return (
<DiffEditor
input={input}
toolbarActions={
<IconButton
icon={maximized ? "minimize" : "maximize"}
title={maximized ? "Restore Size" : "Maximize Editor"}
shortcut={shortcutLabel("toolwindow.hideAll")}
pressed={maximized}
size="small"
onClick={hideAllToolWindows}
/>
}
/>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { Text } from "@codemirror/state";
import { call, commands } from "@/platform/ipc";
import { ideaChunks, type IgnorePolicy } from "./diffOptions";
import { chainNeighbour, loadSide, sideFile, type DiffData } from "./diffInput";
/** Find a textual change without opening binary, unreadable or unchanged siblings. */
export async function changedChainNeighbour(
data: DiffData,
dir: 1 | -1,
ignore: IgnorePolicy,
): Promise<DiffData | null> {
for (let next = chainNeighbour(data, dir); next; next = chainNeighbour(next, dir)) {
try {
const paths = [sideFile(next.left, next.path), sideFile(next.right, next.path)].filter((p): p is string => !!p);
const probes = await Promise.all(paths.map((path) => call(commands.fsProbe(path))));
if (probes.some((probe) => probe.binary || probe.tooLarge)) continue;
const [a, b] = await Promise.all([loadSide(next.path, next.left), loadSide(next.path, next.right)]);
if (a.includes("\0") || b.includes("\0")) continue;
if (ideaChunks(Text.of(a.split(/\r\n?|\n/)), Text.of(b.split(/\r\n?|\n/)), ignore, "none").length) return next;
} catch {
// A stale chain can contain a file that was deleted or is no longer readable.
}
}
return null;
}
+1 -1
View File
@@ -230,7 +230,7 @@ export function activate(): () => void {
}),
registerEditorKind({
kind: DIFF_KIND,
component: lazyComponent(() => import("./DiffEditor").then((m) => m.DiffEditor)),
component: lazyComponent(() => import("./diff-editor-tab").then((m) => m.DiffEditorTab)),
title: (input) => diffTitle(input.data as DiffData),
icon: () => "diff",
}),
+1
View File
@@ -1797,6 +1797,7 @@
"Restore Defaults": "恢复默认值",
"Restore Rows": "恢复行",
"Restore Size": "恢复大小",
"Maximize Editor": "最大化编辑器",
"Restore detected roots": "恢复检测到的根",
"Result": "结果",
"Select a file to preview its changes": "选择文件以预览其更改",
+10
View File
@@ -98,6 +98,16 @@ describe("tool window semantics", () => {
expect(isToolWindowVisible("project")).toBe(true);
});
it("restores the maximized side through the shared Hide All toggle", () => {
showToolWindow("terminal");
toggleMaximizeToolWindow("terminal");
const before = useToolWindowLayout.getState();
hideAllToolWindows();
expect(useToolWindowLayout.getState().maximized).toBeNull();
hideAllToolWindows();
expect(useToolWindowLayout.getState()).toMatchObject({ visible: before.visible, maximized: "bottom" });
});
it("resizes the active window by moving its inner edge", () => {
showToolWindow("terminal");
setFocusedToolWindow("terminal");
+4 -2
View File
@@ -86,6 +86,7 @@ export interface ToolWindowLayoutState {
focusRequest: { id: string; seq: number } | null;
/** Visible set stashed by "Hide All" so pressing it again restores. */
stashed: SlotRecord | null;
stashedMaximized: ToolWindowAnchor | null;
/** Per-window placement overrides (persisted). */
placements: Readonly<Record<string, ToolWindowPlacement>>;
/** User choice of stripe buttons (opened → added, "Hide" → removed); persisted. */
@@ -105,6 +106,7 @@ const initial = (): ToolWindowLayoutState => ({
mounted: new Set(Object.values(DEFAULT_VISIBLE).filter((v): v is string => !!v)),
focusRequest: null,
stashed: null,
stashedMaximized: null,
placements: {},
stripeButtons: {},
distractionFreeStash: null,
@@ -465,10 +467,10 @@ export function hideAllToolWindows() {
const anyVisible = SLOTS.some((slot) => s.visible[slot]);
if (anyVisible) {
const hadFocus = !!s.focused;
update({ stashed: { ...s.visible }, visible: { ...NONE }, maximized: null });
update({ stashed: { ...s.visible }, stashedMaximized: s.maximized, visible: { ...NONE }, maximized: null });
if (hadFocus) focusEditor();
} else if (s.stashed) {
update({ visible: { ...s.stashed }, stashed: null });
update({ visible: { ...s.stashed }, maximized: s.stashedMaximized, stashed: null, stashedMaximized: null });
}
}