From 19b96b60a4cf2825ff19ab73c2425c328e1fe0db Mon Sep 17 00:00:00 2001 From: arookieofc Date: Fri, 30 Oct 2026 19:25:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(vcs):=20offer=20Compare=20With=E2=80=A6=20?= =?UTF-8?q?in=20the=20editor=20and=20tab=20menus,=20and=20outside=20the=20?= =?UTF-8?q?desktop=20app=20(#433)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare With… was only in the Project view menu and needed the OS file dialog. The editor context menu (with Compare with Clipboard) and the editor tab menu now offer it too, and outside the desktop app (remote development, browser) a browser file picker supplies the file, compared as text. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- e2e/compareWith.spec.ts | 41 ++++++++++++++++++ src/editor/core/controller.ts | 9 ++++ src/features/project-tree/contextMenu.test.ts | 11 +++++ src/features/vcs/compare.tsx | 43 +++++++++++++++++-- 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 e2e/compareWith.spec.ts diff --git a/e2e/compareWith.spec.ts b/e2e/compareWith.spec.ts new file mode 100644 index 00000000..58b15e0e --- /dev/null +++ b/e2e/compareWith.spec.ts @@ -0,0 +1,41 @@ +/** + * IDEA's Compare With…: project tree (git and non-git projects alike), the + * editor content menu and the editor tab menu. Outside the desktop app the OS + * dialog is replaced by a browser file picker and the chosen file is compared + * as text. + */ +import { expect, openFromTree, test } from "./fixtures"; + +test("project tree menu offers Compare With… for a file", async ({ workbench: page }) => { + await page.getByRole("treeitem", { name: "README.md", exact: true }).click({ button: "right" }); + // The accessible name carries the shortcut: "Compare With… ⌘D". + await expect(page.getByRole("menuitem", { name: /^Compare With…/ })).toBeVisible(); + await page.keyboard.press("Escape"); +}); + +test("editor tab menu offers Compare With…", async ({ workbench: page }) => { + await openFromTree(page, "README.md"); + await page + .getByRole("tablist", { name: "Editor tabs" }) + .getByRole("tab", { name: "README.md" }) + .click({ button: "right" }); + await expect(page.getByRole("menuitem", { name: /^Compare With…/ })).toBeVisible(); + await page.keyboard.press("Escape"); +}); + +test("editor menu offers Compare With…", async ({ workbench: page }) => { + const editor = await openFromTree(page, "README.md"); + await editor.locator(".cm-content").click({ button: "right" }); + await expect(page.getByRole("menuitem", { name: /^Compare With…/ })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Compare with Clipboard", exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); +}); + +test("Compare With… compares against a file picked in the browser", async ({ workbench: page }) => { + await page.getByRole("treeitem", { name: "README.md", exact: true }).click({ button: "right" }); + const chooser = page.waitForEvent("filechooser"); + await page.getByRole("menuitem", { name: /^Compare With…/ }).click(); + await (await chooser).setFiles({ name: "other.md", mimeType: "text/markdown", buffer: Buffer.from("other text\n") }); + const tabs = page.getByRole("tablist", { name: "Editor tabs" }); + await expect(tabs.getByRole("tab", { name: /other\.md/ })).toBeVisible({ timeout: 15_000 }); +}); diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 6c85fcf1..ff1a6859 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -708,6 +708,12 @@ export class EditorController implements SlotHost { localHistoryMenu(slot.model.path), gitMenu(slot.model.path), { kind: "separator" }, + { + kind: "action", + action: "diff.compareWith", + arg: slot.model.path, + label: "Compare With…", + }, { kind: "action", action: "diff.compareWithClipboard", @@ -1598,6 +1604,9 @@ export class EditorController implements SlotHost { localHistoryMenu(path), ...(inProject ? [gitMenu(path)] : []), { kind: "separator" }, + { kind: "action", action: "diff.compareWith", arg: path, label: "Compare With…" }, + { kind: "action", action: "diff.compareWithClipboard", arg: { path }, label: "Compare with Clipboard" }, + { kind: "separator" }, { label: "Copy Path/Reference…", submenu: [ diff --git a/src/features/project-tree/contextMenu.test.ts b/src/features/project-tree/contextMenu.test.ts index daa6e3ba..80cebc34 100644 --- a/src/features/project-tree/contextMenu.test.ts +++ b/src/features/project-tree/contextMenu.test.ts @@ -177,6 +177,17 @@ describe("project view context menu", () => { expect(l).not.toContain("Compare With…"); }); + it("offers Compare With… in a non-git project too", () => { + useProject.setState({ + project: { name: "p", root: ROOT, isGit: false, settings: {} } as never, + settings: {} as never, + }); + const l = labels(contextMenuItems([node(`${ROOT}/a.py`)])); + expect(l).not.toContain("Git"); + expect(l).toContain("Compare With…"); + expect(labels(contextMenuItems([node(`${ROOT}/a.py`), node(`${ROOT}/b.py`)]))).toContain("Compare Files"); + }); + it("never shows an empty submenu", () => { disposers.forEach((d) => d()); disposers = []; diff --git a/src/features/vcs/compare.tsx b/src/features/vcs/compare.tsx index 8c799284..a2799e61 100644 --- a/src/features/vcs/compare.tsx +++ b/src/features/vcs/compare.tsx @@ -92,6 +92,34 @@ async function pickFile(near: string): Promise { return (Array.isArray(picked) ? picked[0] : picked) ?? null; } +/** + * The file to compare `path` against: the OS file dialog on the desktop; a + * browser file picker's contents elsewhere (remote development / `pnpm dev`), + * compared as text since no absolute path is available to the backend. + */ +async function pickComparison( + path: string, +): Promise<{ type: "file"; path: string } | { type: "text"; text: string; label: string } | null> { + if (isTauri()) { + const picked = await pickFile(path); + return picked ? { type: "file", path: picked } : null; + } + if (typeof document === "undefined") { + info("Compare With needs the desktop app or a browser file picker"); + return null; + } + const chosen = await new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.onchange = () => resolve(input.files?.[0] ?? null); + // Chrome fires `cancel` (or focus returns) when the dialog is dismissed. + input.addEventListener("cancel", () => resolve(null)); + input.click(); + }); + if (!chosen) return null; + return { type: "text", text: await chosen.text(), label: chosen.name }; +} + const contextPath = (arg: unknown) => argPath(arg) ?? maybeEditorService()?.activePath() ?? undefined; export function registerCompareActions(): () => void { @@ -186,9 +214,18 @@ export function registerCompareActions(): () => void { run: async ({ arg }) => { const path = contextPath(arg); if (!path) return info("Open or select a file to compare"); - const other = await pickFile(path); - if (!other || other === path) return; - await openDiff(compareFilesData(path, other)); + const other = await pickComparison(path); + if (!other) return; + if (other.type === "file") { + if (other.path === path) return; + await openDiff(compareFilesData(path, other.path)); + } else { + await openDiff({ + path, + left: { type: "text", text: other.text, label: other.label }, + right: { type: "worktree", label: displayPath(path) }, + }); + } }, }, {