mirror of
https://github.com/silvanocerza/github-gitless-sync.git
synced 2026-07-22 12:10:28 +00:00
Restructure conflict view files and add some documentation
This commit is contained in:
parent
8dfa1fe592
commit
937e576bcc
16 changed files with 632 additions and 596 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import * as React from "react";
|
||||
import { DiffChunk } from "./diff";
|
||||
import { DiffChunk } from "../diff";
|
||||
import {
|
||||
ButtonCross,
|
||||
ButtonLeftArrow,
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
EditorView,
|
||||
ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import { DiffChunk } from "./diff";
|
||||
import { DiffChunk } from "../diff";
|
||||
|
||||
export interface DiffHighlightPluginSpec {
|
||||
diff: DiffChunk[];
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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";
|
||||
}
|
||||
263
src/views/conflicts-resolution/unified-view/decorations.tsx
Normal file
263
src/views/conflicts-resolution/unified-view/decorations.tsx
Normal 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);
|
||||
},
|
||||
);
|
||||
};
|
||||
219
src/views/conflicts-resolution/unified-view/diff-view.tsx
Normal file
219
src/views/conflicts-resolution/unified-view/diff-view.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue