diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index 90ab1a73..e8453d46 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -1,10 +1,10 @@ import type { + MarkdownView, Editor, MarkdownFileInfo, TAbstractFile, WorkspaceLeaf } from "obsidian"; -import type { MarkdownView } from "obsidian"; import { Platform, Plugin, TFile } from "obsidian"; import "../manifest.json"; import { HistoryView } from "./views/history/history-view"; @@ -15,7 +15,7 @@ import { SyncClient, rateLimit, DEFAULT_SETTINGS, Logger } from "sync-client"; import { ObsidianFileSystemOperations } from "./obsidian-file-system"; import { SyncSettingsTab } from "./views/settings/settings-tab"; import { logToConsole } from "./utils/log-to-console"; -import { updateEditorStatusDisplay } from "./views/editor-sync-line/editor-sync-line"; +import { EditorStatusDisplayManager } from "./views/editor-status-display-manager/editor-status-display-manager"; import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme"; import { remoteCursorsPlugin, @@ -124,17 +124,23 @@ export default class VaultLinkPlugin extends Plugin { this.registerEditorEvents(); await this.client.start(); - const interval = setInterval(() => { - updateEditorStatusDisplay(this.app.workspace, this.client); - }, 200); + const editorStatusDisplayManager = new EditorStatusDisplayManager( + this, + this.app.workspace, + this.client + ); this.disposables.push(() => { - clearInterval(interval); + editorStatusDisplayManager.stop(); }); }); } public onunload(): void { - this.client.stop(); + this.client.waitAndStop().catch((err: unknown) => { + this.client.logger.error( + `Error while stopping the sync client: ${err}` + ); + }); this.disposables.forEach((disposable) => { disposable(); }); diff --git a/frontend/obsidian-plugin/src/views/editor-sync-line/editor-sync-line.scss b/frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.scss similarity index 100% rename from frontend/obsidian-plugin/src/views/editor-sync-line/editor-sync-line.scss rename to frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.scss diff --git a/frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.ts b/frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.ts new file mode 100644 index 00000000..5075b847 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.ts @@ -0,0 +1,97 @@ +import type { Workspace } from "obsidian"; +import { FileView, setIcon } from "obsidian"; +import type { SyncClient } from "sync-client"; +import { DocumentSyncStatus } from "sync-client"; +import "./editor-status-display-manager.scss"; +import type VaultLinkPlugin from "src/vault-link-plugin"; +import { HistoryView } from "../history/history-view"; + +export class EditorStatusDisplayManager { + private static readonly UPDATE_INTERVAL_IN_MS = 100; + + private readonly intervalId: NodeJS.Timeout; + private readonly lastStatuses = new Map(); + + public constructor( + private readonly plugin: VaultLinkPlugin, + private readonly workspace: Workspace, + private readonly client: SyncClient + ) { + this.intervalId = setInterval(() => { + this.updateEditorStatusDisplay(); + }, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS); + } + + public stop(): void { + clearInterval(this.intervalId); + } + + private updateEditorStatusDisplay(): void { + this.workspace.iterateAllLeaves((leaf) => { + if (leaf.view instanceof FileView) { + const filePath = leaf.view.file?.path; + if (filePath == null) { + return; + } + + const element = this.getElementFromLeaf(leaf.view); + if (element == null) { + return; + } + + const previousStatus = this.lastStatuses.get(filePath); + const currentStatus = + this.client.getDocumentSyncingStatus(filePath); + if (previousStatus === currentStatus) { + return; + } + this.lastStatuses.set(filePath, currentStatus); + + if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) { + element.remove(); + return; + } + + if (currentStatus == DocumentSyncStatus.SYNCING) { + element.classList.add("loading"); + } else { + element.classList.remove("loading"); + } + + const iconContainer = element.querySelector(".icon"); + if (iconContainer != null) { + setIcon( + iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + currentStatus == DocumentSyncStatus.SYNCING + ? "loader" + : "circle-check" + ); + } + } + }); + } + + private getElementFromLeaf(fileView: FileView): Element | undefined { + const parent = fileView.contentEl.querySelector(".cm-editor"); + if (parent == null) { + return; + } + + return ( + parent.querySelector(".vault-link-sync-status") ?? + parent.createDiv( + { + cls: "vault-link-sync-status" + }, + (el) => { + el.createSpan({ text: "VaultLink sync state" }); + el.createDiv({ + cls: "icon" + }); + el.onclick = async (): Promise => + this.plugin.activateView(HistoryView.TYPE); + } + ) + ); + } +} diff --git a/frontend/obsidian-plugin/src/views/editor-sync-line/editor-sync-line.ts b/frontend/obsidian-plugin/src/views/editor-sync-line/editor-sync-line.ts deleted file mode 100644 index 78ef1bd8..00000000 --- a/frontend/obsidian-plugin/src/views/editor-sync-line/editor-sync-line.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Workspace } from "obsidian"; -import { FileView, setIcon } from "obsidian"; -import type { SyncClient } from "sync-client"; -import { DocumentSyncStatus } from "sync-client"; -import "./editor-sync-line.scss"; - -export function updateEditorStatusDisplay( - workspace: Workspace, - client: SyncClient -): void { - workspace.iterateAllLeaves((leaf) => { - if (leaf.view instanceof FileView) { - const filePath = leaf.view.file?.path; - if (filePath == null) { - return; - } - const parent = leaf.view.contentEl.querySelector(".cm-editor"); - if (parent == null) { - return; - } - - const element = - parent.querySelector(".vault-link-sync-status") ?? - parent.createDiv( - { - cls: "vault-link-sync-status" - }, - (el) => { - el.createSpan({ text: "VaultLink sync state" }); - el.createDiv({ - cls: "icon" - }); - } - ); - - const isLoading = - client.getDocumentSyncingStatus(filePath) == - DocumentSyncStatus.SYNCING; - - if (isLoading) { - element.classList.add("loading"); - } else { - element.classList.remove("loading"); - } - - const iconContainer = element.querySelector(".icon"); - if (iconContainer != null) { - setIcon( - iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - isLoading ? "loader" : "circle-check" - ); - } - } - }); -} diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index dde8f068..a30774f4 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -13,10 +13,11 @@ export class WebSocketManager { cursors: ClientCursors[] ) => unknown)[] = []; - private refreshWebSocketInterval: NodeJS.Timeout | undefined; - private webSocket: WebSocket | undefined; + private isStopped = true; + private _isFirstSyncCompleted = false; + private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket; public constructor( @@ -41,20 +42,15 @@ export class WebSocketManager { } } - this.updateWebSocket(settings.getSettings()); - settings.addOnSettingsChangeListener((newSettings, oldSettings) => { if ( newSettings.remoteUri !== oldSettings.remoteUri || newSettings.vaultName !== oldSettings.vaultName || - newSettings.token !== oldSettings.token || - newSettings.isSyncEnabled !== oldSettings.isSyncEnabled + newSettings.token !== oldSettings.token ) { - this.updateWebSocket(newSettings); + this.initializeWebSocket(newSettings); } }); - - this.setWebSocketRefreshInterval(); } public get isWebSocketConnected(): boolean { @@ -64,6 +60,10 @@ export class WebSocketManager { ); } + public get isFirstSyncCompleted(): boolean { + return this._isFirstSyncCompleted; + } + public addWebSocketStatusChangeListener(listener: () => unknown): void { this.webSocketStatusChangeListeners.push(listener); } @@ -74,19 +74,15 @@ export class WebSocketManager { this.remoteCursorsUpdateListeners.push(listener); } - public async reset(): Promise { - this.setWebSocketRefreshInterval(); - this.updateWebSocket(this.settings.getSettings()); + public start(): void { + this.isStopped = false; + this._isFirstSyncCompleted = false; + this.initializeWebSocket(this.settings.getSettings()); } public stop(): void { - clearInterval(this.refreshWebSocketInterval); - - try { - this.webSocket?.close(); - } catch (e) { - this.logger.warn(`Failed to close WebSocket: ${e}`); - } + this.isStopped = true; + this.webSocket?.close(1000, "WebSocketManager has been stopped"); } public updateLocalCursors(cursorPositions: CursorPositionFromClient): void { @@ -101,23 +97,22 @@ export class WebSocketManager { ...cursorPositions }; this.webSocket?.send(JSON.stringify(message)); - this.logger.info( + this.logger.debug( `Sent cursor positions: ${JSON.stringify(cursorPositions)}` ); } - private updateWebSocket(settings: SyncSettings): void { + private initializeWebSocket(settings: SyncSettings): void { + if (this.isStopped) { + return; + } + try { this.webSocket?.close(); } catch (e) { this.logger.warn(`Failed to close WebSocket: ${e}`); } - if (!settings.isSyncEnabled) { - this.webSocket = undefined; - return; - } - const wsUri = new URL(settings.remoteUri); wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws"; wsUri.pathname = `/vaults/${settings.vaultName}/ws`; @@ -126,55 +121,10 @@ export class WebSocketManager { this.webSocket = new this.webSocketFactoryImplementation(wsUri); - this.webSocket.onmessage = async (event): Promise => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const message = JSON.parse(event.data) as WebSocketServerMessage; - - if (message.type === "vaultUpdate") { - try { - await Promise.all( - message.documents.map(async (document) => - this.syncer.syncRemotelyUpdatedFile(document) - ) - ); - - if (message.isInitialSync && message.documents.length > 0) { - this.database.setLastSeenUpdateId( - message.documents - .map((document) => document.vaultUpdateId) - .reduce((a, b) => Math.max(a, b)) - ); - } - } catch (e) { - this.logger.error( - `Failed to sync remotely updated file: ${e}` - ); - } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - } else if (message.type === "cursorPositions") { - this.logger.debug( - `Received cursor positions for ${JSON.stringify(message.clients)}` - ); - this.remoteCursorsUpdateListeners.forEach((listener) => { - listener( - message.clients.filter( - (client) => client.deviceId !== this.deviceId - ) - ); - }); - } else { - this.logger.warn( - `Received unknown message type: ${JSON.stringify(message)}` - ); - } - }; - // The JS WebSocket API doesn't support setting headers, so we have to send the token as a message this.webSocket.onopen = (): void => { this.logger.info("WebSocket connection opened"); - this.webSocketStatusChangeListeners.forEach((listener) => { - listener(); - }); + this.webSocketStatusChangeListeners.forEach((l) => l()); const message: WebSocketClientMessage = { type: "handshake", @@ -185,25 +135,65 @@ export class WebSocketManager { this.webSocket?.send(JSON.stringify(message)); }; + this.webSocket.onmessage = async (event): Promise => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const message = JSON.parse(event.data) as WebSocketServerMessage; + return this.handleWebSocketMessage(message); + }; + this.webSocket.onclose = (event): void => { this.logger.warn( `WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})` ); - this.webSocketStatusChangeListeners.forEach((listener) => { - listener(); - }); + this.webSocketStatusChangeListeners.forEach((l) => l()); + + if (!this.isStopped) { + setTimeout(() => { + this.initializeWebSocket(this.settings.getSettings()); + }, this.settings.getSettings().webSocketRetryIntervalMs); + } }; } - private setWebSocketRefreshInterval(): void { - this.refreshWebSocketInterval = setInterval(() => { - if ( - this.webSocket?.readyState === - this.webSocketFactoryImplementation.CLOSED - ) { - this.logger.info("WebSocket is closed, reconnecting..."); - this.updateWebSocket(this.settings.getSettings()); + private async handleWebSocketMessage( + message: WebSocketServerMessage + ): Promise { + if (message.type === "vaultUpdate") { + try { + await Promise.all( + message.documents.map(async (document) => + this.syncer.syncRemotelyUpdatedFile(document) + ) + ); + + if (message.isInitialSync && message.documents.length > 0) { + this.database.setLastSeenUpdateId( + message.documents + .map((document) => document.vaultUpdateId) + .reduce((a, b) => Math.max(a, b)) + ); + } + + this._isFirstSyncCompleted = true; + } catch (e) { + this.logger.error(`Failed to sync remotely updated file: ${e}`); } - }, this.settings.getSettings().webSocketRetryIntervalMs); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (message.type === "cursorPositions") { + this.logger.debug( + `Received cursor positions for ${JSON.stringify(message.clients)}` + ); + this.remoteCursorsUpdateListeners.forEach((listener) => { + listener( + message.clients.filter( + (client) => client.deviceId !== this.deviceId + ) + ); + }); + } else { + this.logger.warn( + `Received unknown message type: ${JSON.stringify(message)}` + ); + } } } diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index ddab8860..c8be6e23 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -24,6 +24,7 @@ import { FileChangeNotifier } from "./sync-operations/file-change-notifier"; export class SyncClient { private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000; + private hasFinishedOfflineSync = false; // eslint-disable-next-line @typescript-eslint/max-params private constructor( @@ -43,6 +44,14 @@ export class SyncClient { if (newSettings.vaultName !== oldSettings.vaultName) { await this.reset(); } + + if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) { + if (newSettings.isSyncEnabled) { + await this.start(); + } else { + this.stop(); + } + } } ); } @@ -198,9 +207,12 @@ export class SyncClient { public async start(): Promise { await this.syncer.scheduleSyncForOfflineChanges(); + this.hasFinishedOfflineSync = true; + this.webSocketManager.start(); } public stop(): void { + this.hasFinishedOfflineSync = false; this.webSocketManager.stop(); } @@ -216,7 +228,6 @@ export class SyncClient { this.stop(); this.connectionStatus.startReset(); await this.syncer.reset(); - await this.webSocketManager.reset(); this.history.reset(); this.database.reset(); this._logger.reset(); @@ -286,6 +297,17 @@ export class SyncClient { public getDocumentSyncingStatus( relativePath: RelativePath ): DocumentSyncStatus { + if (!this.settings.getSettings().isSyncEnabled) { + return DocumentSyncStatus.SYNCING_IS_DISABLED; + } + + if ( + !this.webSocketManager.isFirstSyncCompleted || + !this.hasFinishedOfflineSync + ) { + return DocumentSyncStatus.SYNCING; + } + const document = this.database.getLatestDocumentByRelativePath(relativePath); if (document === undefined) { diff --git a/frontend/sync-client/src/types/document-sync-status.ts b/frontend/sync-client/src/types/document-sync-status.ts index a2ec01c2..07a0e801 100644 --- a/frontend/sync-client/src/types/document-sync-status.ts +++ b/frontend/sync-client/src/types/document-sync-status.ts @@ -1,4 +1,5 @@ export enum DocumentSyncStatus { UP_TO_DATE = "UP_TO_DATE", - SYNCING = "SYNCING" + SYNCING = "SYNCING", + SYNCING_IS_DISABLED = "SYNCING_IS_DISABLED" }