дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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
Цей коміт міститься в:
1 джерело
9a425f7ac3
коміт
d41daa8721
9 files changed
+172
-59
No files matched your search
@@ -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>)> {
|
||||
|
||||
@@ -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('-') {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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`). */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача