diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 2d503f9c7..6194db2a1 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -365,6 +365,10 @@ pub struct MergeVersions { pub ours: Option, pub theirs: Option, pub sides: MergeSides, + /// Three-way merge of the versions above: non-conflicting changes merged, each + /// conflict left as a diff3 block (yours / base / theirs). `None` when a side is + /// missing (deleted on one side). + pub merged: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -799,7 +803,11 @@ impl Repo { let (base, ours, theirs) = self.conflict_versions(path)?; let sides = self.merge_sides()?; let (ours, theirs) = if sides.reversed { (theirs, ours) } else { (ours, theirs) }; - Ok(MergeVersions { base, ours, theirs, sides }) + let merged = match (&ours, &theirs) { + (Some(o), Some(t)) => Some(ops::merge_file(self, o, base.as_deref().unwrap_or(""), t)?), + _ => None, + }; + Ok(MergeVersions { base, ours, theirs, sides, merged }) } /// For conflict resolution: (base, ours, theirs) versions of a conflicted file. pub fn conflict_versions(&self, path: &Path) -> IdeResult<(Option, Option, Option)> { diff --git a/crates/ide-vcs/src/ops.rs b/crates/ide-vcs/src/ops.rs index e36f77c92..24f25b0be 100644 --- a/crates/ide-vcs/src/ops.rs +++ b/crates/ide-vcs/src/ops.rs @@ -67,6 +67,32 @@ pub(crate) fn merge_sides(r: &Repo) -> IdeResult { Ok(sides) } +/// Three-way merge of file contents (`git merge-file --diff3`): changes that don't +/// conflict are merged, each conflict is left as a diff3 block. +pub(crate) fn merge_file(r: &Repo, ours: &str, base: &str, theirs: &str) -> IdeResult { + struct TempDir(std::path::PathBuf); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let dir = TempDir(r.git_dir.join(format!("openidea-merge-{}", std::process::id()))); + std::fs::create_dir_all(&dir.0)?; + let file = |name: &str, text: &str| -> IdeResult { + let path = dir.0.join(name); + std::fs::write(&path, text)?; + Ok(path.to_string_lossy().into_owned()) + }; + let (o, b, t) = (file("ours", ours)?, file("base", base)?, file("theirs", theirs)?); + let args = ["merge-file", "-p", "--diff3", "-L", "yours", "-L", "base", "-L", "theirs", &o, &b, &t]; + let out = cli::run_raw(r.root(), &args, None)?; + // The exit status is the number of conflicts; only a negative one is a failure. + match out.code { + Some(code) if code >= 0 => Ok(String::from_utf8_lossy(&out.stdout).into_owned()), + code => Err(cli::failure(&args, &out.stderr, &out.stdout, code)), + } +} + /// The single branch (local first, then remote) pointing at `commit`. fn branch_at(r: &Repo, commit: &str) -> Option { if commit.starts_with('-') { diff --git a/crates/ide-vcs/tests/history_ops.rs b/crates/ide-vcs/tests/history_ops.rs index f29c1d026..7116a98c4 100644 --- a/crates/ide-vcs/tests/history_ops.rs +++ b/crates/ide-vcs/tests/history_ops.rs @@ -122,6 +122,8 @@ fn merge_versions_put_the_users_work_on_the_left() { assert_eq!(v.sides.branch.as_deref(), Some("topic")); assert_eq!(v.sides.other_branch.as_deref(), Some("main")); assert_eq!(v.sides.commit.as_deref(), Some(&topic[..8])); + // The merged text puts the user's side first, whatever git's stage order. + assert_eq!(v.merged.as_deref(), Some("<<<<<<< yours\ntopic\n||||||| base\nbase\n=======\nmain\n>>>>>>> theirs\n")); let (cancel, out) = quiet(); repo.abort_operation(&cancel, &out).unwrap(); diff --git a/src/bindings.ts b/src/bindings.ts index 23b2b8c13..5ede6f18f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2907,6 +2907,12 @@ export type MergeVersions = { ours: string | null, theirs: string | null, sides: MergeSides, + /** + * Three-way merge of the versions above: non-conflicting changes merged, each + * conflict left as a diff3 block (yours / base / theirs). `None` when a side is + * missing (deleted on one side). + */ + merged: string | null, }; /** Per-function totals (a recursive function's samples count once in `total`). */ diff --git a/src/features/search/__tests__/outline.test.ts b/src/features/search/__tests__/outline.test.ts index bdd08a6b4..0b75bfe6f 100644 --- a/src/features/search/__tests__/outline.test.ts +++ b/src/features/search/__tests__/outline.test.ts @@ -47,6 +47,12 @@ describe("outline helpers", () => { expect(filterOutline(nodes, "", true).nodes).toBe(nodes); }); + it("matches word and camel-hump starts, not scattered letters", () => { + const loose = keyOutline([item("items.reduce() callback", "function", 0, 1), item("subtotal", "method", 2, 3)]); + expect(filterOutline(loose, "sub", true).nodes.map((n) => n.item.name)).toEqual(["subtotal"]); + expect(filterOutline(nodes, "OC", true).nodes.map((n) => n.item.name)).toEqual(["OrderController"]); + }); + it("only highlights matches when not narrowing down", () => { const f = filterOutline(nodes, "list", false); expect(f.nodes).toBe(nodes); diff --git a/src/features/search/structure/outline.ts b/src/features/search/structure/outline.ts index 948ae0b12..6219a1ed8 100644 --- a/src/features/search/structure/outline.ts +++ b/src/features/search/structure/outline.ts @@ -68,7 +68,7 @@ export function filterOutline(nodes: readonly OutlineNode[], query: string, narr const walk = (list: readonly OutlineNode[]): OutlineNode[] => { const out: OutlineNode[] = []; for (const node of list) { - const hit = q ? matchText(node.item.name, q) : null; + const hit = q ? matchText(node.item.name, q, { wordAligned: true }) : null; if (hit) { matches.set(node.key, hit.indices); order.push(node.key); diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index 560a6a6e9..3b6866b7c 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -1,7 +1,7 @@ /** Three-pane merge conflict editor: Yours | Result | Theirs. */ import { useCallback, useEffect, useRef, useState } from "react"; import clsx from "clsx"; -import { Facet, RangeSetBuilder, StateField, type EditorState, type Extension } from "@codemirror/state"; +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"; @@ -13,14 +13,7 @@ import { Button, IconButton, Spinner, T } from "@/ui"; import { editorService, type EditorInput } from "@/workbench/contributions"; import { languageFor, viewerBase } from "./cm"; import { confirmDialog } from "./dialogs"; -import { - mergeTitles, - parseConflicts, - resolutionText, - resolveAll, - type ConflictBlock, - type Resolution, -} from "./conflicts"; +import { initialResult, mergeTitles, resolutionText, type Resolution, type ResultConflict } from "./conflicts"; import { baseName } from "./model"; import { errorMessage, readWorkingText, runOp, writeFileText } from "./ops"; import { refreshNow } from "./store"; @@ -29,12 +22,15 @@ import styles from "./MergeEditor.module.css"; import type { MergeData } from "./mergeKind"; export { MERGE_KIND, mergeInput, type MergeData } from "./mergeKind"; -/** The user's commit is the `>>>>>>>` section (a rebase): "Yours" takes it. */ -const reversedSides = Facet.define({ combine: (v) => v.some(Boolean) }); +/** Drops the conflict at an index once a resolution replaced its text. */ +const resolvedConflict = StateEffect.define(); -function resolveBlock(view: EditorView, block: ConflictBlock, how: Resolution) { +function resolveConflict(view: EditorView, index: number, how: Resolution) { + const c = view.state.field(conflictField).conflicts[index]; + if (!c) return; view.dispatch({ - changes: { from: block.from, to: block.to, insert: resolutionText(block, how, view.state.facet(reversedSides)) }, + changes: { from: c.from, to: c.to, insert: resolutionText(c, how) }, + effects: resolvedConflict.of(index), userEvent: "input.resolve", }); } @@ -42,12 +38,12 @@ function resolveBlock(view: EditorView, block: ConflictBlock, how: Resolution) { class ActionsWidget extends WidgetType { constructor( readonly index: number, - readonly block: ConflictBlock, + readonly hasBase: boolean, ) { super(); } eq(o: ActionsWidget) { - return o.index === this.index && o.block.from === this.block.from && o.block.to === this.block.to; + return o.index === this.index && o.hasBase === this.hasBase; } toDOM(view: EditorView) { const el = document.createElement("div"); @@ -60,8 +56,7 @@ class ActionsWidget extends WidgetType { b.onmousedown = (e) => e.preventDefault(); b.onclick = (e) => { e.preventDefault(); - const live = parseConflicts(view.state.doc.toString())[this.index]; - if (live) resolveBlock(view, live, how); + resolveConflict(view, this.index, how); }; el.appendChild(b); }; @@ -71,7 +66,7 @@ class ActionsWidget extends WidgetType { add("Accept Yours", "ours"); add("Accept Theirs", "theirs"); add("Accept Both", "both"); - if (this.block.base !== null) add("Use Base", "base"); + if (this.hasBase) add("Use Base", "base"); return el; } ignoreEvent() { @@ -79,41 +74,38 @@ class ActionsWidget extends WidgetType { } } -const oursLine = Decoration.line({ class: "cm-conflict-ours" }); -const theirsLine = Decoration.line({ class: "cm-conflict-theirs" }); -const markerLine = Decoration.line({ class: "cm-conflict-marker" }); +const conflictLine = Decoration.line({ class: "cm-conflict-marker" }); -function decorate(state: EditorState): { blocks: ConflictBlock[]; deco: DecorationSet } { - const blocks = parseConflicts(state.doc.toString()); +function decorate(state: EditorState, conflicts: readonly ResultConflict[]): DecorationSet { const b = new RangeSetBuilder(); const doc = state.doc; - const [yours, theirs] = state.facet(reversedSides) ? [theirsLine, oursLine] : [oursLine, theirsLine]; - blocks.forEach((blk, i) => { - b.add(blk.from, blk.from, Decoration.widget({ widget: new ActionsWidget(i, blk), block: true, side: -1 })); - const endLine = doc.lineAt(Math.max(blk.from, blk.to - 1)).number; - for (let n = doc.lineAt(blk.from).number; n <= endLine; n++) { - const line = doc.line(n); - const deco = - line.from >= blk.oursFrom && line.from < blk.oursTo - ? yours - : line.from >= blk.theirsFrom && line.from < blk.theirsTo - ? theirs - : markerLine; - b.add(line.from, line.from, deco); - } + 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 { blocks, deco: b.finish() }; + return b.finish(); } -const conflictField = StateField.define<{ blocks: ConflictBlock[]; deco: DecorationSet }>({ - create: decorate, - update: (v, tr) => (tr.docChanged ? decorate(tr.state) : v), +/** + * 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-ours": { backgroundColor: "color-mix(in srgb, var(--accent) 12%, transparent)" }, - ".cm-conflict-theirs": { backgroundColor: "color-mix(in srgb, var(--vcs-added) 12%, transparent)" }, ".cm-conflict-marker": { backgroundColor: "color-mix(in srgb, var(--vcs-conflict) 14%, transparent)", color: "var(--fg-subtle)", @@ -148,6 +140,7 @@ export function MergeEditor({ input }: { input: EditorInput }) { theirs: string; base: string | null; working: string; + merged: string | null; sides: MergeSides; } | null>(null); const [error, setError] = useState(null); @@ -164,7 +157,14 @@ export function MergeEditor({ input }: { input: EditorInput }) { const load = useCallback(async () => { try { const [v, working] = await Promise.all([call(commands.vcsConflictVersions(path)), readWorkingText(path)]); - setState({ ours: v.ours ?? "", theirs: v.theirs ?? "", base: v.base, working: working ?? "", sides: v.sides }); + setState({ + ours: v.ours ?? "", + theirs: v.theirs ?? "", + base: v.base, + working: working ?? "", + merged: v.merged, + sides: v.sides, + }); setError(null); } catch (e) { setError(errorMessage(e)); @@ -191,24 +191,27 @@ export function MergeEditor({ input }: { input: EditorInput }) { ]; 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({ - doc: state.working, + doc: start.text, parent: resultRef.current, extensions: [ viewerBase({ readOnly: false }), history(), keymap.of(historyKeymap), lang, - reversedSides.of(state.sides.reversed), - conflictField, + conflictField.init((s) => ({ conflicts: start.conflicts, deco: decorate(s, start.conflicts) })), resultTheme, EditorView.updateListener.of((u) => { - if (u.docChanged) setLeft(u.state.field(conflictField).blocks.length); + if (u.docChanged) setLeft(u.state.field(conflictField).conflicts.length); }), ], }); resultView.current = result; - setLeft(result.state.field(conflictField).blocks.length); + setLeft(result.state.field(conflictField).conflicts.length); return () => { ours.destroy(); theirs.destroy(); @@ -220,7 +223,7 @@ export function MergeEditor({ input }: { input: EditorInput }) { const goto = (dir: 1 | -1) => { const v = resultView.current; if (!v) return; - const blocks = v.state.field(conflictField).blocks; + const blocks = v.state.field(conflictField).conflicts; if (!blocks.length) return; const head = v.state.selection.main.head; const target = @@ -237,9 +240,10 @@ export function MergeEditor({ input }: { input: EditorInput }) { const acceptAll = (how: Resolution) => { const v = resultView.current; if (!v) return; - const text = v.state.doc.toString(); + const conflicts = v.state.field(conflictField).conflicts; v.dispatch({ - changes: { from: 0, to: text.length, insert: resolveAll(text, how, v.state.facet(reversedSides)) }, + changes: conflicts.map((c) => ({ from: c.from, to: c.to, insert: resolutionText(c, how) })), + effects: conflicts.map((_, i) => resolvedConflict.of(i)), userEvent: "input.resolve", }); }; @@ -247,12 +251,12 @@ export function MergeEditor({ input }: { input: EditorInput }) { const apply = async () => { const v = resultView.current; if (!v) return; - const remaining = v.state.field(conflictField).blocks.length; + const remaining = v.state.field(conflictField).conflicts.length; if ( remaining > 0 && !(await confirmDialog({ title: "Unresolved Conflicts", - message: `${remaining} conflict${remaining === 1 ? "" : "s"} still contain conflict markers. Save and mark the file as resolved anyway?`, + message: `${remaining} conflict${remaining === 1 ? " is" : "s are"} unresolved and keep the base text. Save and mark the file as resolved anyway?`, okLabel: "Mark Resolved", })) ) @@ -263,10 +267,11 @@ export function MergeEditor({ input }: { input: EditorInput }) { async () => { await writeFileText(path, text, { save: true }); await call(commands.vcsStage([path])); + return true; }, { success: `${baseName(path)}: conflicts resolved` }, ); - if (ok !== undefined) { + if (ok) { setResolved(true); await refreshNow(); // Like IDEA, applying the result closes the merge editor. diff --git a/src/features/vcs/__tests__/conflicts.test.ts b/src/features/vcs/__tests__/conflicts.test.ts index 28a1d8c82..cf41d834c 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, mergeTitles, parseConflicts, resolveAll, resolutionText } from "../conflicts"; +import { blockAt, initialResult, mergeTitles, parseConflicts, resolveAll, resolutionText } from "../conflicts"; const text = [ "keep\n", @@ -78,3 +78,23 @@ 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/conflicts.ts b/src/features/vcs/conflicts.ts index 0ebab5529..f51688bb3 100644 --- a/src/features/vcs/conflicts.ts +++ b/src/features/vcs/conflicts.ts @@ -83,7 +83,11 @@ export type Resolution = "ours" | "theirs" | "both" | "base"; * Text replacing `block`. With `reversed` (a rebase, where git writes the upstream as * the `<<<<<<<` section and the user's commit as `>>>>>>>`) the sides swap, as IDEA does. */ -export function resolutionText(block: ConflictBlock, how: Resolution, reversed = false): string { +export function resolutionText( + block: Pick, + how: Resolution, + reversed = false, +): string { const yours = reversed ? block.theirs : block.ours; const theirs = reversed ? block.ours : block.theirs; switch (how) { @@ -150,3 +154,39 @@ 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 }; +}