mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 00:07:50 +00:00
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:
@@ -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();
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -1797,6 +1797,7 @@
|
||||
"Restore Defaults": "恢复默认值",
|
||||
"Restore Rows": "恢复行",
|
||||
"Restore Size": "恢复大小",
|
||||
"Maximize Editor": "最大化编辑器",
|
||||
"Restore detected roots": "恢复检测到的根",
|
||||
"Result": "结果",
|
||||
"Select a file to preview its changes": "选择文件以预览其更改",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user