diff --git a/frontend/obsidian-plugin/src/obsidian-file-system.ts b/frontend/obsidian-plugin/src/obsidian-file-system.ts index 24e06af9..60c49328 100644 --- a/frontend/obsidian-plugin/src/obsidian-file-system.ts +++ b/frontend/obsidian-plugin/src/obsidian-file-system.ts @@ -1,6 +1,12 @@ import type { Stat, Vault, Workspace } from "obsidian"; import { MarkdownView, normalizePath } from "obsidian"; -import type { FileSystemOperations, RelativePath } from "sync-client"; +import type { + FileSystemOperations, + RelativePath, + TextWithCursors +} from "sync-client"; +import { lineAndColumnToPosition } from "./utils/line-and-column-to-position"; +import { positionToLineAndColumn } from "./utils/position-to-line-and-column"; export class ObsidianFileSystemOperations implements FileSystemOperations { public constructor( @@ -42,20 +48,50 @@ export class ObsidianFileSystemOperations implements FileSystemOperations { public async atomicUpdateText( path: RelativePath, - updater: (currentContent: string) => string + updater: (current: TextWithCursors) => TextWithCursors ): Promise { path = normalizePath(path); const view = this.workspace.getActiveViewOfType(MarkdownView); + if (view?.file?.path === path) { - const result = updater(view.editor.getValue()); - const position = view.editor.getCursor(); - view.editor.setValue(result); - view.editor.setCursor(position); - return result; + const cursor = view.editor.getCursor(); + const text = view.editor.getValue(); + const result = updater({ + text, + cursors: [ + { + id: 0, + characterPosition: lineAndColumnToPosition( + text, + cursor.line, + cursor.ch + ) + } + ] + }); + + view.editor.setValue(result.text); + + result.cursors.forEach((movedCursor) => { + const { line, column } = positionToLineAndColumn( + result.text, + movedCursor.characterPosition + ); + view.editor.setCursor(line, column); + }); + + return result.text; } - return this.vault.adapter.process(path, updater); + return this.vault.adapter.process( + path, + (text) => + updater({ + text, + cursors: [] + }).text + ); } public async getFileSize(path: RelativePath): Promise { 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 4f7dd491..2529bab2 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -6,7 +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 } from "./filesystem-operations"; +import type { + FileSystemOperations, + TextWithCursors +} from "./filesystem-operations"; import init, { base64ToBytes } from "sync_lib"; import fs from "fs"; @@ -43,7 +46,7 @@ class FakeFileSystemOperations implements FileSystemOperations { } public async atomicUpdateText( _path: RelativePath, - _updater: (currentContent: string) => string + _updater: (current: TextWithCursors) => TextWithCursors ): Promise { throw new Error("Method not implemented."); } diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 6cac74f3..e6e42c9d 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -1,7 +1,16 @@ import type { Logger } from "../tracing/logger"; -import type { FileSystemOperations } from "./filesystem-operations"; +import type { + FileSystemOperations, + TextWithCursors +} from "./filesystem-operations"; import type { Database, RelativePath } from "../persistence/database"; -import { isBinary, isFileTypeMergable, mergeText } from "sync_lib"; +import { + CursorPosition, + isBinary, + isFileTypeMergable, + mergeTextWithCursors, + TextWithCursors as RustTextWithCursors +} from "sync_lib"; import { SafeFileSystemOperations } from "./safe-filesystem-operations"; export class FileOperations { @@ -90,18 +99,45 @@ export class FileOperations { const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings - await this.fs.atomicUpdateText(path, (currentText) => { - currentText = currentText.replace(this.nativeLineEndings, "\n"); + 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` - ); + this.logger.debug( + `Performing a 3-way merge for ${path} with the expected content` + ); - return mergeText(expectedText, currentText, newText).replace( - "\n", - this.nativeLineEndings - ); - }); + const left = new RustTextWithCursors( + text, + cursors.map( + (cursor) => + new CursorPosition( + cursor.id, + cursor.characterPosition + ) + ) + ); + 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(); + + return { + text: resultText, + cursors: resultCursors + }; + } + ); } public async delete(path: RelativePath): Promise { diff --git a/frontend/sync-client/src/file-operations/filesystem-operations.ts b/frontend/sync-client/src/file-operations/filesystem-operations.ts index 19d319ba..175490d4 100644 --- a/frontend/sync-client/src/file-operations/filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/filesystem-operations.ts @@ -1,5 +1,15 @@ import type { RelativePath } from "../persistence/database"; +export interface Cursor { + id: number; + characterPosition: number; +} + +export interface TextWithCursors { + text: string; + cursors: Cursor[]; +} + export interface FileSystemOperations { // List all files that should be synced. listAllFiles: () => Promise; @@ -13,7 +23,7 @@ export interface FileSystemOperations { // Atomically update the content of a text file. atomicUpdateText: ( path: RelativePath, - updater: (currentContent: string) => string + updater: (current: TextWithCursors) => TextWithCursors ) => Promise; // Get the size of a file in bytes. 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 8b2a547a..433f1d75 100644 --- a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts @@ -1,5 +1,8 @@ import type { RelativePath } from "../persistence/database"; -import type { FileSystemOperations } from "./filesystem-operations"; +import type { + FileSystemOperations, + TextWithCursors +} from "./filesystem-operations"; import type { Logger } from "../tracing/logger"; import { Locks } from "../utils/locks"; import { FileNotFoundError } from "./file-not-found-error"; @@ -44,7 +47,7 @@ export class SafeFileSystemOperations implements FileSystemOperations { public async atomicUpdateText( path: RelativePath, - updater: (currentContent: string) => string + updater: (current: TextWithCursors) => TextWithCursors ): Promise { this.logger.debug(`Atomically updating file '${path}'`); return this.safeOperation( diff --git a/frontend/sync-client/src/index.ts b/frontend/sync-client/src/index.ts index 0a03d0ae..e5760ead 100644 --- a/frontend/sync-client/src/index.ts +++ b/frontend/sync-client/src/index.ts @@ -8,7 +8,11 @@ export { Logger, LogLevel, LogLine } from "./tracing/logger"; export { type SyncSettings } from "./persistence/settings"; export { rateLimit } from "./utils/rate-limit"; export type { RelativePath, StoredDatabase } from "./persistence/database"; -export type { FileSystemOperations } from "./file-operations/filesystem-operations"; +export type { + FileSystemOperations, + TextWithCursors, + Cursor +} from "./file-operations/filesystem-operations"; export type { PersistenceProvider } from "./persistence/persistence"; export type { NetworkConnectionStatus } from "./sync-client"; diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts index 945fd7dd..9939d53c 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -313,7 +313,10 @@ export class MockAgent extends MockClient { `Decided to update file ${file} with ${content}` ); this.doNotTouchWhileOffline.push(file); - await this.atomicUpdateText(file, (old) => old + ` ${content} `); + await this.atomicUpdateText(file, (old) => ({ + text: old.text + ` ${content} `, + cursors: [] + })); } private async deleteFileAction(files: RelativePath[]): Promise { diff --git a/frontend/test-client/src/agent/mock-client.ts b/frontend/test-client/src/agent/mock-client.ts index 5aa3dd6c..29d808f8 100644 --- a/frontend/test-client/src/agent/mock-client.ts +++ b/frontend/test-client/src/agent/mock-client.ts @@ -1,4 +1,4 @@ -import type { StoredDatabase } from "sync-client"; +import type { StoredDatabase, TextWithCursors } from "sync-client"; import { assert } from "../utils/assert"; import { type RelativePath, @@ -87,14 +87,14 @@ export class MockClient implements FileSystemOperations { public async atomicUpdateText( path: RelativePath, - updater: (currentContent: string) => string + updater: (currentContent: TextWithCursors) => TextWithCursors ): Promise { const file = this.localFiles.get(path); if (!file) { throw new Error(`File ${path} does not exist`); } const currentContent = new TextDecoder().decode(file); - const newContent = updater(currentContent); + const newContent = updater({ text: currentContent, cursors: [] }).text; const newContentUint8Array = new TextEncoder().encode(newContent); this.localFiles.set(path, newContentUint8Array);