More progress

This commit is contained in:
Silvano Cerza 2025-02-13 17:23:43 +01:00
parent 6d7c0c8772
commit 9f62324c2a
5 changed files with 401 additions and 81 deletions

View file

@ -1,20 +1,23 @@
import { App } from "obsidian"; import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { EditorView } from "@codemirror/view"; import { EditorView } from "@codemirror/view";
import { EditorState } from "@codemirror/state"; import { EditorState } from "@codemirror/state";
import { markdown } from "@codemirror/lang-markdown"; import { markdown } from "@codemirror/lang-markdown";
import diff from "./diff"; import diff from "./diff";
import { createDiffHighlightPlugin } from "./diff-highlight-plugin"; import { createDiffHighlightPlugin } from "./diff-highlight-plugin";
import DiffConnections from "./diff-connections";
// Add styles for diff highlighting // Add styles for diff highlighting
const styles = document.createElement("style"); const styles = document.createElement("style");
styles.innerHTML = ` styles.innerHTML = `
.diff-remove-background { .diff-modify-background {
background-color: rgba(var(--background-modifier-error-rgb), 0.1); background-color: rgba(var(--color-yellow-rgb), 0.1);
} }
.diff-add-background { .diff-add-background {
background-color: rgba(var(--color-green-rgb), 0.1); background-color: rgba(var(--color-green-rgb), 0.1);
} }
.diff-remove-background {
background-color: rgba(var(--color-red-rgb), 0.1);
}
`; `;
document.head.appendChild(styles); document.head.appendChild(styles);
@ -22,15 +25,14 @@ interface DiffViewProps {
oldText: string; oldText: string;
newText: string; newText: string;
onResolve: (resolvedText: string) => void; onResolve: (resolvedText: string) => void;
app: App;
} }
const DiffView: React.FC<DiffViewProps> = ({ const DiffView: React.FC<DiffViewProps> = ({ oldText, newText, onResolve }) => {
oldText, const [originalEditorView, setOriginalEditorView] =
newText, useState<EditorView | null>(null);
onResolve, const [modifiedEditorView, setModifiedEditorView] =
app, useState<EditorView | null>(null);
}) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const editorViewsRef = useRef<EditorView[]>([]); const editorViewsRef = useRef<EditorView[]>([]);
@ -92,6 +94,12 @@ const DiffView: React.FC<DiffViewProps> = ({
parent: container, parent: container,
}); });
if (isOriginal) {
setOriginalEditorView(view);
} else if (readOnly) {
setModifiedEditorView(view);
}
editorViewsRef.current.push(view); editorViewsRef.current.push(view);
return view; return view;
}; };
@ -128,7 +136,7 @@ const DiffView: React.FC<DiffViewProps> = ({
ref={containerRef} ref={containerRef}
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "1fr 1fr", gridTemplateColumns: "1fr auto 1fr",
gridTemplateRows: "1fr 1fr", gridTemplateRows: "1fr 1fr",
gap: "10px", gap: "10px",
height: "100%", height: "100%",
@ -136,17 +144,39 @@ const DiffView: React.FC<DiffViewProps> = ({
> >
<div <div
className="original-editor" className="original-editor"
style={{ border: "1px solid var(--background-modifier-border)" }} style={{
border: "1px solid var(--background-modifier-border)",
backgroundColor: "var(--background-primary)",
}}
/> />
<div
className="diff-overlay"
style={{
gridRow: "1 / 2",
gridColumn: "2 / 3",
position: "relative",
width: "50px",
}}
>
<DiffConnections
differences={diff(oldText, newText)}
originalEditor={originalEditorView}
modifiedEditor={modifiedEditorView}
/>
</div>
<div <div
className="modified-editor" className="modified-editor"
style={{ border: "1px solid var(--background-modifier-border)" }} style={{
border: "1px solid var(--background-modifier-border)",
backgroundColor: "var(--background-primary)",
}}
/> />
<div <div
className="result-editor" className="result-editor"
style={{ style={{
gridColumn: "1 / span 2", gridColumn: "1 / 4",
border: "1px solid var(--background-modifier-border)", border: "1px solid var(--background-modifier-border)",
backgroundColor: "var(--background-primary)",
}} }}
/> />
</div> </div>

View file

@ -0,0 +1,273 @@
import { setIcon } from "obsidian";
import { useEffect, useState } from "react";
import { EditorView } from "@codemirror/view";
import { DiffResult } from "./diff";
interface DiffConnectionProps {
differences: DiffResult[];
originalEditor: EditorView | null;
modifiedEditor: EditorView | null;
}
type ConnectionType = "add" | "remove" | "modify";
type ConnectionChunk = {
startTop: number;
startBottom: number;
endTop: number;
endBottom: number;
type: ConnectionType;
};
const DiffConnections: React.FC<DiffConnectionProps> = ({
differences,
originalEditor,
modifiedEditor,
}) => {
const [connections, setConnections] = useState<ConnectionChunk[]>([]);
useEffect(() => {
if (!originalEditor || !modifiedEditor) return;
requestAnimationFrame(() => {
const originalContainer =
originalEditor.scrollDOM.getBoundingClientRect();
const modifiedContainer =
modifiedEditor.scrollDOM.getBoundingClientRect();
// Group consecutive diffs into chunks
const chunks: ConnectionChunk[] = [];
let currentChunk: DiffResult[] = [];
differences.forEach((diff, i) => {
if (diff.type === "equal") {
if (currentChunk.length > 0) {
// Process the chunk
const chunk = processChunk(
currentChunk,
originalEditor,
modifiedEditor,
originalContainer,
modifiedContainer,
);
if (chunk) chunks.push(chunk);
currentChunk = [];
}
} else {
currentChunk.push(diff);
}
});
// Process last chunk if exists
if (currentChunk.length > 0) {
const chunk = processChunk(
currentChunk,
originalEditor,
modifiedEditor,
originalContainer,
modifiedContainer,
);
if (chunk) chunks.push(chunk);
}
setConnections(chunks);
});
}, [differences, originalEditor, modifiedEditor]);
const processChunk = (
chunk: DiffResult[],
originalEditor: EditorView,
modifiedEditor: EditorView,
originalContainer: DOMRect,
modifiedContainer: DOMRect,
): ConnectionChunk | null => {
let startTop = Infinity;
let startBottom = -Infinity;
let endTop = Infinity;
let endBottom = -Infinity;
chunk.forEach((diff) => {
if (diff.type === "modify" || diff.type === "remove") {
// Find position in original editor
const line = findLineByContent(
originalEditor,
diff.oldValue || diff.value,
);
if (line) {
startTop = Math.min(startTop, line.top - originalContainer.top);
startBottom = Math.max(
startBottom,
line.bottom - originalContainer.top,
);
}
}
if (diff.type === "modify" || diff.type === "add") {
// Find position in modified editor
const line = findLineByContent(modifiedEditor, diff.value);
if (line) {
endTop = Math.min(endTop, line.top - modifiedContainer.top);
endBottom = Math.max(endBottom, line.bottom - modifiedContainer.top);
}
}
});
if (startTop === Infinity && endTop === Infinity) return null;
// For pure additions, position start at the previous equal line's bottom
if (startTop === Infinity) {
const prevEqual = findPreviousEqual(differences, chunk[0]);
if (prevEqual) {
const line = findLineByContent(originalEditor, prevEqual.value);
if (line) {
startTop = startBottom = line.bottom - originalContainer.top;
}
}
}
return {
startTop: startTop === Infinity ? endTop : startTop,
startBottom: startBottom === -Infinity ? endBottom : startBottom,
endTop: endTop === Infinity ? startTop : endTop,
endBottom: endBottom === -Infinity ? startBottom : endBottom,
type: (chunk.some((d) => d.type === "modify")
? "modify"
: chunk[0].type) as ConnectionType,
};
};
return (
<svg
style={{
width: "100%",
height: "100%",
position: "absolute",
overflow: "visible",
}}
>
{connections.map((chunk, i) => {
const color = getConnectionStyle(chunk.type);
return (
<g key={i}>
<path
d={`
M 0 ${chunk.startTop}
C 20 ${chunk.startTop}, 30 ${chunk.endTop}, 50 ${chunk.endTop}
L 50 ${chunk.endBottom}
C 30 ${chunk.endBottom}, 20 ${chunk.startBottom}, 0 ${chunk.startBottom}
Z
`}
fill={color}
fillOpacity="0.1"
stroke={color}
strokeWidth="1"
/>
<ConnectionButtons
chunk={chunk}
onAction={(action) => {
console.log("Action:", action, "for chunk:", chunk);
// TODO: Implement actual resolution actions
}}
/>
</g>
);
})}
</svg>
);
};
const findLineByContent = (
editor: EditorView,
content?: string,
): { top: number; bottom: number } | null => {
if (!content) return null;
const doc = editor.state.doc;
for (let i = 1; i <= doc.lines; i++) {
const line = doc.line(i);
if (line.text === content) {
const fromCoords = editor.coordsAtPos(line.from);
const toCoords = editor.coordsAtPos(line.to);
if (fromCoords && toCoords) {
return {
top: fromCoords.top,
bottom: toCoords.bottom,
};
}
}
}
return null;
};
const findPreviousEqual = (
differences: DiffResult[],
currentDiff: DiffResult,
): DiffResult | null => {
const currentIndex = differences.findIndex((d) => d === currentDiff);
if (currentIndex === -1) return null;
for (let i = currentIndex - 1; i >= 0; i--) {
if (differences[i].type === "equal") {
return differences[i];
}
}
return null;
};
const getConnectionStyle = (type: ConnectionType): string => {
switch (type) {
case "add":
return "var(--color-green)";
case "remove":
return "var(--color-red)";
case "modify":
return "var(--color-yellow)";
}
};
const ConnectionButtons: React.FC<{
chunk: ConnectionChunk;
onAction: (action: "left" | "right") => void;
}> = ({ chunk, onAction }) => {
const centerY =
(chunk.startTop + chunk.startBottom + chunk.endTop + chunk.endBottom) / 4;
const showLeftArrow = chunk.type === "modify" || chunk.type === "add";
const showRightArrow = chunk.type === "modify" || chunk.type === "remove";
return (
<foreignObject x="0" y={centerY - 8} width="50" height="16">
<div
style={{
display: "flex",
justifyContent:
showLeftArrow && showRightArrow
? "space-between"
: showLeftArrow
? "flex-end"
: "flex-start",
width: "100%",
height: "100%",
}}
>
{showRightArrow && (
<div
style={{ cursor: "pointer", width: 16, height: 16 }}
onClick={() => onAction("right")}
ref={(node) => node && setIcon(node, "arrow-right")}
/>
)}
{showLeftArrow && (
<div
style={{ cursor: "pointer", width: 16, height: 16 }}
onClick={() => onAction("left")}
ref={(node) => node && setIcon(node, "arrow-left")}
/>
)}
</div>
</foreignObject>
);
};
export default DiffConnections;

View file

@ -24,26 +24,36 @@ export function createDiffHighlightPlugin(spec: DiffHighlightPluginSpec) {
const decorations = []; const decorations = [];
const doc = view.state.doc; const doc = view.state.doc;
// Go through the document line by line
for (let i = 1; i <= doc.lines; i++) { for (let i = 1; i <= doc.lines; i++) {
const line = doc.line(i); const line = doc.line(i);
const lineText = line.text; const diffResult = spec.diff.find((d) => {
if (spec.isOriginal) {
return (
d.oldValue === line.text ||
(d.type === "remove" && d.value === line.text)
);
} else {
return d.type !== "remove" && d.value === line.text;
}
});
// Find matching diff for this line if (diffResult && diffResult.type !== "equal") {
const diff = spec.diff.find((d) => d.value === lineText); 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 ( if (className) {
diff && decorations.push(
((spec.isOriginal && diff.type === "remove") || Decoration.line({
(!spec.isOriginal && diff.type === "add")) class: className,
) { }).range(line.from),
decorations.push( );
Decoration.mark({ }
class: spec.isOriginal
? "diff-remove-background"
: "diff-add-background",
}).range(line.from, line.to),
);
} }
} }

View file

@ -1,69 +1,77 @@
export type DiffResult = { type DiffType = "add" | "remove" | "modify" | "equal";
type: "add" | "remove" | "equal";
export interface DiffResult {
type: DiffType;
value: string; value: string;
oldValue?: string; // For modifications, store both values
newValue?: string;
from: number; from: number;
to: number; to: number;
}; }
export default function diff(oldText: string, newText: string): DiffResult[] { function diff(oldText: string, newText: string): DiffResult[] {
const oldLines = oldText.split("\n"); const oldLines = oldText.split("\n");
const newLines = newText.split("\n"); const newLines = newText.split("\n");
const matrix = Array(oldLines.length + 1)
.fill(null)
.map(() => Array(newLines.length + 1).fill(0));
// Fill the matrix
for (let i = 1; i <= oldLines.length; i++) {
for (let j = 1; j <= newLines.length; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
matrix[i][j] = matrix[i - 1][j - 1] + 1;
} else {
matrix[i][j] = Math.max(matrix[i - 1][j], matrix[i][j - 1]);
}
}
}
// Backtrack to find differences
const result: DiffResult[] = []; const result: DiffResult[] = [];
let i = oldLines.length;
let j = newLines.length;
let position = 0; let position = 0;
while (i > 0 || j > 0) { // First pass: find exact matches and obvious modifications
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { for (let i = 0; i < Math.max(oldLines.length, newLines.length); i++) {
const value = oldLines[i - 1]; const oldLine = oldLines[i];
result.unshift({ const newLine = newLines[i];
type: "equal",
value, if (!oldLine && newLine) {
from: position, // Pure addition
to: position + value.length, result.push({
});
position += value.length + 1; // +1 for newline
i--;
j--;
} else if (j > 0 && (i === 0 || matrix[i][j - 1] >= matrix[i - 1][j])) {
const value = newLines[j - 1];
result.unshift({
type: "add", type: "add",
value, value: newLine,
from: position, from: position,
to: position + value.length, to: position + newLine.length,
}); });
position += value.length + 1; } else if (oldLine && !newLine) {
j--; // Pure removal
} else if (i > 0 && (j === 0 || matrix[i][j - 1] < matrix[i - 1][j])) { result.push({
const value = oldLines[i - 1];
result.unshift({
type: "remove", type: "remove",
value, value: oldLine,
from: position, from: position,
to: position + value.length, to: position + oldLine.length,
});
} 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
result.push({
type: "modify",
value: newLine,
oldValue: oldLine,
newValue: newLine,
from: position,
to: position + Math.max(oldLine.length, newLine.length),
}); });
position += value.length + 1;
i--;
} }
position +=
Math.max(oldLine ? oldLine.length : 0, newLine ? newLine.length : 0) + 1;
} }
return result; return result;
} }
function similarity(s1: string, s2: string): number {
if (s1 === s2) return 1.0;
// Simple word-based similarity
const words1 = s1.split(/\s+/);
const words2 = s2.split(/\s+/);
const commonWords = words1.filter((w) => words2.includes(w)).length;
return commonWords / Math.max(words1.length, words2.length);
}
export default diff;

View file

@ -62,7 +62,6 @@ export class ConflictsResolutionView extends ItemView {
<DiffView <DiffView
oldText={oldText1} oldText={oldText1}
newText={newText1} newText={newText1}
app={this.app}
onResolve={(text) => console.log("Resolved:", text)} onResolve={(text) => console.log("Resolved:", text)}
// registerExtension={(ext) => this.plugin.registerEditorExtension(ext)} // registerExtension={(ext) => this.plugin.registerEditorExtension(ext)}
/>, />,