diff --git a/src/views/conflicts-resolution/mobile-app.tsx b/src/views/conflicts-resolution/mobile-app.tsx new file mode 100644 index 0000000..d5e68b9 --- /dev/null +++ b/src/views/conflicts-resolution/mobile-app.tsx @@ -0,0 +1,94 @@ +import * as React from "react"; +import { ConflictFile, ConflictResolution } from "src/sync-manager"; +import UnifiedDiffView from "./unified-diff-view"; + +const MobileApp = ({ + initialFiles, + onResolveAllConflicts, +}: { + initialFiles: ConflictFile[]; + onResolveAllConflicts: (resolutions: ConflictResolution[]) => void; +}) => { + const [files, setFiles] = React.useState(initialFiles); + const [resolvedConflicts, setResolvedConflicts] = React.useState< + ConflictResolution[] + >([]); + + const onConflictResolved = (fileIndex: number) => { + // Remove the file from the conflicts to resolve + const remainingFiles = files.filter((_, index) => index !== fileIndex); + setFiles(remainingFiles); + // Keep track of the resolved conflicts + const newResolvedConflicts = [ + ...resolvedConflicts, + { + filePath: files[fileIndex].filePath, + content: files[fileIndex].localContent, + }, + ]; + setResolvedConflicts(newResolvedConflicts); + if (remainingFiles.length === 0) { + // We solved all conflicts, we can resume syncing + onResolveAllConflicts(newResolvedConflicts); + } + }; + + const renderConflict = (file: ConflictFile, index: number) => { + return ( +
+ onConflictResolved(index)} + /> +
+
+ ); + }; + + return ( + +
+ {files.length === 0 ? ( +
+
+ No conflicts to resolve +
+
+ That's good, keep going +
+
+ ) : ( + files.map(renderConflict) + )} +
+
+ ); +}; + +export default MobileApp; diff --git a/src/views/conflicts-resolution/unified-diff-view.tsx b/src/views/conflicts-resolution/unified-diff-view.tsx new file mode 100644 index 0000000..ab14b32 --- /dev/null +++ b/src/views/conflicts-resolution/unified-diff-view.tsx @@ -0,0 +1,500 @@ +import * as React from "react"; +import CodeMirror, { + EditorState, + RangeSetBuilder, + StateEffect, + StateField, +} from "@uiw/react-codemirror"; +import { unifiedMergeView } from "@codemirror/merge"; +import { + Decoration, + DecorationSet, + EditorView, + GutterMarker, + keymap, + ViewPlugin, + ViewUpdate, + WidgetType, +} from "@codemirror/view"; +import { ChangeSet, Prec, RangeSet, Text } from "@codemirror/state"; +import { markdown } from "@codemirror/lang-markdown"; +import diff, { DiffChunk } from "./diff"; + +const styles = document.createElement("style"); +styles.innerHTML = ` + .cm-changedLine { + background-color: rgba(var(--color-yellow-rgb), 0.1); + } + .cm-addedLine { + background-color: rgba(var(--color-green-rgb), 0.1); + } + .cm-deletedLine { + background-color: rgba(var(--color-red-rgb), 0.1); + } +`; +document.head.appendChild(styles); + +interface UnifiedDiffViewProps { + initialOldText: string; + initialNewText: string; + onConflictResolved: () => void; +} + +const decorateChunks = ViewPlugin.fromClass( + class { + deco: DecorationSet; + gutter: RangeSet | null; + + constructor(view: EditorView) { + ({ deco: this.deco, gutter: this.gutter } = getChunkDeco(view)); + } + + update(update: ViewUpdate) { + if ( + update.docChanged || + update.viewportChanged || + chunksChanged(update.startState, update.state) + ) { + ({ deco: this.deco, gutter: this.gutter } = getChunkDeco(update.view)); + } + } + }, + { decorations: (d) => d.deco }, +); + +function chunksChanged(s1: EditorState, s2: EditorState) { + return s1.field(ChunkField, false) != s2.field(ChunkField, false); +} + +const setChunks = StateEffect.define(); + +const ChunkField = StateField.define({ + create(state) { + return null as any; + }, + update(current, tr) { + tr.effects.forEach((effect) => { + if (effect.is(setChunks)) { + current = effect.value; + } + }); + return current; + }, +}); + +const getChunkDeco = (view: EditorView) => { + const chunks = view.state.field(ChunkField); + const builder = new RangeSetBuilder(); + const gutterBuilder = new RangeSetBuilder(); + let { from, to } = view.viewport; + + const originalDocState = view.state.field(originalDoc); + + // Add boundary checks to prevent errors when accessing lines + const fromPos = Math.max(0, Math.min(from, originalDocState.length)); + const toPos = Math.max(0, Math.min(to, originalDocState.length)); + + const fromLine = fromPos > 0 ? originalDocState.lineAt(fromPos).number : 1; + const toLine = toPos > 0 ? originalDocState.lineAt(toPos).number : 1; + + // const fromLine = originalDocState.lineAt(from).number; + // const toLine = originalDocState.lineAt(to).number; + // from = originalDocState.line(chunk.startLeftLine).from; + // to = originalDocState.line(chunk.endLeftLine - 1).to; + + // const fromLine = view.state.doc.lineAt(from).number; + // const toLine = view.state.doc.lineAt(to).number; + chunks.forEach((chunk: DiffChunk) => { + if (chunk.startLeftLine >= toLine) { + return; + } + + if (chunk.endLeftLine > fromLine) { + buildChunkDeco(chunk, view.state.doc, builder, gutterBuilder); + } + }); + return { deco: builder.finish(), gutter: gutterBuilder.finish() }; +}; + +const changedLine = Decoration.line({ class: "cm-addedLine" }); +const deleted = Decoration.mark({ tagName: "del", class: "cm-deletedLine" }); +const changedLineGutterMarker = new (class extends GutterMarker { + elementClass = "cm-changedLineGutter"; +})(); + +const buildChunkDeco = ( + chunk: DiffChunk, + doc: Text, + builder: RangeSetBuilder, + gutterBuilder: RangeSetBuilder, +) => { + // Using the char indexes makes it easier to handle decorations + // in the following parts + const from = doc.line(chunk.startRightLine).from; + const to = doc.line(chunk.endRightLine - 1).to; + + if (from != to) { + builder.add(from, from, changedLine); + // builder.add(from, to, deleted); + gutterBuilder.add(from, from, changedLineGutterMarker); + + for ( + let iter = doc.iterRange(from, to - 1), pos = from; + !iter.next().done; + + ) { + if (iter.lineBreak) { + pos++; + builder.add(pos, pos, changedLine); + if (gutterBuilder) { + gutterBuilder.add(pos, pos, changedLineGutterMarker); + } + continue; + } + pos = pos + iter.value.length; + } + } +}; + +/// The state effect used to signal changes in the original doc in a +/// unified merge view. +const updateOriginalDoc = StateEffect.define<{ + doc: Text; + changes: ChangeSet; +}>(); + +/// Create an effect that, when added to a transaction on a unified +/// merge view, will update the original document that's being compared against. +function originalDocChangeEffect( + state: EditorState, + changes: ChangeSet, +): StateEffect<{ doc: Text; changes: ChangeSet }> { + return updateOriginalDoc.of({ + doc: changes.apply(getOriginalDoc(state)), + changes, + }); +} + +/// Get the original document from a unified merge editor's state. +function getOriginalDoc(state: EditorState): Text { + return state.field(originalDoc); +} + +const originalDoc = StateField.define({ + create: () => Text.empty, + update(doc, tr) { + for (let e of tr.effects) if (e.is(updateOriginalDoc)) doc = e.value.doc; + return doc; + }, +}); + +const DeletionWidgets: WeakMap = new WeakMap(); + +class DeletionWidget extends WidgetType { + dom: HTMLElement | null = null; + constructor(readonly buildDOM: (view: EditorView) => HTMLElement) { + super(); + } + eq(other: DeletionWidget) { + return this.dom == other.dom; + } + toDOM(view: EditorView) { + return this.dom || (this.dom = this.buildDOM(view)); + } +} + +const deletionWidget = (state: EditorState, chunk: DiffChunk): Decoration => { + const known = DeletionWidgets.get(chunk); + if (known) { + return known; + } + + // const buildDom = (view: EditorView): HTMLElement => { + // // We're using document positions again, makes it easier to write the logic + // const originalDocState = view.state.field(originalDoc); + // let from = 0; + // let to = 0; + // try { + // from = originalDocState.line(chunk.startLeftLine).from; + // to = originalDocState.line(chunk.endLeftLine - 1).to; + // } catch (e) { + // console.log("Original document (left)", originalDocState.text); + // console.log("Modified document (right)", view.state.doc.text); + // console.log( + // `Chunk left, start: ${chunk.startLeftLine}, end ${chunk.endLeftLine}`, + // ); + // console.log( + // `Chunk right, start: ${chunk.startRightLine}, end ${chunk.endRightLine}`, + // ); + // throw e; + // } + // const text = originalDocState.sliceString(from, to); + + // const dom = document.createElement("div"); + // dom.className = "cm-deletedChunk"; + // // TODO: Add buttons here + // if (from >= to) { + // return dom; + // } + // const makeLine = () => { + // let div = dom.appendChild(document.createElement("div")); + // div.className = "cm-deletedLine"; + // return div.appendChild(document.createElement("del")); + // }; + // let line: HTMLElement = makeLine(); + + // const add = (from: number, to: number, cls: string) => { + // for (let at = from; at < to; ) { + // // Handle newline char + // if (text.charAt(at) == "\n") { + // if (!line.firstChild) { + // line.appendChild(document.createElement("br")); + // } + // line = makeLine(); + // at++; + // continue; + // } + // let nextStop = to; + // const newline = text.indexOf("\n", at); + // if (newline > -1 && newline < to) { + // nextStop = newline; + // } + // if (nextStop > at) { + // const textNode = document.createTextNode(text.slice(at, nextStop)); + // if (cls) { + // const span = line.appendChild(document.createElement("span")); + // span.className = cls; + // span.appendChild(textNode); + // } else { + // line.appendChild(textNode); + // } + // at = nextStop; + // } + // } + // }; + // add(0, text.length, ""); + // if (!line.firstChild) { + // line.appendChild(document.createElement("br")); + // } + // return dom; + // }; + // + const buildDom = (view: EditorView): HTMLElement => { + const originalDocState = view.state.field(originalDoc); + const dom = document.createElement("div"); + dom.className = "cm-deletedLine cm-line"; + + // Validate chunk boundaries before accessing the document + if (chunk.endLeftLine > originalDocState.lines) { + console.warn("Invalid chunk bounds:", chunk); + return dom; // Return empty container if invalid + } + + // Safely get positions + let from = originalDocState.line(chunk.startLeftLine).from; + let to = originalDocState.line(chunk.endLeftLine - 1).to; + + // Exit early if nothing to display + if (from >= to) { + return dom; + } + + const text = originalDocState.sliceString(from, to); + + // Create DOM elements for deleted content + const makeLine = () => { + let div = dom.appendChild(document.createElement("div")); + // div.className = "cm-deletedLine"; + return div.appendChild(document.createElement("del")); + }; + + let line = makeLine(); + + const add = (from: number, to: number, cls: string) => { + for (let at = from; at < to; ) { + if (text.charAt(at) == "\n") { + if (!line.firstChild) { + line.appendChild(document.createElement("br")); + } + line = makeLine(); + at++; + continue; + } + + let nextStop = to; + const newline = text.indexOf("\n", at); + if (newline > -1 && newline < to) { + nextStop = newline; + } + + if (nextStop > at) { + const textNode = document.createTextNode(text.slice(at, nextStop)); + if (cls) { + const span = line.appendChild(document.createElement("span")); + span.className = cls; + span.appendChild(textNode); + } else { + line.appendChild(textNode); + } + at = nextStop; + } + } + }; + + add(0, text.length, ""); + if (!line.firstChild) { + line.appendChild(document.createElement("br")); + } + + return dom; + }; + + const deco = Decoration.widget({ + block: true, + side: -1, + widget: new DeletionWidget(buildDom), + }); + DeletionWidgets.set(chunk, deco); + return deco; +}; + +const buildDeletedChunks = (state: EditorState) => { + let builder = new RangeSetBuilder(); + state.field(ChunkField).forEach((chunk: DiffChunk) => { + const from = state.doc.line( + Math.min(state.doc.lines, chunk.startRightLine), + ).from; + builder.add(from, from, deletionWidget(state, chunk)); + }); + + return builder.finish(); +}; + +const deletedChunks = StateField.define({ + create: (state) => buildDeletedChunks(state), + update(deco, tr) { + return tr.state.field(ChunkField, false) != + tr.startState.field(ChunkField, false) + ? buildDeletedChunks(tr.state) + : deco; + }, + provide: (f) => EditorView.decorations.from(f), +}); + +const addUnderline = StateEffect.define<{ from: number; to: number }>({ + map: ({ from, to }, change) => ({ + from: change.mapPos(from), + to: change.mapPos(to), + }), +}); + +const underlineField = StateField.define({ + create() { + return Decoration.none; + }, + update(underlines, tr) { + underlines = underlines.map(tr.changes); + for (let e of tr.effects) + if (e.is(addUnderline)) { + underlines = underlines.update({ + add: [underlineMark.range(e.value.from, e.value.to)], + }); + } + return underlines; + }, + provide: (f) => EditorView.decorations.from(f), +}); + +const underlineTheme = EditorView.theme({ + ".cm-underline": { textDecoration: "underline 3px red" }, +}); + +const underlineMark = Decoration.mark({ class: "cm-underline" }); +const underlineKeymap = keymap.of([ + { + key: "Mod-h", + preventDefault: true, + run: underlineSelection, + }, +]); + +function underlineSelection(view: EditorView) { + let effects: StateEffect[] = view.state.selection.ranges + .filter((r) => !r.empty) + .map(({ from, to }) => addUnderline.of({ from, to })); + if (!effects.length) return false; + + if (!view.state.field(underlineField, false)) + effects.push(StateEffect.appendConfig.of([underlineField, underlineTheme])); + view.dispatch({ effects }); + return true; +} + +const UnifiedDiffView: React.FC = ({ + initialOldText, + initialNewText, + onConflictResolved, +}) => { + const [oldText, setOldText] = React.useState(initialOldText); + const [newText, setNewText] = React.useState(initialNewText); + // const [content, setContent] = React.useState(initialNewText); + // const diffChunks = diff(oldText, newText); + + const extensions = [ + Prec.low(decorateChunks), + deletedChunks, + originalDoc.init(() => Text.of(oldText.split(/\r?\n/))), + ChunkField.init(() => { + const foo = diff(oldText, newText); + console.log(oldText, newText, foo); + return foo; + }), + EditorView.editorAttributes.of({ class: "cm-merge-b" }), + // unifiedMergeView({ original: oldText }), + EditorView.editable.of(true), + EditorView.theme({ + "&": { + backgroundColor: "var(--background-primary)", + color: "var(--text-normal)", + }, + ".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)", + }, + }), + markdown(), + ]; + + return ( +
+ { + // setContent(value); + // onOldTextChange(value); + // }} + // onCreateEditor={(view: EditorView) => { + // editorRef.current = view; + // }} + /> +
+ ); +}; + +export default UnifiedDiffView; diff --git a/src/views/conflicts-resolution/view.tsx b/src/views/conflicts-resolution/view.tsx index c425f79..8c5af30 100644 --- a/src/views/conflicts-resolution/view.tsx +++ b/src/views/conflicts-resolution/view.tsx @@ -3,6 +3,7 @@ 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"; export const CONFLICTS_RESOLUTION_VIEW_TYPE = "conflicts-resolution-view"; @@ -54,10 +55,16 @@ export class ConflictsResolutionView extends ItemView { } this.root.render( - , + <> + + + , ); }