Restructure conflict view files and add some documentation

This commit is contained in:
Silvano Cerza 2025-03-09 16:21:13 +01:00
parent 8dfa1fe592
commit 937e576bcc
16 changed files with 632 additions and 596 deletions

View file

@ -1,5 +1,9 @@
export interface DiffChunk {
type: "add" | "remove" | "modify";
// These use the line number, so they start at 1.
// If the DiffChunk is a single line the end will be start + 1.
// If start and end are identical it means the line from left must
// be added to right, and viceversa.
startLeftLine: number;
endLeftLine: number;
startRightLine: number;

View file

@ -1,5 +1,5 @@
import * as React from "react";
import { DiffChunk } from "./diff";
import { DiffChunk } from "../diff";
import {
ButtonCross,
ButtonLeftArrow,

View file

@ -5,7 +5,7 @@ import {
EditorView,
ViewUpdate,
} from "@codemirror/view";
import { DiffChunk } from "./diff";
import { DiffChunk } from "../diff";
export interface DiffHighlightPluginSpec {
diff: DiffChunk[];

View file

@ -1,9 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { EditorView } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { markdown } from "@codemirror/lang-markdown";
import diff, { DiffChunk } from "./diff";
import { createDiffHighlightPlugin } from "./diff-highlight-plugin";
import diff, { DiffChunk } from "../diff";
import EditorPane from "./editor-pane";
import ActionsGutter from "./actions-gutter";
import * as React from "react";
@ -31,7 +27,7 @@ interface DiffViewProps {
onConflictResolved: () => void;
}
const SplitDiffView: React.FC<DiffViewProps> = ({
const DiffView: React.FC<DiffViewProps> = ({
oldText,
newText,
onOldTextChange,
@ -176,4 +172,4 @@ const SplitDiffView: React.FC<DiffViewProps> = ({
);
};
export default SplitDiffView;
export default DiffView;

View file

@ -1,9 +1,9 @@
import * as React from "react";
import { ConflictFile, ConflictResolution } from "src/sync-manager";
import SplitDiffView from "./split-diff-view";
import DiffView from "./diff-view";
import FilesTabBar from "./files-tab-bar";
const DesktopApp = ({
const SplitView = ({
initialFiles,
onResolveAllConflicts,
}: {
@ -88,7 +88,7 @@ const DesktopApp = ({
currentFile={currentFile?.filePath || ""}
setCurrentFileIndex={setCurrentFileIndex}
/>
<SplitDiffView
<DiffView
oldText={currentFile?.remoteContent || ""}
newText={currentFile?.localContent || ""}
onOldTextChange={(content: string) => {
@ -110,4 +110,4 @@ const DesktopApp = ({
);
};
export default DesktopApp;
export default SplitView;

View file

@ -1,575 +0,0 @@
import * as React from "react";
import CodeMirror, {
EditorState,
Range,
RangeSetBuilder,
StateEffect,
StateField,
Transaction,
} from "@uiw/react-codemirror";
import { Decoration, EditorView, WidgetType } from "@codemirror/view";
import diff, { DiffChunk } from "./diff";
import { createRoot } from "react-dom/client";
import UnifiedResolutionBar from "./unified-resolution-bar";
interface UnifiedDiffViewProps {
initialOldText: string;
initialNewText: string;
onConflictResolved: (content: string) => void;
}
const createUnifiedDocument = (
oldText: string,
newText: string,
diffChunks: DiffChunk[],
): { doc: string; lineRanges: ConflictRange[] } => {
const sortedChunks = [...diffChunks].sort(
(a, b) => a.startLeftLine - b.startLeftLine,
);
const oldLines = oldText.split(/\r?\n/);
const newLines = newText.split(/\r?\n/);
let result: string[] = [];
let lineRanges: ConflictRange[] = [];
let linePosition = 0;
let currentRange: ConflictRange | null = null;
let oldTextLine = 1;
let newTextLine = 1;
const addLine = (line: string, source: "old" | "new" | "both") => {
result.push(line);
const startPos = linePosition;
const endPos = linePosition + line.length;
if (currentRange && currentRange.source === source) {
// Extend existing range
currentRange.to = endPos;
} else {
// Create new range
if (currentRange) {
lineRanges.push(currentRange);
}
currentRange = { from: startPos, to: endPos, source };
}
// Move position to start of next line
linePosition = endPos + 1; // +1 for newline
};
// Process each chunk
for (const chunk of sortedChunks) {
// Add common lines before the chunk
while (
oldTextLine < chunk.startLeftLine &&
newTextLine < chunk.startRightLine
) {
addLine(oldLines[oldTextLine - 1], "both");
oldTextLine++;
newTextLine++;
}
// Add removed lines (from old text)
for (let i = oldTextLine; i < chunk.endLeftLine; i++) {
addLine(oldLines[i - 1], "old");
}
// Add added lines (from new text)
for (let i = newTextLine; i < chunk.endRightLine; i++) {
addLine(newLines[i - 1], "new");
}
// Update line pointers
oldTextLine = chunk.endLeftLine;
newTextLine = chunk.endRightLine;
}
// Add remaining common lines after the last chunk
while (oldTextLine <= oldLines.length && newTextLine <= newLines.length) {
if (oldTextLine > oldLines.length || newTextLine > newLines.length) break;
addLine(oldLines[oldTextLine - 1], "both");
oldTextLine++;
newTextLine++;
}
// Add the final range if there is one
if (currentRange) {
lineRanges.push(currentRange);
}
return { doc: result.join("\n"), lineRanges };
};
type RangeChangeSourceOperation = {
index: number;
newSource: "old" | "new" | "both";
};
type RangeRemoveOperation = {
index: number;
};
type RangeUpdateOperation = RangeChangeSourceOperation | RangeRemoveOperation;
const updateRangesEffect = StateEffect.define<RangeUpdateOperation>();
const createRangesStateField = (
initialRanges: ConflictRange[],
): StateField<ConflictRange[]> => {
return StateField.define<ConflictRange[]>({
create: () => initialRanges,
update: (ranges, tr) => {
const rangeEffects = tr.effects
.filter((e) => e.is(updateRangesEffect))
.reduce((acc, e) => {
const operation = e.value as RangeUpdateOperation;
acc.set(operation.index, operation);
return acc;
}, new Map<number, RangeUpdateOperation>());
if (!tr.docChanged && rangeEffects.size === 0) {
return ranges;
}
// Map all positions through the changes and apply any effect
let newRanges = ranges
.map((range, index) => {
let source = range.source;
const effect = rangeEffects.get(index) as RangeChangeSourceOperation;
if (effect) {
source = effect.newSource;
}
return {
from: tr.changes.mapPos(range.from),
to: tr.changes.mapPos(range.to, 1),
source,
};
})
.filter((range, index) => {
return range.from !== range.to && !rangeEffects.has(index);
});
// Sort ranges by start position (leftmost first)
newRanges.sort((a, b) => a.from - b.from);
// Process ranges line by line
const lineToRangeMap = new Map(); // Maps line number to controlling range
// First pass: determine which range controls each line
for (const range of newRanges) {
const startLine = tr.newDoc.lineAt(range.from).number;
const endLine = tr.newDoc.lineAt(range.to).number;
for (let line = startLine; line <= endLine; line++) {
// If this line isn't claimed yet, the leftmost range (processed first) gets it
if (!lineToRangeMap.has(line)) {
lineToRangeMap.set(line, range);
}
}
}
// Second pass: merge ranges that control consecutive lines
const mergedRanges = [];
let currentRange = null;
// Process lines in order
const allLines = Array.from(lineToRangeMap.keys()).sort((a, b) => a - b);
for (const line of allLines) {
const rangeForLine = lineToRangeMap.get(line);
if (!currentRange) {
// Start a new merged range
currentRange = {
from: tr.newDoc.line(line).from,
to: tr.newDoc.line(line).to,
source: rangeForLine.source,
};
} else if (
currentRange.source === rangeForLine.source &&
line === tr.newDoc.lineAt(currentRange.to).number + 1
) {
// Extend current range if it's the same source and consecutive line
currentRange.to = tr.newDoc.line(line).to;
} else {
// Finish current range and start a new one
mergedRanges.push(currentRange);
currentRange = {
from: tr.newDoc.line(line).from,
to: tr.newDoc.line(line).to,
source: rangeForLine.source,
};
}
}
// Add the last range
if (currentRange) {
mergedRanges.push(currentRange);
}
return mergedRanges;
},
});
};
const createDecorationsExtension = (
rangesStateField: StateField<ConflictRange[]>,
) => {
return EditorView.decorations.compute(["doc", rangesStateField], (state) => {
const ranges = state.field(rangesStateField);
const builder = new RangeSetBuilder<Decoration>();
for (const range of ranges) {
const startLine = state.doc.lineAt(range.from);
const endLine = state.doc.lineAt(range.to);
for (let i = 0; i <= endLine.number - startLine.number; i += 1) {
const line = state.doc.line(startLine.number + i);
if (range.source === "old") {
builder.add(
line.from,
line.from,
Decoration.line({ class: "cm-deletedLine" }),
);
} else if (range.source === "new") {
builder.add(
line.from,
line.from,
Decoration.line({ class: "cm-addedLine" }),
);
}
}
}
return builder.finish();
});
};
interface ConflictRange {
from: number;
to: number;
source: "old" | "new" | "both"; // where the content originated
}
interface ResolutionWidgetProps {
onAccept?: () => void;
onDiscard?: () => void;
onAcceptAbove?: () => void;
onAcceptBelow?: () => void;
onAcceptBoth?: () => void;
onDiscardBoth?: () => void;
}
class ResolutionWidget extends WidgetType {
onAccept?: () => void;
onDiscard?: () => void;
onAcceptAbove?: () => void;
onAcceptBelow?: () => void;
onAcceptBoth?: () => void;
onDiscardBoth?: () => void;
constructor(props: ResolutionWidgetProps) {
super();
({
onAccept: this.onAccept,
onDiscard: this.onDiscard,
onAcceptAbove: this.onAcceptAbove,
onAcceptBelow: this.onAcceptBelow,
onAcceptBoth: this.onAcceptBoth,
onDiscardBoth: this.onDiscardBoth,
} = props);
}
toDOM(): HTMLElement {
const div = document.createElement("div");
const root = createRoot(div);
root.render(
<UnifiedResolutionBar
onAccept={this.onAccept}
onDiscard={this.onDiscard}
onAcceptAbove={this.onAcceptAbove}
onAcceptBelow={this.onAcceptBelow}
onAcceptBoth={this.onAcceptBoth}
onDiscardBoth={this.onDiscardBoth}
/>,
);
return div;
}
}
const createBlockDecorations = (
rangesStateField: StateField<ConflictRange[]>,
getView: () => EditorView,
) => {
return EditorView.decorations.compute(
[rangesStateField],
(state: EditorState) => {
const ranges = state.field(rangesStateField);
let widgets: Range<Decoration>[] = [];
ranges.forEach((range: ConflictRange, index: number) => {
if (range.source === "both") {
return;
}
const previousRange = ranges.at(index - 1);
const nextRange = ranges.at(index + 1);
if (range.source === "old") {
const nextRangeIsNew = nextRange?.source === "new";
if (nextRangeIsNew) {
const deco = Decoration.widget({
widget: new ResolutionWidget({
onAcceptAbove: () => {
getView().dispatch({
changes: {
from: range.to,
to: nextRange.to,
insert: "",
},
effects: [
updateRangesEffect.of({
index,
newSource: "both",
}),
updateRangesEffect.of({
index: index + 1,
}),
],
});
},
onAcceptBelow: () => {
getView().dispatch({
changes: {
from: range.from,
to: nextRange?.from || range.to,
insert: "",
},
effects: [
updateRangesEffect.of({
index,
}),
updateRangesEffect.of({
index: index + 1,
newSource: "both",
}),
],
});
},
onAcceptBoth: () => {
getView().dispatch({
effects: [
updateRangesEffect.of({
index,
newSource: "both",
}),
updateRangesEffect.of({
index: index + 1,
newSource: "both",
}),
],
});
},
onDiscardBoth: () => {
getView().dispatch({
changes: {
from: Math.max(range.from - 1, 0),
to: ranges.at(index + 2)?.from || nextRange.to,
insert: "",
},
effects: [
updateRangesEffect.of({
index,
}),
updateRangesEffect.of({
index: index + 1,
}),
],
});
},
}),
side: 1,
block: true,
});
widgets.push(deco.range(range.to));
} else {
const deco = Decoration.widget({
widget: new ResolutionWidget({
onAccept: () => {
getView().dispatch({
effects: updateRangesEffect.of({
index: index,
newSource: "both",
}),
});
},
onDiscard: () => {
getView().dispatch({
changes: {
from: Math.max(range.from - 1, 0),
to: nextRange?.from || range.to,
insert: "",
},
effects: updateRangesEffect.of({
index: index,
}),
});
},
}),
side: -1,
block: true,
});
widgets.push(deco.range(range.from));
}
} else if (range.source === "new" && previousRange?.source !== "old") {
// We draw this only in case the previous range doesn't come from the old document
// since we handle that above
const deco = Decoration.widget({
widget: new ResolutionWidget({
onAccept: () => {
getView().dispatch({
effects: updateRangesEffect.of({
index: index,
newSource: "both",
}),
});
},
onDiscard: () => {
getView().dispatch({
changes: {
from: Math.max(range.from - 1, 0),
to: nextRange?.from || range.to,
insert: "",
},
effects: updateRangesEffect.of({
index: index,
}),
});
},
}),
side: -1,
block: true,
});
widgets.push(deco.range(range.from));
}
});
return Decoration.set(widgets);
},
);
};
const UnifiedDiffView: React.FC<UnifiedDiffViewProps> = ({
initialOldText,
initialNewText,
onConflictResolved,
}) => {
const editorViewRef = React.useRef<EditorView | null>(null);
const diffChunks = diff(initialOldText, initialNewText);
const { doc, lineRanges } = createUnifiedDocument(
initialOldText,
initialNewText,
diffChunks,
);
const [hasConflicts, setHasConflicts] = React.useState(diffChunks.length > 0);
const extensions = React.useMemo(() => {
const conflictRangesField = createRangesStateField(lineRanges);
return [
conflictRangesField,
createDecorationsExtension(conflictRangesField),
createBlockDecorations(
conflictRangesField,
() => editorViewRef.current!,
),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const conflictRanges = update.state.field(conflictRangesField);
const allConflictsSolved = conflictRanges.some(
(range) => range.source === "old" || range.source === "new",
);
if (!allConflictsSolved) {
setHasConflicts(allConflictsSolved);
}
}
}),
EditorView.editable.of(true),
EditorView.theme({
"&": {
backgroundColor: "var(--background-primary)",
color: "var(--text-normal)",
borderTop: "1px solid var(--background-modifier-border)",
borderBottom: "1px solid var(--background-modifier-border)",
},
".cm-content": {
padding: 0,
caretColor: "var(--caret-color)",
fontSize: "var(--font-text-size)",
fontFamily: "var(--font-text)",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
background: "var(--text-selection)",
},
"&.cm-focused": {
outline: 0,
},
"&.cm-focused .cm-cursor": {
borderLeftColor: "var(--text-normal)",
},
".cm-addedLine": {
backgroundColor: "rgba(var(--color-green-rgb), 0.1)",
},
".cm-deletedLine": {
backgroundColor: "rgba(var(--color-red-rgb), 0.1)",
},
}),
];
}, [initialOldText, initialNewText]);
return (
<div style={{ width: "100%", height: "100%", overflow: "hidden" }}>
<CodeMirror
value={doc}
height="100%"
theme="none"
basicSetup={false}
extensions={extensions}
onCreateEditor={(view: EditorView) => {
editorViewRef.current = view;
}}
/>
<div
style={{
display: "flex",
justifyContent: "center",
paddingTop: "var(--size-4-4)",
}}
>
<button
style={
hasConflicts
? {}
: {
backgroundColor: "var(--interactive-accent)",
color: "var(--text-on-accent)",
}
}
disabled={hasConflicts}
onClick={() => {
const content = editorViewRef.current!.state.doc.toString();
onConflictResolved(content);
}}
>
Resolve conflict
</button>
</div>
</div>
);
};
export default UnifiedDiffView;

View file

@ -0,0 +1,9 @@
/// Defines where the range of the current document shown
/// in the editor comes from.
/// Both from and to and indexes of chars in the document,
/// usually pointing at the start and end of the line respectively.
interface ConflictRange {
from: number;
to: number;
source: "old" | "new" | "both";
}

View file

@ -0,0 +1,263 @@
import {
EditorState,
Range,
RangeSetBuilder,
StateField,
} from "@uiw/react-codemirror";
import { Decoration, EditorView, WidgetType } from "@codemirror/view";
import { createRoot } from "react-dom/client";
import UnifiedResolutionBar from "./unified-resolution-bar";
import { UpdateRangesEffect } from "./ranges-state-field";
interface ResolutionWidgetProps {
onAccept?: () => void;
onDiscard?: () => void;
onAcceptAbove?: () => void;
onAcceptBelow?: () => void;
onAcceptBoth?: () => void;
onDiscardBoth?: () => void;
}
/// Widget that show some buttons to the user so they can resolve the conflicts.
/// This is usually drawn between two conflicting ranges in the document, if
/// the conflict comes only from a single document this will be shown on top.
///
/// If onAccept and onDiscard are set only the accept and discard buttons will be shown,
/// this is used when the conflict comes from a single document.
/// If either is not set all the other buttons will be shown.
class ResolutionWidget extends WidgetType {
onAccept?: () => void;
onDiscard?: () => void;
onAcceptAbove?: () => void;
onAcceptBelow?: () => void;
onAcceptBoth?: () => void;
onDiscardBoth?: () => void;
constructor(props: ResolutionWidgetProps) {
super();
({
onAccept: this.onAccept,
onDiscard: this.onDiscard,
onAcceptAbove: this.onAcceptAbove,
onAcceptBelow: this.onAcceptBelow,
onAcceptBoth: this.onAcceptBoth,
onDiscardBoth: this.onDiscardBoth,
} = props);
}
toDOM(): HTMLElement {
const div = document.createElement("div");
const root = createRoot(div);
root.render(
<UnifiedResolutionBar
onAccept={this.onAccept}
onDiscard={this.onDiscard}
onAcceptAbove={this.onAcceptAbove}
onAcceptBelow={this.onAcceptBelow}
onAcceptBoth={this.onAcceptBoth}
onDiscardBoth={this.onDiscardBoth}
/>,
);
return div;
}
}
/// Create a line decoration for CodeMirror editor that highlights
/// the ranges set by the ranges state field to show where the text comes from.
/// Text coming from remote will be shown as red and local as green.
export const createLineDecorations = (
rangesStateField: StateField<ConflictRange[]>,
) => {
return EditorView.decorations.compute(["doc", rangesStateField], (state) => {
const ranges = state.field(rangesStateField);
const builder = new RangeSetBuilder<Decoration>();
for (const range of ranges) {
const startLine = state.doc.lineAt(range.from);
const endLine = state.doc.lineAt(range.to);
for (let i = 0; i <= endLine.number - startLine.number; i += 1) {
const line = state.doc.line(startLine.number + i);
if (range.source === "old") {
builder.add(
line.from,
line.from,
Decoration.line({ class: "cm-deletedLine" }),
);
} else if (range.source === "new") {
builder.add(
line.from,
line.from,
Decoration.line({ class: "cm-addedLine" }),
);
}
}
}
return builder.finish();
});
};
/// Create the resolution buttons depending on the ranges state field.
/// We need the view to dispatch the transaction to update the document
/// and delete the ranges when the user click the buttons.
export const createResolutionDecorations = (
rangesStateField: StateField<ConflictRange[]>,
getView: () => EditorView,
) => {
return EditorView.decorations.compute(
[rangesStateField],
(state: EditorState) => {
const ranges = state.field(rangesStateField);
let widgets: Range<Decoration>[] = [];
ranges.forEach((range: ConflictRange, index: number) => {
if (range.source === "both") {
return;
}
const previousRange = ranges.at(index - 1);
const nextRange = ranges.at(index + 1);
if (range.source === "old") {
const nextRangeIsNew = nextRange?.source === "new";
if (nextRangeIsNew) {
const deco = Decoration.widget({
widget: new ResolutionWidget({
onAcceptAbove: () => {
getView().dispatch({
changes: {
from: range.to,
to: nextRange.to,
insert: "",
},
effects: [
UpdateRangesEffect.of({
index,
newSource: "both",
}),
UpdateRangesEffect.of({
index: index + 1,
}),
],
});
},
onAcceptBelow: () => {
getView().dispatch({
changes: {
from: range.from,
to: nextRange?.from || range.to,
insert: "",
},
effects: [
UpdateRangesEffect.of({
index,
}),
UpdateRangesEffect.of({
index: index + 1,
newSource: "both",
}),
],
});
},
onAcceptBoth: () => {
getView().dispatch({
effects: [
UpdateRangesEffect.of({
index,
newSource: "both",
}),
UpdateRangesEffect.of({
index: index + 1,
newSource: "both",
}),
],
});
},
onDiscardBoth: () => {
getView().dispatch({
changes: {
from: Math.max(range.from - 1, 0),
to: ranges.at(index + 2)?.from || nextRange.to,
insert: "",
},
effects: [
UpdateRangesEffect.of({
index,
}),
UpdateRangesEffect.of({
index: index + 1,
}),
],
});
},
}),
side: 1,
block: true,
});
widgets.push(deco.range(range.to));
} else {
const deco = Decoration.widget({
widget: new ResolutionWidget({
onAccept: () => {
getView().dispatch({
effects: UpdateRangesEffect.of({
index: index,
newSource: "both",
}),
});
},
onDiscard: () => {
getView().dispatch({
changes: {
from: Math.max(range.from - 1, 0),
to: nextRange?.from || range.to,
insert: "",
},
effects: UpdateRangesEffect.of({
index: index,
}),
});
},
}),
side: -1,
block: true,
});
widgets.push(deco.range(range.from));
}
} else if (range.source === "new" && previousRange?.source !== "old") {
// We draw this only in case the previous range doesn't come from the old document
// since we handle that above
const deco = Decoration.widget({
widget: new ResolutionWidget({
onAccept: () => {
getView().dispatch({
effects: UpdateRangesEffect.of({
index: index,
newSource: "both",
}),
});
},
onDiscard: () => {
getView().dispatch({
changes: {
from: Math.max(range.from - 1, 0),
to: nextRange?.from || range.to,
insert: "",
},
effects: UpdateRangesEffect.of({
index: index,
}),
});
},
}),
side: -1,
block: true,
});
widgets.push(deco.range(range.from));
}
});
return Decoration.set(widgets);
},
);
};

View file

@ -0,0 +1,219 @@
import * as React from "react";
import CodeMirror from "@uiw/react-codemirror";
import { EditorView } from "@codemirror/view";
import diff, { DiffChunk } from "../diff";
import { createRangesStateField } from "./ranges-state-field";
import {
createLineDecorations,
createResolutionDecorations,
} from "./decorations";
interface DiffViewProps {
initialOldText: string;
initialNewText: string;
onConflictResolved: (content: string) => void;
}
/// Create a unique document that combines text from the remote
/// and the local document depending on the diff chunks.
/// This returns a single string of the combined documents and the
/// ranges that specify whether a certain document range comes from
/// remote, local or both documents, so we can highlight them.
const createUnifiedDocument = (
oldText: string,
newText: string,
diffChunks: DiffChunk[],
): { doc: string; lineRanges: ConflictRange[] } => {
const sortedChunks = [...diffChunks].sort(
(a, b) => a.startLeftLine - b.startLeftLine,
);
const oldLines = oldText.split(/\r?\n/);
const newLines = newText.split(/\r?\n/);
let result: string[] = [];
let lineRanges: ConflictRange[] = [];
let linePosition = 0;
let currentRange: ConflictRange | null = null;
let oldTextLine = 1;
let newTextLine = 1;
const addLine = (line: string, source: "old" | "new" | "both") => {
result.push(line);
const startPos = linePosition;
const endPos = linePosition + line.length;
if (currentRange && currentRange.source === source) {
// Extend existing range
currentRange.to = endPos;
} else {
// Create new range
if (currentRange) {
lineRanges.push(currentRange);
}
currentRange = { from: startPos, to: endPos, source };
}
// Move position to start of next line
linePosition = endPos + 1; // +1 for newline
};
// Process each chunk
for (const chunk of sortedChunks) {
// Add common lines before the chunk
while (
oldTextLine < chunk.startLeftLine &&
newTextLine < chunk.startRightLine
) {
addLine(oldLines[oldTextLine - 1], "both");
oldTextLine++;
newTextLine++;
}
// Add removed lines (from old text)
for (let i = oldTextLine; i < chunk.endLeftLine; i++) {
addLine(oldLines[i - 1], "old");
}
// Add added lines (from new text)
for (let i = newTextLine; i < chunk.endRightLine; i++) {
addLine(newLines[i - 1], "new");
}
// Update line pointers
oldTextLine = chunk.endLeftLine;
newTextLine = chunk.endRightLine;
}
// Add remaining common lines after the last chunk
while (oldTextLine <= oldLines.length && newTextLine <= newLines.length) {
if (oldTextLine > oldLines.length || newTextLine > newLines.length) break;
addLine(oldLines[oldTextLine - 1], "both");
oldTextLine++;
newTextLine++;
}
// Add the final range if there is one
if (currentRange) {
lineRanges.push(currentRange);
}
return { doc: result.join("\n"), lineRanges };
};
const DiffView: React.FC<DiffViewProps> = ({
initialOldText,
initialNewText,
onConflictResolved,
}) => {
const editorViewRef = React.useRef<EditorView | null>(null);
const diffChunks = diff(initialOldText, initialNewText);
const { doc, lineRanges } = createUnifiedDocument(
initialOldText,
initialNewText,
diffChunks,
);
const [hasConflicts, setHasConflicts] = React.useState(diffChunks.length > 0);
const extensions = React.useMemo(() => {
const conflictRangesField = createRangesStateField(lineRanges);
return [
conflictRangesField,
createLineDecorations(conflictRangesField),
createResolutionDecorations(
conflictRangesField,
() => editorViewRef.current!,
),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const conflictRanges = update.state.field(conflictRangesField);
const allConflictsSolved = conflictRanges.some(
(range) => range.source === "old" || range.source === "new",
);
if (!allConflictsSolved) {
setHasConflicts(allConflictsSolved);
}
}
}),
EditorView.editable.of(true),
EditorView.theme({
"&": {
backgroundColor: "var(--background-primary)",
color: "var(--text-normal)",
borderTop: "1px solid var(--background-modifier-border)",
borderBottom: "1px solid var(--background-modifier-border)",
},
".cm-content": {
padding: 0,
caretColor: "var(--caret-color)",
fontSize: "var(--font-text-size)",
fontFamily: "var(--font-text)",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
background: "var(--text-selection)",
},
"&.cm-focused": {
outline: 0,
},
"&.cm-focused .cm-cursor": {
borderLeftColor: "var(--text-normal)",
},
".cm-addedLine": {
backgroundColor: "rgba(var(--color-green-rgb), 0.1)",
},
".cm-deletedLine": {
backgroundColor: "rgba(var(--color-red-rgb), 0.1)",
},
}),
];
}, [initialOldText, initialNewText]);
return (
<div style={{ width: "100%", height: "100%", overflow: "hidden" }}>
<CodeMirror
value={doc}
height="100%"
theme="none"
basicSetup={false}
extensions={extensions}
onCreateEditor={(view: EditorView) => {
editorViewRef.current = view;
}}
/>
<div
style={{
display: "flex",
justifyContent: "center",
paddingTop: "var(--size-4-4)",
}}
>
<button
style={
hasConflicts
? {}
: {
backgroundColor: "var(--interactive-accent)",
color: "var(--text-on-accent)",
}
}
disabled={hasConflicts}
onClick={() => {
const content = editorViewRef.current!.state.doc.toString();
onConflictResolved(content);
}}
>
Resolve conflict
</button>
</div>
</div>
);
};
export default DiffView;

View file

@ -0,0 +1,119 @@
import { StateEffect, StateField } from "@uiw/react-codemirror";
/// Possible transaction effects we can apply to the ranges state field
/// when it gets updated.
export type RangeUpdateOperation =
| RangeChangeSourceOperation
| RangeRemoveOperation;
export const UpdateRangesEffect = StateEffect.define<RangeUpdateOperation>();
export type RangeChangeSourceOperation = {
index: number;
newSource: "old" | "new" | "both";
};
export type RangeRemoveOperation = {
index: number;
};
/// Create the ranges state field that we can access and use to highlight and
/// resolve conflicts.
export const createRangesStateField = (
initialRanges: ConflictRange[],
): StateField<ConflictRange[]> => {
return StateField.define<ConflictRange[]>({
create: () => initialRanges,
update: (ranges, tr) => {
const rangeEffects = tr.effects
.filter((e) => e.is(UpdateRangesEffect))
.reduce((acc, e) => {
const operation = e.value as RangeUpdateOperation;
acc.set(operation.index, operation);
return acc;
}, new Map<number, RangeUpdateOperation>());
if (!tr.docChanged && rangeEffects.size === 0) {
return ranges;
}
// Map all positions through the changes and apply any effect
let newRanges = ranges
.map((range, index) => {
let source = range.source;
const effect = rangeEffects.get(index) as RangeChangeSourceOperation;
if (effect) {
source = effect.newSource;
}
return {
from: tr.changes.mapPos(range.from),
to: tr.changes.mapPos(range.to, 1),
source,
};
})
.filter((range, index) => {
return range.from !== range.to && !rangeEffects.has(index);
});
// Sort ranges by start position (leftmost first)
newRanges.sort((a, b) => a.from - b.from);
// Process ranges line by line
const lineToRangeMap = new Map(); // Maps line number to controlling range
// First pass: determine which range controls each line
for (const range of newRanges) {
const startLine = tr.newDoc.lineAt(range.from).number;
const endLine = tr.newDoc.lineAt(range.to).number;
for (let line = startLine; line <= endLine; line++) {
// If this line isn't claimed yet, the leftmost range (processed first) gets it
if (!lineToRangeMap.has(line)) {
lineToRangeMap.set(line, range);
}
}
}
// Second pass: merge ranges that control consecutive lines
const mergedRanges = [];
let currentRange = null;
// Process lines in order
const allLines = Array.from(lineToRangeMap.keys()).sort((a, b) => a - b);
for (const line of allLines) {
const rangeForLine = lineToRangeMap.get(line);
if (!currentRange) {
// Start a new merged range
currentRange = {
from: tr.newDoc.line(line).from,
to: tr.newDoc.line(line).to,
source: rangeForLine.source,
};
} else if (
currentRange.source === rangeForLine.source &&
line === tr.newDoc.lineAt(currentRange.to).number + 1
) {
// Extend current range if it's the same source and consecutive line
currentRange.to = tr.newDoc.line(line).to;
} else {
// Finish current range and start a new one
mergedRanges.push(currentRange);
currentRange = {
from: tr.newDoc.line(line).from,
to: tr.newDoc.line(line).to,
source: rangeForLine.source,
};
}
}
// Add the last range
if (currentRange) {
mergedRanges.push(currentRange);
}
return mergedRanges;
},
});
};

View file

@ -9,6 +9,7 @@ interface UnifiedResolutionBarProps {
onDiscardBoth?: () => void;
}
/// Component that shows buttons the user can click to resolve conflicts
const UnifiedResolutionBar: React.FC<UnifiedResolutionBarProps> = ({
onAccept,
onDiscard,

View file

@ -1,8 +1,8 @@
import * as React from "react";
import { ConflictFile, ConflictResolution } from "src/sync-manager";
import UnifiedDiffView from "./unified-diff-view";
import DiffView from "./diff-view";
const MobileApp = ({
const UnifiedView = ({
initialFiles,
onResolveAllConflicts,
}: {
@ -54,7 +54,7 @@ const MobileApp = ({
>
{file.filePath}
</div>
<UnifiedDiffView
<DiffView
initialOldText={file.remoteContent || ""}
initialNewText={file.localContent || ""}
onConflictResolved={(content: string) => {
@ -105,4 +105,4 @@ const MobileApp = ({
);
};
export default MobileApp;
export default UnifiedView;

View file

@ -2,8 +2,8 @@ import { IconName, ItemView, Menu, WorkspaceLeaf } from "obsidian";
import { Root, createRoot } from "react-dom/client";
import GitHubSyncPlugin from "src/main";
import { ConflictFile, ConflictResolution } from "src/sync-manager";
import DesktopApp from "./desktop-app";
import MobileApp from "./mobile-app";
import SplitView from "./split-view/split-view";
import UnifiedView from "./unified-view/unified-view";
export const CONFLICTS_RESOLUTION_VIEW_TYPE = "conflicts-resolution-view";
@ -56,11 +56,11 @@ export class ConflictsResolutionView extends ItemView {
this.root.render(
<>
<MobileApp
<UnifiedView
initialFiles={conflicts}
onResolveAllConflicts={this.resolveAllConflicts.bind(this)}
/>
<DesktopApp
<SplitView
initialFiles={conflicts}
onResolveAllConflicts={this.resolveAllConflicts.bind(this)}
/>