Add cursor moving (#19)

This commit is contained in:
Andras Schmelczer 2025-04-02 22:06:38 +01:00 committed by GitHub
parent 29d8779786
commit 1f9728d893
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1105 additions and 141 deletions

View file

@ -0,0 +1,3 @@
module.exports = {
preset: "ts-jest/presets/js-with-babel-esm"
};

View file

@ -6,7 +6,7 @@
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "jest --passWithNoTests",
"test": "jest",
"version": "node version-bump.mjs"
},
"keywords": [],
@ -36,4 +36,4 @@
"webpack": "^5.98.0",
"webpack-cli": "^6.0.1"
}
}
}

View file

@ -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<string> {
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<number> {

View file

@ -0,0 +1,43 @@
import { lineAndColumnToPosition } from "./line-and-column-to-position";
describe("lineAndColumnToPosition", () => {
it("should return the correct position for the first line", () => {
const text = "Hello\nWorld";
const position = lineAndColumnToPosition(text, 0, 3);
expect(position).toBe(3);
});
it("should return the correct position for the second line", () => {
const text = "Hello\nWorld";
const position = lineAndColumnToPosition(text, 1, 2);
expect(position).toBe(8);
});
it("should return the correct position for an empty string", () => {
const text = "";
const position = lineAndColumnToPosition(text, 0, 0);
expect(position).toBe(0);
});
it("should handle a single-line string correctly", () => {
const text = "SingleLine";
const position = lineAndColumnToPosition(text, 0, 5);
expect(position).toBe(5);
});
it("should handle multi-line strings with varying lengths", () => {
const text = "Line1\nLongerLine2\nShort3";
const position = lineAndColumnToPosition(text, 2, 4);
expect(position).toBe(22);
});
it("should throw an error if the line number is out of range", () => {
const text = "Line1\nLine2";
expect(() => lineAndColumnToPosition(text, 3, 0)).toThrow();
});
it("should throw an error if the column number is out of range", () => {
const text = "Line1\nLine2";
expect(() => lineAndColumnToPosition(text, 1, 10)).toThrow();
});
});

View file

@ -0,0 +1,34 @@
/**
* Converts line and column coordinates to an absolute character position in a text string.
*
* @param line - The zero-based line number
* @param column - The zero-based column number
* @param text - The text string to calculate position in
* @returns The absolute character position (zero-based index) in the text string
* @throws Error if line number is out of range
* @throws Error if column number is out of range
*/
export function lineAndColumnToPosition(
text: string,
line: number,
column: number
): number {
const lines = text.split("\n");
if (line >= lines.length) {
throw new Error(`Line number ${line} is out of range.`);
}
if (column > lines[line].length) {
throw new Error(`Column number ${column} is out of range.`);
}
let position = 0;
for (let i = 0; i < line; i++) {
position += lines[i].length + 1;
}
position += column;
return position;
}

View file

@ -0,0 +1,69 @@
import { positionToLineAndColumn } from "./position-to-line-and-column";
describe("positionToLineAndColumn", () => {
test("converts position to line and column in a single line text", () => {
const text = "Hello, world!";
expect(positionToLineAndColumn(text, 0)).toEqual({
line: 0,
column: 1
});
expect(positionToLineAndColumn(text, 7)).toEqual({
line: 0,
column: 8
});
expect(positionToLineAndColumn(text, 12)).toEqual({
line: 0,
column: 13
});
});
test("converts position to line and column in multi-line text", () => {
const text = "First line\nSecond line\nThird line";
expect(positionToLineAndColumn(text, 0)).toEqual({
line: 0,
column: 1
});
expect(positionToLineAndColumn(text, 10)).toEqual({
line: 0,
column: 11
});
expect(positionToLineAndColumn(text, 15)).toEqual({
line: 1,
column: 5
});
expect(positionToLineAndColumn(text, 26)).toEqual({
line: 2,
column: 4
});
});
test("handles positions at line breaks", () => {
const text = "Line\nBreak";
expect(positionToLineAndColumn(text, 4)).toEqual({
line: 0,
column: 5
});
expect(positionToLineAndColumn(text, 5)).toEqual({
line: 1,
column: 1
});
});
test("handles empty input", () => {
expect(positionToLineAndColumn("", 0)).toEqual({ line: 0, column: 1 });
});
test("handles positions at the end of text", () => {
const text = "End";
expect(positionToLineAndColumn(text, 3)).toEqual({
line: 0,
column: 4
});
});
test("throws error for position out of range", () => {
const text = "Short text";
expect(() => positionToLineAndColumn(text, 15)).toThrow();
expect(() => positionToLineAndColumn(text, -1)).toThrow();
});
});

View file

@ -0,0 +1,30 @@
/**
* Converts a character position in text to line and column numbers.
*
* @param text The text content to analyze
* @param position The character position to convert
* @returns An object containing line and column numbers (0-based index for line, 1-based index for column)
* @throws Will throw an error if the position is negative or exceeds the text length
*/
export function positionToLineAndColumn(
text: string,
position: number
): { line: number; column: number } {
if (position < 0) {
throw new Error("Position cannot be negative");
}
if (position > text.length) {
throw new Error(
`Position ${position} exceeds text length ${text.length}`
);
}
const textUpToPosition = text.substring(0, position);
const lines = textUpToPosition.split("\n");
const line = lines.length - 1; // 0-based index
const column = lines[lines.length - 1].length + 1; // 1-based index
return { line, column };
}

View file

@ -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<string> {
throw new Error("Method not implemented.");
}

View file

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

View file

@ -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<RelativePath[]>;
@ -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<string>;
// Get the size of a file in bytes.

View file

@ -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<string> {
this.logger.debug(`Atomically updating file '${path}'`);
return this.safeOperation(

View file

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

View file

@ -266,6 +266,8 @@ export class Syncer {
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
wsUri.pathname = `/vaults/${settings.vaultName}/ws`;
this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`);
if (
typeof globalThis !== "undefined" &&
typeof globalThis.WebSocket === "undefined"
@ -288,6 +290,7 @@ export class Syncer {
// The JS WebSocket API doesn't support setting headers, so we have to send the token as a message
this.applyRemoteChangesWebSocket.onopen = (): void => {
this.logger.info("WebSocket connection opened");
this.applyRemoteChangesWebSocket?.send(settings.token);
this.webSocketStatusChangeListeners.forEach((listener) => {
listener();
@ -476,7 +479,10 @@ export class Syncer {
.filter(
(remoteDocument) =>
allLocalFiles.includes(remoteDocument.relativePath) &&
!remoteDocument.isDeleted
!remoteDocument.isDeleted &&
this.database.getDocumentByDocumentId(
remoteDocument.documentId
) === undefined
)
.forEach((remoteDocument) => {
this.database.createNewEmptyDocument(

View file

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

View file

@ -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<string> {
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);