From 937e576bcc0b44d49d836d9e33def254eb73f1c7 Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Sun, 9 Mar 2025 16:21:13 +0100 Subject: [PATCH] Restructure conflict view files and add some documentation --- src/views/conflicts-resolution/diff.ts | 4 + .../{ => split-view}/action-button.tsx | 0 .../{ => split-view}/actions-gutter.tsx | 2 +- .../{ => split-view}/diff-highlight-plugin.ts | 2 +- .../diff-view.tsx} | 10 +- .../{ => split-view}/editor-pane.tsx | 0 .../{ => split-view}/files-tab-bar.tsx | 0 .../split-view.tsx} | 8 +- .../unified-diff-view.tsx | 575 ------------------ .../unified-view/conflict-range.ts | 9 + .../unified-view/decorations.tsx | 263 ++++++++ .../unified-view/diff-view.tsx | 219 +++++++ .../unified-view/ranges-state-field.ts | 119 ++++ .../unified-resolution-bar.tsx | 1 + .../unified-view.tsx} | 8 +- src/views/conflicts-resolution/view.tsx | 8 +- 16 files changed, 632 insertions(+), 596 deletions(-) rename src/views/conflicts-resolution/{ => split-view}/action-button.tsx (100%) rename src/views/conflicts-resolution/{ => split-view}/actions-gutter.tsx (99%) rename src/views/conflicts-resolution/{ => split-view}/diff-highlight-plugin.ts (98%) rename src/views/conflicts-resolution/{split-diff-view.tsx => split-view/diff-view.tsx} (94%) rename src/views/conflicts-resolution/{ => split-view}/editor-pane.tsx (100%) rename src/views/conflicts-resolution/{ => split-view}/files-tab-bar.tsx (100%) rename src/views/conflicts-resolution/{desktop-app.tsx => split-view/split-view.tsx} (96%) delete mode 100644 src/views/conflicts-resolution/unified-diff-view.tsx create mode 100644 src/views/conflicts-resolution/unified-view/conflict-range.ts create mode 100644 src/views/conflicts-resolution/unified-view/decorations.tsx create mode 100644 src/views/conflicts-resolution/unified-view/diff-view.tsx create mode 100644 src/views/conflicts-resolution/unified-view/ranges-state-field.ts rename src/views/conflicts-resolution/{ => unified-view}/unified-resolution-bar.tsx (96%) rename src/views/conflicts-resolution/{mobile-app.tsx => unified-view/unified-view.tsx} (95%) diff --git a/src/views/conflicts-resolution/diff.ts b/src/views/conflicts-resolution/diff.ts index bbf5287..69dd66e 100644 --- a/src/views/conflicts-resolution/diff.ts +++ b/src/views/conflicts-resolution/diff.ts @@ -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; diff --git a/src/views/conflicts-resolution/action-button.tsx b/src/views/conflicts-resolution/split-view/action-button.tsx similarity index 100% rename from src/views/conflicts-resolution/action-button.tsx rename to src/views/conflicts-resolution/split-view/action-button.tsx diff --git a/src/views/conflicts-resolution/actions-gutter.tsx b/src/views/conflicts-resolution/split-view/actions-gutter.tsx similarity index 99% rename from src/views/conflicts-resolution/actions-gutter.tsx rename to src/views/conflicts-resolution/split-view/actions-gutter.tsx index 2df92fa..b95ad6f 100644 --- a/src/views/conflicts-resolution/actions-gutter.tsx +++ b/src/views/conflicts-resolution/split-view/actions-gutter.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { DiffChunk } from "./diff"; +import { DiffChunk } from "../diff"; import { ButtonCross, ButtonLeftArrow, diff --git a/src/views/conflicts-resolution/diff-highlight-plugin.ts b/src/views/conflicts-resolution/split-view/diff-highlight-plugin.ts similarity index 98% rename from src/views/conflicts-resolution/diff-highlight-plugin.ts rename to src/views/conflicts-resolution/split-view/diff-highlight-plugin.ts index 9758f9e..c58b581 100644 --- a/src/views/conflicts-resolution/diff-highlight-plugin.ts +++ b/src/views/conflicts-resolution/split-view/diff-highlight-plugin.ts @@ -5,7 +5,7 @@ import { EditorView, ViewUpdate, } from "@codemirror/view"; -import { DiffChunk } from "./diff"; +import { DiffChunk } from "../diff"; export interface DiffHighlightPluginSpec { diff: DiffChunk[]; diff --git a/src/views/conflicts-resolution/split-diff-view.tsx b/src/views/conflicts-resolution/split-view/diff-view.tsx similarity index 94% rename from src/views/conflicts-resolution/split-diff-view.tsx rename to src/views/conflicts-resolution/split-view/diff-view.tsx index bbf9716..e59f7a3 100644 --- a/src/views/conflicts-resolution/split-diff-view.tsx +++ b/src/views/conflicts-resolution/split-view/diff-view.tsx @@ -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 = ({ +const DiffView: React.FC = ({ oldText, newText, onOldTextChange, @@ -176,4 +172,4 @@ const SplitDiffView: React.FC = ({ ); }; -export default SplitDiffView; +export default DiffView; diff --git a/src/views/conflicts-resolution/editor-pane.tsx b/src/views/conflicts-resolution/split-view/editor-pane.tsx similarity index 100% rename from src/views/conflicts-resolution/editor-pane.tsx rename to src/views/conflicts-resolution/split-view/editor-pane.tsx diff --git a/src/views/conflicts-resolution/files-tab-bar.tsx b/src/views/conflicts-resolution/split-view/files-tab-bar.tsx similarity index 100% rename from src/views/conflicts-resolution/files-tab-bar.tsx rename to src/views/conflicts-resolution/split-view/files-tab-bar.tsx diff --git a/src/views/conflicts-resolution/desktop-app.tsx b/src/views/conflicts-resolution/split-view/split-view.tsx similarity index 96% rename from src/views/conflicts-resolution/desktop-app.tsx rename to src/views/conflicts-resolution/split-view/split-view.tsx index 98bf313..2bad5a4 100644 --- a/src/views/conflicts-resolution/desktop-app.tsx +++ b/src/views/conflicts-resolution/split-view/split-view.tsx @@ -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} /> - { @@ -110,4 +110,4 @@ const DesktopApp = ({ ); }; -export default DesktopApp; +export default SplitView; diff --git a/src/views/conflicts-resolution/unified-diff-view.tsx b/src/views/conflicts-resolution/unified-diff-view.tsx deleted file mode 100644 index 639f8ff..0000000 --- a/src/views/conflicts-resolution/unified-diff-view.tsx +++ /dev/null @@ -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(); - -const createRangesStateField = ( - initialRanges: ConflictRange[], -): StateField => { - return StateField.define({ - 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()); - - 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, -) => { - return EditorView.decorations.compute(["doc", rangesStateField], (state) => { - const ranges = state.field(rangesStateField); - const builder = new RangeSetBuilder(); - - 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( - , - ); - return div; - } -} - -const createBlockDecorations = ( - rangesStateField: StateField, - getView: () => EditorView, -) => { - return EditorView.decorations.compute( - [rangesStateField], - (state: EditorState) => { - const ranges = state.field(rangesStateField); - let widgets: Range[] = []; - - 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 = ({ - initialOldText, - initialNewText, - onConflictResolved, -}) => { - const editorViewRef = React.useRef(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 ( -
- { - editorViewRef.current = view; - }} - /> -
- -
-
- ); -}; - -export default UnifiedDiffView; diff --git a/src/views/conflicts-resolution/unified-view/conflict-range.ts b/src/views/conflicts-resolution/unified-view/conflict-range.ts new file mode 100644 index 0000000..9a044ec --- /dev/null +++ b/src/views/conflicts-resolution/unified-view/conflict-range.ts @@ -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"; +} diff --git a/src/views/conflicts-resolution/unified-view/decorations.tsx b/src/views/conflicts-resolution/unified-view/decorations.tsx new file mode 100644 index 0000000..150dcf4 --- /dev/null +++ b/src/views/conflicts-resolution/unified-view/decorations.tsx @@ -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( + , + ); + 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, +) => { + return EditorView.decorations.compute(["doc", rangesStateField], (state) => { + const ranges = state.field(rangesStateField); + const builder = new RangeSetBuilder(); + + 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, + getView: () => EditorView, +) => { + return EditorView.decorations.compute( + [rangesStateField], + (state: EditorState) => { + const ranges = state.field(rangesStateField); + let widgets: Range[] = []; + + 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); + }, + ); +}; diff --git a/src/views/conflicts-resolution/unified-view/diff-view.tsx b/src/views/conflicts-resolution/unified-view/diff-view.tsx new file mode 100644 index 0000000..1b4fd3d --- /dev/null +++ b/src/views/conflicts-resolution/unified-view/diff-view.tsx @@ -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 = ({ + initialOldText, + initialNewText, + onConflictResolved, +}) => { + const editorViewRef = React.useRef(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 ( +
+ { + editorViewRef.current = view; + }} + /> +
+ +
+
+ ); +}; + +export default DiffView; diff --git a/src/views/conflicts-resolution/unified-view/ranges-state-field.ts b/src/views/conflicts-resolution/unified-view/ranges-state-field.ts new file mode 100644 index 0000000..742035e --- /dev/null +++ b/src/views/conflicts-resolution/unified-view/ranges-state-field.ts @@ -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(); + +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 => { + return StateField.define({ + 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()); + + 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; + }, + }); +}; diff --git a/src/views/conflicts-resolution/unified-resolution-bar.tsx b/src/views/conflicts-resolution/unified-view/unified-resolution-bar.tsx similarity index 96% rename from src/views/conflicts-resolution/unified-resolution-bar.tsx rename to src/views/conflicts-resolution/unified-view/unified-resolution-bar.tsx index 5f45056..7b2f182 100644 --- a/src/views/conflicts-resolution/unified-resolution-bar.tsx +++ b/src/views/conflicts-resolution/unified-view/unified-resolution-bar.tsx @@ -9,6 +9,7 @@ interface UnifiedResolutionBarProps { onDiscardBoth?: () => void; } +/// Component that shows buttons the user can click to resolve conflicts const UnifiedResolutionBar: React.FC = ({ onAccept, onDiscard, diff --git a/src/views/conflicts-resolution/mobile-app.tsx b/src/views/conflicts-resolution/unified-view/unified-view.tsx similarity index 95% rename from src/views/conflicts-resolution/mobile-app.tsx rename to src/views/conflicts-resolution/unified-view/unified-view.tsx index f5978d2..a698476 100644 --- a/src/views/conflicts-resolution/mobile-app.tsx +++ b/src/views/conflicts-resolution/unified-view/unified-view.tsx @@ -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} - { @@ -105,4 +105,4 @@ const MobileApp = ({ ); }; -export default MobileApp; +export default UnifiedView; diff --git a/src/views/conflicts-resolution/view.tsx b/src/views/conflicts-resolution/view.tsx index 8c5af30..1a09515 100644 --- a/src/views/conflicts-resolution/view.tsx +++ b/src/views/conflicts-resolution/view.tsx @@ -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( <> - -