Add API for propagating cursor locations #61

Merged
schmelczer merged 30 commits from asch/show-cursors into main 2025-06-08 20:20:53 +01:00
21 changed files with 89 additions and 74 deletions
Showing only changes of commit 57b2b76932 - Show all commits

Lint & format

Andras Schmelczer 2025-06-07 22:24:27 +01:00
No known key found for this signature in database
GPG key ID: FC8F2C3D3D1A718C

View file

@ -1,4 +1,4 @@
import { Editor } from "obsidian"; import type { Editor } from "obsidian";
import { lineAndColumnToPosition } from "./line-and-column-to-position"; import { lineAndColumnToPosition } from "./line-and-column-to-position";
export interface Cursor { export interface Cursor {

View file

@ -0,0 +1,9 @@
export function getRandomColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash << 5) - hash + name.charCodeAt(i);
hash |= 0; // Convert to 32bit integer
}
const normalised = hash / 0x7fffffff;
return `hsl(${Math.abs(normalised * 360)}, 70%, 30%)`; // HSL color
}

View file

@ -9,13 +9,13 @@ import type { Settings } from "../persistence/settings";
import type { ConnectionStatus } from "./connection-status"; import type { ConnectionStatus } from "./connection-status";
import { sleep } from "../utils/sleep"; import { sleep } from "../utils/sleep";
import { SyncResetError } from "./sync-reset-error"; import { SyncResetError } from "./sync-reset-error";
import { SerializedError } from "./types/SerializedError"; import type { SerializedError } from "./types/SerializedError";
import { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
import { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
import { DocumentVersion } from "./types/DocumentVersion"; import type { DocumentVersion } from "./types/DocumentVersion";
import { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse"; import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse";
import { PingResponse } from "./types/PingResponse"; import type { PingResponse } from "./types/PingResponse";
import { DeleteDocumentVersion } from "./types/DeleteDocumentVersion"; import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion";
export interface CheckConnectionResult { export interface CheckConnectionResult {
isSuccessful: boolean; isSuccessful: boolean;
@ -24,8 +24,8 @@ export interface CheckConnectionResult {
export class SyncService { export class SyncService {
private static readonly NETWORK_RETRY_INTERVAL_MS = 1000; private static readonly NETWORK_RETRY_INTERVAL_MS = 1000;
private client: typeof globalThis.fetch; private readonly client: typeof globalThis.fetch;
private pingClient: typeof globalThis.fetch; private readonly pingClient: typeof globalThis.fetch;
public constructor( public constructor(
private readonly deviceId: string, private readonly deviceId: string,
@ -35,7 +35,7 @@ export class SyncService {
fetchImplementation: typeof globalThis.fetch = globalThis.fetch fetchImplementation: typeof globalThis.fetch = globalThis.fetch
) { ) {
// ensure that if it's called a method, `this` won't be bound to the instance // ensure that if it's called a method, `this` won't be bound to the instance
const unboundFetch: typeof globalThis.fetch = (...args) => const unboundFetch: typeof globalThis.fetch = async (...args) =>
fetchImplementation(...args); fetchImplementation(...args);
this.client = this.connectionStatus.getFetchImplementation( this.client = this.connectionStatus.getFetchImplementation(
@ -45,12 +45,6 @@ export class SyncService {
this.pingClient = unboundFetch; this.pingClient = unboundFetch;
} }
private getUrl(path: string): string {
let { vaultName, remoteUri } = this.settings.getSettings();
remoteUri = remoteUri.replace(/\/+$/, "");
return `${remoteUri}/vaults/${vaultName}${path}`;
}
private static formatError(error: SerializedError): string { private static formatError(error: SerializedError): string {
let result = error.message; let result = error.message;
if (error.causes.length > 0) { if (error.causes.length > 0) {
@ -85,7 +79,9 @@ export class SyncService {
}); });
const result: SerializedError | DocumentVersionWithoutContent = const result: SerializedError | DocumentVersionWithoutContent =
await response.json(); (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
| SerializedError
| DocumentVersionWithoutContent;
if ("errorType" in result) { if ("errorType" in result) {
throw new Error( throw new Error(
@ -133,7 +129,9 @@ export class SyncService {
); );
const result: SerializedError | DocumentUpdateResponse = const result: SerializedError | DocumentUpdateResponse =
await response.json(); (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
| SerializedError
| DocumentUpdateResponse;
if ("errorType" in result) { if ("errorType" in result) {
throw new Error( throw new Error(
@ -175,7 +173,9 @@ export class SyncService {
); );
const result: SerializedError | DocumentVersionWithoutContent = const result: SerializedError | DocumentVersionWithoutContent =
await response.json(); (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
| SerializedError
| DocumentVersionWithoutContent;
if ("errorType" in result) { if ("errorType" in result) {
throw new Error( throw new Error(
@ -205,7 +205,7 @@ export class SyncService {
); );
const result: SerializedError | DocumentVersion = const result: SerializedError | DocumentVersion =
await response.json(); (await response.json()) as SerializedError | DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
if ("errorType" in result) { if ("errorType" in result) {
throw new Error( throw new Error(
@ -234,7 +234,9 @@ export class SyncService {
}); });
const result: SerializedError | FetchLatestDocumentsResponse = const result: SerializedError | FetchLatestDocumentsResponse =
await response.json(); (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
| SerializedError
| FetchLatestDocumentsResponse;
if ("errorType" in result) { if ("errorType" in result) {
throw new Error( throw new Error(
@ -256,7 +258,7 @@ export class SyncService {
headers: this.getDefaultHeaders() headers: this.getDefaultHeaders()
}); });
const result: PingResponse | SerializedError = const result: PingResponse | SerializedError =
await response.json(); (await response.json()) as PingResponse | SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
if ("errorType" in result) { if ("errorType" in result) {
throw new Error( throw new Error(
@ -283,6 +285,12 @@ export class SyncService {
} }
} }
private getUrl(path: string): string {
const { vaultName, remoteUri } = this.settings.getSettings();
const safeRemoteUri = remoteUri.replace(/\/+$/, "");
return `${safeRemoteUri}/vaults/${vaultName}${path}`;
}
private getDefaultHeaders(): Record<string, string> { private getDefaultHeaders(): Record<string, string> {
return { return {
"device-id": this.deviceId, "device-id": this.deviceId,

View file

@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorSpan } from "./CursorSpan"; import type { CursorSpan } from "./CursorSpan";
export type ClientCursors = { userName: string, deviceId: string, cursors: { [key in string]?: Array<CursorSpan> }, }; export interface ClientCursors { userName: string, deviceId: string, cursors: Partial<Record<string, CursorSpan[]>>, }

View file

@ -1,10 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CreateDocumentVersion = { export interface CreateDocumentVersion {
/** /**
* The client can decide the document id (if it wishes to) in order * The client can decide the document id (if it wishes to) in order
* to help with syncing. If the client does not provide a document id, * to help with syncing. If the client does not provide a document id,
* the server will generate one. If the client provides a document id * the server will generate one. If the client provides a document id
* it must not already exist in the database. * it must not already exist in the database.
*/ */
document_id: string | null, relative_path: string, content: Array<number>, }; document_id: string | null, relative_path: string, content: number[], }

View file

@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CursorSpan } from "./CursorSpan"; import type { CursorSpan } from "./CursorSpan";
export type CursorPositionFromClient = { documentToCursors: { [key in string]?: Array<CursorSpan> }, }; export interface CursorPositionFromClient { documentToCursors: Partial<Record<string, CursorSpan[]>>, }

View file

@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ClientCursors } from "./ClientCursors"; import type { ClientCursors } from "./ClientCursors";
export type CursorPositionFromServer = { clients: Array<ClientCursors>, }; export interface CursorPositionFromServer { clients: ClientCursors[], }

View file

@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CursorSpan = { start: number, end: number, }; export interface CursorSpan { start: number, end: number, }

View file

@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DeleteDocumentVersion = { relativePath: string, }; export interface DeleteDocumentVersion { relativePath: string, }

View file

@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DocumentVersion = { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }; export interface DocumentVersion { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }

View file

@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DocumentVersionWithoutContent = { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, }; export interface DocumentVersionWithoutContent { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, }

View file

@ -4,8 +4,8 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
/** /**
* Response to a fetch latest documents request. * Response to a fetch latest documents request.
*/ */
export type FetchLatestDocumentsResponse = { latestDocuments: Array<DocumentVersionWithoutContent>, export interface FetchLatestDocumentsResponse { latestDocuments: DocumentVersionWithoutContent[],
/** /**
* The update ID of the latest document in the response. * The update ID of the latest document in the response.
*/ */
lastUpdateId: bigint, }; lastUpdateId: bigint, }

View file

@ -3,7 +3,7 @@
/** /**
* Response to a ping request. * Response to a ping request.
*/ */
export type PingResponse = { export interface PingResponse {
/** /**
* Semantic version of the server. * Semantic version of the server.
*/ */
@ -12,4 +12,4 @@ serverVersion: string,
* Whether the client is authenticated based on the sent Authorization * Whether the client is authenticated based on the sent Authorization
* header. * header.
*/ */
isAuthenticated: boolean, }; isAuthenticated: boolean, }

View file

@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SerializedError = { errorType: string, message: string, causes: Array<string>, }; export interface SerializedError { errorType: string, message: string, causes: string[], }

View file

@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type UpdateDocumentVersion = { parent_version_id: bigint, relative_path: string, content: Array<number>, }; export interface UpdateDocumentVersion { parent_version_id: bigint, relative_path: string, content: number[], }

View file

@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WebSocketHandshake = { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, }; export interface WebSocketHandshake { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, }

View file

@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
export type WebSocketVaultUpdate = { documents: Array<DocumentVersionWithoutContent>, isInitialSync: boolean, }; export interface WebSocketVaultUpdate { documents: DocumentVersionWithoutContent[], isInitialSync: boolean, }

View file

@ -1,11 +1,11 @@
import type { Database } from "../persistence/database"; import type { Database } from "../persistence/database";
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import type { Settings, SyncSettings } from "../persistence/settings"; import type { Settings, SyncSettings } from "../persistence/settings";
import { WebSocketServerMessage } from "./types/WebSocketServerMessage"; import type { WebSocketServerMessage } from "./types/WebSocketServerMessage";
import { Syncer } from "../sync-operations/syncer"; import type { Syncer } from "../sync-operations/syncer";
import { WebSocketClientMessage } from "./types/WebSocketClientMessage"; import type { WebSocketClientMessage } from "./types/WebSocketClientMessage";
import { CursorPositionFromClient } from "./types/CursorPositionFromClient"; import type { CursorPositionFromClient } from "./types/CursorPositionFromClient";
import { ClientCursors } from "./types/ClientCursors"; import type { ClientCursors } from "./types/ClientCursors";
export class WebSocketManager { export class WebSocketManager {
private readonly webSocketStatusChangeListeners: (() => unknown)[] = []; private readonly webSocketStatusChangeListeners: (() => unknown)[] = [];
@ -19,7 +19,6 @@ export class WebSocketManager {
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket; private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
// eslint-disable-next-line @typescript-eslint/max-params
public constructor( public constructor(
private readonly deviceId: string, private readonly deviceId: string,
private readonly logger: Logger, private readonly logger: Logger,
@ -90,6 +89,23 @@ export class WebSocketManager {
} }
} }
public updateLocalCursors(cursorPositions: CursorPositionFromClient): void {
if (!this.isWebSocketConnected) {
this.logger.warn(
"WebSocket is not connected, cannot send cursor positions"
);
return;
}
const message: WebSocketClientMessage = {
type: "cursorPositions",
...cursorPositions
};
this.webSocket?.send(JSON.stringify(message));
this.logger.info(
`Sent cursor positions: ${JSON.stringify(cursorPositions)}`
);
}
private updateWebSocket(settings: SyncSettings): void { private updateWebSocket(settings: SyncSettings): void {
try { try {
this.webSocket?.close(); this.webSocket?.close();
@ -134,6 +150,7 @@ export class WebSocketManager {
`Failed to sync remotely updated file: ${e}` `Failed to sync remotely updated file: ${e}`
); );
} }
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (message.type === "cursorPositions") { } else if (message.type === "cursorPositions") {
this.logger.info( this.logger.info(
`Received cursor positions for ${JSON.stringify(message.clients)}` `Received cursor positions for ${JSON.stringify(message.clients)}`
@ -159,7 +176,7 @@ export class WebSocketManager {
listener(); listener();
}); });
let message: WebSocketClientMessage = { const message: WebSocketClientMessage = {
type: "handshake", type: "handshake",
deviceId: this.deviceId, deviceId: this.deviceId,
token: settings.token, token: settings.token,
@ -178,23 +195,6 @@ export class WebSocketManager {
}; };
} }
public updateLocalCursors(cursorPositions: CursorPositionFromClient): void {
if (!this.isWebSocketConnected) {
this.logger.warn(
"WebSocket is not connected, cannot send cursor positions"
);
return;
}
let message: WebSocketClientMessage = {
type: "cursorPositions",
...cursorPositions
};
this.webSocket?.send(JSON.stringify(message));
this.logger.info(
`Sent cursor positions: ${JSON.stringify(cursorPositions)}`
);
}
private setWebSocketRefreshInterval(): void { private setWebSocketRefreshInterval(): void {
this.refreshWebSocketInterval = setInterval(() => { this.refreshWebSocketInterval = setInterval(() => {
if ( if (

View file

@ -19,8 +19,8 @@ import type { NetworkConnectionStatus } from "./types/network-connection-status"
import { DocumentUpdateStatus } from "./types/document-update-status"; import { DocumentUpdateStatus } from "./types/document-update-status";
import { WebSocketManager } from "./services/websocket-manager"; import { WebSocketManager } from "./services/websocket-manager";
import { createClientId } from "./utils/create-client-id"; import { createClientId } from "./utils/create-client-id";
import { CursorSpan } from "./services/types/CursorSpan"; import type { CursorSpan } from "./services/types/CursorSpan";
import { ClientCursors } from "./services/types/ClientCursors"; import type { ClientCursors } from "./services/types/ClientCursors";
export class SyncClient { export class SyncClient {
private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000; private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000;
@ -275,10 +275,8 @@ export class SyncClient {
}); });
} }
public async updateLocalCursors(documentToCursors: { public async updateLocalCursors(documentToCursors: Record<RelativePath, CursorSpan[]>): Promise<void> {
[path: RelativePath]: CursorSpan[]; this.webSocketManager.updateLocalCursors({ documentToCursors });
}): Promise<void> {
return this.webSocketManager.updateLocalCursors({ documentToCursors });
} }
public addRemoteCursorsUpdateListener( public addRemoteCursorsUpdateListener(

View file

@ -16,7 +16,7 @@ import type { UnrestrictedSyncer } from "./unrestricted-syncer";
import { createPromise } from "../utils/create-promise"; import { createPromise } from "../utils/create-promise";
import { SyncResetError } from "../services/sync-reset-error"; import { SyncResetError } from "../services/sync-reset-error";
import { Locks } from "../utils/locks"; import { Locks } from "../utils/locks";
import { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
export class Syncer { export class Syncer {
private readonly remoteDocumentsLock: Locks<DocumentId>; private readonly remoteDocumentsLock: Locks<DocumentId>;

View file

@ -24,9 +24,9 @@ import { createPromise } from "../utils/create-promise";
import { FileNotFoundError } from "../file-operations/file-not-found-error"; import { FileNotFoundError } from "../file-operations/file-not-found-error";
import { SyncResetError } from "../services/sync-reset-error"; import { SyncResetError } from "../services/sync-reset-error";
import { globsToRegexes } from "../utils/globs-to-regexes"; import { globsToRegexes } from "../utils/globs-to-regexes";
import { DocumentVersion } from "../services/types/DocumentVersion"; import type { DocumentVersion } from "../services/types/DocumentVersion";
import { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse"; import type { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse";
import { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
export class UnrestrictedSyncer { export class UnrestrictedSyncer {
private ignorePatterns: RegExp[]; private ignorePatterns: RegExp[];