Start using reconcile-text
This commit is contained in:
parent
4543180f7b
commit
0ce5787858
13 changed files with 69 additions and 102 deletions
4
backend/Cargo.lock
generated
4
backend/Cargo.lock
generated
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue