From 5fd4c945663264a52532927ef02c3d7e94636b72 Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Wed, 19 Feb 2025 11:49:09 +0100 Subject: [PATCH] Refactor to be more concise --- .../conflicts-resolution/actions-gutter.tsx | 63 +++ src/views/conflicts-resolution/component.tsx | 530 ++++++++++-------- .../diff-highlight-plugin.ts | 54 +- src/views/conflicts-resolution/diff.ts | 86 +-- .../conflicts-resolution/editor-pane.tsx | 71 +++ src/views/conflicts-resolution/view.tsx | 34 +- 6 files changed, 527 insertions(+), 311 deletions(-) create mode 100644 src/views/conflicts-resolution/actions-gutter.tsx create mode 100644 src/views/conflicts-resolution/editor-pane.tsx diff --git a/src/views/conflicts-resolution/actions-gutter.tsx b/src/views/conflicts-resolution/actions-gutter.tsx new file mode 100644 index 0000000..71f2e6d --- /dev/null +++ b/src/views/conflicts-resolution/actions-gutter.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; +import { DiffChunk } from "./diff"; + +interface ActionsGutterProps { + diffChunks: DiffChunk[]; + // This is essential to correctly draw the lines between + // the left and right editor + lineHeight: number; + width: number; +} + +const ActionsGutter: React.FC = ({ + diffChunks, + lineHeight, + width, +}) => { + const drawChunk = (chunk: DiffChunk, index: number) => { + const topLeft = (chunk.startLeftLine - 1) * lineHeight; + const bottomLeft = (chunk.endLeftLine - 1) * lineHeight; + const topRight = (chunk.startRightLine - 1) * lineHeight; + const bottomRight = (chunk.endRightLine - 1) * lineHeight; + const color = + chunk.type == "add" + ? "var(--color-green)" + : chunk.type == "remove" + ? "var(--color-red)" + : "var(--color-yellow)"; + return ( + + + + ); + }; + + return ( +
+ + {diffChunks.map(drawChunk)} + +
+ ); +}; + +export default ActionsGutter; diff --git a/src/views/conflicts-resolution/component.tsx b/src/views/conflicts-resolution/component.tsx index 8cb7049..03894ad 100644 --- a/src/views/conflicts-resolution/component.tsx +++ b/src/views/conflicts-resolution/component.tsx @@ -5,6 +5,9 @@ import { markdown } from "@codemirror/lang-markdown"; import diff from "./diff"; import { createDiffHighlightPlugin } from "./diff-highlight-plugin"; import DiffConnections, { ConnectionChunk } from "./diff-connections"; +import EditorPane from "./editor-pane"; +import ActionsGutter from "./actions-gutter"; +import * as React from "react"; // Add styles for diff highlighting const styles = document.createElement("style"); @@ -24,254 +27,315 @@ document.head.appendChild(styles); interface DiffViewProps { oldText: string; newText: string; - onResolve: (resolvedText: string) => void; + onOldTextChange: (content: string) => void; + onNewTextChange: (content: string) => void; } -const DiffView: React.FC = ({ oldText, newText, onResolve }) => { - const [originalEditorView, setOriginalEditorView] = - useState(null); - const [modifiedEditorView, setModifiedEditorView] = - useState(null); - - const containerRef = useRef(null); - const editorViewsRef = useRef([]); - - useEffect(() => { - if (!containerRef.current) return; - - const createEditor = ( - container: HTMLElement, - content: string, - readOnly: boolean = false, - isOriginal: boolean = false, - ) => { - const differences = diff(oldText || "", newText || ""); - - const highlightPlugin = createDiffHighlightPlugin({ - diff: differences, - isOriginal, - }); - - const changeListener = !readOnly - ? [ - EditorView.updateListener.of((update) => { - if (update.docChanged) { - onResolve(update.state.doc.toString()); - } - }), - ] - : []; - - const editorState = EditorState.create({ - doc: content, - extensions: [ - // basicSetup minus line numbers - EditorView.lineWrapping, - EditorView.editable.of(!readOnly), - highlightPlugin, - EditorView.theme({ - "&": { - backgroundColor: "var(--background-primary)", - color: "var(--text-normal)", - }, - ".cm-line": { - padding: "0 4px", - }, - "&.cm-focused .cm-selectionBackground, .cm-selectionBackground": { - background: "var(--text-selection)", - }, - "&.cm-focused .cm-cursor": { - borderLeftColor: "var(--text-normal)", - }, - }), - markdown(), - ...changeListener, - ], - }); - - const view = new EditorView({ - state: editorState, - parent: container, - }); - - if (isOriginal) { - setOriginalEditorView(view); - } else if (readOnly) { - setModifiedEditorView(view); - } - - editorViewsRef.current.push(view); - return view; - }; - - // Create the three editors - const originalView = createEditor( - containerRef.current.querySelector(".original-editor")!, - oldText, - true, - true, - ); - - const modifiedView = createEditor( - containerRef.current.querySelector(".modified-editor")!, - newText, - true, - false, - ); - - const resultView = createEditor( - containerRef.current.querySelector(".result-editor")!, - newText, - false, - ); - - return () => { - editorViewsRef.current.forEach((view) => view.destroy()); - editorViewsRef.current = []; - }; - }, [oldText, newText]); - - const handleMerge = (direction: "left" | "right", chunk: ConnectionChunk) => { - if (!originalEditorView || !modifiedEditorView) { - return; - } - - const sourceEditor = - direction === "left" ? modifiedEditorView : originalEditorView; - const targetEditor = - direction === "left" ? originalEditorView : modifiedEditorView; - - const sourceLine = - direction === "left" ? chunk.targetStartLine : chunk.startLine; - const sourceEndLine = - direction === "left" ? chunk.targetEndLine : chunk.endLine; - const targetLine = - direction === "left" ? chunk.startLine : chunk.targetStartLine; - const targetEndLine = - direction === "left" ? chunk.endLine : chunk.targetEndLine; - - const sourceDoc = sourceEditor.state.doc; - const targetDoc = targetEditor.state.doc; - - // Handle source content - let contentToInsert = ""; - if (sourceLine > sourceDoc.lines) { - // Adding new lines at the end - contentToInsert = "\n" + chunk.content.join("\n"); - } else { - const sourceFrom = sourceDoc.line(sourceLine).from; - const sourceTo = - sourceEndLine <= sourceDoc.lines - ? sourceDoc.line(sourceEndLine).to - : sourceDoc.length; - contentToInsert = sourceEditor.state.sliceDoc(sourceFrom, sourceTo); - } - - // Handle target position - let targetFrom = 0; - let targetTo = 0; - - if (targetLine > targetDoc.lines) { - // Appending to end of file - targetFrom = targetTo = targetDoc.length; - if (!contentToInsert.startsWith("\n") && targetDoc.length > 0) { - contentToInsert = "\n" + contentToInsert; - } - } else { - targetFrom = targetDoc.line(targetLine).from; - targetTo = - targetEndLine <= targetDoc.lines - ? targetDoc.line(targetEndLine).to - : targetDoc.length; - } - - // Apply the change - const change = { - from: targetFrom, - to: targetTo, - insert: contentToInsert, - }; - - targetEditor.dispatch({ changes: change }); - - // Update result editor if it exists - // if (resultEditor) { - // const resultDoc = resultEditor.state.doc; - // let resultFrom = targetFrom; - // let resultTo = targetTo; - - // // Handle case where result editor might have different content length - // if (resultFrom > resultDoc.length) { - // resultFrom = resultTo = resultDoc.length; - // if (!contentToInsert.startsWith("\n") && resultDoc.length > 0) { - // contentToInsert = "\n" + contentToInsert; - // } - // } - - // resultEditor.dispatch({ - // changes: { - // from: resultFrom, - // to: resultTo, - // insert: contentToInsert, - // }, - // }); - // } - - // Notify parent - onResolve(targetEditor.state.doc.toString()); +const DiffView: React.FC = ({ + oldText, + newText, + onOldTextChange, + onNewTextChange, +}) => { + // We need to know the line height to correctly draw the ribbon between the left + // and right editor in the actions gutter + const [lineHeight, setLineHeight] = React.useState(0); + const handleEditorReady = (editor: EditorView) => { + setLineHeight(editor.defaultLineHeight); }; + // const [leftWidth, setLeftWidth] = useState("33%"); + const [gutterWidth, setGutterWidth] = useState(100); + // const [rightWidth, setRightWidth] = useState("33%"); + + const diffs = diff(oldText, newText); + return (
-
-
- + +
+
+ +
+
+
-
-
); }; +// const DiffView: React.FC = ({ oldText, newText, onResolve }) => { +// const [originalEditorView, setOriginalEditorView] = +// useState(null); +// const [modifiedEditorView, setModifiedEditorView] = +// useState(null); + +// const containerRef = useRef(null); +// const editorViewsRef = useRef([]); + +// useEffect(() => { +// if (!containerRef.current) return; + +// const createEditor = ( +// container: HTMLElement, +// content: string, +// readOnly: boolean = false, +// isOriginal: boolean = false, +// ) => { +// const differences = diff(oldText || "", newText || ""); + +// const highlightPlugin = createDiffHighlightPlugin({ +// diff: differences, +// isOriginal, +// }); + +// const changeListener = !readOnly +// ? [ +// EditorView.updateListener.of((update) => { +// if (update.docChanged) { +// onResolve(update.state.doc.toString()); +// } +// }), +// ] +// : []; + +// const editorState = EditorState.create({ +// doc: content, +// extensions: [ +// // basicSetup minus line numbers +// EditorView.lineWrapping, +// EditorView.editable.of(!readOnly), +// highlightPlugin, +// EditorView.theme({ +// "&": { +// backgroundColor: "var(--background-primary)", +// color: "var(--text-normal)", +// }, +// ".cm-line": { +// padding: "0 4px", +// }, +// "&.cm-focused .cm-selectionBackground, .cm-selectionBackground": { +// background: "var(--text-selection)", +// }, +// "&.cm-focused .cm-cursor": { +// borderLeftColor: "var(--text-normal)", +// }, +// }), +// markdown(), +// ...changeListener, +// ], +// }); + +// const view = new EditorView({ +// state: editorState, +// parent: container, +// }); + +// if (isOriginal) { +// setOriginalEditorView(view); +// } else if (readOnly) { +// setModifiedEditorView(view); +// } + +// editorViewsRef.current.push(view); +// return view; +// }; + +// // Create the three editors +// const originalView = createEditor( +// containerRef.current.querySelector(".original-editor")!, +// oldText, +// true, +// true, +// ); + +// const modifiedView = createEditor( +// containerRef.current.querySelector(".modified-editor")!, +// newText, +// true, +// false, +// ); + +// const resultView = createEditor( +// containerRef.current.querySelector(".result-editor")!, +// newText, +// false, +// ); + +// return () => { +// editorViewsRef.current.forEach((view) => view.destroy()); +// editorViewsRef.current = []; +// }; +// }, [oldText, newText]); + +// const handleMerge = (direction: "left" | "right", chunk: ConnectionChunk) => { +// if (!originalEditorView || !modifiedEditorView) { +// return; +// } + +// const sourceEditor = +// direction === "left" ? modifiedEditorView : originalEditorView; +// const targetEditor = +// direction === "left" ? originalEditorView : modifiedEditorView; + +// const sourceLine = +// direction === "left" ? chunk.targetStartLine : chunk.startLine; +// const sourceEndLine = +// direction === "left" ? chunk.targetEndLine : chunk.endLine; +// const targetLine = +// direction === "left" ? chunk.startLine : chunk.targetStartLine; +// const targetEndLine = +// direction === "left" ? chunk.endLine : chunk.targetEndLine; + +// const sourceDoc = sourceEditor.state.doc; +// const targetDoc = targetEditor.state.doc; + +// // Handle source content +// let contentToInsert = ""; +// if (sourceLine > sourceDoc.lines) { +// // Adding new lines at the end +// contentToInsert = "\n" + chunk.content.join("\n"); +// } else { +// const sourceFrom = sourceDoc.line(sourceLine).from; +// const sourceTo = +// sourceEndLine <= sourceDoc.lines +// ? sourceDoc.line(sourceEndLine).to +// : sourceDoc.length; +// contentToInsert = sourceEditor.state.sliceDoc(sourceFrom, sourceTo); +// } + +// // Handle target position +// let targetFrom = 0; +// let targetTo = 0; + +// if (targetLine > targetDoc.lines) { +// // Appending to end of file +// targetFrom = targetTo = targetDoc.length; +// if (!contentToInsert.startsWith("\n") && targetDoc.length > 0) { +// contentToInsert = "\n" + contentToInsert; +// } +// } else { +// targetFrom = targetDoc.line(targetLine).from; +// targetTo = +// targetEndLine <= targetDoc.lines +// ? targetDoc.line(targetEndLine).to +// : targetDoc.length; +// } + +// // Apply the change +// const change = { +// from: targetFrom, +// to: targetTo, +// insert: contentToInsert, +// }; + +// targetEditor.dispatch({ changes: change }); + +// // Update result editor if it exists +// // if (resultEditor) { +// // const resultDoc = resultEditor.state.doc; +// // let resultFrom = targetFrom; +// // let resultTo = targetTo; + +// // // Handle case where result editor might have different content length +// // if (resultFrom > resultDoc.length) { +// // resultFrom = resultTo = resultDoc.length; +// // if (!contentToInsert.startsWith("\n") && resultDoc.length > 0) { +// // contentToInsert = "\n" + contentToInsert; +// // } +// // } + +// // resultEditor.dispatch({ +// // changes: { +// // from: resultFrom, +// // to: resultTo, +// // insert: contentToInsert, +// // }, +// // }); +// // } + +// // Notify parent +// onResolve(targetEditor.state.doc.toString()); +// }; + +// return ( +//
+//
+//
+// +//
+//
+//
+//
+// ); +// }; + export default DiffView; diff --git a/src/views/conflicts-resolution/diff-highlight-plugin.ts b/src/views/conflicts-resolution/diff-highlight-plugin.ts index 311b899..c21e832 100644 --- a/src/views/conflicts-resolution/diff-highlight-plugin.ts +++ b/src/views/conflicts-resolution/diff-highlight-plugin.ts @@ -3,11 +3,12 @@ import { Decoration, DecorationSet, EditorView, + ViewUpdate, } from "@codemirror/view"; -import { DiffResult } from "./diff"; +import { DiffChunk } from "./diff"; interface DiffHighlightPluginSpec { - diff: DiffResult[]; + diff: DiffChunk[]; isOriginal: boolean; } @@ -20,40 +21,47 @@ export function createDiffHighlightPlugin(spec: DiffHighlightPluginSpec) { this.decorations = this.buildDecorations(view); } + update(update: ViewUpdate) { + if (update.docChanged) { + this.decorations = this.buildDecorations(update.view); + } + } + buildDecorations(view: EditorView): DecorationSet { const decorations = []; const doc = view.state.doc; for (let i = 1; i <= doc.lines; i++) { const line = doc.line(i); - const diffResult = spec.diff.find((d) => { + + const diffResult = spec.diff.find((chunk) => { if (spec.isOriginal) { return ( - d.oldValue === line.text || - (d.type === "remove" && d.value === line.text) + (chunk.type === "remove" || chunk.type === "modify") && + i >= chunk.startLeftLine && + i < chunk.endLeftLine ); - } else { - return d.type !== "remove" && d.value === line.text; } + return ( + (chunk.type === "add" || chunk.type === "modify") && + i >= chunk.startRightLine && + i < chunk.endRightLine + ); }); - if (diffResult && diffResult.type !== "equal") { - let className = ""; - if (diffResult.type === "modify") { - className = "diff-modify-background"; - } else if (diffResult.type === "add" && !spec.isOriginal) { - className = "diff-add-background"; - } else if (diffResult.type === "remove" && spec.isOriginal) { - className = "diff-remove-background"; - } + if (diffResult) { + const className = + diffResult.type === "modify" + ? "diff-modify-background" + : diffResult.type === "add" + ? "diff-add-background" + : "diff-remove-background"; - if (className) { - decorations.push( - Decoration.line({ - class: className, - }).range(line.from), - ); - } + decorations.push( + Decoration.line({ + class: className, + }).range(line.from), + ); } } diff --git a/src/views/conflicts-resolution/diff.ts b/src/views/conflicts-resolution/diff.ts index 074d723..d1bf1c6 100644 --- a/src/views/conflicts-resolution/diff.ts +++ b/src/views/conflicts-resolution/diff.ts @@ -1,68 +1,70 @@ -type DiffType = "add" | "remove" | "modify" | "equal"; - -export interface DiffResult { - type: DiffType; - value: string; - oldValue?: string; // For modifications, store both values - newValue?: string; - from: number; - to: number; +export interface DiffChunk { + type: "add" | "remove" | "modify"; + startLeftLine: number; + endLeftLine: number; + startRightLine: number; + endRightLine: number; } -function diff(oldText: string, newText: string): DiffResult[] { +function diff(oldText: string, newText: string): DiffChunk[] { const oldLines = oldText.split("\n"); const newLines = newText.split("\n"); - const result: DiffResult[] = []; - // This is an index in oldText - let position = 0; + const result: DiffChunk[] = []; - // First pass: find exact matches and obvious modifications for (let i = 0; i < Math.max(oldLines.length, newLines.length); i++) { const oldLine = oldLines[i]; const newLine = newLines[i]; if (!oldLine && newLine) { - // Pure addition result.push({ type: "add", - value: newLine, - from: position, - to: position + newLine.length, + startLeftLine: i + 1, + endLeftLine: i + 1, + startRightLine: i + 1, + endRightLine: i + 2, }); } else if (oldLine && !newLine) { - // Pure removal result.push({ type: "remove", - value: oldLine, - from: position, - to: position + oldLine.length, + startLeftLine: i + 1, + endLeftLine: i + 2, + startRightLine: i + 1, + endRightLine: i + 1, }); - } else if (oldLine === newLine) { - // Exact match - result.push({ - type: "equal", - value: oldLine, - from: position, - to: position + oldLine.length, - }); - } else { - // Different content at same line number - treat as modification + } else if (oldLine !== newLine) { result.push({ type: "modify", - value: newLine, - oldValue: oldLine, - newValue: newLine, - from: position, - to: position + Math.max(oldLine.length, newLine.length), + startLeftLine: i + 1, + endLeftLine: i + 2, + startRightLine: i + 1, + endRightLine: i + 2, }); } - - const oldLineLength = oldLine ? oldLine.length : 0; - const newLineLength = newLine ? newLine.length : 0; - position += Math.max(oldLineLength, newLineLength) + 1; } + // return result; + return mergeDiffs(result); +} - return result; +function mergeDiffs(chunks: DiffChunk[]): DiffChunk[] { + if (chunks.length <= 1) return chunks; + + return chunks.reduce((merged: DiffChunk[], current) => { + const previous = merged[merged.length - 1]; + + if ( + previous && + (previous.endLeftLine === current.startLeftLine || + previous.endRightLine === current.startRightLine) + ) { + previous.endLeftLine = current.endLeftLine; + previous.endRightLine = current.endRightLine; + previous.type = "modify"; + return merged; + } + + merged.push({ ...current }); + return merged; + }, []); } export default diff; diff --git a/src/views/conflicts-resolution/editor-pane.tsx b/src/views/conflicts-resolution/editor-pane.tsx new file mode 100644 index 0000000..6873520 --- /dev/null +++ b/src/views/conflicts-resolution/editor-pane.tsx @@ -0,0 +1,71 @@ +import { markdown } from "@codemirror/lang-markdown"; +import { EditorState, StateEffect } from "@codemirror/state"; +import { EditorView, ViewPlugin } from "@codemirror/view"; +import * as React from "react"; + +interface EditorPaneProps { + content: string; + highlightPlugin: ViewPlugin; + onEditorUpdate?: (editor: EditorView) => void; + onContentChange: (content: string) => void; +} + +const EditorPane: React.FC = ({ + content, + highlightPlugin, + onEditorUpdate, + onContentChange, +}) => { + const editorRef = React.useRef(null); + + React.useEffect(() => { + if (editorRef.current) { + const editorState = EditorState.create({ + doc: content, + extensions: [ + // basicSetup minus line numbers + // EditorView.lineWrapping, + EditorView.editable.of(true), + highlightPlugin, + EditorView.theme({ + "&": { + backgroundColor: "var(--background-primary)", + color: "var(--text-normal)", + }, + ".cm-content": { + padding: 0, + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground": { + background: "var(--text-selection)", + }, + "&.cm-focused .cm-cursor": { + borderLeftColor: "var(--text-normal)", + }, + }), + markdown(), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + onContentChange(update.state.doc.toString()); + } + // We want to know when it updates in case the line height changes + onEditorUpdate?.(update.view); + }), + ], + }); + + const editor = new EditorView({ + parent: editorRef.current, + state: editorState, + }); + + return () => { + // Cleanup + editor.destroy(); + }; + } + }, []); + + return
; +}; + +export default EditorPane; diff --git a/src/views/conflicts-resolution/view.tsx b/src/views/conflicts-resolution/view.tsx index f0b5615..65f2449 100644 --- a/src/views/conflicts-resolution/view.tsx +++ b/src/views/conflicts-resolution/view.tsx @@ -3,25 +3,26 @@ import { Root, createRoot } from "react-dom/client"; import DiffView from "./component"; import GitHubSyncPlugin from "src/main"; import { PluginContext } from "../hooks"; +import * as React from "react"; export const CONFLICTS_RESOLUTION_VIEW_TYPE = "conflicts-resolution-view"; // Test Case 1: Simple line changes -const oldText1 = `# My Document +let oldText1 = `# My Document This is a test Some content here Some line Another line Final line`; -const newText1 = `# My Document +let newText1 = `# My Document This is a modified test Some new content here Some line Final line`; // Test Case 2: Markdown with formatting -const oldText2 = `# Title +let oldText2 = `# Title ## Subtitle - List item 1 - List item 2 @@ -29,7 +30,7 @@ const oldText2 = `# Title **Bold text** and *italic* text Regular paragraph`; -const newText2 = `# Modified Title +let newText2 = `# Modified Title ## Subtitle - List item 1 - List item 2 @@ -61,15 +62,22 @@ export class ConflictsResolutionView extends ItemView { const container = this.containerEl.children[1]; container.empty(); const root: Root = createRoot(container); - root.render( - - console.log("Resolved:", text)} - /> - , - ); + const App = () => { + const [oldText, setOldText] = React.useState(oldText1); + const [newText, setNewText] = React.useState(newText1); + + return ( + + + + ); + }; + root.render(); } async onClose() {