fix(vcs): Start the merge result from a three-way merge; close the editor on Apply

The result pane now begins with non-conflicting changes merged and each
conflict showing the base text, resolved from the side panes, instead of
raw conflict markers. Apply now reports success, so the tab closes.

fix(search): Match File Structure filter on word starts
Цей коміт міститься в:
l0ng-ai committed 2026-10-25 09:34:37 +08:00
1 джерело 9a425f7ac3
коміт d41daa8721
9 files changed
+172 -59

No files matched your search

+9 -1
Переглянути файл
@@ -365,6 +365,10 @@ pub struct MergeVersions {
pub ours: Option<String>,
pub theirs: Option<String>,
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<String>,
}
#[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<String>, Option<String>, Option<String>)> {
+26
Переглянути файл
@@ -67,6 +67,32 @@ pub(crate) fn merge_sides(r: &Repo) -> IdeResult<MergeSides> {
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<String> {
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<String> {
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<String> {
if commit.starts_with('-') {
+2
Переглянути файл
@@ -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();
+6
Переглянути файл
@@ -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`). */
+6
Переглянути файл
@@ -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);
+1 -1
Переглянути файл
@@ -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);
+60 -55
Переглянути файл
@@ -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<boolean, boolean>({ combine: (v) => v.some(Boolean) });
/** Drops the conflict at an index once a resolution replaced its text. */
const resolvedConflict = StateEffect.define<number>();
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<Decoration>();
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<string | null>(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.
+21 -1
Переглянути файл
@@ -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: [] });
});
});
+41 -1
Переглянути файл
@@ -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<ConflictBlock, "ours" | "base" | "theirs">,
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 };
}