Файли
openidea/e2e/mergeEditor.spec.ts
T
ntbang0901 f752f2db08 feat(vcs): make the merge editor work like IntelliJ's merge tool (#902)
The result pane starts from the base and every change, conflicting or not, gets Accept / Ignore next to its side; accepting one side of a conflict still lets the other side be appended, and the modifier key resolves using that side directly. Adds Resolve Simple Conflicts (word-level), Apply Non-Conflicting Changes from the left, right or both (optionally on opening), Revert Conflict Resolution, Accept Yours / Accept Theirs / Cancel / Apply, Compare Contents between any two versions, and undo back to the conflicted state. CRLF and UTF-16 files are written back exactly, deleted sides and binary files are handled, and the commands are registered as merge.* actions.

Refs #826

Co-authored-by: ntbang0901 <[email protected]>
2026-11-03 23:37:38 +08:00

346 рядки
17 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить невидимі символи Юнікоду
Цей файл містить невидимі символи Юнікоду, які не розрізняються людиною, але можуть по-різному оброблятися комп'ютером. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "./fixtures";
const lines = (...l: string[]) => l.map((x) => `${x}\n`).join("");
const pad = (a: string[], b: string[]) => [...a, "p1", "p2", ...b];
// 0: a word-level conflict (price / tax), 1: a change of theirs only, 2: a real conflict.
const BASE = lines(...pad(["const total = price + tax;"], pad(["c"], pad(["same"], ["end"]))));
const OURS = lines(...pad(["const total = cost + tax;"], pad(["c"], pad(["mine"], ["end"]))));
const THEIRS = lines(...pad(["const total = price + fee;"], pad(["C"], pad(["yours"], ["end"]))));
/** Mounts the real merge editor on fixed versions, with the backend call replaced. */
async function mountMerge(
page: Page,
versions: [string | null, string | null, string | null] = [BASE, OURS, THEIRS],
failure: string | null = null,
) {
await page.goto("/");
await page.evaluate(
async ({ versions: [base, ours, theirs], failure }) => {
const reactPath = "/node_modules/.vite/deps/react.js";
const domPath = "/node_modules/.vite/deps/react-dom_client.js";
const react = await import(reactPath);
const dom = await import(domPath);
const { createElement } = react.default ?? react;
const { createRoot } = dom.default ?? dom;
const ipcPath = "/src/platform/ipc.ts";
const editorPath = "/src/features/vcs/MergeEditor.tsx";
const ipc = await import(ipcPath);
(ipc.commands as unknown as Record<string, unknown>).vcsConflictVersions = async () =>
failure
? {
status: "error",
error: { kind: "io", message: failure, data: null },
}
: {
status: "ok",
data: {
base,
ours,
theirs,
merged: null,
sides: { operation: "merge", reversed: false, branch: "main", otherBranch: "feature", commit: null },
},
};
const { MergeEditor } = await import(editorPath);
const host = document.createElement("div");
host.style.cssText = "position:fixed;inset:0;z-index:500;display:flex;background:white";
host.id = "merge-host";
document.body.append(host);
createRoot(host).render(
createElement(MergeEditor, { input: { key: "merge:demo.ts", data: { path: "/project/demo.ts" } } }),
);
},
{ versions, failure },
);
if (!failure && !versions.some((text) => text?.includes("\0")))
await expect(page.getByRole("region", { name: "Result" }).locator(".cm-content")).toBeVisible();
}
const result = (page: Page) => page.getByRole("region", { name: "Result" });
const text = async (pane: Locator) => (await pane.locator(".cm-content").innerText()).replace(/​/g, "");
const status = (page: Page) => page.locator("#merge-host [class*=status]").first();
/** Capture the save boundary, keeping real editor state, dialogs and Apply behavior. */
async function captureWrites(page: Page) {
await page.evaluate(async () => {
const ipcPath = "/src/platform/ipc.ts";
const { commands } = await import(ipcPath);
const calls: { command: string; args: unknown[] }[] = [];
Object.assign(window, { mergeWrites: calls });
commands.fsReadText = async () => ({ status: "ok", data: "working copy\n" });
commands.docOpen = async () => ({ status: "ok", data: { id: 777, version: 1 } });
for (const command of ["docEdit", "docSave", "docClose", "fsDelete", "vcsStage"])
commands[command] = async (...args: unknown[]) => {
calls.push({ command, args });
return { status: "ok", data: null };
};
});
}
const writes = (page: Page) =>
page.evaluate(
() =>
(
window as unknown as {
mergeWrites: { command: string; args: unknown[] }[];
}
).mergeWrites,
);
test("CRLF resolutions use UTF-16 editor positions and preserve line endings on Apply", async ({ page }) => {
const base = "标题😀\r\nunchanged\r\nbase\r\ntail";
const ours = base.replace("base", "左侧🚀");
await mountMerge(page, [base, ours, base.replace("base", "右侧🧪")]);
await captureWrites(page);
await page
.getByRole("region", { name: "Your version" })
.locator(".cm-merge-accept")
.click({ modifiers: ["Meta"] });
await expect(status(page)).toHaveText("All changes processed");
expect((await text(result(page))).trimEnd()).toBe(ours.replace(/\r\n/g, "\n"));
await page.locator("#merge-host").getByRole("button", { name: "Apply", exact: true }).click();
await expect.poll(async () => (await writes(page)).some((call) => call.command === "vcsStage")).toBe(true);
const calls = await writes(page);
expect(calls.map((call) => call.command)).toEqual(["docEdit", "docSave", "docClose", "vcsStage"]);
expect(calls[0]!.args[2]).toEqual({ changes: [{ from: 0, to: 13, insert: ours }] });
});
test("Apply writes and stages unresolved text only after confirmation", async ({ page }) => {
await mountMerge(page);
await captureWrites(page);
const apply = page.locator("#merge-host").getByRole("button", { name: "Apply", exact: true });
await apply.click();
await expect(page.getByRole("dialog")).toContainText("Save and mark the file as resolved anyway?");
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
expect(await writes(page)).toEqual([]);
await apply.click();
await page.getByRole("button", { name: "Mark Resolved", exact: true }).click();
await expect.poll(async () => (await writes(page)).some((call) => call.command === "vcsStage")).toBe(true);
expect((await writes(page))[0]!.args[2]).toEqual({ changes: [{ from: 0, to: 13, insert: BASE }] });
});
test("binary and unreadable versions show an error with no Apply controls", async ({ page }) => {
await mountMerge(page, ["\0base", "\0ours", "\0theirs"]);
await expect(page.locator("#merge-host")).toContainText("Binary files cannot be resolved in the merge editor.");
await expect(page.locator("#merge-host").getByRole("button", { name: "Apply", exact: true })).toHaveCount(0);
await mountMerge(page, [BASE, OURS, THEIRS], "Cannot read conflict versions");
await expect(page.locator("#merge-host")).toContainText("Cannot read conflict versions");
await expect(page.locator("#merge-host").getByRole("button", { name: "Retry", exact: true })).toBeVisible();
});
test("per-change controls also work from the keyboard", async ({ page }) => {
await mountMerge(page);
const accept = page.getByRole("region", { name: "Your version" }).locator(".cm-merge-accept");
await accept.nth(1).focus();
await page.keyboard.press("Enter");
expect(await text(result(page))).toContain("\nmine\n");
await expect(accept).toHaveCount(1);
const ignore = page.getByRole("region", { name: "Their version" }).locator(".cm-merge-ignore");
await ignore.nth(2).focus();
await page.keyboard.press("Space");
await expect(status(page)).toHaveText("2 changes left, 1 conflict");
});
test("accepting a deleted version removes the file before staging", async ({ page }) => {
await mountMerge(page, [BASE, OURS, null]);
await captureWrites(page);
await page.locator("#merge-host").getByRole("button", { name: "Accept Theirs", exact: true }).click();
await expect.poll(async () => (await writes(page)).some((call) => call.command === "vcsStage")).toBe(true);
expect(await writes(page)).toEqual([
{ command: "fsDelete", args: ["/project/demo.ts"] },
{ command: "vcsStage", args: [["/project/demo.ts"]] },
]);
});
test("the result starts as the base and every change waits for a decision", async ({ page }) => {
await mountMerge(page);
await expect(status(page)).toHaveText("3 changes left, 2 conflicts");
expect((await text(result(page))).trimEnd()).toBe(BASE.trimEnd());
// Accept and Ignore beside the changes of each side, a wand beside the conflict that needs no decision.
await expect(page.getByRole("region", { name: "Your version" }).locator(".cm-merge-accept")).toHaveCount(2);
await expect(page.getByRole("region", { name: "Their version" }).locator(".cm-merge-accept")).toHaveCount(3);
await expect(result(page).locator(".cm-merge-wand")).toHaveCount(1);
});
test("resolves the simple conflicts and applies the other changes in one step", async ({ page }) => {
await mountMerge(page);
await page.getByRole("button", { name: "Resolve Simple Conflicts" }).click();
await expect(status(page)).toHaveText("1 change left, 1 conflict");
const out = await text(result(page));
expect(out).toContain("const total = cost + fee;");
expect(out).toContain("\nC\n");
expect(out).toContain("\nsame\n");
await page.getByRole("button", { name: "Revert Conflict Resolution" }).click();
await expect(status(page)).toHaveText("3 changes left, 2 conflicts");
expect((await text(result(page))).trimEnd()).toBe(BASE.trimEnd());
});
test("accepts both sides of a conflict in turn, and undo brings the buttons back", async ({ page }) => {
await mountMerge(page);
const mine = page.getByRole("region", { name: "Your version" }).locator(".cm-merge-accept");
const yours = page.getByRole("region", { name: "Their version" }).locator(".cm-merge-accept");
// The last conflict is the third change of the right pane and the second of the left.
await mine.nth(1).click();
await yours.nth(2).click();
expect(await text(result(page))).toContain("\nmine\nyours\n");
await expect(status(page)).toHaveText("2 changes left, 1 conflict");
await expect(mine).toHaveCount(1);
await result(page).locator(".cm-content").click();
await page.keyboard.press("Meta+Z");
await expect(yours).toHaveCount(3);
expect(await text(result(page))).toContain("\nmine\n");
expect(await text(result(page))).not.toContain("yours");
await page.keyboard.press("Meta+Z");
await expect(mine).toHaveCount(2);
await expect(status(page)).toHaveText("3 changes left, 2 conflicts");
await page.screenshot({ path: process.env.MERGE_SHOT, animations: "disabled" }).catch(() => undefined);
});
test("the toolbar applies the non-conflicting changes of one side", async ({ page }) => {
await mountMerge(page);
// Nothing in this file is a change of the left side only.
await expect(page.getByRole("button", { name: "Apply Non-Conflicting Changes from the Left Side" })).toBeDisabled();
await page.getByRole("button", { name: "Apply Non-Conflicting Changes from the Right Side" }).click();
await expect(status(page)).toHaveText("2 changes left, 2 conflicts");
expect(await text(result(page))).toContain("\nC\n");
await expect(page.getByRole("button", { name: "Apply All Non-Conflicting Changes" })).toBeDisabled();
});
test("has the dialog's buttons: Accept Yours, Accept Theirs, Cancel and Apply", async ({ page }) => {
await mountMerge(page);
const footer = page.locator("#merge-host [class*=footer]");
for (const name of ["Accept Yours", "Accept Theirs", "Cancel", "Apply"])
await expect(footer.getByRole("button", { name, exact: true })).toBeVisible();
// Unedited, Cancel just closes; once something was decided it asks first.
await page.getByRole("button", { name: "Resolve Simple Conflicts" }).click();
await footer.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(page.getByRole("dialog")).toContainText("Exit without applying the changes");
await page.getByRole("button", { name: "Cancel", exact: true }).last().click();
await footer.getByRole("button", { name: "Accept Theirs", exact: true }).click();
await expect(page.getByRole("dialog")).toContainText("Discard the changes and take this version");
});
test("compares the versions in pairs", async ({ page }) => {
await mountMerge(page);
await page.getByRole("button", { name: "Compare Contents" }).click();
for (const name of [
"Yours and Result",
"Result and Theirs",
"Yours and Theirs",
"Base and Yours",
"Base and Result",
"Base and Theirs",
])
await expect(page.getByRole("menuitem", { name })).toBeVisible();
});
test("keeps the option to apply the non-conflicting changes on opening in its settings", async ({ page }) => {
await mountMerge(page);
await page.locator("#merge-host").getByRole("button", { name: "Settings" }).click();
const option = page.getByRole("menuitemcheckbox", { name: "Automatically Apply Non-Conflicting Changes" });
await expect(option).toHaveAttribute("aria-checked", "false");
await option.click();
await expect(status(page)).toHaveText("2 changes left, 2 conflicts");
});
test("the context menus decide the changes under the caret", async ({ page }) => {
await mountMerge(page);
await result(page).locator(".cm-line", { hasText: "same" }).click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Resolve Automatically" })).toBeDisabled();
await page.getByRole("menuitem", { name: "Ignore", exact: true }).click();
await expect(status(page)).toHaveText("2 changes left, 1 conflict");
expect(await text(result(page))).toContain("\nsame\n");
await page
.getByRole("region", { name: "Their version" })
.locator(".cm-line", { hasText: /^C$/ })
.first()
.click({ button: "right" });
await page.getByRole("menuitem", { name: "Resolve Using Theirs" }).click();
await expect(status(page)).toHaveText("1 change left, 1 conflict");
expect(await text(result(page))).toContain("\nC\n");
});
test("applies the non-conflicting changes on opening when asked to, and that is not a step to undo", async ({
page,
}) => {
await page.addInitScript(() => localStorage.setItem("openidea.merge.autoApplyNonConflicting", "true"));
await mountMerge(page);
await expect(status(page)).toHaveText("2 changes left, 2 conflicts");
expect(await text(result(page))).toContain("\nC\n");
await result(page).locator(".cm-content").click();
await page.keyboard.press("Meta+Z");
expect(await text(result(page))).toContain("\nC\n");
});
test("Find Action runs merge commands with the editor context and clears it on external focus", async ({ page }) => {
await mountMerge(page);
await result(page).locator(".cm-content").click();
await page.keyboard.press("Meta+Shift+A");
const query = page.getByRole("combobox", { name: "Search query" });
await query.fill("Resolve Simple Conflicts");
await expect(page.locator("#se-results")).toContainText("Resolve Simple Conflicts");
await query.press("Enter");
await expect(status(page)).toHaveText("1 change left, 1 conflict");
await expect(query).toHaveCount(0);
await page.keyboard.press("Meta+Shift+A");
await query.fill("Revert Conflict Resolution");
await expect(page.locator("#se-results")).toContainText("Revert Conflict Resolution");
await query.press("Enter");
await expect(status(page)).toHaveText("3 changes left, 2 conflicts");
expect((await text(result(page))).trimEnd()).toBe(BASE.trimEnd());
await page.evaluate(() => {
const button = document.createElement("button");
button.id = "outside-merge";
document.body.append(button);
button.focus();
});
expect(
await page.evaluate(async () => {
const path = "/src/platform/actions.ts";
return (await import(path)).executeAction("merge.resolveSimple");
}),
).toBe(false);
await expect(status(page)).toHaveText("3 changes left, 2 conflicts");
});
test("merge actions follow the focused instance and survive a context menu", async ({ page }) => {
await mountMerge(page);
await page.evaluate(async () => {
const reactPath = "/node_modules/.vite/deps/react.js";
const domPath = "/node_modules/.vite/deps/react-dom_client.js";
const editorPath = "/src/features/vcs/MergeEditor.tsx";
const { createElement } = (await import(reactPath)).default;
const dom = await import(domPath);
const { createRoot } = dom.default ?? dom;
const { MergeEditor } = await import(editorPath);
const first = document.getElementById("merge-host")!;
first.style.right = "50%";
const second = document.createElement("div");
second.id = "merge-second";
second.style.cssText = "position:fixed;inset:0 0 0 50%;z-index:500;display:flex;background:white";
document.body.append(second);
createRoot(second).render(
createElement(MergeEditor, {
input: { key: "merge:second.ts", data: { path: "/project/second.ts" } },
}),
);
});
const second = page.locator("#merge-second");
await expect(second.getByRole("region", { name: "Result" }).locator(".cm-content")).toBeVisible();
await page.locator("#merge-host").getByRole("button", { name: "Resolve Simple Conflicts" }).click();
await expect(status(page)).toHaveText("1 change left, 1 conflict");
await expect(second.locator("[class*=status]").first()).toHaveText("3 changes left, 2 conflicts");
await second
.getByRole("region", { name: "Result" })
.locator(".cm-line", { hasText: "same" })
.click({ button: "right" });
await page.getByRole("menuitem", { name: "Ignore", exact: true }).click();
await expect(second.locator("[class*=status]").first()).toHaveText("2 changes left, 1 conflict");
await expect(status(page)).toHaveText("1 change left, 1 conflict");
});