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]>
No files matched your search
@@ -65,6 +65,11 @@ from its tests (`LineComparisonUtilTest`, `WordComparisonUtilTest`,
|
||||
`SplitComparisonUtilTest`). Modified from the originals. Copyright 2000-2026
|
||||
JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
|
||||
`src/features/vcs/mergeDiff3.ts`, `chunkOptimizer.ts` and `mergeModel.ts` also adapt
|
||||
the IntelliJ Platform merge implementation (`MergeRangeUtil`, `MergeResolveUtil`,
|
||||
`ChunkOptimizer.LineChunkOptimizer` and `MergeConflictModel`). Modified from the
|
||||
originals. Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
|
||||
`src/ui/openidea/**` holds OpenIDEA's own file-type icons drawn in the expui style for
|
||||
languages whose JetBrains icons are not open source (Go, Ruby, Dart, Lua, Zig,
|
||||
Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`,
|
||||
|
||||
@@ -4,11 +4,15 @@ import { expect, test as base, type Page } from "@playwright/test";
|
||||
export const PROJECT = "acme-api";
|
||||
|
||||
export const test = base.extend<{ workbench: Page }>({
|
||||
// Every spec presses ⌘-shortcuts; CodeMirror picks Mod-key bindings from navigator.platform, not the user agent,
|
||||
// so without this they silently become Ctrl on the Linux CI.
|
||||
page: async ({ page }, provide) => {
|
||||
await page.addInitScript(() => Object.defineProperty(Navigator.prototype, "platform", { get: () => "MacIntel" }));
|
||||
await provide(page);
|
||||
},
|
||||
workbench: async ({ page }, provide) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
// CodeMirror picks Mod-key bindings from navigator.platform, not the user agent.
|
||||
await page.addInitScript(() => Object.defineProperty(Navigator.prototype, "platform", { get: () => "MacIntel" }));
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("tree", { name: "Project" })).toBeVisible();
|
||||
await expect(page.getByRole("treeitem", { name: /acme-api/ })).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
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");
|
||||
});
|
||||
@@ -583,7 +583,7 @@ export interface DocumentAttachment {
|
||||
readOnly: boolean;
|
||||
/** Add to the view (one view at a time). */
|
||||
extension: Extension;
|
||||
save(): Promise<void>;
|
||||
save(options?: { exact?: boolean }): Promise<void>;
|
||||
/** Detach; the document closes when nothing else shows it. */
|
||||
release(): void;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,20 @@ function setup(files: Record<string, string>) {
|
||||
afterEach(() => useDocMeta.setState({}, true));
|
||||
|
||||
describe("attached document views (editable diff side)", () => {
|
||||
it("can save a confirmed merge result without trimming or adding a final newline", async () => {
|
||||
const { backend, manager } = setup({ "/p/merge.txt": "old\n" });
|
||||
const att = await attachDocument(manager, "/p/merge.txt");
|
||||
const view = new EditorView({ doc: att.text(), extensions: [att.extension] });
|
||||
const model = manager.get("/p/merge.txt")!;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "标题😀 \nexact result " } });
|
||||
await att.save({ exact: true });
|
||||
expect(view.state.doc.toString()).toBe("标题😀 \nexact result ");
|
||||
expect(backend.docs.get(model.doc)?.text).toBe("标题😀 \nexact result ");
|
||||
expect(useDocMeta.getState()["/p/merge.txt"]?.dirty).toBe(false);
|
||||
view.destroy();
|
||||
att.release();
|
||||
});
|
||||
|
||||
it("shares the document with editor tabs both ways and syncs to the backend", async () => {
|
||||
const { backend, manager, frame } = setup({ "/p/a.txt": "one\ntwo\n" });
|
||||
const model = await manager.acquire("/p/a.txt");
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr
|
||||
for (const tr of u.transactions) if (tr.docChanged && !isExternalChange(tr)) model.onLocal(tr.changes, target);
|
||||
}),
|
||||
],
|
||||
save: () => model.save("explicit"),
|
||||
save: (options) => model.save(options?.exact ? "exact" : "explicit"),
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
|
||||
@@ -593,7 +593,7 @@ export class DocModel {
|
||||
|
||||
// --- saving -----------------------------------------------------------------
|
||||
|
||||
save(reason: "explicit" | "auto"): Promise<void> {
|
||||
save(reason: "explicit" | "auto" | "exact"): Promise<void> {
|
||||
const run = async () => {
|
||||
if (this.closed || this.meta.readOnly || !this.meta.dirty) return;
|
||||
const slot = this.primarySlot();
|
||||
@@ -607,7 +607,7 @@ export class DocModel {
|
||||
}
|
||||
}
|
||||
const files: FileSettings | undefined = this.manager.settings()?.files;
|
||||
if (slot && !this.closed) {
|
||||
if (slot && !this.closed && reason !== "exact") {
|
||||
const state = slot.currentState();
|
||||
const protectLines = new Set<number>();
|
||||
// Like IDEA's "keep trailing spaces on caret line": never strip under a visible caret.
|
||||
|
||||
@@ -69,6 +69,19 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.footer {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.footerGroup {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.message {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -1,143 +1,94 @@
|
||||
/** Three-pane merge conflict editor: Yours | Result | Theirs. */
|
||||
/** Three-pane merge editor: Yours | Result | Theirs, after IntelliJ's merge tool. */
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { RangeSetBuilder, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state";
|
||||
import { Decoration, EditorView, WidgetType, type DecorationSet } from "@codemirror/view";
|
||||
import { history, historyKeymap } from "@codemirror/commands";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import { Transaction, type EditorState, type Extension, type TransactionSpec } from "@codemirror/state";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { history, historyKeymap, undoDepth } from "@codemirror/commands";
|
||||
import { unifiedMergeView } from "@codemirror/merge";
|
||||
import type { MergeSides } from "@/bindings";
|
||||
import { embeddedEditorExtensions } from "@/editor/api";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { tx, useTxTemplate } from "@/platform/i18n";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { Button, IconButton, showContextMenu, Spinner, T } from "@/ui";
|
||||
import { copyText } from "@/ui/selectableText";
|
||||
import { Button, Spinner, T } from "@/ui";
|
||||
import { editorService, type EditorInput } from "@/workbench/contributions";
|
||||
import { blameSlot, toggleAnnotations } from "./blame";
|
||||
import { languageFor, viewerBase } from "./cm";
|
||||
import { confirmDialog } from "./dialogs";
|
||||
import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator";
|
||||
import { syncMergeScroll } from "./mergeScroll";
|
||||
import { initialResult, mergeTitles, resolutionText, type Resolution, type ResultConflict } from "./conflicts";
|
||||
import { mergeTitles } from "./conflicts";
|
||||
import {
|
||||
mergeChanged,
|
||||
mergeTheme,
|
||||
resultControls,
|
||||
resultMarks,
|
||||
sideControls,
|
||||
syncSide,
|
||||
type MergeControls,
|
||||
} from "./mergeGutters";
|
||||
import { COMPARISONS, showCompareMenu, showResultMenu, showSettingsMenu, showSideMenu } from "./mergeMenus";
|
||||
import { openDiff } from "./diffInput";
|
||||
import {
|
||||
clearFocusedMergeEditor,
|
||||
refreshMergeActions,
|
||||
setFocusedMergeEditor,
|
||||
type MergeActionHost,
|
||||
type MergeCommand,
|
||||
} from "./mergeActions";
|
||||
import {
|
||||
applyNonConflicting,
|
||||
applyTo,
|
||||
canApply,
|
||||
changesIn,
|
||||
changesInSide,
|
||||
resolveSimple,
|
||||
revertResolved,
|
||||
mergeField,
|
||||
mergeHistory,
|
||||
startMerge,
|
||||
summarize,
|
||||
unresolved,
|
||||
type MergeAction,
|
||||
type MergeSide,
|
||||
type MergeSummary,
|
||||
} from "./mergeModel";
|
||||
import { MergeFooter, MergeToolbar } from "./MergeToolbar";
|
||||
import { baseName } from "./model";
|
||||
import { errorMessage, readWorkingText, runOp, writeFileText } from "./ops";
|
||||
import { errorMessage, runOp, writeFileText } from "./ops";
|
||||
import { refreshNow } from "./store";
|
||||
import styles from "./MergeEditor.module.css";
|
||||
|
||||
import type { MergeData } from "./mergeKind";
|
||||
export { MERGE_KIND, mergeInput, type MergeData } from "./mergeKind";
|
||||
|
||||
/** Drops the conflict at an index once a resolution replaced its text. */
|
||||
const resolvedConflict = StateEffect.define<number>();
|
||||
const NOTHING_LEFT: MergeSummary = {
|
||||
pending: 0,
|
||||
conflicts: 0,
|
||||
left: false,
|
||||
all: false,
|
||||
right: false,
|
||||
simple: false,
|
||||
revert: false,
|
||||
};
|
||||
|
||||
function resolveConflict(view: EditorView, index: number, how: Resolution) {
|
||||
const c = view.state.field(conflictField).conflicts[index];
|
||||
if (!c) return;
|
||||
view.dispatch({
|
||||
changes: { from: c.from, to: c.to, insert: resolutionText(c, how) },
|
||||
effects: resolvedConflict.of(index),
|
||||
userEvent: "input.resolve",
|
||||
});
|
||||
}
|
||||
|
||||
class ActionsWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly index: number,
|
||||
readonly hasBase: boolean,
|
||||
) {
|
||||
super();
|
||||
// IntelliJ's "Automatically apply non-conflicting changes" (Tools | Diff & Merge); off by default.
|
||||
const AUTO_APPLY_KEY = "openidea.merge.autoApplyNonConflicting";
|
||||
const readAutoApply = () => {
|
||||
try {
|
||||
return localStorage.getItem(AUTO_APPLY_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
eq(o: ActionsWidget) {
|
||||
return o.index === this.index && o.hasBase === this.hasBase;
|
||||
};
|
||||
const writeAutoApply = (on: boolean) => {
|
||||
try {
|
||||
localStorage.setItem(AUTO_APPLY_KEY, String(on));
|
||||
} catch {
|
||||
// The choice then lasts only until the editor closes.
|
||||
}
|
||||
toDOM(view: EditorView) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "cm-conflict-actions";
|
||||
const add = (label: string, how: Resolution) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.textContent = label;
|
||||
// Keep the caret / focus in the editor; resolve on click (mouse or keyboard).
|
||||
b.onmousedown = (e) => e.preventDefault();
|
||||
b.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
resolveConflict(view, this.index, how);
|
||||
};
|
||||
el.appendChild(b);
|
||||
};
|
||||
const title = document.createElement("span");
|
||||
title.textContent = `Conflict ${this.index + 1}`;
|
||||
el.appendChild(title);
|
||||
add("Accept Yours", "ours");
|
||||
add("Accept Theirs", "theirs");
|
||||
add("Accept Both", "both");
|
||||
if (this.hasBase) add("Use Base", "base");
|
||||
return el;
|
||||
}
|
||||
ignoreEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const conflictLine = Decoration.line({ class: "cm-conflict-marker" });
|
||||
|
||||
function decorate(state: EditorState, conflicts: readonly ResultConflict[]): DecorationSet {
|
||||
const b = new RangeSetBuilder<Decoration>();
|
||||
const doc = state.doc;
|
||||
conflicts.forEach((c, i) => {
|
||||
b.add(c.from, c.from, Decoration.widget({ widget: new ActionsWidget(i, c.base !== null), block: true, side: -1 }));
|
||||
if (c.to <= c.from) return;
|
||||
const endLine = doc.lineAt(c.to - 1).number;
|
||||
for (let n = doc.lineAt(c.from).number; n <= endLine; n++) b.add(doc.line(n).from, doc.line(n).from, conflictLine);
|
||||
});
|
||||
return b.finish();
|
||||
}
|
||||
|
||||
/**
|
||||
* The result's unresolved conflicts, tracked as ranges holding the base text (no
|
||||
* markers), as IDEA's merge dialog shows them. Typing at a conflict's edges stays in it.
|
||||
*/
|
||||
const conflictField = StateField.define<{ conflicts: ResultConflict[]; deco: DecorationSet }>({
|
||||
create: () => ({ conflicts: [], deco: Decoration.none }),
|
||||
update(v, tr) {
|
||||
const done = new Set(tr.effects.filter((e) => e.is(resolvedConflict)).map((e) => e.value as number));
|
||||
let conflicts = v.conflicts.filter((_, i) => !done.has(i));
|
||||
if (tr.docChanged)
|
||||
conflicts = conflicts.map((c) => ({ ...c, from: tr.changes.mapPos(c.from, -1), to: tr.changes.mapPos(c.to, 1) }));
|
||||
if (!tr.docChanged && !done.size) return v;
|
||||
return { conflicts, deco: decorate(tr.state, conflicts) };
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
|
||||
});
|
||||
|
||||
const resultTheme = EditorView.baseTheme({
|
||||
".cm-conflict-marker": {
|
||||
backgroundColor: "color-mix(in srgb, var(--vcs-conflict) 14%, transparent)",
|
||||
color: "var(--fg-subtle)",
|
||||
},
|
||||
".cm-conflict-actions": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "3px 8px",
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: "var(--fs-small)",
|
||||
color: "var(--vcs-conflict)",
|
||||
background: "var(--bg-elev)",
|
||||
borderTop: "1px solid var(--border)",
|
||||
},
|
||||
".cm-conflict-actions button": {
|
||||
padding: "0 6px",
|
||||
border: "1px solid var(--border-strong)",
|
||||
borderRadius: "var(--radius-small)",
|
||||
color: "var(--fg-secondary)",
|
||||
background: "var(--bg-control)",
|
||||
font: "inherit",
|
||||
lineHeight: "18px",
|
||||
},
|
||||
".cm-conflict-actions button:hover": { color: "var(--fg-strong)", background: "var(--bg-control-hover)" },
|
||||
});
|
||||
};
|
||||
|
||||
export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
const { path } = input.data as MergeData;
|
||||
@@ -145,33 +96,46 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
ours: string;
|
||||
theirs: string;
|
||||
base: string | null;
|
||||
working: string;
|
||||
merged: string | null;
|
||||
sides: MergeSides;
|
||||
deleted: Record<MergeSide, boolean>;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lang, setLang] = useState<Extension | null>(null);
|
||||
const [left, setLeft] = useState(0);
|
||||
const [summary, setSummary] = useState<MergeSummary>(NOTHING_LEFT);
|
||||
const [autoApply, setAutoApply] = useState(readAutoApply);
|
||||
const [resolved, setResolved] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const paneRef = useRef<MergeSide | "result">("result");
|
||||
const [actionHost] = useState<MergeActionHost>(() => ({
|
||||
enabled: () => false,
|
||||
run: () => {},
|
||||
autoApply: () => false,
|
||||
}));
|
||||
const oursRef = useRef<HTMLDivElement>(null);
|
||||
const resultRef = useRef<HTMLDivElement>(null);
|
||||
const theirsRef = useRef<HTMLDivElement>(null);
|
||||
const resultView = useRef<EditorView | null>(null);
|
||||
const oursView = useRef<EditorView | null>(null);
|
||||
const theirsView = useRef<EditorView | null>(null);
|
||||
const autoApplyRef = useRef(autoApply);
|
||||
useEffect(() => {
|
||||
autoApplyRef.current = autoApply;
|
||||
}, [autoApply]);
|
||||
const txt = useTxTemplate();
|
||||
const titles = mergeTitles(state?.sides);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [v, working] = await Promise.all([call(commands.vcsConflictVersions(path)), readWorkingText(path)]);
|
||||
const v = await call(commands.vcsConflictVersions(path));
|
||||
if ([v.base, v.ours, v.theirs].some((text) => text?.includes("\0"))) {
|
||||
throw new Error(tx("Binary files cannot be resolved in the merge editor."));
|
||||
}
|
||||
setState({
|
||||
ours: v.ours ?? "",
|
||||
theirs: v.theirs ?? "",
|
||||
base: v.base,
|
||||
working: working ?? "",
|
||||
merged: v.merged,
|
||||
sides: v.sides,
|
||||
deleted: { left: v.ours === null, right: v.theirs === null },
|
||||
});
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
@@ -192,38 +156,65 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!state || lang === null || !oursRef.current || !resultRef.current || !theirsRef.current) return;
|
||||
const side = () => [
|
||||
// Like IntelliJ, the result starts as the base, with every change waiting to be decided.
|
||||
const start = startMerge(state.ours, state.base ?? "", state.theirs);
|
||||
const change = (index: number, action: MergeAction) => {
|
||||
setFocusedMergeEditor(actionHost);
|
||||
void executeAction("merge.change", { indexes: [index], action });
|
||||
};
|
||||
const controls: MergeControls = {
|
||||
accept: (index, side, resolve) => change(index, { type: "accept", side, resolve }),
|
||||
ignore: (index, side, resolve) => change(index, { type: "ignore", side: resolve ? null : side }),
|
||||
resolveAutomatically: (index) => change(index, { type: "auto", side: "base" }),
|
||||
};
|
||||
const side = (which: MergeSide): Extension[] => [
|
||||
viewerBase({ readOnly: true }),
|
||||
lang,
|
||||
blameSlot(),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.selectionSet) refreshMergeActions(actionHost);
|
||||
}),
|
||||
state.base !== null ? unifiedMergeView({ original: state.base, mergeControls: false, gutter: true }) : [],
|
||||
sideControls(which, controls),
|
||||
mergeTheme,
|
||||
];
|
||||
const ours = new EditorView({ doc: state.ours, parent: oursRef.current, extensions: side() });
|
||||
const theirs = new EditorView({ doc: state.theirs, parent: theirsRef.current, extensions: side() });
|
||||
// Like IDEA, start from the three-way merge (non-conflicting changes applied, conflicts
|
||||
// showing the base) rather than the marker-laden working copy.
|
||||
const start =
|
||||
state.merged !== null ? initialResult(state.merged) : initialResult(state.working, state.sides.reversed);
|
||||
const result = new EditorView({
|
||||
const ours = new EditorView({ doc: state.ours, parent: oursRef.current, extensions: side("left") });
|
||||
const theirs = new EditorView({ doc: state.theirs, parent: theirsRef.current, extensions: side("right") });
|
||||
const result: EditorView = new EditorView({
|
||||
doc: start.text,
|
||||
parent: resultRef.current,
|
||||
extensions: [
|
||||
viewerBase({ readOnly: false }),
|
||||
history(),
|
||||
keymap.of(historyKeymap),
|
||||
mergeField.init(() => start.changes),
|
||||
mergeHistory,
|
||||
embeddedEditorExtensions(path),
|
||||
lang,
|
||||
conflictField.init((s) => ({ conflicts: start.conflicts, deco: decorate(s, start.conflicts) })),
|
||||
resultTheme,
|
||||
resultMarks,
|
||||
resultControls(controls),
|
||||
mergeTheme,
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) setLeft(u.state.field(conflictField).conflicts.length);
|
||||
if (u.selectionSet || mergeChanged(u)) refreshMergeActions(actionHost);
|
||||
if (!mergeChanged(u)) return;
|
||||
const changes = u.state.field(mergeField);
|
||||
ours.dispatch({ effects: syncSide.of(changes) });
|
||||
theirs.dispatch({ effects: syncSide.of(changes) });
|
||||
setSummary(summarize(u.state));
|
||||
}),
|
||||
],
|
||||
});
|
||||
resultView.current = result;
|
||||
setLeft(result.state.field(conflictField).conflicts.length);
|
||||
oursView.current = ours;
|
||||
theirsView.current = theirs;
|
||||
ours.dispatch({ effects: syncSide.of(start.changes) });
|
||||
theirs.dispatch({ effects: syncSide.of(start.changes) });
|
||||
setSummary(summarize(result.state));
|
||||
if (autoApplyRef.current) {
|
||||
const spec = applyNonConflicting(result.state, "base");
|
||||
// The starting point, not a step to undo.
|
||||
if (spec) result.dispatch({ ...spec, annotations: Transaction.addToHistory.of(false) });
|
||||
}
|
||||
// Scroll the three panes together, lined up through their changes.
|
||||
const stopSync = syncMergeScroll({ ours, result, theirs });
|
||||
return () => {
|
||||
@@ -235,57 +226,43 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
oursView.current = null;
|
||||
theirsView.current = null;
|
||||
};
|
||||
}, [state, lang, path]);
|
||||
}, [state, lang, path, actionHost]);
|
||||
|
||||
/** Puts the caret at a change and brings it into view. */
|
||||
const moveTo = (view: EditorView, at: number) =>
|
||||
view.dispatch({ selection: { anchor: at }, effects: EditorView.scrollIntoView(at, { y: "center" }) });
|
||||
|
||||
const goto = (dir: 1 | -1) => {
|
||||
const v = resultView.current;
|
||||
if (!v) return;
|
||||
const blocks = v.state.field(conflictField).conflicts;
|
||||
if (!blocks.length) return;
|
||||
const open = unresolved(v.state);
|
||||
if (!open.length) return;
|
||||
const head = v.state.selection.main.head;
|
||||
const target =
|
||||
dir === 1
|
||||
? (blocks.find((b) => b.from > head) ?? blocks[0]!)
|
||||
: ([...blocks].reverse().find((b) => b.to < head) ?? blocks[blocks.length - 1]!);
|
||||
v.dispatch({
|
||||
selection: { anchor: target.from },
|
||||
effects: EditorView.scrollIntoView(target.from, { y: "center" }),
|
||||
});
|
||||
? (open.find((c) => c.from > head) ?? open[0]!)
|
||||
: ([...open].reverse().find((c) => c.from < head) ?? open[open.length - 1]!);
|
||||
moveTo(v, target.from);
|
||||
v.focus();
|
||||
};
|
||||
|
||||
const acceptAll = (how: Resolution) => {
|
||||
/** Runs a command on the result; `reveal` then moves to what is left to decide. */
|
||||
const command = (make: (s: EditorState) => TransactionSpec | null, reveal = false) => {
|
||||
const v = resultView.current;
|
||||
if (!v) return;
|
||||
const conflicts = v.state.field(conflictField).conflicts;
|
||||
v.dispatch({
|
||||
changes: conflicts.map((c) => ({ from: c.from, to: c.to, insert: resolutionText(c, how) })),
|
||||
effects: conflicts.map((_, i) => resolvedConflict.of(i)),
|
||||
userEvent: "input.resolve",
|
||||
});
|
||||
const spec = v && make(v.state);
|
||||
if (!v || !spec) return;
|
||||
v.dispatch(spec);
|
||||
const first = reveal ? unresolved(v.state)[0] : undefined;
|
||||
if (first) moveTo(v, first.from);
|
||||
};
|
||||
|
||||
const apply = async () => {
|
||||
const v = resultView.current;
|
||||
if (!v) return;
|
||||
const remaining = v.state.field(conflictField).conflicts.length;
|
||||
if (
|
||||
remaining > 0 &&
|
||||
!(await confirmDialog({
|
||||
title: "Unresolved Conflicts",
|
||||
message:
|
||||
remaining === 1
|
||||
? tx`${remaining} conflict is unresolved and keeps the base text. Save and mark the file as resolved anyway?`
|
||||
: tx`${remaining} conflicts are unresolved and keep the base text. Save and mark the file as resolved anyway?`,
|
||||
okLabel: "Mark Resolved",
|
||||
}))
|
||||
)
|
||||
return;
|
||||
const text = v.state.doc.toString();
|
||||
/** Saves `text` as the resolved file and stages it; like IDEA, that closes the merge editor. */
|
||||
const writeResolved = async (text: string | null) => {
|
||||
const ok = await runOp(
|
||||
`Resolve ${baseName(path)}`,
|
||||
async () => {
|
||||
await writeFileText(path, text, { save: true });
|
||||
if (text === null) await call(commands.fsDelete(path));
|
||||
else await writeFileText(path, text, { save: true, exact: true });
|
||||
await call(commands.vcsStage([path]));
|
||||
return true;
|
||||
},
|
||||
@@ -294,12 +271,67 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
if (ok) {
|
||||
setResolved(true);
|
||||
await refreshNow();
|
||||
// Like IDEA, applying the result closes the merge editor.
|
||||
await editorService().closeInput?.(input.key);
|
||||
}
|
||||
};
|
||||
|
||||
// Next / Previous Difference (F7 / Shift+F7 by default) jump between conflicts through
|
||||
const apply = async () => {
|
||||
const v = resultView.current;
|
||||
if (!v) return;
|
||||
const remaining = unresolved(v.state).length;
|
||||
if (
|
||||
remaining > 0 &&
|
||||
!(await confirmDialog({
|
||||
title: "Unresolved Changes",
|
||||
message:
|
||||
remaining === 1
|
||||
? tx`${remaining} change is unresolved and keeps the base text. Save and mark the file as resolved anyway?`
|
||||
: tx`${remaining} changes are unresolved and keep the base text. Save and mark the file as resolved anyway?`,
|
||||
okLabel: "Mark Resolved",
|
||||
}))
|
||||
)
|
||||
return;
|
||||
const separator = (state?.base ?? state?.ours ?? state?.theirs ?? "").includes("\r\n") ? "\r\n" : "\n";
|
||||
await writeResolved(v.state.doc.sliceString(0, undefined, separator));
|
||||
};
|
||||
|
||||
/** Whether the user decided or edited anything: what Accept and Cancel ask about. */
|
||||
const modified = () => {
|
||||
const v = resultView.current;
|
||||
return !!v && undoDepth(v.state) > 0;
|
||||
};
|
||||
|
||||
/** Accept Yours / Accept Theirs: that version as it is, whatever was done in the result. */
|
||||
const acceptSide = async (side: MergeSide) => {
|
||||
if (!state) return;
|
||||
if (
|
||||
modified() &&
|
||||
!(await confirmDialog({
|
||||
title: side === "left" ? "Accept Yours" : "Accept Theirs",
|
||||
message: "The result was changed. Discard the changes and take this version as it is?",
|
||||
okLabel: "Discard Changes",
|
||||
danger: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await writeResolved(state.deleted[side] ? null : side === "left" ? state.ours : state.theirs);
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
if (
|
||||
modified() &&
|
||||
!(await confirmDialog({
|
||||
title: "Cancel Merge",
|
||||
message: "Exit without applying the changes made in the result?",
|
||||
okLabel: "Exit",
|
||||
danger: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await editorService().closeInput?.(input.key);
|
||||
};
|
||||
|
||||
// Next / Previous Difference (F7 / Shift+F7 by default) jump between changes through
|
||||
// the keymap, as in the diff viewer.
|
||||
// `goto` reads only refs, so the first render's one stays valid.
|
||||
const [nav] = useState<DiffNavigator>(() => ({
|
||||
@@ -308,7 +340,7 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
jumpToSource: () => void editorService().openFile(path, { focus: true }),
|
||||
applyChange: () => false,
|
||||
file: () => {},
|
||||
showSettings: () => {},
|
||||
showSettings: () => void actionHost.run("settings"),
|
||||
}));
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -320,29 +352,212 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
[nav],
|
||||
);
|
||||
|
||||
// Each side pane can annotate its version with Git Blame from the context menu, on
|
||||
// the branch it belongs to, so lines that only exist on the other branch show their
|
||||
// real history instead of "Not Committed Yet".
|
||||
const otherRev = state?.sides.commit ?? "HEAD";
|
||||
const setAutoApplyNonConflicting = (on: boolean) => {
|
||||
setAutoApply(on);
|
||||
writeAutoApply(on);
|
||||
if (on) command((s) => applyNonConflicting(s, "base"), true);
|
||||
};
|
||||
|
||||
const sideMenu = (e: React.MouseEvent, viewRef: { current: EditorView | null }, text: string, revision?: string) => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
e.preventDefault();
|
||||
showContextMenu(e.nativeEvent, [
|
||||
{
|
||||
label: "Annotate with Git Blame",
|
||||
icon: "changelist",
|
||||
run: () => void toggleAnnotations(view, path, text, revision),
|
||||
},
|
||||
]);
|
||||
const activePane = () => {
|
||||
const which = paneRef.current;
|
||||
return (which === "left" ? oursView : which === "right" ? theirsView : resultView).current;
|
||||
};
|
||||
|
||||
const selectionDecision = (id: MergeCommand) => {
|
||||
const view = activePane();
|
||||
const result = resultView.current;
|
||||
if (!view || !result) return null;
|
||||
const { from, to } = view.state.selection.main;
|
||||
const side = paneRef.current;
|
||||
const indexes =
|
||||
side === "result"
|
||||
? changesIn(result.state, from, to)
|
||||
: changesInSide(result.state, side, view.state.doc, from, to);
|
||||
let action: MergeAction;
|
||||
if (id === "resolveAutomatically") action = { type: "auto", side: "base" };
|
||||
else if (id === "ignore") action = { type: "ignore", side: side === "result" ? null : side };
|
||||
else if (id === "resolveUsingYours" || id === "resolveUsingTheirs")
|
||||
action = { type: "accept", side: id === "resolveUsingYours" ? "left" : "right", resolve: true };
|
||||
else if (id === "accept" && side !== "result") action = { type: "accept", side, resolve: false };
|
||||
else return null;
|
||||
return { indexes, action };
|
||||
};
|
||||
|
||||
const enabled = (id: MergeCommand) => {
|
||||
if (id === "reload" || id === "cancel") return !resolved;
|
||||
const v = resultView.current;
|
||||
if (!v || error || resolved) return false;
|
||||
const available = summarize(v.state);
|
||||
switch (id) {
|
||||
case "applyLeft":
|
||||
return available.left;
|
||||
case "applyAll":
|
||||
return available.all;
|
||||
case "applyRight":
|
||||
return available.right;
|
||||
case "resolveSimple":
|
||||
return available.simple;
|
||||
case "revert":
|
||||
return available.revert;
|
||||
case "accept":
|
||||
case "resolveUsingYours":
|
||||
case "resolveUsingTheirs":
|
||||
case "ignore":
|
||||
case "resolveAutomatically": {
|
||||
const decision = selectionDecision(id);
|
||||
return !!decision?.indexes.some((i) => canApply(v.state.doc, v.state.field(mergeField)[i]!, decision.action));
|
||||
}
|
||||
case "annotate":
|
||||
return paneRef.current !== "result";
|
||||
case "cut":
|
||||
return paneRef.current === "result" && !v.state.selection.main.empty;
|
||||
case "copy":
|
||||
return !!activePane() && !activePane()!.state.selection.main.empty;
|
||||
case "paste":
|
||||
return paneRef.current === "result";
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const menuPosition = (name: "compare" | "settings") => {
|
||||
const button = rootRef.current?.querySelector(`[data-merge-menu="${name}"]`);
|
||||
const rect = (button ?? rootRef.current)?.getBoundingClientRect();
|
||||
return { clientX: rect?.left ?? 0, clientY: rect?.bottom ?? 0 };
|
||||
};
|
||||
|
||||
const runAction = async (id: MergeCommand, arg?: unknown) => {
|
||||
switch (id) {
|
||||
case "applyLeft":
|
||||
return command((s) => applyNonConflicting(s, "left"), true);
|
||||
case "applyAll":
|
||||
return command((s) => applyNonConflicting(s, "base"), true);
|
||||
case "applyRight":
|
||||
return command((s) => applyNonConflicting(s, "right"), true);
|
||||
case "resolveSimple":
|
||||
return command(resolveSimple, true);
|
||||
case "revert":
|
||||
return command(revertResolved, true);
|
||||
case "acceptYours":
|
||||
return acceptSide("left");
|
||||
case "acceptTheirs":
|
||||
return acceptSide("right");
|
||||
case "apply":
|
||||
return apply();
|
||||
case "cancel":
|
||||
return cancel();
|
||||
case "reload":
|
||||
return load();
|
||||
case "compare":
|
||||
return showCompareMenu(menuPosition("compare"));
|
||||
case "settings":
|
||||
return showSettingsMenu(menuPosition("settings"));
|
||||
case "autoApply":
|
||||
return setAutoApplyNonConflicting(!autoApply);
|
||||
case "accept":
|
||||
case "resolveUsingYours":
|
||||
case "resolveUsingTheirs":
|
||||
case "ignore":
|
||||
case "resolveAutomatically": {
|
||||
const decision = selectionDecision(id);
|
||||
if (decision) command((s) => applyTo(s, decision.indexes, decision.action));
|
||||
return;
|
||||
}
|
||||
case "change": {
|
||||
const change = arg as { indexes: number[]; action: MergeAction } | undefined;
|
||||
if (change) command((s) => applyTo(s, change.indexes, change.action));
|
||||
return;
|
||||
}
|
||||
case "annotate": {
|
||||
const which = paneRef.current;
|
||||
const view = activePane();
|
||||
if (!state || !view || which === "result") return;
|
||||
const isHead = which === "left" ? !state.sides.reversed : state.sides.reversed;
|
||||
await toggleAnnotations(
|
||||
view,
|
||||
path,
|
||||
which === "left" ? state.ours : state.theirs,
|
||||
isHead ? "HEAD" : (state.sides.commit ?? "HEAD"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "cut":
|
||||
case "copy":
|
||||
case "paste":
|
||||
case "selectAll": {
|
||||
const view = activePane();
|
||||
if (!view) return;
|
||||
const { from, to } = view.state.selection.main;
|
||||
if (id === "selectAll") view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
||||
else if (id === "paste") {
|
||||
const text = await navigator.clipboard?.readText().catch(() => undefined);
|
||||
if (text !== undefined) view.dispatch({ ...view.state.replaceSelection(text), userEvent: "input.paste" });
|
||||
} else {
|
||||
await copyText(view.state.sliceDoc(from, to));
|
||||
if (id === "cut") view.dispatch({ changes: { from, to }, userEvent: "delete.cut" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
const v = resultView.current;
|
||||
if (!state || !v || !(id in COMPARISONS)) return;
|
||||
const versions = {
|
||||
yours: { text: state.ours, label: txt(titles.yours) },
|
||||
base: { text: state.base ?? "", label: tx("Base") },
|
||||
result: { text: v.state.doc.toString(), label: tx("Result") },
|
||||
theirs: { text: state.theirs, label: txt(titles.theirs) },
|
||||
};
|
||||
const [a, b] = COMPARISONS[id as keyof typeof COMPARISONS];
|
||||
await openDiff({ path, left: { type: "text", ...versions[a] }, right: { type: "text", ...versions[b] } });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Object.assign(actionHost, { enabled, run: runAction, autoApply: () => autoApply });
|
||||
refreshMergeActions(actionHost);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Popup focus belongs to the editor that opened it; a real focus change releases it.
|
||||
const onFocus = (e: FocusEvent) => {
|
||||
const target = e.target as Element | null;
|
||||
if (!rootRef.current?.contains(target) && !target?.closest("[data-popup], [role=dialog]"))
|
||||
clearFocusedMergeEditor(actionHost);
|
||||
};
|
||||
document.addEventListener("focusin", onFocus);
|
||||
return () => {
|
||||
document.removeEventListener("focusin", onFocus);
|
||||
clearFocusedMergeEditor(actionHost);
|
||||
};
|
||||
}, [actionHost]);
|
||||
|
||||
const focusProps = {
|
||||
onFocusCapture: (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
const target = e.target as Node;
|
||||
if (oursRef.current?.contains(target)) paneRef.current = "left";
|
||||
else if (theirsRef.current?.contains(target)) paneRef.current = "right";
|
||||
else if (resultRef.current?.contains(target)) paneRef.current = "result";
|
||||
setFocusedMergeEditor(actionHost);
|
||||
setFocusedDiffNavigator(nav);
|
||||
setContext("diffEditorFocus", true);
|
||||
},
|
||||
onBlurCapture: (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
const target = e.relatedTarget as Element | null;
|
||||
if (e.currentTarget.contains(target) || target?.closest("[data-popup], [role=dialog]")) return;
|
||||
clearFocusedMergeEditor(actionHost);
|
||||
if (focusedDiffNavigator() === nav) {
|
||||
setFocusedDiffNavigator(null);
|
||||
setContext("diffEditorFocus", false);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<div className={styles.message}>
|
||||
<div ref={rootRef} className={styles.message} {...focusProps}>
|
||||
{error}
|
||||
<Button onClick={() => void load()}>
|
||||
<Button onClick={() => void executeAction("merge.reload")}>
|
||||
<T>Retry</T>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -358,49 +573,8 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.root}
|
||||
onFocusCapture={() => {
|
||||
setFocusedDiffNavigator(nav);
|
||||
setContext("diffEditorFocus", true);
|
||||
}}
|
||||
onBlurCapture={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedDiffNavigator() === nav) {
|
||||
setFocusedDiffNavigator(null);
|
||||
setContext("diffEditorFocus", false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.toolbar} role="toolbar" aria-label={tx("Merge")}>
|
||||
<IconButton
|
||||
icon="arrow-up"
|
||||
title="Previous Conflict"
|
||||
shortcut={shortcutLabel("diff.prevDifference")}
|
||||
size="small"
|
||||
onClick={() => goto(-1)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="arrow-down"
|
||||
title="Next Conflict"
|
||||
shortcut={shortcutLabel("diff.nextDifference")}
|
||||
size="small"
|
||||
onClick={() => goto(1)}
|
||||
/>
|
||||
<span className={styles.sep} />
|
||||
<Button size="small" onClick={() => acceptAll("ours")}>
|
||||
<T>Accept All Yours</T>
|
||||
</Button>
|
||||
<Button size="small" onClick={() => acceptAll("theirs")}>
|
||||
<T>Accept All Theirs</T>
|
||||
</Button>
|
||||
<IconButton icon="refresh" title="Reload from Disk" size="small" onClick={() => void load()} />
|
||||
<span className={clsx(styles.status, !left && styles.statusDone)}>
|
||||
{left === 1 ? tx`${left} conflict left` : left ? tx`${left} conflicts left` : tx("All conflicts resolved")}
|
||||
</span>
|
||||
<Button size="small" variant="primary" onClick={() => void apply()}>
|
||||
<T>Apply</T>
|
||||
</Button>
|
||||
</div>
|
||||
<div ref={rootRef} className={styles.root} {...focusProps}>
|
||||
<MergeToolbar summary={summary} />
|
||||
<div className={styles.labels}>
|
||||
<span>{txt(titles.yours)}</span>
|
||||
<span>
|
||||
@@ -419,17 +593,24 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
className={styles.pane}
|
||||
aria-label={tx("Your version")}
|
||||
role="region"
|
||||
onContextMenu={(e) => sideMenu(e, oursView, state?.ours ?? "", state?.sides.reversed ? otherRev : "HEAD")}
|
||||
onContextMenu={(e) => oursView.current && showSideMenu(e, oursView.current, "left")}
|
||||
/>
|
||||
<div
|
||||
ref={resultRef}
|
||||
className={styles.pane}
|
||||
aria-label={tx("Result")}
|
||||
role="region"
|
||||
onContextMenu={(e) => resultView.current && showResultMenu(e, resultView.current)}
|
||||
/>
|
||||
<div ref={resultRef} className={styles.pane} aria-label={tx("Result")} role="region" />
|
||||
<div
|
||||
ref={theirsRef}
|
||||
className={styles.pane}
|
||||
aria-label={tx("Their version")}
|
||||
role="region"
|
||||
onContextMenu={(e) => sideMenu(e, theirsView, state?.theirs ?? "", state?.sides.reversed ? "HEAD" : otherRev)}
|
||||
onContextMenu={(e) => theirsView.current && showSideMenu(e, theirsView.current, "right")}
|
||||
/>
|
||||
</div>
|
||||
<MergeFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/** The merge editor's toolbar and the buttons along its bottom, after IntelliJ's merge dialog. */
|
||||
import clsx from "clsx";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { Button, IconButton, T } from "@/ui";
|
||||
import type { MergeSummary } from "./mergeModel";
|
||||
import styles from "./MergeEditor.module.css";
|
||||
|
||||
export interface ToolbarProps {
|
||||
summary: MergeSummary;
|
||||
}
|
||||
|
||||
function statusText({ pending, conflicts }: MergeSummary) {
|
||||
if (!pending) return tx("All changes processed");
|
||||
const left = pending === 1 ? tx`${pending} change left` : tx`${pending} changes left`;
|
||||
if (!conflicts) return left;
|
||||
return `${left}, ${conflicts === 1 ? tx`${conflicts} conflict` : tx`${conflicts} conflicts`}`;
|
||||
}
|
||||
|
||||
export function MergeToolbar({ summary }: ToolbarProps) {
|
||||
return (
|
||||
<div className={styles.toolbar} role="toolbar" aria-label={tx("Merge")}>
|
||||
<IconButton icon="arrow-up" title="Previous Change" action="diff.prevDifference" size="small" />
|
||||
<IconButton icon="arrow-down" title="Next Change" action="diff.nextDifference" size="small" />
|
||||
<span className={styles.sep} />
|
||||
<IconButton
|
||||
icon="diff-apply-left"
|
||||
title="Apply Non-Conflicting Changes from the Left Side"
|
||||
size="small"
|
||||
disabled={!summary.left}
|
||||
action="merge.applyLeft"
|
||||
/>
|
||||
<IconButton
|
||||
icon="diff-apply-all"
|
||||
title="Apply All Non-Conflicting Changes"
|
||||
size="small"
|
||||
disabled={!summary.all}
|
||||
action="merge.applyAll"
|
||||
/>
|
||||
<IconButton
|
||||
icon="diff-apply-right"
|
||||
title="Apply Non-Conflicting Changes from the Right Side"
|
||||
size="small"
|
||||
disabled={!summary.right}
|
||||
action="merge.applyRight"
|
||||
/>
|
||||
<IconButton
|
||||
icon="diff-magic-toolbar"
|
||||
title="Resolve Simple Conflicts"
|
||||
size="small"
|
||||
disabled={!summary.simple}
|
||||
action="merge.resolveSimple"
|
||||
/>
|
||||
<IconButton
|
||||
icon="diff-revert"
|
||||
title="Revert Conflict Resolution"
|
||||
size="small"
|
||||
disabled={!summary.revert}
|
||||
action="merge.revert"
|
||||
/>
|
||||
<span className={styles.sep} />
|
||||
<IconButton icon="diff" title="Compare Contents" size="small" action="merge.compare" data-merge-menu="compare" />
|
||||
<span className={clsx(styles.status, !summary.pending && styles.statusDone)}>{statusText(summary)}</span>
|
||||
<IconButton icon="refresh" title="Reload from Disk" size="small" action="merge.reload" />
|
||||
<IconButton icon="settings" title="Settings" size="small" action="merge.settings" data-merge-menu="settings" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MergeFooter() {
|
||||
return (
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.footerGroup}>
|
||||
<Button onClick={() => void executeAction("merge.acceptYours")}>
|
||||
<T>Accept Yours</T>
|
||||
</Button>
|
||||
<Button onClick={() => void executeAction("merge.acceptTheirs")}>
|
||||
<T>Accept Theirs</T>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.footerGroup}>
|
||||
<Button onClick={() => void executeAction("merge.cancel")}>
|
||||
<T>Cancel</T>
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => void executeAction("merge.apply")}>
|
||||
<T>Apply</T>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { diff } from "@codemirror/merge";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { changesBetween, optimizeLineChunks, unchangedBetween, type Unchanged } from "../chunkOptimizer";
|
||||
import { mergeChanges } from "../mergeDiff3";
|
||||
|
||||
/** One character per distinct line, and the weight (characters other than blanks) of each. */
|
||||
function encode(lines: readonly string[]) {
|
||||
return {
|
||||
text: lines.map((l) => String.fromCharCode(0x100 + [...new Set(ALL)].indexOf(l))).join(""),
|
||||
weights: lines.map((l) => l.replace(/\s/g, "").length),
|
||||
};
|
||||
}
|
||||
const BLOCK = ["if (a) {", " x();", "}"];
|
||||
const ALL = ["if (a) {", " x();", "}", "if (b) {", " y();", ""];
|
||||
|
||||
describe("optimizeLineChunks", () => {
|
||||
it("moves a block that starts in the middle of a copy to where the copy starts", () => {
|
||||
// The base has the block `if (a) {…}` then `if (b) {…}`; the left repeats the first block.
|
||||
// A diff may say the lines "}", "if (a) {", " x();" were inserted after the first two.
|
||||
const base = [...BLOCK, "if (b) {", " y();", "}"];
|
||||
const left = [...BLOCK, ...BLOCK, "if (b) {", " y();", "}"];
|
||||
const a = encode(base);
|
||||
const b = encode(left);
|
||||
const misaligned: Unchanged[] = [
|
||||
{ s1: 0, e1: 2, s2: 0, e2: 2 },
|
||||
{ s1: 2, e1: 6, s2: 5, e2: 9 },
|
||||
];
|
||||
const out = optimizeLineChunks(a.text, b.text, a.weights, b.weights, misaligned);
|
||||
// The inserted lines become a whole block: lines 0–3 of the left.
|
||||
expect(changesBetween(out, base.length, left.length)).toEqual([[0, 0, 0, 3]]);
|
||||
});
|
||||
|
||||
it("leaves a change that already starts and ends at a blank line alone", () => {
|
||||
const base = ["a();", "", "b();"];
|
||||
const left = ["a();", "", "c();", "", "b();"];
|
||||
const a = encode(base.map((l) => (l === "a();" ? "if (a) {" : l === "b();" ? " x();" : l)));
|
||||
const b = encode(
|
||||
left.map((l) => (l === "a();" ? "if (a) {" : l === "b();" ? " x();" : l === "c();" ? " y();" : l)),
|
||||
);
|
||||
const found: Unchanged[] = [
|
||||
{ s1: 0, e1: 2, s2: 0, e2: 2 },
|
||||
{ s1: 2, e1: 3, s2: 4, e2: 5 },
|
||||
];
|
||||
expect(optimizeLineChunks(a.text, b.text, a.weights, b.weights, found)).toEqual(found);
|
||||
});
|
||||
|
||||
it("only rearranges: the unchanged stretches stay equal, ordered, and as many lines", () => {
|
||||
let seed = 99;
|
||||
const rnd = () => (seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 2 ** 32;
|
||||
const vocab = ["{", "}", "", " a();", " b();", "if (x) {", "return;", " );"];
|
||||
const pick = () => vocab[Math.floor(rnd() * vocab.length)]!;
|
||||
for (let round = 0; round < 500; round++) {
|
||||
const base = Array.from({ length: Math.floor(rnd() * 25) }, pick);
|
||||
const side = [...base];
|
||||
for (let n = 1 + Math.floor(rnd() * 4); n > 0; n--) {
|
||||
const at = Math.floor(rnd() * (side.length + 1));
|
||||
if (rnd() < 0.5) side.splice(at, 1 + Math.floor(rnd() * 3));
|
||||
else side.splice(at, 0, ...Array.from({ length: 1 + Math.floor(rnd() * 3) }, pick));
|
||||
}
|
||||
const pool = [...new Set([...base, ...side])];
|
||||
const code = (l: string) => String.fromCharCode(0x100 + pool.indexOf(l));
|
||||
const [a, b] = [base.map(code).join(""), side.map(code).join("")];
|
||||
const weights = (ls: string[]) => ls.map((l) => l.replace(/\s/g, "").length);
|
||||
const found = diff(a, b);
|
||||
const before = unchangedBetween(
|
||||
found.map((c) => [c.fromA, c.toA, c.fromB, c.toB]),
|
||||
a.length,
|
||||
b.length,
|
||||
);
|
||||
const after = optimizeLineChunks(a, b, weights(base), weights(side), before);
|
||||
const total = (rs: Unchanged[]) => rs.reduce((n, r) => n + r.e1 - r.s1, 0);
|
||||
expect(total(after), `round ${round}`).toBe(total(before));
|
||||
let p1 = 0;
|
||||
let p2 = 0;
|
||||
for (const r of after) {
|
||||
expect(r.e1 - r.s1).toBe(r.e2 - r.s2);
|
||||
expect(r.s1).toBeGreaterThanOrEqual(p1);
|
||||
expect(r.s2).toBeGreaterThanOrEqual(p2);
|
||||
for (let i = 0; i < r.e1 - r.s1; i++) expect(a[r.s1 + i], `round ${round}`).toBe(b[r.s2 + i]);
|
||||
[p1, p2] = [r.e1, r.e2];
|
||||
}
|
||||
expect(p1).toBeLessThanOrEqual(a.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the merge with optimized changes", () => {
|
||||
it("puts an added function between its neighbours, not across their braces", () => {
|
||||
const base = ["a() {", " x();", "}", "", "b() {", " y();", "}", ""].map((l) => `${l}\n`).join("");
|
||||
const left = ["a() {", " x();", "}", "", "c() {", " z();", "}", "", "b() {", " y();", "}", ""]
|
||||
.map((l) => `${l}\n`)
|
||||
.join("");
|
||||
const [added] = mergeChanges(left, base, base)!;
|
||||
expect(added!.base).toBe("");
|
||||
expect(added!.left.startsWith("c() {") || added!.left.startsWith("\nc() {")).toBe(true);
|
||||
expect(added!.left.trimEnd().endsWith("}")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { blockAt, initialResult, mergeTitles, parseConflicts, resolveAll, resolutionText } from "../conflicts";
|
||||
import { blockAt, mergeTitles, parseConflicts, resolveAll, resolutionText } from "../conflicts";
|
||||
|
||||
const text = [
|
||||
"keep\n",
|
||||
@@ -84,23 +84,3 @@ describe("mergeTitles", () => {
|
||||
expect(mergeTitles(null)).toEqual({ yours: "Your version", theirs: "Theirs (incoming)" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialResult", () => {
|
||||
const merged = "a\n<<<<<<< yours\nmine\n||||||| base\nold\n=======\nhers\n>>>>>>> theirs\nz\n";
|
||||
|
||||
it("replaces each conflict with its base text, without markers", () => {
|
||||
const { text, conflicts } = initialResult(merged);
|
||||
expect(text).toBe("a\nold\nz\n");
|
||||
expect(conflicts).toEqual([{ from: 2, to: 6, ours: "mine\n", base: "old\n", theirs: "hers\n" }]);
|
||||
expect(resolutionText(conflicts[0]!, "ours")).toBe("mine\n");
|
||||
});
|
||||
|
||||
it("swaps marker sides written in git's order during a rebase", () => {
|
||||
const { conflicts } = initialResult(merged, true);
|
||||
expect(conflicts[0]).toMatchObject({ ours: "hers\n", theirs: "mine\n" });
|
||||
});
|
||||
|
||||
it("keeps text without conflicts as is", () => {
|
||||
expect(initialResult("x\n")).toEqual({ text: "x\n", conflicts: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { allActions, executeAction, getAction, isActionEnabled } from "@/platform/actions";
|
||||
import { resolveMenuItems } from "@/ui/Menu";
|
||||
import {
|
||||
clearFocusedMergeEditor,
|
||||
refreshMergeActions,
|
||||
registerMergeActions,
|
||||
setFocusedMergeEditor,
|
||||
type MergeActionHost,
|
||||
} from "../mergeActions";
|
||||
|
||||
let dispose: () => void;
|
||||
const host = (): MergeActionHost => ({ enabled: () => true, run: vi.fn(), autoApply: () => false });
|
||||
beforeEach(() => {
|
||||
setFocusedMergeEditor(null);
|
||||
dispose = registerMergeActions();
|
||||
});
|
||||
afterEach(() => {
|
||||
setFocusedMergeEditor(null);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("keeps merge commands registered and disabled outside a merge editor", async () => {
|
||||
const commands = allActions().filter((a) => a.id.startsWith("merge."));
|
||||
expect(commands.length).toBe(30);
|
||||
expect(commands.filter((a) => !a.hidden).every((a) => a.keywords?.includes("merge"))).toBe(true);
|
||||
expect(commands.every((a) => !isActionEnabled(a))).toBe(true);
|
||||
expect(await executeAction("merge.acceptYours")).toBe(false);
|
||||
});
|
||||
|
||||
it("routes only to the focused editor and preserves it when another editor unmounts", async () => {
|
||||
const first = host();
|
||||
const second = host();
|
||||
setFocusedMergeEditor(first);
|
||||
await executeAction("merge.applyAll");
|
||||
expect(first.run).toHaveBeenCalledWith("applyAll", undefined);
|
||||
setFocusedMergeEditor(second);
|
||||
clearFocusedMergeEditor(first);
|
||||
await executeAction("merge.revert");
|
||||
expect(second.run).toHaveBeenCalledWith("revert", undefined);
|
||||
clearFocusedMergeEditor(second);
|
||||
expect(await executeAction("merge.revert")).toBe(false);
|
||||
});
|
||||
|
||||
it("shares changing enablement and toggle state with action menus", async () => {
|
||||
let enabled = true;
|
||||
let checked = false;
|
||||
const editor = host();
|
||||
editor.enabled = () => enabled;
|
||||
editor.autoApply = () => checked;
|
||||
setFocusedMergeEditor(editor);
|
||||
const menu = () => resolveMenuItems([{ kind: "action", action: "merge.autoApply" }])[0]!;
|
||||
expect(menu()).toMatchObject({ disabled: false, checked: false });
|
||||
checked = true;
|
||||
expect(menu().checked).toBe(true);
|
||||
enabled = false;
|
||||
refreshMergeActions(editor);
|
||||
expect(menu().disabled).toBe(true);
|
||||
expect(isActionEnabled(getAction("merge.resolveSimple")!)).toBe(false);
|
||||
expect(await executeAction("merge.resolveSimple")).toBe(false);
|
||||
expect(editor.run).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { diff3, mergeChanges, splitLines, splitWords, tryResolve, type TextChange } from "../mergeDiff3";
|
||||
|
||||
/** Deterministic pseudo-random numbers, so a failing case can be replayed. */
|
||||
function random(seed: number) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||
return s / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
/** The text of `side` rebuilt from the base and the changes: it must give the version back. */
|
||||
function rebuild(base: string, changes: readonly TextChange[], side: "left" | "right") {
|
||||
let out = "";
|
||||
let pos = 0;
|
||||
for (const c of changes) {
|
||||
out += base.slice(pos, c.baseFrom) + c[side];
|
||||
pos = c.baseTo;
|
||||
}
|
||||
return out + base.slice(pos);
|
||||
}
|
||||
|
||||
const lines = (...l: string[]) => l.map((x) => `${x}\n`).join("");
|
||||
|
||||
describe("splitting", () => {
|
||||
it("keeps every character", () => {
|
||||
for (const text of ["", "a", "a\n", "a\n\nb", "a\r\nb\r\n", "foo(bar, 12) // ünï — 日本語\t x\r"]) {
|
||||
expect(splitLines(text).join("")).toBe(text);
|
||||
expect(splitWords(text).join("")).toBe(text);
|
||||
}
|
||||
});
|
||||
|
||||
it("separates words, punctuation, blanks and line breaks", () => {
|
||||
expect(splitWords("foo(a, b)\n")).toEqual(["foo", "(", "a", ",", " ", "b", ")", "\n"]);
|
||||
expect(splitWords("a \t b\r\nc")).toEqual(["a", " \t ", "b", "\r\n", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diff3", () => {
|
||||
it("keeps edits of both sides apart when unchanged tokens lie between them", () => {
|
||||
const clusters = diff3(["a", "X", "c", "d", "e"], ["a", "b", "c", "d", "e"], ["a", "b", "c", "d", "Y"]);
|
||||
expect(clusters).toEqual([
|
||||
{ left: [1, 2], base: [1, 2], right: [1, 2] },
|
||||
{ left: [4, 5], base: [4, 5], right: [4, 5] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("joins edits that touch into one cluster", () => {
|
||||
const clusters = diff3(["a", "X", "c"], ["a", "b", "c"], ["a", "b", "Y"]);
|
||||
expect(clusters).toEqual([{ left: [1, 3], base: [1, 3], right: [1, 3] }]);
|
||||
});
|
||||
|
||||
it("handles insertions at the same point and at the ends", () => {
|
||||
expect(diff3(["a", "L", "b"], ["a", "b"], ["a", "R", "b"])).toEqual([
|
||||
{ left: [1, 2], base: [1, 1], right: [1, 2] },
|
||||
]);
|
||||
expect(diff3(["L", "a"], ["a"], ["a", "R"])).toEqual([
|
||||
{ left: [0, 1], base: [0, 0], right: [0, 0] },
|
||||
{ left: [2, 2], base: [1, 1], right: [1, 2] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives up on more distinct tokens than it can intern", () => {
|
||||
const many = Array.from({ length: 70_000 }, (_, i) => `t${i}`);
|
||||
expect(diff3(many, many, many)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeChanges (line level)", () => {
|
||||
const base = lines("a", "b", "c", "d", "e", "f", "g");
|
||||
|
||||
it("classifies one-sided, identical and conflicting changes", () => {
|
||||
const left = lines("a", "B", "c", "d", "e", "f", "G");
|
||||
const right = lines("a", "b", "c", "D", "e", "f", "G");
|
||||
const changes = mergeChanges(left, base, right)!;
|
||||
expect(changes.map((c) => c.kind)).toEqual(["left", "right", "both"]);
|
||||
expect(changes[0]).toMatchObject({ baseFrom: 2, baseTo: 4, left: "B\n", base: "b\n", right: "b\n" });
|
||||
|
||||
// Changes of adjacent lines are one change, which conflicts, as in IntelliJ.
|
||||
const adjacent = mergeChanges(lines("a", "B", "c"), lines("a", "b", "c"), lines("a", "b", "C"))!;
|
||||
expect(adjacent.map((c) => c.kind)).toEqual(["conflict"]);
|
||||
|
||||
const conflict = mergeChanges(lines("a", "L", "c"), lines("a", "b", "c"), lines("a", "R", "c"))!;
|
||||
expect(conflict.map((c) => c.kind)).toEqual(["conflict"]);
|
||||
expect(conflict[0]!.auto).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a deletion by both sides and equal insertions as agreement", () => {
|
||||
expect(mergeChanges(lines("a", "c"), lines("a", "b", "c"), lines("a", "c"))!.map((c) => c.kind)).toEqual(["both"]);
|
||||
expect(mergeChanges(lines("a", "N", "b"), lines("a", "b"), lines("a", "N", "b"))!.map((c) => c.kind)).toEqual([
|
||||
"both",
|
||||
]);
|
||||
});
|
||||
|
||||
it("classifies an insertion by one side, and an insertion against a change", () => {
|
||||
const [insert] = mergeChanges(lines("a", "N", "b"), lines("a", "b"), lines("a", "b"))!;
|
||||
expect(insert).toMatchObject({ kind: "left", base: "", left: "N\n", baseFrom: 2, baseTo: 2 });
|
||||
const [clash] = mergeChanges(lines("a", "N", "b"), lines("a", "b"), lines("a", "M", "b"))!;
|
||||
expect(clash!.kind).toBe("conflict");
|
||||
expect(clash!.auto).toBeNull();
|
||||
});
|
||||
|
||||
it("does not resolve a conflict where one side deleted what the other changed", () => {
|
||||
const [c] = mergeChanges(lines("a", "c"), lines("a", "b", "c"), lines("a", "B", "c"))!;
|
||||
expect(c).toMatchObject({ kind: "conflict", auto: null });
|
||||
});
|
||||
|
||||
it("offers the word-level merge of a conflict whose sides changed different words", () => {
|
||||
const [both] = mergeChanges("let x = 1 + b;\n", "let a = 1 + b;\n", "let a = 1 + 2;\n")!;
|
||||
expect(both).toMatchObject({ kind: "conflict", auto: "let x = 1 + 2;\n" });
|
||||
});
|
||||
|
||||
it("reports no change for equal versions", () => {
|
||||
expect(mergeChanges(base, base, base)).toEqual([]);
|
||||
expect(mergeChanges("", "", "")).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks a line that gained its terminator as changed", () => {
|
||||
const [c] = mergeChanges("a\nb\n", "a\nb", "a\nb")!;
|
||||
expect(c).toMatchObject({ kind: "left", left: "b\n", base: "b" });
|
||||
});
|
||||
|
||||
it("rebuilds both versions from the base and the changes, whatever the edits", () => {
|
||||
const rnd = random(7);
|
||||
const vocab = ["alpha", "beta", "gamma", "delta", "eps", "zeta", "eta", "theta"];
|
||||
const pick = () => vocab[Math.floor(rnd() * vocab.length)]!;
|
||||
for (let round = 0; round < 300; round++) {
|
||||
const baseLines = Array.from({ length: Math.floor(rnd() * 14) }, pick);
|
||||
const mutate = () => {
|
||||
const out = [...baseLines];
|
||||
for (let n = Math.floor(rnd() * 4); n > 0; n--) {
|
||||
const at = Math.floor(rnd() * (out.length + 1));
|
||||
const op = rnd();
|
||||
if (op < 0.34) out.splice(at, 1);
|
||||
else if (op < 0.67) out.splice(at, 0, `${pick()}!`);
|
||||
else if (at < out.length) out[at] = `${out[at]}?`;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const [l, b, r] = [lines(...mutate()), lines(...baseLines), lines(...mutate())];
|
||||
const changes = mergeChanges(l, b, r)!;
|
||||
expect(rebuild(b, changes, "left"), `round ${round}`).toBe(l);
|
||||
expect(rebuild(b, changes, "right"), `round ${round}`).toBe(r);
|
||||
for (const c of changes) {
|
||||
if (c.kind === "left") expect(c.right).toBe(c.base);
|
||||
if (c.kind === "right") expect(c.left).toBe(c.base);
|
||||
if (c.kind === "both") expect(c.left).toBe(c.right);
|
||||
if (c.kind === "conflict") expect(c.left).not.toBe(c.right);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryResolve (word level)", () => {
|
||||
it("merges changes to different words", () => {
|
||||
expect(tryResolve("foo(1, b)", "foo(a, b)", "foo(a, 2)")).toBe("foo(1, 2)");
|
||||
expect(tryResolve("let x = 1;", "let a = 1;", "let a = 2;")).toBe("let x = 2;");
|
||||
});
|
||||
|
||||
it("merges an edit at the start of a line with one at its end", () => {
|
||||
expect(tryResolve("const a = 1; // first", "var a = 1;", "var a = 1; // note")).toBeNull();
|
||||
expect(tryResolve("const a = 1;", "var a = 1;", "var a = 1; // note")).toBe("const a = 1; // note");
|
||||
});
|
||||
|
||||
it("gives up when both sides changed the same word differently", () => {
|
||||
expect(tryResolve("let x = 1;", "let a = 1;", "let y = 1;")).toBeNull();
|
||||
expect(tryResolve("f(a)", "f(b)", "f(c)")).toBeNull();
|
||||
});
|
||||
|
||||
it("gives up when the edits touch", () => {
|
||||
expect(tryResolve("x y", "a b", "a z")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts identical changes", () => {
|
||||
expect(tryResolve("let x = 1;", "let a = 1;", "let x = 1;")).toBe("let x = 1;");
|
||||
});
|
||||
|
||||
it("falls back to ignoring the width of blanks, keeping the changed side's blanks", () => {
|
||||
// The edits touch, so the exact pass fails; the blank-insensitive one merges them.
|
||||
expect(tryResolve("a = 1", "a = 1", "a = 2")).toBe("a = 2");
|
||||
expect(tryResolve("a b c", "a b c", "a b c")).toBe("a b c");
|
||||
expect(tryResolve("a b", "a b", "a b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("does not take a line break for a blank", () => {
|
||||
// Were a line break a blank, both sides would equal the base here and the merge would succeed.
|
||||
expect(tryResolve("a\nb", "a b", "a\n\nb")).toBeNull();
|
||||
expect(tryResolve("a\nb", "a b", "a b c")).toBe("a\nb c");
|
||||
});
|
||||
|
||||
it("returns the changed side when only one side changed", () => {
|
||||
const rnd = random(11);
|
||||
const vocab = ["foo", "bar", "baz", "(", ")", ",", " ", "\n", "12", "x_1"];
|
||||
const pick = () => vocab[Math.floor(rnd() * vocab.length)]!;
|
||||
for (let round = 0; round < 300; round++) {
|
||||
const base = Array.from({ length: 1 + Math.floor(rnd() * 20) }, pick).join("");
|
||||
const other = Array.from({ length: Math.floor(rnd() * 20) }, pick).join("");
|
||||
expect(tryResolve(other, base, base), `left ${round}`).toBe(other);
|
||||
expect(tryResolve(base, base, other), `right ${round}`).toBe(other);
|
||||
expect(tryResolve(other, base, other), `same ${round}`).toBe(other);
|
||||
}
|
||||
});
|
||||
|
||||
it("is symmetric in the sides it can merge", () => {
|
||||
const rnd = random(5);
|
||||
const vocab = ["a", "b", "c", "d", "e", "f", " ", ","];
|
||||
const pick = () => vocab[Math.floor(rnd() * vocab.length)]!;
|
||||
for (let round = 0; round < 300; round++) {
|
||||
const tokens = Array.from({ length: 12 }, pick);
|
||||
const edit = (i: number) => tokens.map((t, k) => (k === i ? `${t}${t}` : t)).join("");
|
||||
const base = tokens.join("");
|
||||
const [i, j] = [Math.floor(rnd() * 12), Math.floor(rnd() * 12)];
|
||||
const ab = tryResolve(edit(i), base, edit(j));
|
||||
const ba = tryResolve(edit(j), base, edit(i));
|
||||
expect(ab === null, `round ${round}`).toBe(ba === null);
|
||||
if (ab !== null && Math.abs(i - j) > 1) expect(ab).toBe(ba);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
import { history, redo, undo } from "@codemirror/commands";
|
||||
import { EditorState, Transaction, type TransactionSpec } from "@codemirror/state";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyNonConflicting,
|
||||
applyTo,
|
||||
canApply,
|
||||
changesIn,
|
||||
changesInSide,
|
||||
hasNonConflicting,
|
||||
hasSimple,
|
||||
isResolved,
|
||||
mergeField,
|
||||
mergeHistory,
|
||||
resolveSimple,
|
||||
revertResolved,
|
||||
startMerge,
|
||||
summarize,
|
||||
unresolved,
|
||||
type MergeAction,
|
||||
} from "../mergeModel";
|
||||
|
||||
const lines = (...l: string[]) => l.map((x) => `${x}\n`).join("");
|
||||
|
||||
/** A merge editor's model on a plain editor state, with the history the real one has. */
|
||||
function merge(left: string, base: string, right: string) {
|
||||
const start = startMerge(left, base, right);
|
||||
let state = EditorState.create({
|
||||
doc: start.text,
|
||||
extensions: [history(), mergeHistory, mergeField.init(() => start.changes)],
|
||||
});
|
||||
const view = {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
// The history commands dispatch a finished transaction, detached from the view.
|
||||
dispatch: (arg: Transaction | TransactionSpec) => {
|
||||
state = arg instanceof Transaction ? arg.state : state.update(arg).state;
|
||||
},
|
||||
};
|
||||
const run = (spec: TransactionSpec | null) => {
|
||||
expect(spec).not.toBeNull();
|
||||
view.dispatch(spec!);
|
||||
};
|
||||
return {
|
||||
view,
|
||||
get text() {
|
||||
return state.doc.toString();
|
||||
},
|
||||
get changes() {
|
||||
return state.field(mergeField);
|
||||
},
|
||||
act: (indexes: number[], action: MergeAction) => run(applyTo(state, indexes, action)),
|
||||
run,
|
||||
undo: () => undo(view),
|
||||
redo: () => redo(view),
|
||||
};
|
||||
}
|
||||
|
||||
const base = lines("a", "b", "c", "d", "e", "f", "g");
|
||||
const left = lines("a", "L", "c", "d", "e", "f", "G");
|
||||
const right = lines("a", "R", "c", "D", "e", "f", "G");
|
||||
// changes: 0 conflict (b / L / R), 1 right-only (d), 2 identical on both (g)
|
||||
|
||||
describe("the model", () => {
|
||||
it("keeps CRLF changes in CodeMirror coordinates after earlier lines", () => {
|
||||
const base = "标题😀\r\nunchanged\r\nbase\r\ntail\r\n";
|
||||
const left = base.replace("base", "ours中文");
|
||||
const right = base.replace("base", "theirs中文");
|
||||
const m = merge(left, base, right);
|
||||
const c = m.changes[0]!;
|
||||
expect(m.view.state.doc.sliceString(c.from, c.to)).toBe("base\n");
|
||||
m.act([0], { type: "accept", side: "left", resolve: true });
|
||||
expect(m.view.state.doc.sliceString(0, undefined, "\r\n")).toBe(left);
|
||||
expect(m.undo()).toBe(true);
|
||||
expect(unresolved(m.view.state)).toHaveLength(1);
|
||||
expect(m.view.state.doc.sliceString(0, undefined, "\r\n")).toBe(base);
|
||||
m.act([0], { type: "accept", side: "right", resolve: true });
|
||||
expect(m.view.state.doc.sliceString(0, undefined, "\r\n")).toBe(right);
|
||||
});
|
||||
|
||||
it("preserves a missing final newline when resolving after non-ASCII text", () => {
|
||||
const base = "标题😀\nunchanged\nbase";
|
||||
const m = merge(base.replace("base", "左侧🚀"), base, base.replace("base", "右侧🧪"));
|
||||
m.act([0], { type: "accept", side: "left", resolve: true });
|
||||
expect(m.text).toBe("标题😀\nunchanged\n左侧🚀");
|
||||
m.undo();
|
||||
m.act([0], { type: "accept", side: "right", resolve: true });
|
||||
expect(m.text).toBe("标题😀\nunchanged\n右侧🧪");
|
||||
});
|
||||
|
||||
it("starts from the base with every change pending", () => {
|
||||
const m = merge(left, base, right);
|
||||
expect(m.text).toBe(base);
|
||||
expect(m.changes.map((c) => c.kind)).toEqual(["conflict", "right", "both"]);
|
||||
expect(unresolved(m.view.state)).toHaveLength(3);
|
||||
expect(m.changes[0]).toMatchObject({ from: 2, to: 4 });
|
||||
});
|
||||
|
||||
it("has no changes for identical versions, and one whole-file conflict for files too different to compare", () => {
|
||||
expect(merge(base, base, base).changes).toEqual([]);
|
||||
const many = (p: string) => Array.from({ length: 70_000 }, (_, i) => `${p}${i}`).join("\n");
|
||||
const big = merge(many("l"), many("b"), many("r"));
|
||||
expect(big.changes).toHaveLength(1);
|
||||
expect(big.changes[0]).toMatchObject({ kind: "conflict", from: 0 });
|
||||
});
|
||||
|
||||
it("keeps what both sides did when the file is too different to compare and they agree", () => {
|
||||
const many = (p: string) => Array.from({ length: 70_000 }, (_, i) => `${p}${i}`).join("\n");
|
||||
const [b, same, other] = [many("b"), many("s"), many("o")];
|
||||
const agreed = merge(same, b, same);
|
||||
expect(agreed.changes).toHaveLength(1);
|
||||
expect(agreed.changes[0]!.kind).toBe("both");
|
||||
agreed.act([0], { type: "accept", side: "left", resolve: false });
|
||||
expect(agreed.text).toBe(same);
|
||||
// One side left the file as it was: the other's version is the change.
|
||||
expect(merge(b, b, other).changes[0]!.kind).toBe("right");
|
||||
expect(merge(b, b, b).changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives the position of a change in each version", () => {
|
||||
const m = merge(left, base, right);
|
||||
expect(left.slice(...m.changes[0]!.leftRange)).toBe("L\n");
|
||||
expect(right.slice(...m.changes[0]!.rightRange)).toBe("R\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("accepting from a side", () => {
|
||||
it("takes the text, and leaves a conflict open for the other side", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.act([0], { type: "accept", side: "left", resolve: false });
|
||||
expect(m.text).toBe(lines("a", "L", "c", "d", "e", "f", "g"));
|
||||
expect(m.changes[0]).toMatchObject({ oneside: true, done: { left: true, right: false } });
|
||||
expect(isResolved(m.changes[0]!)).toBe(false);
|
||||
expect(canApply(m.view.state.doc, m.changes[0]!, { type: "accept", side: "left", resolve: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("appends the other side after the first, in either order", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.act([0], { type: "accept", side: "right", resolve: false });
|
||||
m.act([0], { type: "accept", side: "left", resolve: false });
|
||||
expect(m.text).toBe(lines("a", "R", "L", "c", "d", "e", "f", "g"));
|
||||
expect(isResolved(m.changes[0]!)).toBe(true);
|
||||
expect(m.changes[0]).toMatchObject({ from: 2, to: 6 });
|
||||
});
|
||||
|
||||
it("starts a line of its own when the first side did not end its last line", () => {
|
||||
const m = merge("a\nL", "a\nb", "a\nR");
|
||||
m.act([0], { type: "accept", side: "left", resolve: false });
|
||||
m.act([0], { type: "accept", side: "right", resolve: false });
|
||||
expect(m.text).toBe("a\nL\nR");
|
||||
});
|
||||
|
||||
it("settles the change at once when resolving using a side", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.act([0], { type: "accept", side: "left", resolve: true });
|
||||
expect(m.text).toBe(lines("a", "L", "c", "d", "e", "f", "g"));
|
||||
expect(isResolved(m.changes[0]!)).toBe(true);
|
||||
});
|
||||
|
||||
it("settles a conflict whose other side deleted the lines, there being nothing to append", () => {
|
||||
const m = merge(lines("a", "c"), lines("a", "b", "c"), lines("a", "B", "c"));
|
||||
m.act([0], { type: "accept", side: "right", resolve: false });
|
||||
expect(m.text).toBe(lines("a", "B", "c"));
|
||||
expect(isResolved(m.changes[0]!)).toBe(true);
|
||||
});
|
||||
|
||||
it("applies a one-sided change from its side, and settles it from the other without a text change", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.act([1], { type: "accept", side: "right", resolve: false });
|
||||
expect(m.text).toBe(lines("a", "b", "c", "D", "e", "f", "g"));
|
||||
const n = merge(left, base, right);
|
||||
n.act([1], { type: "accept", side: "left", resolve: false });
|
||||
expect(n.text).toBe(base);
|
||||
expect(isResolved(n.changes[1]!)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the other changes where they are as the text grows and shrinks", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.act([0], { type: "accept", side: "right", resolve: false });
|
||||
m.act([0], { type: "accept", side: "left", resolve: false });
|
||||
m.act([1], { type: "accept", side: "right", resolve: false });
|
||||
m.act([2], { type: "accept", side: "left", resolve: false });
|
||||
expect(m.text).toBe(lines("a", "R", "L", "c", "D", "e", "f", "G"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ignoring", () => {
|
||||
it("settles one side of a conflict and leaves the text; the other side then replaces it", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.act([0], { type: "ignore", side: "left" });
|
||||
expect(m.text).toBe(base);
|
||||
expect(m.changes[0]).toMatchObject({ done: { left: true, right: false } });
|
||||
m.act([0], { type: "accept", side: "right", resolve: false });
|
||||
expect(m.text).toBe(lines("a", "R", "c", "d", "e", "f", "g"));
|
||||
expect(isResolved(m.changes[0]!)).toBe(true);
|
||||
});
|
||||
|
||||
it("settles a conflict with the base when ignoring both sides", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.act([0], { type: "ignore", side: null });
|
||||
expect(m.text).toBe(base);
|
||||
expect(isResolved(m.changes[0]!)).toBe(true);
|
||||
});
|
||||
|
||||
it("settles a one-sided change whichever side ignores it", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.act([1], { type: "ignore", side: "right" });
|
||||
expect(isResolved(m.changes[1]!)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolving automatically", () => {
|
||||
const b = "let a = 1 + b;\n";
|
||||
const l = "let x = 1 + b;\n";
|
||||
const r = "let a = 1 + 2;\n";
|
||||
|
||||
it("merges a conflict whose sides changed different words", () => {
|
||||
const m = merge(l, b, r);
|
||||
expect(hasSimple(m.view.state)).toBe(true);
|
||||
m.act([0], { type: "auto", side: "base" });
|
||||
expect(m.text).toBe("let x = 1 + 2;\n");
|
||||
expect(isResolved(m.changes[0]!)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not touch a conflict the user edited, or one already partly decided", () => {
|
||||
const m = merge(l, b, r);
|
||||
m.view.dispatch({ changes: { from: 0, insert: "// " } });
|
||||
expect(canApply(m.view.state.doc, m.changes[0]!, { type: "auto", side: "base" })).toBe(false);
|
||||
expect(hasSimple(m.view.state)).toBe(false);
|
||||
const n = merge(l, b, r);
|
||||
n.act([0], { type: "ignore", side: "left" });
|
||||
expect(hasSimple(n.view.state)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not offer it for a conflict of the same word", () => {
|
||||
const m = merge("let x = 1;\n", "let a = 1;\n", "let y = 1;\n");
|
||||
expect(hasSimple(m.view.state)).toBe(false);
|
||||
expect(applyTo(m.view.state, [0], { type: "auto", side: "base" })).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the simple conflicts and the non-conflicting changes together, and no more", () => {
|
||||
const pad = (mid: string, end: string) => lines("1", "2", "3", "4", mid, "5", "6", "7", end);
|
||||
// 0: one side changed a line; 1: a real conflict.
|
||||
const m = merge(pad("x", "L"), pad("a", "b"), pad("a", "R"));
|
||||
m.run(resolveSimple(m.view.state));
|
||||
expect(m.text).toBe(pad("x", "b"));
|
||||
expect(m.changes.map(isResolved)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applying the non-conflicting changes", () => {
|
||||
it("applies those of the chosen side only", () => {
|
||||
const m = merge(left, base, right);
|
||||
expect(hasNonConflicting(m.view.state, "left")).toBe(true);
|
||||
m.run(applyNonConflicting(m.view.state, "left"));
|
||||
// The right-only change is not the left's to apply; the identical one is.
|
||||
expect(m.text).toBe(lines("a", "b", "c", "d", "e", "f", "G"));
|
||||
expect(m.changes.map(isResolved)).toEqual([false, false, true]);
|
||||
expect(hasNonConflicting(m.view.state, "left")).toBe(false);
|
||||
expect(hasNonConflicting(m.view.state, "right")).toBe(true);
|
||||
});
|
||||
|
||||
it("applies all of them from the base, and leaves conflicts", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.run(applyNonConflicting(m.view.state, "base"));
|
||||
expect(m.text).toBe(lines("a", "b", "c", "D", "e", "f", "G"));
|
||||
expect(m.changes.map(isResolved)).toEqual([false, true, true]);
|
||||
expect(applyNonConflicting(m.view.state, "base")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("history", () => {
|
||||
it("undoes and redoes a resolution with the state of the change", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.act([0], { type: "accept", side: "left", resolve: false });
|
||||
m.act([0], { type: "accept", side: "right", resolve: false });
|
||||
expect(isResolved(m.changes[0]!)).toBe(true);
|
||||
|
||||
m.undo();
|
||||
expect(m.text).toBe(lines("a", "L", "c", "d", "e", "f", "g"));
|
||||
expect(m.changes[0]).toMatchObject({ oneside: true, done: { left: true, right: false }, from: 2, to: 4 });
|
||||
m.undo();
|
||||
expect(m.text).toBe(base);
|
||||
expect(m.changes[0]).toMatchObject({ oneside: false, done: { left: false, right: false }, from: 2, to: 4 });
|
||||
|
||||
m.act([0], { type: "accept", side: "right", resolve: true });
|
||||
expect(m.text).toBe(lines("a", "R", "c", "d", "e", "f", "g"));
|
||||
m.undo();
|
||||
m.redo();
|
||||
expect(isResolved(m.changes[0]!)).toBe(true);
|
||||
});
|
||||
|
||||
it("undoes an ignore, which changes no text", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.act([1], { type: "ignore", side: "right" });
|
||||
m.undo();
|
||||
expect(isResolved(m.changes[1]!)).toBe(false);
|
||||
m.redo();
|
||||
expect(isResolved(m.changes[1]!)).toBe(true);
|
||||
});
|
||||
|
||||
it("undoes a bulk resolution of several changes in one step", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.run(applyNonConflicting(m.view.state, "base"));
|
||||
m.undo();
|
||||
expect(m.text).toBe(base);
|
||||
expect(m.changes.map(isResolved)).toEqual([false, false, false]);
|
||||
expect(m.changes.map((c) => c.from)).toEqual([2, 6, 12]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reverting", () => {
|
||||
it("sends the settled changes back to the base and leaves the pending ones", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.act([0], { type: "accept", side: "left", resolve: true });
|
||||
m.act([2], { type: "accept", side: "left", resolve: false });
|
||||
m.act([1], { type: "accept", side: "right", resolve: false });
|
||||
m.run(revertResolved(m.view.state));
|
||||
expect(m.text).toBe(base);
|
||||
expect(m.changes.map(isResolved)).toEqual([false, false, false]);
|
||||
expect(revertResolved(m.view.state)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not revert a conflict that is only half decided", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.act([0], { type: "accept", side: "left", resolve: false });
|
||||
expect(revertResolved(m.view.state)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editing the result", () => {
|
||||
it("keeps typing at the edge of a change inside it, and shifts the changes below", () => {
|
||||
const m = merge(left, base, right);
|
||||
m.view.dispatch({ changes: { from: 0, insert: "top\n" } });
|
||||
expect(m.changes[0]).toMatchObject({ from: 6, to: 8 });
|
||||
m.view.dispatch({ changes: { from: 8, insert: "x" } });
|
||||
expect(m.changes[0]).toMatchObject({ from: 6, to: 9 });
|
||||
// "c\n" lies between the first two changes.
|
||||
expect(m.changes[1]!.from).toBe(m.changes[0]!.to + 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection", () => {
|
||||
it("finds the changes in the selected lines of the result", () => {
|
||||
const m = merge(left, base, right);
|
||||
const state = m.view.state;
|
||||
expect(changesIn(state, 2, 2)).toEqual([0]);
|
||||
expect(changesIn(state, 0, 0)).toEqual([]);
|
||||
// A caret at the start of the line after a change is not in the change.
|
||||
expect(changesIn(state, 4, 4)).toEqual([]);
|
||||
expect(changesIn(state, 0, state.doc.length)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("finds the changes in the selected lines of a side", () => {
|
||||
const m = merge(left, base, right);
|
||||
const sideDoc = EditorState.create({ doc: left }).doc;
|
||||
expect(changesInSide(m.view.state, "left", sideDoc, 2, 2)).toEqual([0]);
|
||||
// The left side did not change the second change, so it has nothing there.
|
||||
expect(changesInSide(m.view.state, "left", sideDoc, 6, 6)).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds an insertion at the point where it sits", () => {
|
||||
const m = merge(lines("a", "N", "b"), lines("a", "b"), lines("a", "b"));
|
||||
expect(changesIn(m.view.state, 2, 2)).toEqual([0]);
|
||||
expect(changesIn(m.view.state, 0, 0)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summary", () => {
|
||||
it("counts what is left and which toolbar commands have something to do (simple conflicts include the non-conflicting changes, as in IntelliJ)", () => {
|
||||
const m = merge(left, base, right);
|
||||
expect(summarize(m.view.state)).toEqual({
|
||||
pending: 3,
|
||||
conflicts: 1,
|
||||
left: true,
|
||||
all: true,
|
||||
right: true,
|
||||
simple: true,
|
||||
revert: false,
|
||||
});
|
||||
m.run(applyNonConflicting(m.view.state, "base"));
|
||||
expect(summarize(m.view.state)).toEqual({
|
||||
pending: 1,
|
||||
conflicts: 1,
|
||||
left: false,
|
||||
all: false,
|
||||
right: false,
|
||||
simple: false,
|
||||
revert: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Moves the boundaries of a line diff to where a reader expects them, ported from
|
||||
* IntelliJ's `ChunkOptimizer.LineChunkOptimizer`. A diff has several equally short answers
|
||||
* when an inserted or deleted block repeats the lines around it (an added method can be
|
||||
* said to start at its first line or to end at its closing brace); the shortest diff does not
|
||||
* say which, and this picks the one that starts or ends at a blank or near-blank line.
|
||||
*/
|
||||
|
||||
/** An unchanged stretch: lines `s1`–`e1` of the first text are lines `s2`–`e2` of the second. */
|
||||
export interface Unchanged {
|
||||
s1: number;
|
||||
e1: number;
|
||||
s2: number;
|
||||
e2: number;
|
||||
}
|
||||
|
||||
/** Lines with this many characters or fewer, apart from blanks, count as unimportant (`{`, `}`, `);`). */
|
||||
const UNIMPORTANT = 3;
|
||||
|
||||
/** The unchanged stretches between `changes` (each `[from1, to1, from2, to2]`) of two texts of the given lengths. */
|
||||
export function unchangedBetween(
|
||||
changes: readonly (readonly [number, number, number, number])[],
|
||||
length1: number,
|
||||
length2: number,
|
||||
): Unchanged[] {
|
||||
const out: Unchanged[] = [];
|
||||
let p1 = 0;
|
||||
let p2 = 0;
|
||||
for (const [f1, t1, f2, t2] of changes) {
|
||||
if (f1 > p1) out.push({ s1: p1, e1: f1, s2: p2, e2: f2 });
|
||||
p1 = t1;
|
||||
p2 = t2;
|
||||
}
|
||||
if (length1 > p1) out.push({ s1: p1, e1: length1, s2: p2, e2: length2 });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The changes between `unchanged` stretches of two texts of the given lengths. */
|
||||
export function changesBetween(
|
||||
unchanged: readonly Unchanged[],
|
||||
length1: number,
|
||||
length2: number,
|
||||
): [number, number, number, number][] {
|
||||
const out: [number, number, number, number][] = [];
|
||||
let p1 = 0;
|
||||
let p2 = 0;
|
||||
for (const u of unchanged) {
|
||||
if (u.s1 > p1 || u.s2 > p2) out.push([p1, u.s1, p2, u.s2]);
|
||||
p1 = u.e1;
|
||||
p2 = u.e2;
|
||||
}
|
||||
if (length1 > p1 || length2 > p2) out.push([p1, length1, p2, length2]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimizes the changes between two texts. `a` and `b` hold one character per line (equal
|
||||
* characters are equal lines); `weightA` and `weightB` the number of characters other than
|
||||
* whitespace of each line.
|
||||
*/
|
||||
export function optimizeLineChunks(
|
||||
a: string,
|
||||
b: string,
|
||||
weightA: readonly number[],
|
||||
weightB: readonly number[],
|
||||
unchanged: readonly Unchanged[],
|
||||
): Unchanged[] {
|
||||
const ranges: Unchanged[] = [];
|
||||
|
||||
/** How many lines from (`i1`, `i2`) on are equal, at most `max`. */
|
||||
const equalForward = (i1: number, i2: number, max: number) => {
|
||||
let n = 0;
|
||||
while (n < max && i1 + n < a.length && i2 + n < b.length && a[i1 + n] === b[i2 + n]) n++;
|
||||
return n;
|
||||
};
|
||||
/** How many lines before (`i1`, `i2`) are equal, at most `max`. */
|
||||
const equalBackward = (i1: number, i2: number, max: number) => {
|
||||
let n = 0;
|
||||
while (n < max && i1 - 1 - n >= 0 && i2 - 1 - n >= 0 && a[i1 - 1 - n] === b[i2 - 1 - n]) n++;
|
||||
return n;
|
||||
};
|
||||
|
||||
const nextUnimportant = (weights: readonly number[], offset: number, count: number, limit: number) => {
|
||||
for (let i = 0; i < count; i++) if (weights[offset + i]! <= limit) return i;
|
||||
return -1;
|
||||
};
|
||||
const previousUnimportant = (weights: readonly number[], offset: number, count: number, limit: number) => {
|
||||
for (let i = 0; i < count; i++) if (weights[offset - i]! <= limit) return i;
|
||||
return -1;
|
||||
};
|
||||
/** A stretch already at a boundary stays; else forward if one is found there, else backward. */
|
||||
const pick = (forward: number, backward: number): number | null => {
|
||||
if (forward === -1 && backward === -1) return null;
|
||||
if (forward !== 0 && backward !== 0) return forward !== -1 ? forward : -backward;
|
||||
return 0;
|
||||
};
|
||||
|
||||
/** How far the change between `r1` and `r2` moves; `touchA` when the lines of `a` touch (the change only adds to `b`). */
|
||||
const shiftOf = (touchA: boolean, forward: number, backward: number, r1: Unchanged, r2: Unchanged) => {
|
||||
const touch = touchA ? weightA : weightB;
|
||||
const other = touchA ? weightB : weightA;
|
||||
const touchStart = touchA ? r2.s1 : r2.s2;
|
||||
const changeStart = touchA ? r1.e2 : r1.e1;
|
||||
const changeEnd = touchA ? r2.s2 : r2.s1;
|
||||
const atTouch = (limit: number) =>
|
||||
pick(
|
||||
nextUnimportant(touch, touchStart, forward + 1, limit),
|
||||
previousUnimportant(touch, touchStart - 1, backward + 1, limit),
|
||||
);
|
||||
const atChange = (limit: number) =>
|
||||
pick(
|
||||
nextUnimportant(other, changeStart, forward + 1, limit),
|
||||
previousUnimportant(other, changeEnd - 1, backward + 1, limit),
|
||||
);
|
||||
return atTouch(0) ?? atChange(0) ?? atTouch(UNIMPORTANT) ?? atChange(UNIMPORTANT) ?? 0;
|
||||
};
|
||||
|
||||
const processLast = () => {
|
||||
if (ranges.length < 2) return;
|
||||
const r1 = ranges[ranges.length - 2]!;
|
||||
const r2 = ranges[ranges.length - 1]!;
|
||||
// Only a change that adds or removes lines (touching on one side) can move.
|
||||
if (r1.e1 !== r2.s1 && r1.e2 !== r2.s2) return;
|
||||
const count1 = r1.e1 - r1.s1;
|
||||
const count2 = r2.e1 - r2.s1;
|
||||
const forward = equalForward(r1.e1, r1.e2, count2);
|
||||
const backward = equalBackward(r2.s1, r2.s2, count1);
|
||||
if (forward === 0 && backward === 0) return;
|
||||
if (forward === count2) {
|
||||
// The change slides all the way to the next stretch, and disappears into it.
|
||||
ranges.splice(-2, 2, { s1: r1.s1, e1: r1.e1 + count2, s2: r1.s2, e2: r1.e2 + count2 });
|
||||
processLast();
|
||||
} else if (backward === count1) {
|
||||
ranges.splice(-2, 2, { s1: r2.s1 - count1, e1: r2.e1, s2: r2.s2 - count1, e2: r2.e2 });
|
||||
processLast();
|
||||
} else {
|
||||
const shift = shiftOf(r1.e1 === r2.s1, forward, backward, r1, r2);
|
||||
if (shift !== 0)
|
||||
ranges.splice(
|
||||
-2,
|
||||
2,
|
||||
{ s1: r1.s1, e1: r1.e1 + shift, s2: r1.s2, e2: r1.e2 + shift },
|
||||
{ s1: r2.s1 + shift, e1: r2.e1, s2: r2.s2 + shift, e2: r2.e2 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
for (const u of unchanged) {
|
||||
ranges.push(u);
|
||||
processLast();
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
@@ -154,39 +154,3 @@ export function mergeTitles(sides: MergeSides | null | undefined): { yours: stri
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** An unresolved conflict in the merge editor's result: `from`–`to` holds the base text. */
|
||||
export interface ResultConflict {
|
||||
from: number;
|
||||
to: number;
|
||||
/** The user's side ("Accept Yours"). */
|
||||
ours: string;
|
||||
base: string | null;
|
||||
theirs: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The merge editor's starting result, as IDEA shows it: the merged text with every
|
||||
* conflict block replaced by its base section, so no markers appear. `reversed` swaps
|
||||
* the sides of marker sections written in git's order (a rebase).
|
||||
*/
|
||||
export function initialResult(merged: string, reversed = false): { text: string; conflicts: ResultConflict[] } {
|
||||
const conflicts: ResultConflict[] = [];
|
||||
let text = "";
|
||||
let pos = 0;
|
||||
for (const b of parseConflicts(merged)) {
|
||||
text += merged.slice(pos, b.from);
|
||||
const base = b.base ?? "";
|
||||
const from = text.length;
|
||||
text += base;
|
||||
conflicts.push({
|
||||
from,
|
||||
to: text.length,
|
||||
ours: reversed ? b.theirs : b.ours,
|
||||
base: b.base,
|
||||
theirs: reversed ? b.ours : b.theirs,
|
||||
});
|
||||
pos = b.to;
|
||||
}
|
||||
return { text: text + merged.slice(pos), conflicts };
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { installHunkPopup } from "./HunkPopup";
|
||||
import { LOCAL_HISTORY_KIND } from "./localHistoryKind";
|
||||
import { registerLocalHistoryActions } from "./localHistoryActions";
|
||||
import { MERGE_KIND, type MergeData } from "./mergeKind";
|
||||
import { registerMergeActions } from "./mergeActions";
|
||||
import { baseName } from "./model";
|
||||
import { installDiagnosticsTracking } from "./commitChecks";
|
||||
import { installPreparedMessage, useCommit } from "./commitStore";
|
||||
@@ -179,6 +180,7 @@ export function activate(): () => void {
|
||||
installChangelists(),
|
||||
trackOperationContext(),
|
||||
registerCompareActions(),
|
||||
registerMergeActions(),
|
||||
trackTitleItem(),
|
||||
installHunkPopup(),
|
||||
registerOverlay({ id: "vcs.dialogs", component: VcsDialogHost }),
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/** Commands target the merge editor that opened a toolbar menu or Find Action. */
|
||||
import { registerActions, type Action } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
|
||||
const COMMANDS = [
|
||||
["applyLeft", "Apply Non-Conflicting Changes from the Left Side", "diff-apply-left"],
|
||||
["applyAll", "Apply All Non-Conflicting Changes", "diff-apply-all"],
|
||||
["applyRight", "Apply Non-Conflicting Changes from the Right Side", "diff-apply-right"],
|
||||
["resolveSimple", "Resolve Simple Conflicts", "diff-magic-toolbar"],
|
||||
["revert", "Revert Conflict Resolution", "diff-revert"],
|
||||
["acceptYours", "Accept Yours"],
|
||||
["acceptTheirs", "Accept Theirs"],
|
||||
["apply", "Apply Merge Result"],
|
||||
["cancel", "Cancel Merge"],
|
||||
["reload", "Reload from Disk", "refresh"],
|
||||
["compare", "Compare Contents", "diff"],
|
||||
["settings", "Merge Settings", "settings"],
|
||||
["autoApply", "Automatically Apply Non-Conflicting Changes"],
|
||||
["compareYoursResult", "Yours and Result"],
|
||||
["compareResultTheirs", "Result and Theirs"],
|
||||
["compareYoursTheirs", "Yours and Theirs"],
|
||||
["compareBaseYours", "Base and Yours"],
|
||||
["compareBaseResult", "Base and Result"],
|
||||
["compareBaseTheirs", "Base and Theirs"],
|
||||
["accept", "Accept"],
|
||||
["resolveUsingYours", "Resolve Using Yours"],
|
||||
["resolveUsingTheirs", "Resolve Using Theirs"],
|
||||
["ignore", "Ignore", "diff-remove"],
|
||||
["resolveAutomatically", "Resolve Automatically", "diff-magic"],
|
||||
["annotate", "Annotate with Git Blame", "changelist"],
|
||||
["cut", "Cut"],
|
||||
["copy", "Copy", "copy"],
|
||||
["paste", "Paste"],
|
||||
["selectAll", "Select All"],
|
||||
["change", "Resolve Merge Change"],
|
||||
] as const;
|
||||
|
||||
export type MergeCommand = (typeof COMMANDS)[number][0];
|
||||
|
||||
export interface MergeActionHost {
|
||||
enabled(command: MergeCommand): boolean;
|
||||
run(command: MergeCommand, arg?: unknown): void | Promise<void>;
|
||||
autoApply(): boolean;
|
||||
}
|
||||
|
||||
let focused: MergeActionHost | null = null;
|
||||
|
||||
export function setFocusedMergeEditor(host: MergeActionHost | null) {
|
||||
focused = host;
|
||||
setContext("mergeEditorFocus", !!host);
|
||||
for (const [command] of COMMANDS) setContext(`merge.${command}`, host?.enabled(command) ?? false);
|
||||
}
|
||||
|
||||
export function refreshMergeActions(host: MergeActionHost) {
|
||||
if (focused === host) setFocusedMergeEditor(host);
|
||||
}
|
||||
|
||||
export function clearFocusedMergeEditor(host: MergeActionHost) {
|
||||
if (focused === host) setFocusedMergeEditor(null);
|
||||
}
|
||||
|
||||
export function registerMergeActions(): () => void {
|
||||
return registerActions(
|
||||
COMMANDS.map(([command, title, icon]): Action => ({
|
||||
id: `merge.${command}`,
|
||||
title,
|
||||
icon,
|
||||
category: "Merge",
|
||||
when: `mergeEditorFocus && merge.${command}`,
|
||||
keywords: ["merge", "conflict"],
|
||||
hidden: command === "change",
|
||||
toggled: command === "autoApply" ? () => focused?.autoApply() ?? false : undefined,
|
||||
run: ({ arg }) => focused?.run(command, arg),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Three-way merge behind the merge editor, ported from IntelliJ's merge tool
|
||||
* (`MergeRangeUtil.getMergeType`, `SimpleHelper`): the changes between the sides at line
|
||||
* level, each classified as one-sided, identical or conflicting, and the word-level merge
|
||||
* that resolves a conflict whose sides touched different words.
|
||||
*/
|
||||
import { diff } from "@codemirror/merge";
|
||||
import { changesBetween, optimizeLineChunks, unchangedBetween } from "./chunkOptimizer";
|
||||
|
||||
/** A change between the base (A) and a side (B), in tokens. */
|
||||
interface Delta {
|
||||
fromA: number;
|
||||
toA: number;
|
||||
fromB: number;
|
||||
toB: number;
|
||||
}
|
||||
|
||||
/** A half-open token range. */
|
||||
type Span = readonly [from: number, to: number];
|
||||
|
||||
/** What the sides hold in place of one stretch of the base. */
|
||||
export interface Cluster {
|
||||
left: Span;
|
||||
base: Span;
|
||||
right: Span;
|
||||
}
|
||||
|
||||
// Tokens are interned into BMP characters so the string diff of @codemirror/merge can
|
||||
// compare them. Control characters and the surrogate range stay unused.
|
||||
const FIRST_CHAR = 0x100;
|
||||
const SURROGATES = 0x800;
|
||||
const MAX_TOKENS = 0x10000 - FIRST_CHAR - SURROGATES;
|
||||
|
||||
const charFor = (id: number) => {
|
||||
const code = FIRST_CHAR + id;
|
||||
return String.fromCharCode(code < 0xd800 ? code : code + SURROGATES);
|
||||
};
|
||||
|
||||
/**
|
||||
* The stretches of the base that either side changed. Changes of the two sides that
|
||||
* overlap or touch form one cluster, so two edits next to each other conflict, as in
|
||||
* IntelliJ. `null` when there are too many distinct tokens to compare.
|
||||
*/
|
||||
export function diff3(
|
||||
left: readonly string[],
|
||||
base: readonly string[],
|
||||
right: readonly string[],
|
||||
key: (token: string) => string = (token) => token,
|
||||
timeout?: number,
|
||||
/** The tokens are lines: move the changes to where a reader expects their boundaries. */
|
||||
optimizeLines = false,
|
||||
): Cluster[] | null {
|
||||
const ids = new Map<string, string>();
|
||||
const encode = (tokens: readonly string[]): string | null => {
|
||||
let out = "";
|
||||
for (const token of tokens) {
|
||||
const k = key(token);
|
||||
let c = ids.get(k);
|
||||
if (c === undefined) {
|
||||
if (ids.size >= MAX_TOKENS) return null;
|
||||
c = charFor(ids.size);
|
||||
ids.set(k, c);
|
||||
}
|
||||
out += c;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const b = encode(base);
|
||||
const l = encode(left);
|
||||
const r = encode(right);
|
||||
if (b === null || l === null || r === null) return null;
|
||||
const config = timeout === undefined ? undefined : { timeout };
|
||||
const weights = (tokens: readonly string[]) => tokens.map((t) => t.replace(/\s/g, "").length);
|
||||
const against = (side: string, sideTokens: readonly string[]): readonly Delta[] => {
|
||||
const found = diff(b, side, config);
|
||||
if (!optimizeLines) return found;
|
||||
const moved = optimizeLineChunks(
|
||||
b,
|
||||
side,
|
||||
weights(base),
|
||||
weights(sideTokens),
|
||||
unchangedBetween(
|
||||
found.map((c) => [c.fromA, c.toA, c.fromB, c.toB]),
|
||||
b.length,
|
||||
side.length,
|
||||
),
|
||||
);
|
||||
return changesBetween(moved, b.length, side.length).map(([fromA, toA, fromB, toB]) => ({ fromA, toA, fromB, toB }));
|
||||
};
|
||||
const lc = against(l, left);
|
||||
const rc = against(r, right);
|
||||
|
||||
const clusters: Cluster[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
// How far each side's tokens are shifted from the base's by the changes before the cluster.
|
||||
let shiftL = 0;
|
||||
let shiftR = 0;
|
||||
while (i < lc.length || j < rc.length) {
|
||||
const fromLeft = i < lc.length && (j >= rc.length || lc[i]!.fromA <= rc[j]!.fromA);
|
||||
const first = fromLeft ? lc[i]! : rc[j]!;
|
||||
const start = first.fromA;
|
||||
let end = first.toA;
|
||||
let growL = 0;
|
||||
let growR = 0;
|
||||
if (fromLeft) {
|
||||
growL += first.toB - first.fromB - (first.toA - first.fromA);
|
||||
i++;
|
||||
} else {
|
||||
growR += first.toB - first.fromB - (first.toA - first.fromA);
|
||||
j++;
|
||||
}
|
||||
for (let progressed = true; progressed;) {
|
||||
progressed = false;
|
||||
while (i < lc.length && lc[i]!.fromA <= end) {
|
||||
const c = lc[i++]!;
|
||||
end = Math.max(end, c.toA);
|
||||
growL += c.toB - c.fromB - (c.toA - c.fromA);
|
||||
progressed = true;
|
||||
}
|
||||
while (j < rc.length && rc[j]!.fromA <= end) {
|
||||
const c = rc[j++]!;
|
||||
end = Math.max(end, c.toA);
|
||||
growR += c.toB - c.fromB - (c.toA - c.fromA);
|
||||
progressed = true;
|
||||
}
|
||||
}
|
||||
clusters.push({
|
||||
base: [start, end],
|
||||
left: [start + shiftL, end + shiftL + growL],
|
||||
right: [start + shiftR, end + shiftR + growR],
|
||||
});
|
||||
shiftL += growL;
|
||||
shiftR += growR;
|
||||
}
|
||||
return clusters;
|
||||
}
|
||||
|
||||
/** Which sides changed a stretch, and whether the changes agree. */
|
||||
export type ChangeKind = "left" | "right" | "both" | "conflict";
|
||||
|
||||
/**
|
||||
* Classifies a stretch from its texts in each version (`MergeRangeUtil.getMergeType`).
|
||||
* `same` compares texts; `null` when neither side changed it.
|
||||
*/
|
||||
function classify(
|
||||
left: string,
|
||||
base: string,
|
||||
right: string,
|
||||
same: (a: string, b: string) => boolean,
|
||||
): ChangeKind | null {
|
||||
if (base === "") {
|
||||
if (left === "") return "right";
|
||||
if (right === "") return "left";
|
||||
return same(left, right) ? "both" : "conflict";
|
||||
}
|
||||
if (left === "" && right === "") return "both";
|
||||
const unchangedLeft = same(base, left);
|
||||
const unchangedRight = same(base, right);
|
||||
if (unchangedLeft && unchangedRight) return null;
|
||||
if (unchangedLeft) return "right";
|
||||
if (unchangedRight) return "left";
|
||||
return same(left, right) ? "both" : "conflict";
|
||||
}
|
||||
|
||||
/** Lines of `text`, each with its terminator, so joining them gives `text` back. */
|
||||
export const splitLines = (text: string): string[] => text.match(/[^\n]*\n|[^\n]+/g) ?? [];
|
||||
|
||||
/** One stretch the sides changed, with the text each version has there. */
|
||||
export interface TextChange {
|
||||
/** Offsets of the stretch in the base. */
|
||||
baseFrom: number;
|
||||
baseTo: number;
|
||||
/** Offsets of the stretch in the left and the right version. */
|
||||
leftFrom: number;
|
||||
leftTo: number;
|
||||
rightFrom: number;
|
||||
rightTo: number;
|
||||
left: string;
|
||||
base: string;
|
||||
right: string;
|
||||
kind: ChangeKind;
|
||||
/** The word-level merge of a conflict, when the sides changed different words. */
|
||||
auto: string | null;
|
||||
}
|
||||
|
||||
/** The changes between the versions line by line; `null` when the files are too different to compare. */
|
||||
export function mergeChanges(left: string, base: string, right: string): TextChange[] | null {
|
||||
const lines = [splitLines(left), splitLines(base), splitLines(right)] as const;
|
||||
const clusters = diff3(lines[0], lines[1], lines[2], undefined, 3000, true);
|
||||
if (!clusters) return null;
|
||||
const offsets = (ls: readonly string[]) => {
|
||||
const out = [0];
|
||||
for (const line of ls) out.push(out[out.length - 1]! + line.length);
|
||||
return out;
|
||||
};
|
||||
const at = [offsets(lines[0]), offsets(lines[1]), offsets(lines[2])] as const;
|
||||
const slice = (text: string, side: 0 | 1 | 2, [from, to]: Span) => text.slice(at[side][from], at[side][to]);
|
||||
const out: TextChange[] = [];
|
||||
for (const c of clusters) {
|
||||
const l = slice(left, 0, c.left);
|
||||
const b = slice(base, 1, c.base);
|
||||
const r = slice(right, 2, c.right);
|
||||
const kind = classify(l, b, r, (x, y) => x === y);
|
||||
if (!kind) continue;
|
||||
out.push({
|
||||
baseFrom: at[1][c.base[0]]!,
|
||||
baseTo: at[1][c.base[1]]!,
|
||||
leftFrom: at[0][c.left[0]]!,
|
||||
leftTo: at[0][c.left[1]]!,
|
||||
rightFrom: at[2][c.right[0]]!,
|
||||
rightTo: at[2][c.right[1]]!,
|
||||
left: l,
|
||||
base: b,
|
||||
right: r,
|
||||
kind,
|
||||
auto: kind === "conflict" && b !== "" && l !== "" && r !== "" ? tryResolve(l, b, r) : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const WORDS = /[\p{L}\p{N}\p{M}_]+|\r?\n|[^\S\r\n]+|[\s\S]/gu;
|
||||
const HORIZONTAL_SPACE = /[^\S\r\n]+/g;
|
||||
|
||||
/** Words, punctuation, line breaks and runs of blanks of `text`; joined they give `text` back. */
|
||||
export const splitWords = (text: string): string[] => text.match(WORDS) ?? [];
|
||||
|
||||
/**
|
||||
* Merges the three versions word by word, when no word was changed by both sides
|
||||
* differently (`MergeResolveUtil.tryResolve`). Tried exactly first, then ignoring the
|
||||
* width of blanks, which keeps the whitespace of the side that changed it. `null` when
|
||||
* the sides really conflict.
|
||||
*/
|
||||
export function tryResolve(left: string, base: string, right: string): string | null {
|
||||
return mergeWords(left, base, right, false) ?? mergeWords(left, base, right, true);
|
||||
}
|
||||
|
||||
function mergeWords(left: string, base: string, right: string, ignoreBlanks: boolean): string | null {
|
||||
const squash = (token: string) => (ignoreBlanks ? token.replace(HORIZONTAL_SPACE, " ") : token);
|
||||
const same = (a: string, b: string) => a === b || (ignoreBlanks && squash(a) === squash(b));
|
||||
const tokens = [splitWords(left), splitWords(base), splitWords(right)] as const;
|
||||
// A conflict can be large; past the timeout the diff is longer than needed, which only
|
||||
// makes the merge more careful, never wrong.
|
||||
const clusters = diff3(tokens[0], tokens[1], tokens[2], squash, 1000);
|
||||
if (!clusters) return null;
|
||||
const text = (side: 0 | 1 | 2, from: number, to: number) => tokens[side].slice(from, to).join("");
|
||||
let out = "";
|
||||
let l = 0;
|
||||
let b = 0;
|
||||
let r = 0;
|
||||
const gap = (lTo: number, bTo: number, rTo: number) => {
|
||||
const gl = text(0, l, lTo);
|
||||
const gb = text(1, b, bTo);
|
||||
const gr = text(2, r, rTo);
|
||||
// Equal under the policy; a side that changed only blanks there keeps its version.
|
||||
out += gl !== gb ? gl : gr !== gb ? gr : gb;
|
||||
};
|
||||
for (const c of clusters) {
|
||||
gap(c.left[0], c.base[0], c.right[0]);
|
||||
const tl = text(0, c.left[0], c.left[1]);
|
||||
const tb = text(1, c.base[0], c.base[1]);
|
||||
const tr = text(2, c.right[0], c.right[1]);
|
||||
const kind = classify(tl, tb, tr, same);
|
||||
if (kind === "conflict") return null;
|
||||
out += kind === "right" ? tr : tl;
|
||||
[l, b, r] = [c.left[1], c.base[1], c.right[1]];
|
||||
}
|
||||
gap(tokens[0].length, tokens[1].length, tokens[2].length);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* The merge editor's per-change controls, as in IntelliJ: an Accept arrow and an Ignore
|
||||
* cross beside each change in the side panes, a magic wand beside a conflict of the
|
||||
* result that can be resolved without a decision, and the changes tinted in the result.
|
||||
*/
|
||||
import { RangeSetBuilder, StateEffect, StateField, type Extension } from "@codemirror/state";
|
||||
import { Decoration, EditorView, GutterMarker, WidgetType, gutter, type ViewUpdate } from "@codemirror/view";
|
||||
import { hasPrimaryModifier } from "@/platform/keymap";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { iconMarkup } from "@/ui";
|
||||
import {
|
||||
canResolveAutomatically,
|
||||
changesSide,
|
||||
isConflict,
|
||||
isResolved,
|
||||
mergeField,
|
||||
type MergeChange,
|
||||
type MergeSide,
|
||||
} from "./mergeModel";
|
||||
|
||||
/** What the controls do; `resolve` is set when the platform's modifier key is held. */
|
||||
export interface MergeControls {
|
||||
accept(index: number, side: MergeSide, resolve: boolean): void;
|
||||
ignore(index: number, side: MergeSide, resolve: boolean): void;
|
||||
resolveAutomatically(index: number): void;
|
||||
}
|
||||
|
||||
/** Tells a side pane how the changes stand, whenever the result moves them on. */
|
||||
export const syncSide = StateEffect.define<readonly MergeChange[]>();
|
||||
|
||||
const sideChanges = StateField.define<readonly MergeChange[]>({
|
||||
create: () => [],
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) if (e.is(syncSide)) return e.value;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
function button(className: string, title: string, icon: string, press: (e: MouseEvent) => void): HTMLButtonElement {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = className;
|
||||
b.title = title;
|
||||
b.setAttribute("aria-label", title);
|
||||
b.innerHTML = iconMarkup(icon, 14) ?? "";
|
||||
b.addEventListener("mousedown", (e) => {
|
||||
// Pointer presses keep the editor's focus and selection.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
b.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
press(e);
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
class SideMarker extends GutterMarker {
|
||||
constructor(
|
||||
readonly index: number,
|
||||
readonly side: MergeSide,
|
||||
readonly oneside: boolean,
|
||||
readonly controls: MergeControls,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
eq(other: SideMarker) {
|
||||
return other.index === this.index && other.side === this.side && other.oneside === this.oneside;
|
||||
}
|
||||
toDOM() {
|
||||
const box = document.createElement("div");
|
||||
box.className = "cm-merge-ops";
|
||||
const toward = this.side === "left" ? "right" : "left";
|
||||
const arrow = this.oneside ? `diff-arrow-${toward}-down` : `diff-arrow-${toward}`;
|
||||
box.append(
|
||||
button(
|
||||
"cm-merge-op cm-merge-accept",
|
||||
tx("Accept (hold the modifier key to resolve using this side)"),
|
||||
arrow,
|
||||
(e) => this.controls.accept(this.index, this.side, hasPrimaryModifier(e)),
|
||||
),
|
||||
button(
|
||||
"cm-merge-op cm-merge-ignore",
|
||||
tx("Ignore (hold the modifier key to ignore both sides)"),
|
||||
"diff-remove",
|
||||
(e) => this.controls.ignore(this.index, this.side, hasPrimaryModifier(e)),
|
||||
),
|
||||
);
|
||||
return box;
|
||||
}
|
||||
}
|
||||
|
||||
/** Accept and Ignore beside each change of a side pane that is still to be decided. */
|
||||
export function sideControls(side: MergeSide, controls: MergeControls): Extension {
|
||||
return [
|
||||
sideChanges,
|
||||
gutter({
|
||||
class: "cm-merge-gutter",
|
||||
lineMarker(view, line) {
|
||||
const doc = view.state.doc;
|
||||
const changes = view.state.field(sideChanges);
|
||||
const index = changes.findIndex((c) => {
|
||||
if (!changesSide(c, side) || c.done[side]) return false;
|
||||
const from = (side === "left" ? c.leftRange : c.rightRange)[0];
|
||||
return doc.lineAt(Math.min(from, doc.length)).from === line.from;
|
||||
});
|
||||
return index < 0 ? null : new SideMarker(index, side, changes[index]!.oneside, controls);
|
||||
},
|
||||
lineMarkerChange: (update) => update.transactions.some((tr) => tr.effects.some((e) => e.is(syncSide))),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
class WandMarker extends GutterMarker {
|
||||
constructor(
|
||||
readonly index: number,
|
||||
readonly controls: MergeControls,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
eq(other: WandMarker) {
|
||||
return other.index === this.index;
|
||||
}
|
||||
toDOM() {
|
||||
const box = document.createElement("div");
|
||||
box.className = "cm-merge-ops";
|
||||
box.append(
|
||||
button("cm-merge-op cm-merge-wand", tx("Resolve Automatically"), "diff-magic", () =>
|
||||
this.controls.resolveAutomatically(this.index),
|
||||
),
|
||||
);
|
||||
return box;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the changes of the result moved, were decided or were edited. */
|
||||
export const mergeChanged = (u: ViewUpdate) =>
|
||||
u.docChanged || u.startState.field(mergeField) !== u.state.field(mergeField);
|
||||
|
||||
/** The wand beside a conflict of the result that needs no decision. */
|
||||
export function resultControls(controls: MergeControls): Extension {
|
||||
return gutter({
|
||||
class: "cm-merge-gutter",
|
||||
lineMarker(view, line) {
|
||||
const doc = view.state.doc;
|
||||
const found = view.state
|
||||
.field(mergeField)
|
||||
.findIndex(
|
||||
(c) =>
|
||||
isConflict(c) &&
|
||||
canResolveAutomatically(doc, c, "base") &&
|
||||
doc.lineAt(Math.min(c.from, doc.length)).from === line.from,
|
||||
);
|
||||
return found < 0 ? null : new WandMarker(found, controls);
|
||||
},
|
||||
lineMarkerChange: mergeChanged,
|
||||
});
|
||||
}
|
||||
|
||||
/** A change that adds lines has no text in the result yet; a thin line shows where it goes. */
|
||||
class PointWidget extends WidgetType {
|
||||
constructor(readonly className: string) {
|
||||
super();
|
||||
}
|
||||
eq(other: PointWidget) {
|
||||
return other.className === this.className;
|
||||
}
|
||||
toDOM() {
|
||||
const el = document.createElement("div");
|
||||
el.className = `cm-merge-point ${this.className}`;
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
/** The changes tinted in the result: conflicts, the other changes and the settled ones. */
|
||||
export const resultMarks = EditorView.decorations.compute([mergeField], (state) => {
|
||||
const out = new RangeSetBuilder<Decoration>();
|
||||
const doc = state.doc;
|
||||
for (const c of state.field(mergeField)) {
|
||||
const className = isResolved(c) ? "cm-merge-resolved" : isConflict(c) ? "cm-merge-conflict" : "cm-merge-change";
|
||||
if (c.to > c.from) {
|
||||
const last = doc.lineAt(c.to - 1).number;
|
||||
for (let n = doc.lineAt(c.from).number; n <= last; n++)
|
||||
out.add(doc.line(n).from, doc.line(n).from, Decoration.line({ class: className }));
|
||||
} else if (!isResolved(c)) {
|
||||
out.add(c.from, c.from, Decoration.widget({ widget: new PointWidget(className), block: true, side: -1 }));
|
||||
}
|
||||
}
|
||||
return out.finish();
|
||||
});
|
||||
|
||||
export const mergeTheme = EditorView.baseTheme({
|
||||
// A fixed width, so the text does not shift as controls come and go while scrolling.
|
||||
".cm-merge-gutter": { minWidth: "34px" },
|
||||
".cm-merge-ops": { display: "flex", alignItems: "center", justifyContent: "center", height: "100%" },
|
||||
".cm-merge-op": {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "14px",
|
||||
height: "14px",
|
||||
padding: 0,
|
||||
border: "none",
|
||||
borderRadius: "var(--radius-small)",
|
||||
color: "var(--fg-secondary)",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
},
|
||||
".cm-merge-op:hover": { color: "var(--fg-strong)", background: "var(--bg-control-hover)" },
|
||||
".cm-merge-conflict": { backgroundColor: "color-mix(in srgb, var(--vcs-conflict) 14%, transparent)" },
|
||||
".cm-merge-change": { backgroundColor: "color-mix(in srgb, var(--vcs-modified) 14%, transparent)" },
|
||||
".cm-merge-resolved": { color: "var(--fg-subtle)" },
|
||||
".cm-merge-point": { height: "2px", margin: "0", pointerEvents: "none" },
|
||||
".cm-merge-point.cm-merge-conflict": { backgroundColor: "var(--vcs-conflict)" },
|
||||
".cm-merge-point.cm-merge-change": { backgroundColor: "var(--vcs-modified)" },
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Action-backed context menus and drop-downs of the merge editor. */
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { showContextMenu, type MenuItem } from "@/ui";
|
||||
import type { MergeSide } from "./mergeModel";
|
||||
|
||||
export type MenuPosition = { clientX: number; clientY: number };
|
||||
|
||||
function selectUnderPointer(view: EditorView, e: React.MouseEvent) {
|
||||
const at = view.posAtCoords({ x: e.clientX, y: e.clientY });
|
||||
const picked = view.state.selection.main;
|
||||
if (at !== null && (at < picked.from || at > picked.to)) view.dispatch({ selection: { anchor: at } });
|
||||
view.focus();
|
||||
}
|
||||
|
||||
export function showResultMenu(e: React.MouseEvent, view: EditorView) {
|
||||
e.preventDefault();
|
||||
selectUnderPointer(view, e);
|
||||
showContextMenu(e.nativeEvent, [
|
||||
{ kind: "action", action: "merge.resolveAutomatically" },
|
||||
{ kind: "action", action: "merge.ignore" },
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "merge.cut" },
|
||||
{ kind: "action", action: "merge.copy" },
|
||||
{ kind: "action", action: "merge.paste" },
|
||||
{ kind: "action", action: "merge.selectAll" },
|
||||
]);
|
||||
}
|
||||
|
||||
export function showSideMenu(e: React.MouseEvent, view: EditorView, which: MergeSide) {
|
||||
e.preventDefault();
|
||||
selectUnderPointer(view, e);
|
||||
showContextMenu(e.nativeEvent, [
|
||||
{ kind: "action", action: "merge.accept" },
|
||||
{ kind: "action", action: which === "left" ? "merge.resolveUsingYours" : "merge.resolveUsingTheirs" },
|
||||
{ kind: "action", action: "merge.ignore" },
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "merge.annotate" },
|
||||
]);
|
||||
}
|
||||
|
||||
export const COMPARISONS = {
|
||||
compareYoursResult: ["yours", "result"],
|
||||
compareResultTheirs: ["result", "theirs"],
|
||||
compareYoursTheirs: ["yours", "theirs"],
|
||||
compareBaseYours: ["base", "yours"],
|
||||
compareBaseResult: ["base", "result"],
|
||||
compareBaseTheirs: ["base", "theirs"],
|
||||
} as const;
|
||||
|
||||
export function showCompareMenu(at: MenuPosition) {
|
||||
const items: MenuItem[] = Object.keys(COMPARISONS).map((command) => ({
|
||||
kind: "action",
|
||||
action: `merge.${command}`,
|
||||
}));
|
||||
items.splice(3, 0, { kind: "separator" });
|
||||
showContextMenu(at, items);
|
||||
}
|
||||
|
||||
export function showSettingsMenu(at: MenuPosition) {
|
||||
showContextMenu(at, [{ kind: "action", action: "merge.autoApply" }]);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* The merge editor's model, after IntelliJ's `MergeConflictModel`: the result starts as the
|
||||
* base, and every change the sides made to it waits there to be accepted from either side,
|
||||
* ignored or resolved. A change is settled once both sides are; a conflict can take one
|
||||
* side, then the other (which is appended), as in IntelliJ.
|
||||
*/
|
||||
import { invertedEffects } from "@codemirror/commands";
|
||||
import {
|
||||
ChangeSet,
|
||||
StateEffect,
|
||||
StateField,
|
||||
type EditorState,
|
||||
type Text,
|
||||
type TransactionSpec,
|
||||
} from "@codemirror/state";
|
||||
import { mergeChanges, type ChangeKind } from "./mergeDiff3";
|
||||
|
||||
export type MergeSide = "left" | "right";
|
||||
|
||||
export interface MergeChange {
|
||||
/** Where the change sits in the result; follows the document as it is edited. */
|
||||
from: number;
|
||||
to: number;
|
||||
kind: ChangeKind;
|
||||
left: string;
|
||||
base: string;
|
||||
right: string;
|
||||
/** Where the change is in the left and the right version, for the side panes. */
|
||||
leftRange: readonly [number, number];
|
||||
rightRange: readonly [number, number];
|
||||
/** The word-level merge of a conflict whose sides changed different words. */
|
||||
auto: string | null;
|
||||
/** The sides that were accepted or ignored. */
|
||||
done: { readonly left: boolean; readonly right: boolean };
|
||||
/** One side of a conflict was accepted and the other is still to be decided. */
|
||||
oneside: boolean;
|
||||
}
|
||||
|
||||
export const isResolved = (c: MergeChange) => c.done.left && c.done.right;
|
||||
export const isConflict = (c: MergeChange) => c.kind === "conflict";
|
||||
/** Whether `side` differs from the base here. */
|
||||
export const changesSide = (c: MergeChange, side: MergeSide) =>
|
||||
c.kind === "both" || c.kind === "conflict" || c.kind === side;
|
||||
|
||||
const textOf = (c: MergeChange, side: MergeSide) => (side === "left" ? c.left : c.right);
|
||||
const opposite = (side: MergeSide): MergeSide => (side === "left" ? "right" : "left");
|
||||
const settled = (c: MergeChange): MergeChange => ({ ...c, done: { left: true, right: true } });
|
||||
|
||||
interface Placed {
|
||||
index: number;
|
||||
change: MergeChange;
|
||||
}
|
||||
|
||||
/** Puts a change in the model; its positions are those of the document after the transaction. */
|
||||
const setChange = StateEffect.define<Placed>({
|
||||
map: ({ index, change }, mapping) => ({
|
||||
index,
|
||||
change: { ...change, from: mapping.mapPos(change.from, -1), to: mapping.mapPos(change.to, 1) },
|
||||
}),
|
||||
});
|
||||
|
||||
/** The changes of the merge, in document order. Typing at the edge of a change stays inside it. */
|
||||
export const mergeField = StateField.define<readonly MergeChange[]>({
|
||||
create: () => [],
|
||||
update(value, tr) {
|
||||
let next = tr.docChanged
|
||||
? value.map((c) => ({ ...c, from: tr.changes.mapPos(c.from, -1), to: tr.changes.mapPos(c.to, 1) }))
|
||||
: value;
|
||||
for (const e of tr.effects) {
|
||||
if (!e.is(setChange)) continue;
|
||||
if (next === value) next = [...value];
|
||||
(next as MergeChange[])[e.value.index] = e.value.change;
|
||||
}
|
||||
return next;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Undo and redo of the editor's history bring back the text only; this brings back the
|
||||
* state of the changes with it, so an undone resolution can be made again.
|
||||
*/
|
||||
export const mergeHistory = invertedEffects.of((tr) => {
|
||||
const before = tr.startState.field(mergeField);
|
||||
return tr.effects
|
||||
.filter((e) => e.is(setChange))
|
||||
.map((e) => setChange.of({ index: e.value.index, change: before[e.value.index]! }));
|
||||
});
|
||||
|
||||
/** The result's starting text, the base, and the changes waiting in it. */
|
||||
export function startMerge(left: string, base: string, right: string): { text: string; changes: MergeChange[] } {
|
||||
// CodeMirror counts each line break as one UTF-16 code unit, including CRLF.
|
||||
left = left.replace(/\r\n?/g, "\n");
|
||||
base = base.replace(/\r\n?/g, "\n");
|
||||
right = right.replace(/\r\n?/g, "\n");
|
||||
const found = mergeChanges(left, base, right);
|
||||
// Too different to compare line by line: the whole file is one change.
|
||||
const whole = (): MergeChange[] => {
|
||||
if (left === base && right === base) return [];
|
||||
const kind: ChangeKind = left === right ? "both" : left === base ? "right" : right === base ? "left" : "conflict";
|
||||
return [
|
||||
{
|
||||
from: 0,
|
||||
to: base.length,
|
||||
kind,
|
||||
left,
|
||||
base,
|
||||
right,
|
||||
auto: null,
|
||||
leftRange: [0, left.length],
|
||||
rightRange: [0, right.length],
|
||||
done: { left: false, right: false },
|
||||
oneside: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
const changes: MergeChange[] =
|
||||
found?.map((c) => ({
|
||||
from: c.baseFrom,
|
||||
to: c.baseTo,
|
||||
kind: c.kind,
|
||||
left: c.left,
|
||||
base: c.base,
|
||||
right: c.right,
|
||||
leftRange: [c.leftFrom, c.leftTo],
|
||||
rightRange: [c.rightFrom, c.rightTo],
|
||||
auto: c.auto,
|
||||
done: { left: false, right: false },
|
||||
oneside: false,
|
||||
})) ?? whole();
|
||||
return { text: base, changes };
|
||||
}
|
||||
|
||||
/** A change's new state, and the edit of the result that goes with it. */
|
||||
interface Plan {
|
||||
index: number;
|
||||
next: MergeChange;
|
||||
edit?: { from: number; to: number; insert: string };
|
||||
}
|
||||
|
||||
/** The transaction applying `plans`, or null when there is nothing to do. */
|
||||
function commit(state: EditorState, plans: readonly Plan[], userEvent: string): TransactionSpec | null {
|
||||
if (!plans.length) return null;
|
||||
const changes = ChangeSet.of(
|
||||
plans.flatMap((p) => (p.edit ? [p.edit] : [])),
|
||||
state.doc.length,
|
||||
);
|
||||
return {
|
||||
changes,
|
||||
effects: plans.map((p) =>
|
||||
setChange.of({
|
||||
index: p.index,
|
||||
change: { ...p.next, from: changes.mapPos(p.next.from, -1), to: changes.mapPos(p.next.to, 1) },
|
||||
}),
|
||||
),
|
||||
userEvent,
|
||||
};
|
||||
}
|
||||
|
||||
const replace = (c: MergeChange, insert: string) => ({ from: c.from, to: c.to, insert });
|
||||
|
||||
/**
|
||||
* Takes the text of `side` for the change (IntelliJ's `replaceChange`). A conflict stays open
|
||||
* for the other side unless `resolve` settles it, or the other side has nothing to add.
|
||||
*/
|
||||
function accept(doc: Text, c: MergeChange, index: number, side: MergeSide, resolve: boolean): Plan | null {
|
||||
if (c.done[side]) return null;
|
||||
if (!changesSide(c, side)) return { index, next: settled(c) };
|
||||
const text = textOf(c, side);
|
||||
if (!isConflict(c)) return { index, next: settled(c), edit: replace(c, text) };
|
||||
if (!c.oneside) {
|
||||
const partial = !resolve && textOf(c, opposite(side)) !== "";
|
||||
return {
|
||||
index,
|
||||
edit: replace(c, text),
|
||||
next: partial ? { ...c, oneside: true, done: { ...c.done, [side]: true } } : settled(c),
|
||||
};
|
||||
}
|
||||
// The other side's text is already there; this one follows it, on a line of its own.
|
||||
const there = doc.sliceString(c.from, c.to);
|
||||
const insert = (there !== "" && !there.endsWith("\n") ? "\n" : "") + text;
|
||||
return {
|
||||
index,
|
||||
edit: { from: c.to, to: c.to, insert },
|
||||
next: resolve ? settled(c) : { ...c, done: { ...c.done, [side]: true } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Ignores the change of `side` (IntelliJ's `ignoreChange`): the result keeps what it has. */
|
||||
function ignore(c: MergeChange, index: number, side: MergeSide | null): Plan {
|
||||
return { index, next: isConflict(c) && side ? { ...c, done: { ...c.done, [side]: true } } : settled(c) };
|
||||
}
|
||||
|
||||
/** Whether the user edited the change's text in the result: it is then left alone by automatic resolving. */
|
||||
const edited = (doc: Text, c: MergeChange) => doc.sliceString(c.from, c.to) !== c.base;
|
||||
|
||||
/** IntelliJ's `canResolveChangeAutomatically`: `base` stands for "whatever fits". */
|
||||
export function canResolveAutomatically(doc: Text, c: MergeChange, side: MergeSide | "base"): boolean {
|
||||
if (edited(doc, c)) return false;
|
||||
if (isConflict(c)) return side === "base" && c.auto !== null && !c.done.left && !c.done.right;
|
||||
return !isResolved(c) && (side === "base" || changesSide(c, side));
|
||||
}
|
||||
|
||||
function resolveAutomatically(doc: Text, c: MergeChange, index: number, side: MergeSide | "base"): Plan | null {
|
||||
if (!canResolveAutomatically(doc, c, side)) return null;
|
||||
if (isConflict(c)) return { index, next: settled(c), edit: replace(c, c.auto!) };
|
||||
const from = side === "base" ? (changesSide(c, "left") ? "left" : "right") : side;
|
||||
return { index, next: settled(c), edit: replace(c, textOf(c, from)) };
|
||||
}
|
||||
|
||||
/** What the user can do with the changes in the merge editor. */
|
||||
export type MergeAction =
|
||||
| { type: "accept"; side: MergeSide; resolve: boolean }
|
||||
| { type: "ignore"; side: MergeSide | null }
|
||||
| { type: "auto"; side: MergeSide | "base" };
|
||||
|
||||
/** Whether `action` applies to the change now (the enabled state of IntelliJ's actions). */
|
||||
export function canApply(doc: Text, c: MergeChange, action: MergeAction): boolean {
|
||||
switch (action.type) {
|
||||
case "accept":
|
||||
return !c.done[action.side];
|
||||
case "ignore":
|
||||
return action.side ? !c.done[action.side] : !isResolved(c);
|
||||
case "auto":
|
||||
return canResolveAutomatically(doc, c, action.side);
|
||||
}
|
||||
}
|
||||
|
||||
function plan(doc: Text, c: MergeChange, index: number, action: MergeAction): Plan | null {
|
||||
switch (action.type) {
|
||||
case "accept":
|
||||
return accept(doc, c, index, action.side, action.resolve);
|
||||
case "ignore":
|
||||
return ignore(c, index, action.side);
|
||||
case "auto":
|
||||
return resolveAutomatically(doc, c, index, action.side);
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies `action` to the changes at `indexes` that it applies to. */
|
||||
export function applyTo(state: EditorState, indexes: readonly number[], action: MergeAction): TransactionSpec | null {
|
||||
const changes = state.field(mergeField);
|
||||
const plans: Plan[] = [];
|
||||
for (const i of indexes) {
|
||||
const c = changes[i];
|
||||
const p = c && canApply(state.doc, c, action) ? plan(state.doc, c, i, action) : null;
|
||||
if (p) plans.push(p);
|
||||
}
|
||||
return commit(state, plans, "input.resolve");
|
||||
}
|
||||
|
||||
/**
|
||||
* The indexes of the changes whose text, `range(change)` in `doc`, lies in the lines `from`–`to`.
|
||||
* A change that adds lines has an empty range: it is where its offset is. A caret at the start
|
||||
* of the line after a change is not in the change.
|
||||
*/
|
||||
function selected(
|
||||
state: EditorState,
|
||||
doc: Text,
|
||||
from: number,
|
||||
to: number,
|
||||
range: (c: MergeChange) => readonly [number, number] | null,
|
||||
): number[] {
|
||||
const start = doc.lineAt(from).from;
|
||||
const end = Math.min(doc.length, doc.lineAt(to).to + 1);
|
||||
const out: number[] = [];
|
||||
state.field(mergeField).forEach((c, i) => {
|
||||
const r = range(c);
|
||||
if (!r) return;
|
||||
const [a, b] = r;
|
||||
const hit = a === b ? a >= start && (a < end || a === doc.length) : a < end && b > start;
|
||||
if (hit) out.push(i);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The changes whose text lies in the lines `from`–`to` of the result. */
|
||||
export const changesIn = (state: EditorState, from: number, to: number) =>
|
||||
selected(state, state.doc, from, to, (c) => [c.from, c.to]);
|
||||
|
||||
/** The changes of a side pane's version that lie in the lines `from`–`to` of that pane. */
|
||||
export const changesInSide = (state: EditorState, side: MergeSide, doc: Text, from: number, to: number) =>
|
||||
selected(state, doc, from, to, (c) => (changesSide(c, side) ? (side === "left" ? c.leftRange : c.rightRange) : null));
|
||||
|
||||
const allIndexes = (state: EditorState) => state.field(mergeField).map((_, i) => i);
|
||||
|
||||
/** Apply Non-Conflicting Changes from `side` (`base`: from either). */
|
||||
export function applyNonConflicting(state: EditorState, side: MergeSide | "base"): TransactionSpec | null {
|
||||
const changes = state.field(mergeField);
|
||||
const indexes = allIndexes(state).filter((i) => !isConflict(changes[i]!));
|
||||
return applyTo(state, indexes, { type: "auto", side });
|
||||
}
|
||||
|
||||
export const hasNonConflicting = (state: EditorState, side: MergeSide | "base") =>
|
||||
state.field(mergeField).some((c) => !isConflict(c) && canResolveAutomatically(state.doc, c, side));
|
||||
|
||||
/** Resolve Simple Conflicts: every change that can be resolved without a decision. */
|
||||
export const resolveSimple = (state: EditorState) => applyTo(state, allIndexes(state), { type: "auto", side: "base" });
|
||||
|
||||
export const hasSimple = (state: EditorState) =>
|
||||
state.field(mergeField).some((c) => canResolveAutomatically(state.doc, c, "base"));
|
||||
|
||||
/** Revert Conflict Resolution: the settled changes go back to the base. */
|
||||
export function revertResolved(state: EditorState): TransactionSpec | null {
|
||||
const plans: Plan[] = [];
|
||||
state.field(mergeField).forEach((c, index) => {
|
||||
if (!isResolved(c)) return;
|
||||
plans.push({
|
||||
index,
|
||||
edit: replace(c, c.base),
|
||||
next: { ...c, oneside: false, done: { left: false, right: false } },
|
||||
});
|
||||
});
|
||||
return commit(state, plans, "input.resolve");
|
||||
}
|
||||
|
||||
/** The changes still to be settled. */
|
||||
export const unresolved = (state: EditorState) => state.field(mergeField).filter((c) => !isResolved(c));
|
||||
|
||||
/** What the toolbar needs to know: how much is left, and which of its commands do something. */
|
||||
export interface MergeSummary {
|
||||
pending: number;
|
||||
conflicts: number;
|
||||
/** Apply Non-Conflicting Changes from the left side, from either side, from the right side. */
|
||||
left: boolean;
|
||||
all: boolean;
|
||||
right: boolean;
|
||||
/** Resolve Simple Conflicts. */
|
||||
simple: boolean;
|
||||
/** Revert Conflict Resolution. */
|
||||
revert: boolean;
|
||||
}
|
||||
|
||||
export function summarize(state: EditorState): MergeSummary {
|
||||
const open = unresolved(state);
|
||||
return {
|
||||
pending: open.length,
|
||||
conflicts: open.filter(isConflict).length,
|
||||
left: hasNonConflicting(state, "left"),
|
||||
all: hasNonConflicting(state, "base"),
|
||||
right: hasNonConflicting(state, "right"),
|
||||
simple: hasSimple(state),
|
||||
revert: state.field(mergeField).some(isResolved),
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { create } from "zustand";
|
||||
import type { GitOperation, RemoteOp } from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { editorsFor } from "@/editor/api";
|
||||
import { attachDocument, editorsFor } from "@/editor/api";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { cancelTask, upsertTaskSoon, useTasks } from "@/workbench/tasks";
|
||||
import { progressFraction } from "./model";
|
||||
@@ -448,14 +448,17 @@ const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
* file: through the open editor (one undoable transaction) or, when the file
|
||||
* isn't open, through a transient backend document that is saved.
|
||||
*/
|
||||
export async function applyFileEdits(path: string, edits: TextEdit[], opts: { save?: boolean } = {}) {
|
||||
export async function applyFileEdits(path: string, edits: TextEdit[], opts: { save?: boolean; exact?: boolean } = {}) {
|
||||
const editor = editorsFor(path)[0];
|
||||
if (editor) {
|
||||
editor.view.dispatch({ changes: edits, userEvent: "input.vcs", scrollIntoView: true });
|
||||
if (opts.save) {
|
||||
// Let editor/core sync the change to the backend before saving.
|
||||
await tick(60);
|
||||
await maybeEditorService()?.saveAll();
|
||||
if (opts.exact) await saveExactText(path);
|
||||
else {
|
||||
// Let editor/core sync the change to the backend before saving.
|
||||
await tick(60);
|
||||
await maybeEditorService()?.saveAll();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -469,11 +472,24 @@ export async function applyFileEdits(path: string, edits: TextEdit[], opts: { sa
|
||||
}
|
||||
|
||||
/** Replaces the whole content of a working file. */
|
||||
export async function writeFileText(path: string, text: string, opts: { save?: boolean } = {}) {
|
||||
export async function writeFileText(path: string, text: string, opts: { save?: boolean; exact?: boolean } = {}) {
|
||||
const current = (await readWorkingText(path)) ?? "";
|
||||
if (current === text) {
|
||||
if (opts.save) await saveAll();
|
||||
if (opts.save) {
|
||||
if (opts.exact) await saveExactText(path);
|
||||
else await saveAll();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await applyFileEdits(path, [{ from: 0, to: current.length, insert: text }], opts);
|
||||
}
|
||||
|
||||
/** Merge results keep their text even when the file is also shown in an editor tab. */
|
||||
async function saveExactText(path: string) {
|
||||
const document = await attachDocument(path);
|
||||
try {
|
||||
await document.save({ exact: true });
|
||||
} finally {
|
||||
document.release();
|
||||
}
|
||||
}
|
||||
@@ -165,8 +165,6 @@
|
||||
"Absolute path of the current file": "当前文件的绝对路径",
|
||||
"Accent color": "强调色",
|
||||
"Accent colour": "强调色",
|
||||
"Accept All Theirs": "全部接受他们的",
|
||||
"Accept All Yours": "全部接受您的",
|
||||
"Accept Both (Conflict at Caret)": "全部接受(光标处的冲突)",
|
||||
"Accept Theirs": "接受他们的",
|
||||
"Accept Theirs (Conflict at Caret)": "接受他们的(光标处的冲突)",
|
||||
@@ -1293,7 +1291,6 @@
|
||||
"New…": "新建…",
|
||||
"Next Bookmark": "下一个书签",
|
||||
"Next Change": "下一个更改",
|
||||
"Next Conflict": "下一个冲突",
|
||||
"Next Conflict Marker": "下一个冲突标记",
|
||||
"Next Difference": "下一个差异",
|
||||
"Next Edit Location": "下一个编辑位置",
|
||||
@@ -1543,7 +1540,6 @@
|
||||
"Preview failed": "预览失败",
|
||||
"Previous Bookmark": "上一个书签",
|
||||
"Previous Change": "上一个更改",
|
||||
"Previous Conflict": "上一个冲突",
|
||||
"Previous Conflict Marker": "上一个冲突标记",
|
||||
"Previous Difference": "上一个差异",
|
||||
"Previous Occurrence": "上一个匹配项",
|
||||
@@ -2332,7 +2328,6 @@
|
||||
"Unmute Breakpoints": "取消静音断点",
|
||||
"Unpause": "取消暂停",
|
||||
"Unpin Tab": "取消固定标签页",
|
||||
"Unresolved Conflicts": "未解决的冲突",
|
||||
"Unshelve": "取消搁置",
|
||||
"Unshelve Changes": "取消搁置更改",
|
||||
"Unshelve…": "取消搁置…",
|
||||
@@ -4113,8 +4108,6 @@
|
||||
"Revert {count} files under {folder} to label '{label}'?": "是否将 {folder} 下的 {count} 个文件还原到标签 '{label}'?",
|
||||
"Revert {file} to label '{label}'?": "是否将 {file} 还原到标签 '{label}'?",
|
||||
"Revert from Local History": "从本地历史记录还原",
|
||||
"{count} conflict is unresolved and keeps the base text. Save and mark the file as resolved anyway?": "{count} 个冲突未解决,将保留基础文本。仍要保存并将文件标记为已解决吗?",
|
||||
"{count} conflicts are unresolved and keep the base text. Save and mark the file as resolved anyway?": "{count} 个冲突未解决,将保留基础文本。仍要保存并将文件标记为已解决吗?",
|
||||
"{submodule} points to another commit. Check out the recorded commit in the submodule (git submodule update) to revert it.": "{submodule} 指向另一个提交。请在子模块中签出记录的提交 (git submodule update) 以还原它。",
|
||||
"New Branch from '{name}'…": "从 '{name}' 新建分支…",
|
||||
"Rebase Current onto '{name}'": "将当前分支变基到 '{name}'",
|
||||
@@ -4650,8 +4643,6 @@
|
||||
"{current} of {count} differences": "第 {current} 处差异,共 {count} 处",
|
||||
"{count} difference": "{count} 处差异",
|
||||
"{count} differences": "{count} 处差异",
|
||||
"{left} conflict left": "剩余 {left} 个冲突",
|
||||
"{left} conflicts left": "剩余 {left} 个冲突",
|
||||
"Cloning…": "正在克隆…",
|
||||
"locked": "已锁定",
|
||||
"prunable": "可清理",
|
||||
@@ -5630,5 +5621,40 @@
|
||||
"This Go file belongs to no package of the workspace: code insight is limited.": "此 Go 文件不属于工作区中的任何包:代码洞察功能受限。",
|
||||
"This Rust file is not included in any crate: rust-analyzer can't offer code insight for it.": "此 Rust 文件未包含在任何 crate 中:rust-analyzer 无法为它提供代码洞察。",
|
||||
"Reload Maven Projects": "重新加载 Maven 项目",
|
||||
"Reload Gradle Project": "重新加载 Gradle 项目"
|
||||
"Reload Gradle Project": "重新加载 Gradle 项目",
|
||||
"Binary files cannot be resolved in the merge editor.": "无法在合并编辑器中解决二进制文件冲突。",
|
||||
"Accept (hold the modifier key to resolve using this side)": "接受(按住修饰键可使用此侧解决冲突)",
|
||||
"Ignore (hold the modifier key to ignore both sides)": "忽略(按住修饰键可忽略两侧)",
|
||||
"Unresolved Changes": "未解决的更改",
|
||||
"{remaining} change is unresolved and keeps the base text. Save and mark the file as resolved anyway?": "{remaining} 个更改未解决,将保留基础文本。仍要保存并将文件标记为已解决吗?",
|
||||
"{remaining} changes are unresolved and keep the base text. Save and mark the file as resolved anyway?": "{remaining} 个更改未解决,将保留基础文本。仍要保存并将文件标记为已解决吗?",
|
||||
"Apply Non-Conflicting Changes from the Left Side": "应用左侧的非冲突更改",
|
||||
"Apply All Non-Conflicting Changes": "应用所有非冲突更改",
|
||||
"Apply Non-Conflicting Changes from the Right Side": "应用右侧的非冲突更改",
|
||||
"Automatically Apply Non-Conflicting Changes": "自动应用非冲突更改",
|
||||
"Resolve Automatically": "自动解决",
|
||||
"Resolve Simple Conflicts": "解决简单冲突",
|
||||
"Revert Conflict Resolution": "还原冲突解决",
|
||||
"Ignore": "忽略",
|
||||
"Accept": "接受",
|
||||
"Resolve Using Yours": "使用我的版本解决",
|
||||
"Resolve Using Theirs": "使用对方的版本解决",
|
||||
"All changes processed": "所有更改均已处理",
|
||||
"{pending} change left": "剩余 {pending} 个更改",
|
||||
"{pending} changes left": "剩余 {pending} 个更改",
|
||||
"Compare Contents": "比较内容",
|
||||
"Yours and Result": "您的版本与结果",
|
||||
"Result and Theirs": "结果与对方的版本",
|
||||
"Yours and Theirs": "您的版本与对方的版本",
|
||||
"Base and Yours": "基础版本与您的版本",
|
||||
"Base and Result": "基础版本与结果",
|
||||
"Base and Theirs": "基础版本与对方的版本",
|
||||
"Base": "基础版本",
|
||||
"Discard Changes": "放弃更改",
|
||||
"The result was changed. Discard the changes and take this version as it is?": "结果已被修改。要放弃这些更改并原样采用此版本吗?",
|
||||
"Cancel Merge": "取消合并",
|
||||
"Exit without applying the changes made in the result?": "不应用对结果所做的更改并退出吗?",
|
||||
"Apply Merge Result": "应用合并结果",
|
||||
"Merge Settings": "合并设置",
|
||||
"Resolve Merge Change": "解决合并更改"
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { create } from "zustand";
|
||||
import { getAction, isActionEnabled, executeAction } from "@/platform/actions";
|
||||
import { getAction, isActionEnabled, executeAction, actionToggled } from "@/platform/actions";
|
||||
import { useContextKeys } from "@/platform/context";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
@@ -83,6 +83,7 @@ export function resolveMenuItems(items: MenuItem[]): Resolved[] {
|
||||
icon: item.icon ?? a.icon,
|
||||
shortcut: shortcutLabel(item.action),
|
||||
disabled: !!item.disabled || !isActionEnabled(a),
|
||||
checked: actionToggled(a),
|
||||
run: () => void executeAction(item.action, item.arg),
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.5 2.5L7.5 5.5L4.5 8.5M0.5 8.5L3.5 5.5L0.5 2.5" stroke="#6C707E" stroke-linecap="round"/>
|
||||
<path d="M11.5 13.5L8.5 10.5L11.5 7.5M15.5 7.5L12.5 10.5L15.5 13.5" stroke="#6C707E" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 441 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.5 12.5L13 8L8.5 3.5M3.5 12.5L8 8L3.5 3.5" stroke="#6C707E" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 326 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.5 12.5L13 8L8.5 3.5M3.5 12.5L8 8L3.5 3.5" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 326 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.5 3.5L3 8L7.5 12.5M12.5 3.5L8 8L12.5 12.5" stroke="#6C707E" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 327 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.5 3.5L3 8L7.5 12.5M12.5 3.5L8 8L12.5 12.5" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 327 B |
@@ -0,0 +1,5 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.5 2.5L7.5 5.5L4.5 8.5M0.5 8.5L3.5 5.5L0.5 2.5" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
<path d="M11.5 13.5L8.5 10.5L11.5 7.5M15.5 7.5L12.5 10.5L15.5 13.5" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 441 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 2.5L2 7L6.5 11.5M11.5 2.5L7 7L11.5 11.5" stroke="#6C707E" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 327 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 2.5L2 7L6.5 11.5M11.5 2.5L7 7L11.5 11.5" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 327 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.5 4.5V11.5H9.5M5.5 1.5V8.5H12.5" stroke="#6C707E" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 317 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.5 4.5V11.5H9.5M5.5 1.5V8.5H12.5" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 317 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.5 11.5L12 7L7.5 2.5M2.5 11.5L7 7L2.5 2.5" stroke="#6C707E" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 326 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.5 11.5L12 7L7.5 2.5M2.5 11.5L7 7L2.5 2.5" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 326 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.5 4.5V11.5H4.5M8.5 1.5V8.5H1.5" stroke="#6C707E" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 317 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.5 4.5V11.5H4.5M8.5 1.5V8.5H1.5" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 317 B |
@@ -0,0 +1,7 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.79289 12.7929L1.20711 12.2071C0.816583 11.8166 0.816582 11.1834 1.20711 10.7929L7.79289 4.20711C8.18342 3.81658 8.81658 3.81658 9.20711 4.20711L9.79289 4.79289C10.1834 5.18342 10.1834 5.81658 9.79289 6.20711L3.20711 12.7929C2.81658 13.1834 2.18342 13.1834 1.79289 12.7929Z" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.5 7.5L6.5 5.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.5 1.5V0.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.5 4.5H13.5M10.5 8.5L11.5 9.5M5.5 3.5L4.5 2.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 889 B |
@@ -0,0 +1,7 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.79289 12.7929L1.20711 12.2071C0.816583 11.8166 0.816582 11.1834 1.20711 10.7929L7.79289 4.20711C8.18342 3.81658 8.81658 3.81658 9.20711 4.20711L9.79289 4.79289C10.1834 5.18342 10.1834 5.81658 9.79289 6.20711L3.20711 12.7929C2.81658 13.1834 2.18342 13.1834 1.79289 12.7929Z" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.5 7.5L6.5 5.5" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.5 1.5V0.5" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.5 4.5H13.5M10.5 8.5L11.5 9.5M5.5 3.5L4.5 2.5" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 889 B |
@@ -0,0 +1,7 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.79289 13.7929L2.20711 13.2071C1.81658 12.8166 1.81658 12.1834 2.20711 11.7929L8.79289 5.20711C9.18342 4.81658 9.81658 4.81658 10.2071 5.20711L10.7929 5.79289C11.1834 6.18342 11.1834 6.81658 10.7929 7.20711L4.20711 13.7929C3.81658 14.1834 3.18342 14.1834 2.79289 13.7929Z" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.5 8.5L7.5 6.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.5 2.5V1.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.5 5.5H14.5M11.5 9.5L12.5 10.5M6.5 4.5L5.5 3.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 889 B |
@@ -0,0 +1,7 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.79289 13.7929L2.20711 13.2071C1.81658 12.8166 1.81658 12.1834 2.20711 11.7929L8.79289 5.20711C9.18342 4.81658 9.81658 4.81658 10.2071 5.20711L10.7929 5.79289C11.1834 6.18342 11.1834 6.81658 10.7929 7.20711L4.20711 13.7929C3.81658 14.1834 3.18342 14.1834 2.79289 13.7929Z" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.5 8.5L7.5 6.5" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.5 2.5V1.5" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.5 5.5H14.5M11.5 9.5L12.5 10.5M6.5 4.5L5.5 3.5" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 889 B |
@@ -0,0 +1,5 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.5 12.5L12.5 1.5" stroke="#6C707E" stroke-linecap="round"/>
|
||||
<path d="M12.5 12.5L1.5 1.5" stroke="#6C707E" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 372 B |
@@ -0,0 +1,5 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.5 12.5L12.5 1.5" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
<path d="M12.5 12.5L1.5 1.5" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 372 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.66659 12.5H9.45825C11.6444 12.5 13.4166 10.7278 13.4166 8.54167V8.54167C13.4166 6.35554 11.6444 4.58333 9.45825 4.58333H0.5M0.5 4.58333L4.5 0.5M0.5 4.58333L4.5 8.5" stroke="#6C707E" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 473 B |
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.66659 12.5H9.45825C11.6444 12.5 13.4166 10.7278 13.4166 8.54167V8.54167C13.4166 6.35554 11.6444 4.58333 9.45825 4.58333H0.5M0.5 4.58333L4.5 0.5M0.5 4.58333L4.5 8.5" stroke="#CED0D6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 473 B |
@@ -409,6 +409,18 @@ const ICONS = {
|
||||
"vcs-outgoing": "vcs/changesPush@12x12",
|
||||
diff: "vcs/diff",
|
||||
revert: "vcs/revert",
|
||||
// The merge tool's own controls (IntelliJ's AllIcons.Diff)
|
||||
"diff-apply-left": "diff/applyNotConflictsLeft",
|
||||
"diff-apply-all": "diff/applyNotConflicts",
|
||||
"diff-apply-right": "diff/applyNotConflictsRight",
|
||||
"diff-arrow-right": "diff/arrowRight@14x14",
|
||||
"diff-arrow-left": "diff/arrow@14x14",
|
||||
"diff-arrow-right-down": "diff/arrowRightDown@14x14",
|
||||
"diff-arrow-left-down": "diff/arrowLeftDown@14x14",
|
||||
"diff-magic": "diff/magicResolve@14x14",
|
||||
"diff-magic-toolbar": "diff/magicResolveToolbar",
|
||||
"diff-remove": "diff/remove@14x14",
|
||||
"diff-revert": "diff/revert@14x14",
|
||||
shelve: "vcs/shelve",
|
||||
unshelve: "vcs/unshelve",
|
||||
changelist: "vcs/changelist",
|
||||
|
||||