diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 6b09f4585..ad5047878 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -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`, diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index b3c4dc4ad..05369faf2 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -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(); diff --git a/e2e/mergeEditor.spec.ts b/e2e/mergeEditor.spec.ts new file mode 100644 index 000000000..4c9a94af8 --- /dev/null +++ b/e2e/mergeEditor.spec.ts @@ -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).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"); +}); diff --git a/src/editor/api.ts b/src/editor/api.ts index 26a803448..3be21b070 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -583,7 +583,7 @@ export interface DocumentAttachment { readOnly: boolean; /** Add to the view (one view at a time). */ extension: Extension; - save(): Promise; + save(options?: { exact?: boolean }): Promise; /** Detach; the document closes when nothing else shows it. */ release(): void; } diff --git a/src/editor/core/model/attachment.test.ts b/src/editor/core/model/attachment.test.ts index acbe64efe..1be4ef1dc 100644 --- a/src/editor/core/model/attachment.test.ts +++ b/src/editor/core/model/attachment.test.ts @@ -36,6 +36,20 @@ function setup(files: Record) { 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"); diff --git a/src/editor/core/model/attachment.ts b/src/editor/core/model/attachment.ts index 0ac7bd141..66de3578d 100644 --- a/src/editor/core/model/attachment.ts +++ b/src/editor/core/model/attachment.ts @@ -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; diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index d8843183a..b017024d9 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -593,7 +593,7 @@ export class DocModel { // --- saving ----------------------------------------------------------------- - save(reason: "explicit" | "auto"): Promise { + save(reason: "explicit" | "auto" | "exact"): Promise { 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(); // Like IDEA's "keep trailing spaces on caret line": never strip under a visible caret. diff --git a/src/features/vcs/MergeEditor.module.css b/src/features/vcs/MergeEditor.module.css index 077ab55eb..947662af8 100644 --- a/src/features/vcs/MergeEditor.module.css +++ b/src/features/vcs/MergeEditor.module.css @@ -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; diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index b04e51675..0d5c77966 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -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(); +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(); - 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; } | null>(null); const [error, setError] = useState(null); const [lang, setLang] = useState(null); - const [left, setLeft] = useState(0); + const [summary, setSummary] = useState(NOTHING_LEFT); + const [autoApply, setAutoApply] = useState(readAutoApply); const [resolved, setResolved] = useState(false); + const rootRef = useRef(null); + const paneRef = useRef("result"); + const [actionHost] = useState(() => ({ + enabled: () => false, + run: () => {}, + autoApply: () => false, + })); const oursRef = useRef(null); const resultRef = useRef(null); const theirsRef = useRef(null); const resultView = useRef(null); const oursView = useRef(null); const theirsView = useRef(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(() => ({ @@ -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) => { + 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) => { + 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 ( -
+
{error} -
@@ -358,49 +573,8 @@ export function MergeEditor({ input }: { input: EditorInput }) { ); return ( -
{ - setFocusedDiffNavigator(nav); - setContext("diffEditorFocus", true); - }} - onBlurCapture={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedDiffNavigator() === nav) { - setFocusedDiffNavigator(null); - setContext("diffEditorFocus", false); - } - }} - > -
- goto(-1)} - /> - goto(1)} - /> - - - - void load()} /> - - {left === 1 ? tx`${left} conflict left` : left ? tx`${left} conflicts left` : tx("All conflicts resolved")} - - -
+
+
{txt(titles.yours)} @@ -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")} + /> +
resultView.current && showResultMenu(e, resultView.current)} /> -
sideMenu(e, theirsView, state?.theirs ?? "", state?.sides.reversed ? "HEAD" : otherRev)} + onContextMenu={(e) => theirsView.current && showSideMenu(e, theirsView.current, "right")} />
+
); } diff --git a/src/features/vcs/MergeToolbar.tsx b/src/features/vcs/MergeToolbar.tsx new file mode 100644 index 000000000..2be80a9b3 --- /dev/null +++ b/src/features/vcs/MergeToolbar.tsx @@ -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 ( +
+ + + + + + + + + + + {statusText(summary)} + + +
+ ); +} + +export function MergeFooter() { + return ( +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/src/features/vcs/__tests__/chunkOptimizer.test.ts b/src/features/vcs/__tests__/chunkOptimizer.test.ts new file mode 100644 index 000000000..8425c880d --- /dev/null +++ b/src/features/vcs/__tests__/chunkOptimizer.test.ts @@ -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); + }); +}); diff --git a/src/features/vcs/__tests__/conflicts.test.ts b/src/features/vcs/__tests__/conflicts.test.ts index 13c693723..12b6fddea 100644 --- a/src/features/vcs/__tests__/conflicts.test.ts +++ b/src/features/vcs/__tests__/conflicts.test.ts @@ -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: [] }); - }); -}); diff --git a/src/features/vcs/__tests__/mergeActions.test.ts b/src/features/vcs/__tests__/mergeActions.test.ts new file mode 100644 index 000000000..7bd5501c3 --- /dev/null +++ b/src/features/vcs/__tests__/mergeActions.test.ts @@ -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(); +}); diff --git a/src/features/vcs/__tests__/mergeDiff3.test.ts b/src/features/vcs/__tests__/mergeDiff3.test.ts new file mode 100644 index 000000000..c3b7efec6 --- /dev/null +++ b/src/features/vcs/__tests__/mergeDiff3.test.ts @@ -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); + } + }); +}); diff --git a/src/features/vcs/__tests__/mergeModel.test.ts b/src/features/vcs/__tests__/mergeModel.test.ts new file mode 100644 index 000000000..e9269b88f --- /dev/null +++ b/src/features/vcs/__tests__/mergeModel.test.ts @@ -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, + }); + }); +}); diff --git a/src/features/vcs/chunkOptimizer.ts b/src/features/vcs/chunkOptimizer.ts new file mode 100644 index 000000000..a7e111ecb --- /dev/null +++ b/src/features/vcs/chunkOptimizer.ts @@ -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; +} diff --git a/src/features/vcs/conflicts.ts b/src/features/vcs/conflicts.ts index f51688bb3..1a5179842 100644 --- a/src/features/vcs/conflicts.ts +++ b/src/features/vcs/conflicts.ts @@ -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 }; -} diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index 9e74b30c3..b360573d9 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -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 }), diff --git a/src/features/vcs/mergeActions.ts b/src/features/vcs/mergeActions.ts new file mode 100644 index 000000000..706c3b176 --- /dev/null +++ b/src/features/vcs/mergeActions.ts @@ -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; + 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), + })), + ); +} diff --git a/src/features/vcs/mergeDiff3.ts b/src/features/vcs/mergeDiff3.ts new file mode 100644 index 000000000..134e1dc86 --- /dev/null +++ b/src/features/vcs/mergeDiff3.ts @@ -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(); + 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; +} diff --git a/src/features/vcs/mergeGutters.ts b/src/features/vcs/mergeGutters.ts new file mode 100644 index 000000000..854270796 --- /dev/null +++ b/src/features/vcs/mergeGutters.ts @@ -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(); + +const sideChanges = StateField.define({ + 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(); + 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)" }, +}); diff --git a/src/features/vcs/mergeMenus.ts b/src/features/vcs/mergeMenus.ts new file mode 100644 index 000000000..2fdc923e6 --- /dev/null +++ b/src/features/vcs/mergeMenus.ts @@ -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" }]); +} diff --git a/src/features/vcs/mergeModel.ts b/src/features/vcs/mergeModel.ts new file mode 100644 index 000000000..34f936ebe --- /dev/null +++ b/src/features/vcs/mergeModel.ts @@ -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({ + 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({ + 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), + }; +} diff --git a/src/features/vcs/ops.ts b/src/features/vcs/ops.ts index 17d442e9a..db1344529 100644 --- a/src/features/vcs/ops.ts +++ b/src/features/vcs/ops.ts @@ -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(); + } +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 1a8863abc..e28a534f8 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "解决合并更改" } diff --git a/src/ui/Menu.tsx b/src/ui/Menu.tsx index dfa141caf..7ccaf8d30 100644 --- a/src/ui/Menu.tsx +++ b/src/ui/Menu.tsx @@ -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; diff --git a/src/ui/expui/diff/applyNotConflicts.svg b/src/ui/expui/diff/applyNotConflicts.svg new file mode 100644 index 000000000..b5904f4c7 --- /dev/null +++ b/src/ui/expui/diff/applyNotConflicts.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/diff/applyNotConflictsLeft.svg b/src/ui/expui/diff/applyNotConflictsLeft.svg new file mode 100644 index 000000000..00b3c60a5 --- /dev/null +++ b/src/ui/expui/diff/applyNotConflictsLeft.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/diff/applyNotConflictsLeft_dark.svg b/src/ui/expui/diff/applyNotConflictsLeft_dark.svg new file mode 100644 index 000000000..65ed829f6 --- /dev/null +++ b/src/ui/expui/diff/applyNotConflictsLeft_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/diff/applyNotConflictsRight.svg b/src/ui/expui/diff/applyNotConflictsRight.svg new file mode 100644 index 000000000..da8c4afe4 --- /dev/null +++ b/src/ui/expui/diff/applyNotConflictsRight.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/diff/applyNotConflictsRight_dark.svg b/src/ui/expui/diff/applyNotConflictsRight_dark.svg new file mode 100644 index 000000000..04e785a8b --- /dev/null +++ b/src/ui/expui/diff/applyNotConflictsRight_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/diff/applyNotConflicts_dark.svg b/src/ui/expui/diff/applyNotConflicts_dark.svg new file mode 100644 index 000000000..67b72a57b --- /dev/null +++ b/src/ui/expui/diff/applyNotConflicts_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/diff/arrow@14x14.svg b/src/ui/expui/diff/arrow@14x14.svg new file mode 100644 index 000000000..a5f652b29 --- /dev/null +++ b/src/ui/expui/diff/arrow@14x14.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/diff/arrow@14x14_dark.svg b/src/ui/expui/diff/arrow@14x14_dark.svg new file mode 100644 index 000000000..a0e7513ae --- /dev/null +++ b/src/ui/expui/diff/arrow@14x14_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/diff/arrowLeftDown@14x14.svg b/src/ui/expui/diff/arrowLeftDown@14x14.svg new file mode 100644 index 000000000..778ca59b0 --- /dev/null +++ b/src/ui/expui/diff/arrowLeftDown@14x14.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/diff/arrowLeftDown@14x14_dark.svg b/src/ui/expui/diff/arrowLeftDown@14x14_dark.svg new file mode 100644 index 000000000..56a2d4628 --- /dev/null +++ b/src/ui/expui/diff/arrowLeftDown@14x14_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/diff/arrowRight@14x14.svg b/src/ui/expui/diff/arrowRight@14x14.svg new file mode 100644 index 000000000..d8dd4aaf9 --- /dev/null +++ b/src/ui/expui/diff/arrowRight@14x14.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/diff/arrowRight@14x14_dark.svg b/src/ui/expui/diff/arrowRight@14x14_dark.svg new file mode 100644 index 000000000..a20a06ac6 --- /dev/null +++ b/src/ui/expui/diff/arrowRight@14x14_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/diff/arrowRightDown@14x14.svg b/src/ui/expui/diff/arrowRightDown@14x14.svg new file mode 100644 index 000000000..0fe7fe5bf --- /dev/null +++ b/src/ui/expui/diff/arrowRightDown@14x14.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/diff/arrowRightDown@14x14_dark.svg b/src/ui/expui/diff/arrowRightDown@14x14_dark.svg new file mode 100644 index 000000000..e2d75323e --- /dev/null +++ b/src/ui/expui/diff/arrowRightDown@14x14_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/diff/magicResolve@14x14.svg b/src/ui/expui/diff/magicResolve@14x14.svg new file mode 100644 index 000000000..7d09f32f1 --- /dev/null +++ b/src/ui/expui/diff/magicResolve@14x14.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/diff/magicResolve@14x14_dark.svg b/src/ui/expui/diff/magicResolve@14x14_dark.svg new file mode 100644 index 000000000..daa3c319c --- /dev/null +++ b/src/ui/expui/diff/magicResolve@14x14_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/diff/magicResolveToolbar.svg b/src/ui/expui/diff/magicResolveToolbar.svg new file mode 100644 index 000000000..5ad2cdb16 --- /dev/null +++ b/src/ui/expui/diff/magicResolveToolbar.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/diff/magicResolveToolbar_dark.svg b/src/ui/expui/diff/magicResolveToolbar_dark.svg new file mode 100644 index 000000000..3c5f7ed0d --- /dev/null +++ b/src/ui/expui/diff/magicResolveToolbar_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/ui/expui/diff/remove@14x14.svg b/src/ui/expui/diff/remove@14x14.svg new file mode 100644 index 000000000..ff7d49018 --- /dev/null +++ b/src/ui/expui/diff/remove@14x14.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/diff/remove@14x14_dark.svg b/src/ui/expui/diff/remove@14x14_dark.svg new file mode 100644 index 000000000..52b407d30 --- /dev/null +++ b/src/ui/expui/diff/remove@14x14_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/expui/diff/revert@14x14.svg b/src/ui/expui/diff/revert@14x14.svg new file mode 100644 index 000000000..4da28b144 --- /dev/null +++ b/src/ui/expui/diff/revert@14x14.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/expui/diff/revert@14x14_dark.svg b/src/ui/expui/diff/revert@14x14_dark.svg new file mode 100644 index 000000000..70810a300 --- /dev/null +++ b/src/ui/expui/diff/revert@14x14_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 51f45b46d..631711f3f 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -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",