Start using reconcile-text

This commit is contained in:
Andras Schmelczer 2025-07-12 12:20:54 +01:00
commit 0ce5787858
No known key found for this signature in database
GPG key ID: FC8F2C3D3D1A718C
13 changed files with 69 additions and 102 deletions

4
backend/Cargo.lock generated
View file

@ -1700,9 +1700,9 @@ dependencies = [
[[package]] [[package]]
name = "reconcile-text" name = "reconcile-text"
version = "0.4.8" version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54fa4679b1042b1110aeac9c00fe292339af66426833da724a3fcaae0052b4da" checksum = "eb6c98d553dd72cd0e863f7cc1c610abd2cc7fe33e24f14262daf1420941cb3d"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
] ]

View file

@ -35,7 +35,7 @@ clap-verbosity-flag = "3.0.3"
bimap = "0.6.3" bimap = "0.6.3"
ts-rs = { version = "10.1", features = ["uuid-impl", "chrono-impl"] } ts-rs = { version = "10.1", features = ["uuid-impl", "chrono-impl"] }
serde_with = "3.12.0" serde_with = "3.12.0"
reconcile-text = "0.4.8" reconcile-text = "0.4.10"
[lints] [lints]
workspace = true workspace = true

View file

@ -1,13 +1,14 @@
import type { Stat, Vault, Workspace } from "obsidian"; import type { Stat, Vault, Workspace } from "obsidian";
import { MarkdownView, normalizePath } from "obsidian"; import { MarkdownView, normalizePath } from "obsidian";
import type { import type {
CursorPosition,
FileSystemOperations, FileSystemOperations,
RelativePath, RelativePath,
TextWithCursors TextWithCursors
} from "sync-client"; } from "sync-client";
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position"; import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
import { positionToLineAndColumn } from "./utils/position-to-line-and-column"; import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
import { getCursorsFromEditor } from "./views/cursors/get-cursors-from-editor"; import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor";
export class ObsidianFileSystemOperations implements FileSystemOperations { export class ObsidianFileSystemOperations implements FileSystemOperations {
public constructor( public constructor(
@ -80,18 +81,18 @@ export class ObsidianFileSystemOperations implements FileSystemOperations {
if (view?.file?.path === path) { if (view?.file?.path === path) {
const text = view.editor.getValue(); const text = view.editor.getValue();
const cursors = getCursorsFromEditor(view.editor).flatMap( const cursors: CursorPosition[] = getSelectionsFromEditor(
({ id, start: anchor, end: head }) => [ view.editor
{ ).flatMap(({ id, start: anchor, end: head }) => [
id: 2 * id, {
characterPosition: anchor id: 2 * id,
}, position: anchor
{ },
id: 2 * id + 1, {
characterPosition: head id: 2 * id + 1,
} position: head
] }
); ]);
const result = updater({ const result = updater({
text, text,
@ -105,17 +106,15 @@ export class ObsidianFileSystemOperations implements FileSystemOperations {
view.editor.setValue(result.text); view.editor.setValue(result.text);
const selections = []; const selections = [];
for (let i = 0; i < result.cursors.length / 2; i++) { const resultCursors = result.cursors ?? [];
const from = result.cursors[2 * i]; for (let i = 0; i < resultCursors.length / 2; i++) {
const to = result.cursors[2 * i + 1]; const from = resultCursors[2 * i];
const to = resultCursors[2 * i + 1];
const { line: fromLine, column: fromColumn } = const { line: fromLine, column: fromColumn } =
positionToLineAndColumn( positionToLineAndColumn(result.text, from.position);
result.text,
from.characterPosition
);
const { line: toLine, column: toColumn } = const { line: toLine, column: toColumn } =
positionToLineAndColumn(result.text, to.characterPosition); positionToLineAndColumn(result.text, to.position);
selections.push({ selections.push({
anchor: { line: fromLine, ch: fromColumn }, anchor: { line: fromLine, ch: fromColumn },

View file

@ -1,9 +1,7 @@
import type { import type {
Editor, Editor,
EventRef,
MarkdownFileInfo, MarkdownFileInfo,
TAbstractFile, TAbstractFile,
Workspace,
WorkspaceLeaf WorkspaceLeaf
} from "obsidian"; } from "obsidian";
import type { MarkdownView } from "obsidian"; import type { MarkdownView } from "obsidian";
@ -13,7 +11,6 @@ import { HistoryView } from "./views/history/history-view";
import { StatusBar } from "./views/status-bar/status-bar"; import { StatusBar } from "./views/status-bar/status-bar";
import { LogsView } from "./views/logs/logs-view"; import { LogsView } from "./views/logs/logs-view";
import { StatusDescription } from "./views/status-description/status-description"; import { StatusDescription } from "./views/status-description/status-description";
import type { CursorSpan, RelativePath } from "sync-client";
import { SyncClient, rateLimit, DEFAULT_SETTINGS } from "sync-client"; import { SyncClient, rateLimit, DEFAULT_SETTINGS } from "sync-client";
import { ObsidianFileSystemOperations } from "./obsidian-file-system"; import { ObsidianFileSystemOperations } from "./obsidian-file-system";
import { SyncSettingsTab } from "./views/settings/settings-tab"; import { SyncSettingsTab } from "./views/settings/settings-tab";
@ -24,7 +21,6 @@ import {
remoteCursorsPlugin, remoteCursorsPlugin,
setCursors setCursors
} from "./views/cursors/remote-cursors-plugin"; } from "./views/cursors/remote-cursors-plugin";
import { getCursorsFromEditor } from "./views/cursors/get-cursors-from-editor";
import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener"; import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener";
const MIN_WAIT_BETWEEN_UPDATES_IN_MS = 250; const MIN_WAIT_BETWEEN_UPDATES_IN_MS = 250;

View file

@ -1,13 +1,13 @@
import type { Editor } from "obsidian"; import type { Editor } from "obsidian";
import { lineAndColumnToPosition } from "../../utils/line-and-column-to-position"; import { lineAndColumnToPosition } from "../../utils/line-and-column-to-position";
export interface Cursor { export interface Selection {
id: number; id: number;
start: number; start: number;
end: number; end: number;
} }
export function getCursorsFromEditor(editor: Editor): Cursor[] { export function getSelectionsFromEditor(editor: Editor): Selection[] {
const text = editor.getValue(); const text = editor.getValue();
return editor.listSelections().map(({ anchor, head }, i) => ({ return editor.listSelections().map(({ anchor, head }, i) => ({
id: i, id: i,

View file

@ -1,20 +1,20 @@
import type { Workspace } from "obsidian"; import type { Workspace } from "obsidian";
import { EventRef, Editor, MarkdownView, MarkdownFileInfo } from "obsidian"; import { MarkdownView } from "obsidian";
import type { Logger, SyncClient } from "sync-client"; import type { SyncClient } from "sync-client";
import type { Cursor } from "./get-cursors-from-editor"; import type { Selection } from "./get-selections-from-editor";
import { getCursorsFromEditor } from "./get-cursors-from-editor"; import { getSelectionsFromEditor } from "./get-selections-from-editor";
export class LocalCursorUpdateListener { export class LocalCursorUpdateListener {
private static readonly UPDATE_INTERVAL_MS = 50; private static readonly UPDATE_INTERVAL_MS = 50;
private readonly eventHandle: NodeJS.Timeout; private readonly eventHandle: NodeJS.Timeout;
private lastCursorState: Record<string, Cursor[]> = {}; private lastCursorState: Record<string, Selection[]> = {};
public constructor( public constructor(
private readonly client: SyncClient, private readonly client: SyncClient,
private readonly workspace: Workspace private readonly workspace: Workspace
) { ) {
this.eventHandle = setInterval(() => { this.eventHandle = setInterval(() => {
this.updateAllCursors(); this.updateAllSelections();
}, LocalCursorUpdateListener.UPDATE_INTERVAL_MS); }, LocalCursorUpdateListener.UPDATE_INTERVAL_MS);
} }
@ -22,8 +22,8 @@ export class LocalCursorUpdateListener {
clearInterval(this.eventHandle); clearInterval(this.eventHandle);
} }
private updateAllCursors(): void { private updateAllSelections(): void {
const currentCursors = this.getAllCursors(); const currentCursors = this.getAllSelections();
if ( if (
JSON.stringify(this.lastCursorState) === JSON.stringify(this.lastCursorState) ===
JSON.stringify(currentCursors) JSON.stringify(currentCursors)
@ -40,8 +40,8 @@ export class LocalCursorUpdateListener {
}); });
} }
private getAllCursors(): Record<string, Cursor[]> { private getAllSelections(): Record<string, Selection[]> {
const cursors: Record<string, Cursor[]> = {}; const cursors: Record<string, Selection[]> = {};
this.workspace this.workspace
.getLeavesOfType("markdown") .getLeavesOfType("markdown")
.map((leaf) => leaf.view) .map((leaf) => leaf.view)
@ -51,7 +51,7 @@ export class LocalCursorUpdateListener {
if (!file) { if (!file) {
return; return;
} }
cursors[file.path] = getCursorsFromEditor(view.editor); cursors[file.path] = getSelectionsFromEditor(view.editor);
}); });
return cursors; return cursors;
} }

View file

@ -6139,6 +6139,13 @@
"node": ">= 10.13.0" "node": ">= 10.13.0"
} }
}, },
"node_modules/reconcile-text": {
"version": "0.4.10",
"resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.4.10.tgz",
"integrity": "sha512-WfcVG2+QX7P8600hDoDv5Jxv5dxw3QwrjyVLO+qP8Xg4CoUTSar/SbTCdtMrrDiau+Zwoom+cLtNCUVX1AmWoQ==",
"dev": true,
"license": "MIT"
},
"node_modules/regex-parser": { "node_modules/regex-parser": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz",
@ -7728,6 +7735,7 @@
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.15.30", "@types/node": "^22.15.30",
"jest": "^29.7.0", "jest": "^29.7.0",
"reconcile-text": "^0.4.10",
"sync_lib": "file:../../backend/sync_lib/pkg", "sync_lib": "file:../../backend/sync_lib/pkg",
"ts-jest": "^29.3.4", "ts-jest": "^29.3.4",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.2",

View file

@ -22,6 +22,7 @@
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.15.30", "@types/node": "^22.15.30",
"jest": "^29.7.0", "jest": "^29.7.0",
"reconcile-text": "^0.4.10",
"sync_lib": "file:../../backend/sync_lib/pkg", "sync_lib": "file:../../backend/sync_lib/pkg",
"ts-jest": "^29.3.4", "ts-jest": "^29.3.4",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.2",

View file

@ -6,12 +6,10 @@ import type {
import { FileOperations } from "./file-operations"; import { FileOperations } from "./file-operations";
import { Logger } from "../tracing/logger"; import { Logger } from "../tracing/logger";
import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly"; import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
import type { import type { FileSystemOperations } from "./filesystem-operations";
FileSystemOperations, import init from "sync_lib";
TextWithCursors
} from "./filesystem-operations";
import init, { base64ToBytes } from "sync_lib";
import fs from "fs"; import fs from "fs";
import { TextWithCursors } from "reconcile-text";
class MockDatabase implements Partial<Database> { class MockDatabase implements Partial<Database> {
public getLatestDocumentByRelativePath( public getLatestDocumentByRelativePath(

View file

@ -1,18 +1,10 @@
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import type { import type { FileSystemOperations } from "./filesystem-operations";
FileSystemOperations,
TextWithCursors
} from "./filesystem-operations";
import type { Database, RelativePath } from "../persistence/database"; import type { Database, RelativePath } from "../persistence/database";
import { import { isFileTypeMergable } from "sync_lib";
CursorPosition,
isBinary,
isFileTypeMergable,
mergeTextWithCursors,
TextWithCursors as RustTextWithCursors
} from "sync_lib";
import { SafeFileSystemOperations } from "./safe-filesystem-operations"; import { SafeFileSystemOperations } from "./safe-filesystem-operations";
import type { TextWithCursors } from "reconcile-text";
import { isBinary, reconcile } from "reconcile-text";
export class FileOperations { export class FileOperations {
private static readonly PARENTHESES_REGEX = / \((\d+)\)$/; private static readonly PARENTHESES_REGEX = / \((\d+)\)$/;
private readonly fs: SafeFileSystemOperations; private readonly fs: SafeFileSystemOperations;
@ -102,39 +94,25 @@ export class FileOperations {
await this.fs.atomicUpdateText( await this.fs.atomicUpdateText(
path, path,
({ text, cursors }: TextWithCursors): TextWithCursors => { ({ text, cursors }: TextWithCursors): TextWithCursors => {
text = text.replace(this.nativeLineEndings, "\n");
this.logger.debug( this.logger.debug(
`Performing a 3-way merge for ${path} with the expected content` `Performing a 3-way merge for ${path} with the expected content`
); );
const left = new RustTextWithCursors( text = text.replace(this.nativeLineEndings, "\n");
text, const merged = reconcile(
cursors.map( expectedText,
(cursor) => { text, cursors },
new CursorPosition( newText
cursor.id,
cursor.characterPosition
)
)
); );
const right = new RustTextWithCursors(newText, []);
const merged = mergeTextWithCursors(expectedText, left, right);
const resultText = merged const resultText = merged.text.replace(
.text() "\n",
.replace("\n", this.nativeLineEndings); this.nativeLineEndings
);
const resultCursors = merged.cursors().map((cursor) => ({
id: cursor.id(),
characterPosition: cursor.characterPosition()
}));
merged.free();
return { return {
text: resultText, text: resultText,
cursors: resultCursors cursors: merged.cursors
}; };
} }
); );

View file

@ -1,16 +1,6 @@
import type { RelativePath } from "../persistence/database"; import type { RelativePath } from "../persistence/database";
export interface Cursor { import type { TextWithCursors } from "reconcile-text";
id: number;
/// The character position is the index of the character in the text where the text lines are separated by '\n' new line character even if the actual text uses different line endings.
characterPosition: number;
}
export interface TextWithCursors {
text: string;
cursors: Cursor[];
}
export interface FileSystemOperations { export interface FileSystemOperations {
// List all files that should be synced. // List all files that should be synced.

View file

@ -1,11 +1,9 @@
import type { RelativePath } from "../persistence/database"; import type { RelativePath } from "../persistence/database";
import type { import type { FileSystemOperations } from "./filesystem-operations";
FileSystemOperations,
TextWithCursors
} from "./filesystem-operations";
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import { Locks } from "../utils/locks"; import { Locks } from "../utils/locks";
import { FileNotFoundError } from "./file-not-found-error"; import { FileNotFoundError } from "./file-not-found-error";
import { TextWithCursors } from "reconcile-text";
/** /**
* Decorates `FileSystemOperations` to replace errors with `FileNotFoundError` * Decorates `FileSystemOperations` to replace errors with `FileNotFoundError`

View file

@ -13,14 +13,13 @@ export { Logger, LogLevel, LogLine } from "./tracing/logger";
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings"; export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
export { rateLimit } from "./utils/rate-limit"; export { rateLimit } from "./utils/rate-limit";
export type { RelativePath, StoredDatabase } from "./persistence/database"; export type { RelativePath, StoredDatabase } from "./persistence/database";
export type { export type { FileSystemOperations } from "./file-operations/filesystem-operations";
FileSystemOperations,
TextWithCursors,
Cursor
} from "./file-operations/filesystem-operations";
export type { PersistenceProvider } from "./persistence/persistence"; export type { PersistenceProvider } from "./persistence/persistence";
export type { CursorSpan } from "./services/types/CursorSpan"; export type { CursorSpan } from "./services/types/CursorSpan";
export type { ClientCursors } from "./services/types/ClientCursors"; export type { ClientCursors } from "./services/types/ClientCursors";
export type { NetworkConnectionStatus } from "./types/network-connection-status"; export type { NetworkConnectionStatus } from "./types/network-connection-status";
export { DocumentUpdateStatus } from "./types/document-update-status"; export { DocumentUpdateStatus } from "./types/document-update-status";
export { SyncClient } from "./sync-client"; export { SyncClient } from "./sync-client";
// re-export reconcile-text types as they're part of the public API
export type { TextWithCursors, CursorPosition } from "reconcile-text";