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) }, + }); + } }, }, {