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]>
Цей коміт міститься в:
ntbang0901 authored and GitHub committed 2026-11-03 23:37:38 +08:00
1 джерело 2d4d6ee6bf
коміт f752f2db08
49 files changed
+2970 -317

No files matched your search

+5
Переглянути файл
@@ -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`,
+6 -2
Переглянути файл
@@ -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();
+345
Переглянути файл
@@ -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");
});
+1 -1
Переглянути файл
@@ -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;
}
+14
Переглянути файл
@@ -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");
+1 -1
Переглянути файл
@@ -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;
+2 -2
Переглянути файл
@@ -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.
+13
Переглянути файл
@@ -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;
+417 -236
Переглянути файл
@@ -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>
);
}
+91
Переглянути файл
@@ -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>
);
}
+98
Переглянути файл
@@ -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 -21
Переглянути файл
@@ -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: [] });
});
});
+62
Переглянути файл
@@ -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();
});
+220
Переглянути файл
@@ -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);
}
});
});
+393
Переглянути файл
@@ -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,
});
});
});
+153
Переглянути файл
@@ -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;
}
-36
Переглянути файл
@@ -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 };
}
+2
Переглянути файл
@@ -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 }),
+76
Переглянути файл
@@ -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),
})),
);
}
+271
Переглянути файл
@@ -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;
}
+217
Переглянути файл
@@ -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)" },
});
+61
Переглянути файл
@@ -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" }]);
}
+344
Переглянути файл
@@ -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),
};
}
+23 -7
Переглянути файл
@@ -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();
}
}
+36 -10
Переглянути файл
@@ -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": "解决合并更改"
}
+2 -1
Переглянути файл
@@ -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;
+5
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+5
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+7
Переглянути файл
@@ -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

+7
Переглянути файл
@@ -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

+7
Переглянути файл
@@ -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

+7
Переглянути файл
@@ -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

+5
Переглянути файл
@@ -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

+5
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+4
Переглянути файл
@@ -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

+12
Переглянути файл
@@ -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",