import type { 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"; import { StatusBar } from "./views/status-bar/status-bar"; import { LogsView } from "./views/logs/logs-view"; import { StatusDescription } from "./views/status-description/status-description"; import { SyncClient, rateLimit, DEFAULT_SETTINGS } 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 { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme"; import { remoteCursorsPlugin, setCursors } from "./views/cursors/remote-cursors-plugin"; import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener"; import { slowFetchFactory } from "./debugging/slow-fetch-factory"; import { flakyWebSocketFactory } from "./debugging/flaky-websocket-factory"; const MIN_WAIT_BETWEEN_UPDATES_IN_MS = 250; export default class VaultLinkPlugin extends Plugin { private readonly disposables: (() => unknown)[] = []; private settingsTab: SyncSettingsTab | undefined; private client!: SyncClient; private readonly rateLimitedUpdatesPerFile = new Map< string, () => Promise >(); public async onload(): Promise { DEFAULT_SETTINGS.ignorePatterns.push( ".obsidian/**", ".git/**", ".trash/**" ); const isDebugBuild = process.env.NODE_ENV === "development"; const debugOptions = isDebugBuild ? { fetch: slowFetchFactory(1), webSocket: flakyWebSocketFactory(1, new Logger()) } : {}; this.client = await SyncClient.create({ fs: new ObsidianFileSystemOperations( this.app.vault, this.app.workspace ), persistence: { load: this.loadData.bind(this), save: this.saveData.bind(this) }, nativeLineEndings: Platform.isWin ? "\r\n" : "\n" }); logToConsole(this.client); const statusDescription = new StatusDescription(this.client); this.settingsTab = new SyncSettingsTab({ app: this.app, plugin: this, syncClient: this.client, statusDescription }); this.addSettingTab(this.settingsTab); new StatusBar(this, this.client); this.registerView( HistoryView.TYPE, (leaf) => new HistoryView(this.client, leaf) ); this.registerView( LogsView.TYPE, (leaf) => new LogsView(this.client, leaf) ); this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]); this.client.addRemoteCursorsUpdateListener((cursors) => { setCursors(cursors, this.app); }); const cursorListener = new LocalCursorUpdateListener( this.client, this.app.workspace ); this.disposables.push(() => { cursorListener.dispose(); }); this.app.workspace.updateOptions(); this.addRibbonIcon( HistoryView.ICON, "Open VaultLink events", async (_: MouseEvent) => this.activateView(HistoryView.TYPE) ); this.addRibbonIcon( LogsView.ICON, "Open VaultLink logs", async (_: MouseEvent) => this.activateView(LogsView.TYPE) ); this.app.workspace.onLayoutReady(async () => { this.registerEditorEvents(); void this.client.start(); const interval = setInterval(() => { updateEditorStatusDisplay(this.app.workspace, this.client); }, 200); this.disposables.push(() => { clearInterval(interval); }); }); } public onunload(): void { this.client.stop(); this.disposables.forEach((disposable) => { disposable(); }); } public openSettings(): void { // eslint-disable-next-line (this.app as any).setting.open(); // this is undocumented // eslint-disable-next-line (this.app as any).setting.openTab(this.settingsTab); // this is undocumented } public closeSettings(): void { // eslint-disable-next-line (this.app as any).setting.close(); // this is undocumented } public async activateView(type: string): Promise { const { workspace } = this.app; let leaf: WorkspaceLeaf | null = null; const leaves = workspace.getLeavesOfType(type); if (leaves.length > 0) { [leaf] = leaves; } else { leaf = workspace.getRightLeaf(false); await leaf?.setViewState({ type: type, active: true }); } if (leaf) { await workspace.revealLeaf(leaf); } } private registerEditorEvents(): void { [ this.app.workspace.on( "editor-change", async ( _editor: Editor, info: MarkdownView | MarkdownFileInfo ) => { const { file } = info; if (file) { await this.rateLimitedUpdate(file.path); } } ), this.app.vault.on("create", async (file: TAbstractFile) => { if (file instanceof TFile) { await this.client.syncLocallyCreatedFile(file.path); } }), this.app.vault.on("modify", async (file: TAbstractFile) => { if (file instanceof TFile) { await this.rateLimitedUpdate(file.path); } }), this.app.vault.on("delete", async (file: TAbstractFile) => { await this.client.syncLocallyDeletedFile(file.path); }), this.app.vault.on( "rename", async (file: TAbstractFile, oldPath: string) => { if (file instanceof TFile) { await this.client.syncLocallyUpdatedFile({ oldPath, relativePath: file.path }); } } ) ].forEach((event) => { this.registerEvent(event); }); } private async rateLimitedUpdate(path: string): Promise { if (!this.rateLimitedUpdatesPerFile.has(path)) { this.rateLimitedUpdatesPerFile.set( path, rateLimit( async () => this.client.syncLocallyUpdatedFile({ relativePath: path }), MIN_WAIT_BETWEEN_UPDATES_IN_MS ) ); } await this.rateLimitedUpdatesPerFile.get(path)?.(); } }