diff --git a/e2e/diff-navigation.spec.ts b/e2e/diff-navigation.spec.ts new file mode 100644 index 00000000..9e74754b --- /dev/null +++ b/e2e/diff-navigation.spec.ts @@ -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(); +}); diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index cfb95a41..69d04972 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -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 }); } diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index 332d041e..ae46a328 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -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(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(null); const pendingFile = useRef<0 | 1 | -1>(0); + const navigation = useRef(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(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`)} {contribs.map((c, i) => c.Toolbar && )} + {toolbarActions} vi.fn()); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + 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" }); +}); diff --git a/src/features/vcs/__tests__/diffToolbar.test.tsx b/src/features/vcs/__tests__/diffToolbar.test.tsx index dbf74852..e7f1e33d 100644 --- a/src/features/vcs/__tests__/diffToolbar.test.tsx +++ b/src/features/vcs/__tests__/diffToolbar.test.tsx @@ -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 = { 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()), - 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(); + try { + await waitFor(() => expect(document.querySelectorAll(".cm-mergeViewEditor .cm-editor")).toHaveLength(2)); + const dom = document.querySelectorAll(".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(); + 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(); + fireEvent.click(screen.getByRole("button", { name: "Maximize Editor" })); + first.unmount(); + const second = render(); + 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(); + await waitFor(() => expect(document.querySelectorAll(".cm-mergeViewEditor .cm-editor")).toHaveLength(2)); + const view = EditorView.findFromDOM(document.querySelectorAll(".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", () => { diff --git a/src/features/vcs/__tests__/show-diff-chain.test.ts b/src/features/vcs/__tests__/show-diff-chain.test.ts new file mode 100644 index 00000000..5a79094f --- /dev/null +++ b/src/features/vcs/__tests__/show-diff-chain.test.ts @@ -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()), + 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", + ]); +}); diff --git a/src/features/vcs/actions.tsx b/src/features/vcs/actions.tsx index 01582c32..c35af046 100644 --- a/src/features/vcs/actions.tsx +++ b/src/features/vcs/actions.tsx @@ -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 }, + ); + } }, }, { diff --git a/src/features/vcs/changes.ts b/src/features/vcs/changes.ts index 73627c73..307db4f9 100644 --- a/src/features/vcs/changes.ts +++ b/src/features/vcs/changes.ts @@ -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[]; diff --git a/src/features/vcs/diff-editor-tab.tsx b/src/features/vcs/diff-editor-tab.tsx new file mode 100644 index 00000000..12f19afb --- /dev/null +++ b/src/features/vcs/diff-editor-tab.tsx @@ -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 ( + + } + /> + ); +} diff --git a/src/features/vcs/diffNavigation.ts b/src/features/vcs/diffNavigation.ts new file mode 100644 index 00000000..810e66ca --- /dev/null +++ b/src/features/vcs/diffNavigation.ts @@ -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 { + 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; +} diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index 949f5651..8d2234d8 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -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", }), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 53ed5110..e863bcd9 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1797,6 +1797,7 @@ "Restore Defaults": "恢复默认值", "Restore Rows": "恢复行", "Restore Size": "恢复大小", + "Maximize Editor": "最大化编辑器", "Restore detected roots": "恢复检测到的根", "Result": "结果", "Select a file to preview its changes": "选择文件以预览其更改", diff --git a/src/workbench/toolwindows/store.test.ts b/src/workbench/toolwindows/store.test.ts index d05982b0..744a1d53 100644 --- a/src/workbench/toolwindows/store.test.ts +++ b/src/workbench/toolwindows/store.test.ts @@ -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"); diff --git a/src/workbench/toolwindows/store.ts b/src/workbench/toolwindows/store.ts index 3c448ae9..4eca1c65 100644 --- a/src/workbench/toolwindows/store.ts +++ b/src/workbench/toolwindows/store.ts @@ -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>; /** 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 }); } }