From 0ce5787858398aca3e04781040e33f95daf1ad33 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 12 Jul 2025 12:20:54 +0100 Subject: [PATCH] Start using reconcile-text --- backend/Cargo.lock | 4 +- backend/sync_server/Cargo.toml | 2 +- .../src/obsidian-file-system.ts | 41 ++++++++------- .../obsidian-plugin/src/vault-link-plugin.ts | 4 -- ...ditor.ts => get-selections-from-editor.ts} | 4 +- .../cursors/local-cursor-update-listener.ts | 22 ++++---- frontend/package-lock.json | 8 +++ frontend/sync-client/package.json | 1 + .../file-operations/file-operations.test.ts | 8 ++- .../src/file-operations/file-operations.ts | 50 ++++++------------- .../file-operations/filesystem-operations.ts | 12 +---- .../safe-filesystem-operations.ts | 6 +-- frontend/sync-client/src/index.ts | 9 ++-- 13 files changed, 69 insertions(+), 102 deletions(-) rename frontend/obsidian-plugin/src/views/cursors/{get-cursors-from-editor.ts => get-selections-from-editor.ts} (80%) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 153bd4b5..200fcd82 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1700,9 +1700,9 @@ dependencies = [ [[package]] name = "reconcile-text" -version = "0.4.8" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54fa4679b1042b1110aeac9c00fe292339af66426833da724a3fcaae0052b4da" +checksum = "eb6c98d553dd72cd0e863f7cc1c610abd2cc7fe33e24f14262daf1420941cb3d" dependencies = [ "cfg-if", ] diff --git a/backend/sync_server/Cargo.toml b/backend/sync_server/Cargo.toml index 25d4f1df..e854ba5d 100644 --- a/backend/sync_server/Cargo.toml +++ b/backend/sync_server/Cargo.toml @@ -35,7 +35,7 @@ clap-verbosity-flag = "3.0.3" bimap = "0.6.3" ts-rs = { version = "10.1", features = ["uuid-impl", "chrono-impl"] } serde_with = "3.12.0" -reconcile-text = "0.4.8" +reconcile-text = "0.4.10" [lints] workspace = true diff --git a/frontend/obsidian-plugin/src/obsidian-file-system.ts b/frontend/obsidian-plugin/src/obsidian-file-system.ts index adf78a16..a3951991 100644 --- a/frontend/obsidian-plugin/src/obsidian-file-system.ts +++ b/frontend/obsidian-plugin/src/obsidian-file-system.ts @@ -1,13 +1,14 @@ import type { Stat, Vault, Workspace } from "obsidian"; import { MarkdownView, normalizePath } from "obsidian"; import type { + CursorPosition, FileSystemOperations, RelativePath, TextWithCursors } from "sync-client"; import { lineAndColumnToPosition } from "./utils/line-and-column-to-position"; 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 { public constructor( @@ -80,18 +81,18 @@ export class ObsidianFileSystemOperations implements FileSystemOperations { if (view?.file?.path === path) { const text = view.editor.getValue(); - const cursors = getCursorsFromEditor(view.editor).flatMap( - ({ id, start: anchor, end: head }) => [ - { - id: 2 * id, - characterPosition: anchor - }, - { - id: 2 * id + 1, - characterPosition: head - } - ] - ); + const cursors: CursorPosition[] = getSelectionsFromEditor( + view.editor + ).flatMap(({ id, start: anchor, end: head }) => [ + { + id: 2 * id, + position: anchor + }, + { + id: 2 * id + 1, + position: head + } + ]); const result = updater({ text, @@ -105,17 +106,15 @@ export class ObsidianFileSystemOperations implements FileSystemOperations { view.editor.setValue(result.text); const selections = []; - for (let i = 0; i < result.cursors.length / 2; i++) { - const from = result.cursors[2 * i]; - const to = result.cursors[2 * i + 1]; + const resultCursors = result.cursors ?? []; + for (let i = 0; i < resultCursors.length / 2; i++) { + const from = resultCursors[2 * i]; + const to = resultCursors[2 * i + 1]; const { line: fromLine, column: fromColumn } = - positionToLineAndColumn( - result.text, - from.characterPosition - ); + positionToLineAndColumn(result.text, from.position); const { line: toLine, column: toColumn } = - positionToLineAndColumn(result.text, to.characterPosition); + positionToLineAndColumn(result.text, to.position); selections.push({ anchor: { line: fromLine, ch: fromColumn }, diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index 315e2d19..c013e8f7 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -1,9 +1,7 @@ import type { Editor, - EventRef, MarkdownFileInfo, TAbstractFile, - Workspace, WorkspaceLeaf } 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 { LogsView } from "./views/logs/logs-view"; import { StatusDescription } from "./views/status-description/status-description"; -import type { CursorSpan, RelativePath } from "sync-client"; import { SyncClient, rateLimit, DEFAULT_SETTINGS } from "sync-client"; import { ObsidianFileSystemOperations } from "./obsidian-file-system"; import { SyncSettingsTab } from "./views/settings/settings-tab"; @@ -24,7 +21,6 @@ import { remoteCursorsPlugin, setCursors } from "./views/cursors/remote-cursors-plugin"; -import { getCursorsFromEditor } from "./views/cursors/get-cursors-from-editor"; import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener"; const MIN_WAIT_BETWEEN_UPDATES_IN_MS = 250; diff --git a/frontend/obsidian-plugin/src/views/cursors/get-cursors-from-editor.ts b/frontend/obsidian-plugin/src/views/cursors/get-selections-from-editor.ts similarity index 80% rename from frontend/obsidian-plugin/src/views/cursors/get-cursors-from-editor.ts rename to frontend/obsidian-plugin/src/views/cursors/get-selections-from-editor.ts index f5ea0a85..03cce4a8 100644 --- a/frontend/obsidian-plugin/src/views/cursors/get-cursors-from-editor.ts +++ b/frontend/obsidian-plugin/src/views/cursors/get-selections-from-editor.ts @@ -1,13 +1,13 @@ import type { Editor } from "obsidian"; import { lineAndColumnToPosition } from "../../utils/line-and-column-to-position"; -export interface Cursor { +export interface Selection { id: number; start: number; end: number; } -export function getCursorsFromEditor(editor: Editor): Cursor[] { +export function getSelectionsFromEditor(editor: Editor): Selection[] { const text = editor.getValue(); return editor.listSelections().map(({ anchor, head }, i) => ({ id: i, diff --git a/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts b/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts index 99a9828d..883a92ea 100644 --- a/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts +++ b/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts @@ -1,20 +1,20 @@ import type { Workspace } from "obsidian"; -import { EventRef, Editor, MarkdownView, MarkdownFileInfo } from "obsidian"; -import type { Logger, SyncClient } from "sync-client"; -import type { Cursor } from "./get-cursors-from-editor"; -import { getCursorsFromEditor } from "./get-cursors-from-editor"; +import { MarkdownView } from "obsidian"; +import type { SyncClient } from "sync-client"; +import type { Selection } from "./get-selections-from-editor"; +import { getSelectionsFromEditor } from "./get-selections-from-editor"; export class LocalCursorUpdateListener { private static readonly UPDATE_INTERVAL_MS = 50; private readonly eventHandle: NodeJS.Timeout; - private lastCursorState: Record = {}; + private lastCursorState: Record = {}; public constructor( private readonly client: SyncClient, private readonly workspace: Workspace ) { this.eventHandle = setInterval(() => { - this.updateAllCursors(); + this.updateAllSelections(); }, LocalCursorUpdateListener.UPDATE_INTERVAL_MS); } @@ -22,8 +22,8 @@ export class LocalCursorUpdateListener { clearInterval(this.eventHandle); } - private updateAllCursors(): void { - const currentCursors = this.getAllCursors(); + private updateAllSelections(): void { + const currentCursors = this.getAllSelections(); if ( JSON.stringify(this.lastCursorState) === JSON.stringify(currentCursors) @@ -40,8 +40,8 @@ export class LocalCursorUpdateListener { }); } - private getAllCursors(): Record { - const cursors: Record = {}; + private getAllSelections(): Record { + const cursors: Record = {}; this.workspace .getLeavesOfType("markdown") .map((leaf) => leaf.view) @@ -51,7 +51,7 @@ export class LocalCursorUpdateListener { if (!file) { return; } - cursors[file.path] = getCursorsFromEditor(view.editor); + cursors[file.path] = getSelectionsFromEditor(view.editor); }); return cursors; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 330f85ea..9bf856eb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6139,6 +6139,13 @@ "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": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", @@ -7728,6 +7735,7 @@ "@types/jest": "^29.5.14", "@types/node": "^22.15.30", "jest": "^29.7.0", + "reconcile-text": "^0.4.10", "sync_lib": "file:../../backend/sync_lib/pkg", "ts-jest": "^29.3.4", "ts-loader": "^9.5.2", diff --git a/frontend/sync-client/package.json b/frontend/sync-client/package.json index 74efc30a..6eafc0dc 100644 --- a/frontend/sync-client/package.json +++ b/frontend/sync-client/package.json @@ -22,6 +22,7 @@ "@types/jest": "^29.5.14", "@types/node": "^22.15.30", "jest": "^29.7.0", + "reconcile-text": "^0.4.10", "sync_lib": "file:../../backend/sync_lib/pkg", "ts-jest": "^29.3.4", "ts-loader": "^9.5.2", diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts index 2529bab2..1b339e38 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -6,12 +6,10 @@ import type { import { FileOperations } from "./file-operations"; import { Logger } from "../tracing/logger"; import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly"; -import type { - FileSystemOperations, - TextWithCursors -} from "./filesystem-operations"; -import init, { base64ToBytes } from "sync_lib"; +import type { FileSystemOperations } from "./filesystem-operations"; +import init from "sync_lib"; import fs from "fs"; +import { TextWithCursors } from "reconcile-text"; class MockDatabase implements Partial { public getLatestDocumentByRelativePath( diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index e6e42c9d..0bafc6cb 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -1,18 +1,10 @@ import type { Logger } from "../tracing/logger"; -import type { - FileSystemOperations, - TextWithCursors -} from "./filesystem-operations"; +import type { FileSystemOperations } from "./filesystem-operations"; import type { Database, RelativePath } from "../persistence/database"; -import { - CursorPosition, - isBinary, - isFileTypeMergable, - mergeTextWithCursors, - TextWithCursors as RustTextWithCursors -} from "sync_lib"; +import { isFileTypeMergable } from "sync_lib"; import { SafeFileSystemOperations } from "./safe-filesystem-operations"; - +import type { TextWithCursors } from "reconcile-text"; +import { isBinary, reconcile } from "reconcile-text"; export class FileOperations { private static readonly PARENTHESES_REGEX = / \((\d+)\)$/; private readonly fs: SafeFileSystemOperations; @@ -102,39 +94,25 @@ export class FileOperations { await this.fs.atomicUpdateText( path, ({ text, cursors }: TextWithCursors): TextWithCursors => { - text = text.replace(this.nativeLineEndings, "\n"); - this.logger.debug( `Performing a 3-way merge for ${path} with the expected content` ); - const left = new RustTextWithCursors( - text, - cursors.map( - (cursor) => - new CursorPosition( - cursor.id, - cursor.characterPosition - ) - ) + text = text.replace(this.nativeLineEndings, "\n"); + const merged = reconcile( + expectedText, + { text, cursors }, + newText ); - const right = new RustTextWithCursors(newText, []); - const merged = mergeTextWithCursors(expectedText, left, right); - const resultText = merged - .text() - .replace("\n", this.nativeLineEndings); - - const resultCursors = merged.cursors().map((cursor) => ({ - id: cursor.id(), - characterPosition: cursor.characterPosition() - })); - - merged.free(); + const resultText = merged.text.replace( + "\n", + this.nativeLineEndings + ); return { text: resultText, - cursors: resultCursors + cursors: merged.cursors }; } ); diff --git a/frontend/sync-client/src/file-operations/filesystem-operations.ts b/frontend/sync-client/src/file-operations/filesystem-operations.ts index 4caf538d..d5d1eedc 100644 --- a/frontend/sync-client/src/file-operations/filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/filesystem-operations.ts @@ -1,16 +1,6 @@ import type { RelativePath } from "../persistence/database"; -export interface Cursor { - 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[]; -} +import type { TextWithCursors } from "reconcile-text"; export interface FileSystemOperations { // List all files that should be synced. diff --git a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts index 304723b2..6ffe7fc4 100644 --- a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts @@ -1,11 +1,9 @@ import type { RelativePath } from "../persistence/database"; -import type { - FileSystemOperations, - TextWithCursors -} from "./filesystem-operations"; +import type { FileSystemOperations } from "./filesystem-operations"; import type { Logger } from "../tracing/logger"; import { Locks } from "../utils/locks"; import { FileNotFoundError } from "./file-not-found-error"; +import { TextWithCursors } from "reconcile-text"; /** * Decorates `FileSystemOperations` to replace errors with `FileNotFoundError` diff --git a/frontend/sync-client/src/index.ts b/frontend/sync-client/src/index.ts index 0cd94277..3711c1f9 100644 --- a/frontend/sync-client/src/index.ts +++ b/frontend/sync-client/src/index.ts @@ -13,14 +13,13 @@ export { Logger, LogLevel, LogLine } from "./tracing/logger"; export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings"; export { rateLimit } from "./utils/rate-limit"; export type { RelativePath, StoredDatabase } from "./persistence/database"; -export type { - FileSystemOperations, - TextWithCursors, - Cursor -} from "./file-operations/filesystem-operations"; +export type { FileSystemOperations } from "./file-operations/filesystem-operations"; export type { PersistenceProvider } from "./persistence/persistence"; export type { CursorSpan } from "./services/types/CursorSpan"; export type { ClientCursors } from "./services/types/ClientCursors"; export type { NetworkConnectionStatus } from "./types/network-connection-status"; export { DocumentUpdateStatus } from "./types/document-update-status"; 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";