Start using reconcile-text

This commit is contained in:
Andras Schmelczer 2025-07-12 12:20:54 +01:00
parent 4543180f7b
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]]
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",
]

View file

@ -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

View file

@ -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 },

View file

@ -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;

View file

@ -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,

View file

@ -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<string, Cursor[]> = {};
private lastCursorState: Record<string, Selection[]> = {};
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<string, Cursor[]> {
const cursors: Record<string, Cursor[]> = {};
private getAllSelections(): Record<string, Selection[]> {
const cursors: Record<string, Selection[]> = {};
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;
}

View file

@ -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",

View file

@ -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",

View file

@ -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<Database> {
public getLatestDocumentByRelativePath(

View file

@ -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
};
}
);

View file

@ -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.

View file

@ -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`

View file

@ -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";