From 828c4acb18187cf0f10636b10b0d95c64f09edfe Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Fri, 16 Jan 2026 10:09:42 -0800 Subject: [PATCH 1/8] fix: Mark HasProvider as synced if it has ever synced this session --- src/HasProvider.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/HasProvider.ts b/src/HasProvider.ts index 3cef93e..6ce9449 100644 --- a/src/HasProvider.ts +++ b/src/HasProvider.ts @@ -58,6 +58,9 @@ export class HasProvider extends HasLogging { path?: string; ydoc: Y.Doc; clientToken: ClientToken; + // Track if provider has ever synced. We use our own flag because + // _provider.synced can be reset to false on reconnection. + _providerSynced: boolean = false; private _offConnectionError: () => void; private _offState: () => void; listeners: Map; @@ -216,7 +219,7 @@ export class HasProvider extends HasLogging { } public get synced(): boolean { - return this._provider.synced; + return this._providerSynced; } disconnect() { @@ -249,11 +252,12 @@ export class HasProvider extends HasLogging { } onceProviderSynced(): Promise { - if (this._provider.synced) { + if (this._providerSynced) { return Promise.resolve(); } return new Promise((resolve) => { this._provider.once("synced", () => { + this._providerSynced = true; resolve(); }); }); From ec7ac9446f2f52459813dc505e87c033a50c30f9 Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Fri, 16 Jan 2026 10:23:13 -0800 Subject: [PATCH 2/8] fix: Don't block LiveView creation on folder.ready --- src/LiveViews.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/LiveViews.ts b/src/LiveViews.ts index 5ce0979..c2b4afb 100644 --- a/src/LiveViews.ts +++ b/src/LiveViews.ts @@ -991,21 +991,21 @@ export class LiveViewManager { } const folder = this.sharedFolders.lookup(viewFilePath); if (folder && canvasView.file) { - const canvas = folder.getFile(canvasView.file); - if (isCanvas(canvas)) { - if (!this.loginManager.loggedIn) { - const view = new LoggedOutView(this, canvasView, () => { - return this.loginManager.openLoginPage(); - }); - views.push(view); - } else if (folder.ready) { + if (!this.loginManager.loggedIn) { + const view = new LoggedOutView(this, canvasView, () => { + return this.loginManager.openLoginPage(); + }); + views.push(view); + } else if (folder.ready) { + const canvas = folder.getFile(canvasView.file); + if (isCanvas(canvas)) { const view = new RelayCanvasView(this, canvasView, canvas); views.push(view); } else { - this.log(`Folder not ready, skipping views. folder=${folder.path}`); + this.log(`Skipping canvas view connection for ${viewFilePath}`); } } else { - this.log(`Skipping canvas view connection for ${viewFilePath}`); + this.log(`Folder not ready, skipping views. folder=${folder.path}`); } } }); From 7f501df158cce480e1d3dfd0e6a230a2e7a4f89e Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Fri, 16 Jan 2026 11:05:40 -0800 Subject: [PATCH 3/8] feat: Add metrics infrastructure for obsidian-metrics integration Adds RelayMetrics class that integrates with the obsidian-metrics plugin when available, with graceful no-op fallback when disabled. --- src/debug.ts | 79 +++++++++++++++++ src/main.ts | 2 + src/types/obsidian-metrics.d.ts | 149 ++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 src/types/obsidian-metrics.d.ts diff --git a/src/debug.ts b/src/debug.ts index 494f1aa..b5ee251 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -277,3 +277,82 @@ const debug = BUILD_TYPE === "debug"; export function createToast(notifier: INotifier) { return createToastFunction(notifier, debug); } + +// ============================================================================ +// Metrics Integration (for obsidian-metrics plugin) +// ============================================================================ + +import type { + IObsidianMetricsAPI, + MetricInstance, + ObsidianMetricsPlugin, +} from "./types/obsidian-metrics"; + +/** + * Metrics for Relay - uses obsidian-metrics plugin if available, no-ops otherwise. + * + * Uses event-based initialization to handle plugin load order. The obsidian-metrics + * plugin emits 'obsidian-metrics:ready' when loaded, and metric creation is idempotent. + */ +class RelayMetrics { + private dbSize: MetricInstance | null = null; + private compactions: MetricInstance | null = null; + private compactionDuration: MetricInstance | null = null; + + /** + * Initialize metrics from the API. Called when obsidian-metrics becomes available. + * Safe to call multiple times - metric creation is idempotent. + */ + initializeFromAPI(api: IObsidianMetricsAPI): void { + this.dbSize = api.createGauge({ + name: "relay_db_size", + help: "Number of updates stored in IndexedDB per document", + labelNames: ["document"], + }); + this.compactions = api.createCounter({ + name: "relay_compactions_total", + help: "Total compaction operations", + labelNames: ["document"], + }); + this.compactionDuration = api.createHistogram({ + name: "relay_compaction_duration_seconds", + help: "Compaction duration in seconds", + labelNames: ["document"], + buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10], + }); + } + + setDbSize(document: string, count: number): void { + this.dbSize?.labels({ document }).set(count); + } + + recordCompaction(document: string, durationSeconds: number): void { + this.compactions?.labels({ document }).inc(); + this.compactionDuration?.labels({ document }).observe(durationSeconds); + } +} + +/** + * Initialize metrics integration with Obsidian app. + * Sets up event listener for obsidian-metrics:ready and checks if already available. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function initializeMetrics(app: any, registerEvent: (eventRef: any) => void): void { + // Listen for metrics API becoming available (or re-initializing after reload) + registerEvent( + app.workspace.on("obsidian-metrics:ready", (api: IObsidianMetricsAPI) => { + metrics.initializeFromAPI(api); + }) + ); + + // Also try to get it immediately in case metrics plugin loaded first + const metricsPlugin = app.plugins?.plugins?.["obsidian-metrics"] as + | ObsidianMetricsPlugin + | undefined; + if (metricsPlugin?.api) { + metrics.initializeFromAPI(metricsPlugin.api); + } +} + +/** Singleton metrics instance */ +export const metrics = new RelayMetrics(); diff --git a/src/main.ts b/src/main.ts index 386ac66..68c8e93 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,6 +30,7 @@ import { RelayInstances, initializeLogger, flushLogs, + initializeMetrics, } from "./debug"; import { getPatcher, Patcher } from "./Patcher"; import { LiveTokenStore } from "./LiveTokenStore"; @@ -329,6 +330,7 @@ export default class Live extends Plugin { disableConsole: false, // Disable console logging }, ); + initializeMetrics(this.app, (ref) => this.registerEvent(ref)); this.notifier = new ObsidianNotifier(); this.debug = curryLog("[System 3][Relay]", "debug"); diff --git a/src/types/obsidian-metrics.d.ts b/src/types/obsidian-metrics.d.ts new file mode 100644 index 0000000..254d5c2 --- /dev/null +++ b/src/types/obsidian-metrics.d.ts @@ -0,0 +1,149 @@ +/** + * Type declarations for the Obsidian Metrics API + * + * Copy this file into your plugin to get type-safe access to the metrics API. + * + * ## Accessing the API + * + * Access via the plugin instance: + * ```typescript + * const metricsPlugin = this.app.plugins.plugins['obsidian-metrics'] as ObsidianMetricsPlugin | undefined; + * const api = metricsPlugin?.api; + * ``` + * + * ## Handling Plugin Load Order + * + * The metrics plugin emits 'obsidian-metrics:ready' when loaded. Listen for this + * event to handle cases where your plugin loads before obsidian-metrics: + * + * ```typescript + * class MyPlugin extends Plugin { + * private metricsApi: IObsidianMetricsAPI | undefined; + * private documentGauge: MetricInstance | undefined; + * + * async onload() { + * // Listen for metrics API becoming available (or re-initializing after reload) + * this.registerEvent( + * this.app.workspace.on('obsidian-metrics:ready', (api: IObsidianMetricsAPI) => { + * this.initializeMetrics(api); + * }) + * ); + * + * // Also try to get it immediately in case metrics plugin loaded first + * const metricsPlugin = this.app.plugins.plugins['obsidian-metrics'] as ObsidianMetricsPlugin | undefined; + * if (metricsPlugin?.api) { + * this.initializeMetrics(metricsPlugin.api); + * } + * } + * + * private initializeMetrics(api: IObsidianMetricsAPI) { + * this.metricsApi = api; + * // Metric creation is idempotent - safe to call multiple times + * this.documentGauge = api.createGauge({ + * name: 'my_document_size_bytes', + * help: 'Size of documents in bytes', + * labelNames: ['document'] + * }); + * } + * + * updateDocumentSize(doc: string, bytes: number) { + * this.documentGauge?.labels({ document: doc }).set(bytes); + * } + * } + * ``` + * + * ## Key Points + * + * - **Do NOT cache the API or metrics long-term** - they become stale if obsidian-metrics reloads + * - Listen for 'obsidian-metrics:ready' and re-initialize your metrics each time it fires + * - Metric creation is idempotent: calling createGauge() with the same name returns the existing metric + * - It's safe to store metric references within an initialization cycle, but always re-create them + * when 'obsidian-metrics:ready' fires + */ + +export interface MetricLabels { + [key: string]: string; +} + +export interface CounterOptions { + name: string; + help: string; + labelNames?: string[]; +} + +export interface GaugeOptions { + name: string; + help: string; + labelNames?: string[]; +} + +export interface HistogramOptions { + name: string; + help: string; + labelNames?: string[]; + buckets?: number[]; +} + +export interface SummaryOptions { + name: string; + help: string; + labelNames?: string[]; + percentiles?: number[]; + maxAgeSeconds?: number; + ageBuckets?: number; +} + +export interface LabeledMetricInstance { + inc(value?: number): void; + dec(value?: number): void; + set(value: number): void; + observe(value: number): void; + startTimer(): () => void; +} + +export interface MetricInstance { + inc(value?: number, labels?: MetricLabels): void; + dec(value?: number, labels?: MetricLabels): void; + set(value: number, labels?: MetricLabels): void; + observe(value: number, labels?: MetricLabels): void; + startTimer(labels?: MetricLabels): () => void; + labels(labels: MetricLabels): LabeledMetricInstance; +} + +export interface IObsidianMetricsAPI { + // Metric retrieval + getMetric(name: string): MetricInstance | undefined; + getAllMetrics(): Promise; + clearMetric(name: string): boolean; + clearAllMetrics(): void; + + // Metric creation (idempotent - returns existing metric if name matches) + createCounter(options: CounterOptions): MetricInstance; + createGauge(options: GaugeOptions): MetricInstance; + createHistogram(options: HistogramOptions): MetricInstance; + createSummary(options: SummaryOptions): MetricInstance; + + // Convenience methods (create + optional initial value) + counter(name: string, help: string, value?: number): MetricInstance; + gauge(name: string, help: string, value?: number): MetricInstance; + histogram(name: string, help: string, buckets?: number[]): MetricInstance; + summary(name: string, help: string, percentiles?: number[]): MetricInstance; + + // Timing utilities + createTimer(metricName: string): () => number; + measureAsync(metricName: string, fn: () => Promise): Promise; + measureSync(metricName: string, fn: () => T): T; +} + +/** Type for the obsidian-metrics plugin instance */ +export interface ObsidianMetricsPlugin { + api: IObsidianMetricsAPI; +} + +/** Augment Obsidian's workspace events to include our custom event */ +declare module 'obsidian' { + interface Workspace { + on(name: 'obsidian-metrics:ready', callback: (api: IObsidianMetricsAPI) => void): EventRef; + trigger(name: 'obsidian-metrics:ready', api: IObsidianMetricsAPI): void; + } +} From 61b599192e6c75a64c0ee8b85948bc79fee84126 Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Wed, 21 Jan 2026 10:44:38 -0800 Subject: [PATCH 4/8] feat: Track IndexedDB document size and compaction metrics --- src/storage/y-indexeddb.js | 63 ++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/src/storage/y-indexeddb.js b/src/storage/y-indexeddb.js index 3ed601c..cf724ca 100644 --- a/src/storage/y-indexeddb.js +++ b/src/storage/y-indexeddb.js @@ -2,10 +2,25 @@ import * as Y from 'yjs' import * as idb from 'lib0/indexeddb' import * as promise from 'lib0/promise' import { Observable } from 'lib0/observable' +import { metrics } from '../debug' const customStoreName = 'custom' const updatesStoreName = 'updates' +/** + * Compare two Uint8Arrays for equality + * @param {Uint8Array} a + * @param {Uint8Array} b + * @returns {boolean} + */ +const uint8ArrayEquals = (a, b) => { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + // Use a higher threshold on startup to avoid slow initial compaction // After sync, use the lower threshold to keep the database lean export const STARTUP_TRIM_SIZE = 500 @@ -27,7 +42,10 @@ export const fetchUpdates = (idbPersistence, beforeApplyUpdatesCallback = () => } }) .then(() => idb.getLastKey(updatesStore).then(lastKey => { idbPersistence._dbref = lastKey + 1 })) - .then(() => idb.count(updatesStore).then(cnt => { idbPersistence._dbsize = cnt })) + .then(() => idb.count(updatesStore).then(cnt => { + idbPersistence._dbsize = cnt + metrics.setDbSize(idbPersistence.name, cnt) + })) .then(() => { if (!idbPersistence._destroyed) { afterApplyUpdatesCallback(updatesStore) @@ -44,9 +62,18 @@ export const storeState = (idbPersistence, forceStore = true) => fetchUpdates(idbPersistence) .then(updatesStore => { if (forceStore || idbPersistence._dbsize >= RUNTIME_TRIM_SIZE) { - idb.addAutoKey(updatesStore, Y.encodeStateAsUpdate(idbPersistence.doc)) + const compactedState = Y.encodeStateAsUpdate(idbPersistence.doc) + const startTime = performance.now() + idb.addAutoKey(updatesStore, compactedState) .then(() => idb.del(updatesStore, idb.createIDBKeyRangeUpperBound(idbPersistence._dbref, true))) - .then(() => idb.count(updatesStore).then(cnt => { idbPersistence._dbsize = cnt })) + .then(() => idb.count(updatesStore).then(cnt => { + idbPersistence._dbsize = cnt + metrics.setDbSize(idbPersistence.name, cnt) + })) + .then(() => { + const durationSeconds = (performance.now() - startTime) / 1000 + metrics.recordCompaction(idbPersistence.name, durationSeconds) + }) } }) @@ -90,12 +117,29 @@ export class IndexeddbPersistence extends Observable { this._db.then(db => { this.db = db + // Capture pending state before loading from IDB + /** @type {Uint8Array|null} */ + let pendingState = null /** * @param {IDBObjectStore} updatesStore */ - const beforeApplyUpdatesCallback = (updatesStore) => idb.addAutoKey(updatesStore, Y.encodeStateAsUpdate(doc)) - const afterApplyUpdatesCallback = () => { + const beforeApplyUpdatesCallback = (updatesStore) => { + // Capture any in-memory state before loading from IDB + pendingState = Y.encodeStateAsUpdate(doc) + } + const afterApplyUpdatesCallback = (updatesStore) => { if (this._destroyed) return this + // After loading from IDB, check if pending state had anything new + if (pendingState && pendingState.length > 2) { + const vectorBeforePending = Y.encodeStateVector(doc) + Y.applyUpdate(doc, pendingState, this) + const vectorAfterPending = Y.encodeStateVector(doc) + const changed = !uint8ArrayEquals(vectorBeforePending, vectorAfterPending) + // Only write if applying pending state actually changed something + if (changed) { + idb.addAutoKey(updatesStore, pendingState) + } + } this.synced = true this.emit('synced', [this]) } @@ -115,10 +159,17 @@ export class IndexeddbPersistence extends Observable { */ this._storeUpdate = (update, origin) => { if (this.db && origin !== this) { + // Skip updates with empty state vectors (no actual content) + const stateVector = Y.encodeStateVectorFromUpdate(update) + if (stateVector.length === 0) { + return + } const [updatesStore] = idb.transact(/** @type {IDBDatabase} */ (this.db), [updatesStoreName]) idb.addAutoKey(updatesStore, update) + ++this._dbsize + metrics.setDbSize(this.name, this._dbsize) const trimSize = this.synced ? RUNTIME_TRIM_SIZE : STARTUP_TRIM_SIZE - if (++this._dbsize >= trimSize) { + if (this._dbsize >= trimSize) { // debounce store call if (this._storeTimeoutId !== null) { clearTimeout(this._storeTimeoutId) From ce8262a954ec1958bd00f6298aaab49fc8eb309a Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Tue, 20 Jan 2026 15:04:36 -0800 Subject: [PATCH 5/8] fix: Don't try to reconnect to realtime continuously on auth failure --- src/LoginManager.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/LoginManager.ts b/src/LoginManager.ts index 6b4e583..dbcb7e1 100644 --- a/src/LoginManager.ts +++ b/src/LoginManager.ts @@ -351,6 +351,10 @@ export class LoginManager extends Observable { const result = await this.endpointManager.validateAndSetEndpoints(timeoutMs); if (result.success && this.endpointManager.hasValidatedEndpoints()) { + // Clean up old PocketBase instance before creating new one + this.pb.cancelAllRequests(); + this.pb.realtime.unsubscribe(); + // Recreate PocketBase instance with new auth URL const pbLog = curryLog("[Pocketbase]", "debug"); this.pb = new PocketBase(this.endpointManager.getAuthUrl(), this.authStore); @@ -389,6 +393,7 @@ export class LoginManager extends Observable { logout() { this.pb.cancelAllRequests(); + this.pb.realtime.unsubscribe(); this.pb.authStore.clear(); this.user = undefined; this.notifyListeners(); @@ -549,10 +554,18 @@ export class LoginManager extends Observable { async login(provider: string): Promise { this.beforeLogin(); - const authData = await this.pb.collection("users").authWithOAuth2({ - provider: provider, - }); - return this.setup(authData, provider); + try { + const authData = await this.pb.collection("users").authWithOAuth2({ + provider: provider, + }); + return this.setup(authData, provider); + } catch (e) { + // Clean up realtime subscription to prevent reconnection loops + // authWithOAuth2 internally subscribes to @oauth2 via SSE, and if it fails, + // PocketBase's realtime client will keep trying to reconnect indefinitely + this.pb.realtime.unsubscribe(); + throw e; + } } async openLoginPage() { From dbde61dc24f84317564038c025e53a7d5e7cca00 Mon Sep 17 00:00:00 2001 From: Daniel Grossmann-Kavanagh Date: Tue, 20 Jan 2026 17:33:03 -0800 Subject: [PATCH 6/8] refactor: Consolidate banner/button logic into Banner class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Banner now automatically detects mobile Obsidian ≥1.11.0 and renders as a header button (short text) vs traditional banner (long text) - Added BannerText type supporting string | { short, long } - Removed setLoginIcon/clearLoginButton from LoggedOutView - Removed setMergeButton/clearMergeButton from LiveView - Removed font-weight bold and text-shadow from desktop banner --- src/LiveViews.ts | 139 +++++++---------------------------------------- src/ui/Banner.ts | 68 ++++++++++++++++++++--- styles.css | 6 +- 3 files changed, 81 insertions(+), 132 deletions(-) diff --git a/src/LiveViews.ts b/src/LiveViews.ts index c2b4afb..66ec4b6 100644 --- a/src/LiveViews.ts +++ b/src/LiveViews.ts @@ -124,65 +124,24 @@ export class LoggedOutView implements S3View { this.login = login; } - setLoginIcon(): void { - const viewHeaderElement = - this.view.containerEl.querySelector(".view-header"); - const viewHeaderLeftElement = - this.view.containerEl.querySelector(".view-header-left"); - - if (viewHeaderElement && viewHeaderLeftElement) { - this.clearLoginButton(); - - // Create login button element - const loginButton = document.createElement("button"); - loginButton.className = "view-header-left system3-login-button"; - loginButton.textContent = "Login to enable Live edits"; - loginButton.setAttribute("aria-label", "Login to enable Live edits"); - loginButton.setAttribute("tabindex", "0"); - - // Add click handler - loginButton.addEventListener("click", async () => { - await this.login(); - }); - - // Insert after view-header-left - viewHeaderLeftElement.insertAdjacentElement("afterend", loginButton); - } - } - - clearLoginButton() { - const existingButton = this.view.containerEl.querySelector(".system3-login-button"); - if (existingButton) { - existingButton.remove(); - } - } - attach(): Promise { - // Use header button approach on mobile for Obsidian >=1.11.0 to avoid banner positioning issues - if (Platform.isMobile && requireApiVersion("1.11.0")) { - this.setLoginIcon(); - } else { - this.banner = new Banner( - this.view, - "Login to enable Live edits", - async () => { - return await this.login(); - }, - ); - } + this.banner = new Banner( + this.view, + { short: "Login to Relay", long: "Login to enable Live edits" }, + async () => { + return await this.login(); + }, + ); return Promise.resolve(this); } release() { this.banner?.destroy(); - this.clearLoginButton(); } destroy() { - this.release(); this.banner?.destroy(); this.banner = undefined; - this.clearLoginButton(); this.view = null as any; } } @@ -260,7 +219,7 @@ export class RelayCanvasView implements S3View { if (this.shouldConnect) { const banner = new Banner( this.view, - "You're offline -- click to reconnect", + { short: "Offline", long: "You're offline -- click to reconnect" }, async () => { this._parent.networkStatus.checkStatus(); this.connect(); @@ -475,29 +434,15 @@ export class LiveView } } - setMergeButton(): void { - const viewHeaderElement = - this.view.containerEl.querySelector(".view-header"); - const viewHeaderLeftElement = - this.view.containerEl.querySelector(".view-header-left"); - - if (viewHeaderElement && viewHeaderLeftElement) { - this.clearMergeButton(); - - // Create merge button element - const mergeButton = document.createElement("button"); - mergeButton.className = "view-header-left system3-merge-button"; - mergeButton.textContent = "Merge conflict"; - mergeButton.setAttribute("aria-label", "Merge conflict -- click to resolve"); - mergeButton.setAttribute("tabindex", "0"); - - // Add click handler - mergeButton.addEventListener("click", async () => { + mergeBanner(): () => void { + this._banner = new Banner( + this.view, + { short: "Merge conflict", long: "Merge conflict -- click to resolve" }, + async () => { const diskBuffer = await this.document.diskBuffer(); const stale = await this.document.checkStale(); if (!stale) { - this.clearMergeButton(); - return; + return true; } this._parent.openDiffView({ file1: this.document, @@ -505,7 +450,7 @@ export class LiveView showMergeOption: true, onResolve: async () => { this.document.clearDiskBuffer(); - this.clearMergeButton(); + this._banner?.destroy(); // Force view to sync to CRDT state after differ resolution if ( this._plugin && @@ -515,53 +460,9 @@ export class LiveView } }, }); - }); - - // Insert after view-header-left - viewHeaderLeftElement.insertAdjacentElement("afterend", mergeButton); - } - } - - clearMergeButton() { - const existingButton = this.view.containerEl.querySelector(".system3-merge-button"); - if (existingButton) { - existingButton.remove(); - } - } - - mergeBanner(): () => void { - // Use header button approach on mobile for Obsidian >=1.11.0 to avoid banner positioning issues - if (Platform.isMobile && requireApiVersion("1.11.0")) { - this.setMergeButton(); - } else { - this._banner = new Banner( - this.view, - "Merge conflict -- click to resolve", - async () => { - const diskBuffer = await this.document.diskBuffer(); - const stale = await this.document.checkStale(); - if (!stale) { - return true; - } - this._parent.openDiffView({ - file1: this.document, - file2: diskBuffer, - showMergeOption: true, - onResolve: async () => { - this.document.clearDiskBuffer(); - // Force view to sync to CRDT state after differ resolution - if ( - this._plugin && - typeof this._plugin.syncViewToCRDT === "function" - ) { - await this._plugin.syncViewToCRDT(); - } - }, - }); - return true; - }, - ); - } + return true; + }, + ); return () => {}; } @@ -569,7 +470,7 @@ export class LiveView if (this.shouldConnect) { const banner = new Banner( this.view, - "You're offline -- click to reconnect", + { short: "Offline", long: "You're offline -- click to reconnect" }, async () => { this._parent.networkStatus.checkStatus(); this.connect(); @@ -725,7 +626,6 @@ export class LiveView this._viewActions = undefined; this._banner?.destroy(); this._banner = undefined; - this.clearMergeButton(); if (this.offConnectionStatusSubscription) { this.offConnectionStatusSubscription(); this.offConnectionStatusSubscription = undefined; @@ -741,7 +641,6 @@ export class LiveView destroy() { this.release(); this.clearViewActions(); - this.clearMergeButton(); (this.view.leaf as any).rebuildView?.(); this._parent = null as any; this.view = null as any; diff --git a/src/ui/Banner.ts b/src/ui/Banner.ts index 6dbb9b3..c1cf966 100644 --- a/src/ui/Banner.ts +++ b/src/ui/Banner.ts @@ -1,32 +1,50 @@ "use strict"; -import { TextFileView } from "obsidian"; +import { Platform, requireApiVersion, TextFileView } from "obsidian"; import type { CanvasView } from "src/CanvasView"; +export type BannerText = string | { short: string; long: string }; + export class Banner { view: TextFileView | CanvasView; - text: string; + text: BannerText; onClick: () => Promise; + private useHeaderButton: boolean; constructor( view: TextFileView | CanvasView, - text: string, + text: BannerText, onClick: () => Promise, ) { this.view = view; this.text = text; this.onClick = onClick; + // Use header button approach on mobile for Obsidian >=1.11.0 to avoid banner positioning issues + this.useHeaderButton = Platform.isMobile && requireApiVersion("1.11.0"); this.display(); } + private get shortText(): string { + return typeof this.text === "string" ? this.text : this.text.short; + } + + private get longText(): string { + return typeof this.text === "string" ? this.text : this.text.long; + } + display() { if (!this.view) return true; const leafContentEl = this.view.containerEl; - const contentEl = this.view.containerEl.querySelector(".view-content"); if (!leafContentEl) { return; } + if (this.useHeaderButton) { + return this.displayHeaderButton(); + } + + const contentEl = this.view.containerEl.querySelector(".view-content"); + // container to enable easy removal of the banner let bannerBox = leafContentEl.querySelector(".system3-banner-box"); if (!bannerBox) { @@ -40,7 +58,7 @@ export class Banner { banner = document.createElement("div"); banner.classList.add("system3-banner"); const span = banner.createSpan(); - span.setText(this.text); + span.setText(this.longText); banner.appendChild(span); bannerBox.appendChild(banner); const onClick = async () => { @@ -54,14 +72,48 @@ export class Banner { return true; } + private displayHeaderButton() { + const leafContentEl = this.view.containerEl; + const viewHeaderLeftElement = + leafContentEl.querySelector(".view-header-left"); + + if (!viewHeaderLeftElement) { + return; + } + + // Remove existing button if any + leafContentEl.querySelector(".system3-header-button")?.remove(); + + const button = document.createElement("button"); + button.className = "view-header-left system3-header-button"; + button.textContent = this.shortText; + button.setAttribute("aria-label", this.longText); + button.setAttribute("tabindex", "0"); + + button.addEventListener("click", async () => { + const destroy = await this.onClick(); + if (destroy) { + this.destroy(); + } + }); + + viewHeaderLeftElement.insertAdjacentElement("afterend", button); + return true; + } + destroy() { const leafContentEl = this.view.containerEl; if (!leafContentEl) { return; } - const bannerBox = leafContentEl.querySelector(".system3-banner-box"); - if (bannerBox) { - bannerBox.replaceChildren(); + + if (this.useHeaderButton) { + leafContentEl.querySelector(".system3-header-button")?.remove(); + } else { + const bannerBox = leafContentEl.querySelector(".system3-banner-box"); + if (bannerBox) { + bannerBox.replaceChildren(); + } } this.onClick = async () => true; return true; diff --git a/styles.css b/styles.css index aed8e5a..0720f00 100644 --- a/styles.css +++ b/styles.css @@ -123,17 +123,15 @@ } .system3-banner > span { - font-weight: bold; display: flex; flex-grow: 1; font-size: var(--font-ui-medium); color: var(--text-on-accent); font: var(--font-interface-theme); - text-shadow: var(--input-shadow); } -.system3-login-button, -.system3-merge-button { +.system3-header-button, +.system3-login-button { background: var(--interactive-accent) !important; color: var(--text-on-accent) !important; border: var(--modal-border-width) solid var(--pill-border-color-hover) !important; From f8c181863586d8e72179fc0b109a5bc79e7bf533 Mon Sep 17 00:00:00 2001 From: Clark Feusier Date: Wed, 28 Jan 2026 13:18:32 -0800 Subject: [PATCH 7/8] fix: Preserve user disconnect intent across navigation When a user explicitly disconnected a document via the UI toggle and then navigated away and back, the document would auto-reconnect. This was because multiple code paths were triggering connections without checking user intent. Added `userDisconnectedIntent` flag to Document and Canvas that: - Defaults to false (documents connect by default) - Set to true only via explicit user action in toggleConnection() - Checked in getViews(), whenReady(), ViewHookPlugin, and LiveEditPlugin --- __tests__/UserDisconnectedIntent.test.ts | 257 +++++++++++++++++++++++ src/Canvas.ts | 11 +- src/Document.ts | 11 +- src/LiveViews.ts | 16 +- src/plugins/ViewHookPlugin.ts | 5 +- src/y-codemirror.next/LiveEditPlugin.ts | 7 +- 6 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 __tests__/UserDisconnectedIntent.test.ts diff --git a/__tests__/UserDisconnectedIntent.test.ts b/__tests__/UserDisconnectedIntent.test.ts new file mode 100644 index 0000000..cd5b919 --- /dev/null +++ b/__tests__/UserDisconnectedIntent.test.ts @@ -0,0 +1,257 @@ +"use strict"; + +/** + * Tests for the userDisconnectedIntent flag pattern. + * + * This flag tracks whether a user has EXPLICITLY disconnected a document + * via the UI toggle, as opposed to the document being disconnected due to: + * - View lifecycle (release() when navigating away) + * - Network issues + * - Other system-initiated disconnections + * + * The flag should: + * - Default to false (documents should connect by default) + * - Only be set to true via explicit user action (toggleConnection) + * - NOT be affected by disconnect() calls + * - Be used by LiveViews.getViews() to determine shouldConnect for new LiveView instances + * + * Bug context: When a user disconnects a note and navigates away/back, + * the note was auto-reconnecting because getViews() created new LiveView + * instances with shouldConnect=true by default, ignoring user intent. + * + * This test file tests the FLAG BEHAVIOR PATTERN in isolation, without + * requiring full Obsidian dependencies. The actual Document/Canvas classes + * follow this same pattern. + * + * For E2E verification, use the live-debug skill: + * python .claude/scripts/obsidian_debug.py --vault relay file-status "test.md" + */ + +/** + * Minimal class that mimics the userDisconnectedIntent pattern + * implemented in Document and Canvas. + */ +class MockDocumentWithUserIntent { + /** + * Tracks whether the user has explicitly disconnected this document. + * - Default: false (connect by default) + * - Set to true: only via explicit user action (toggleConnection) + * - NOT modified by: disconnect(), release(), network issues + */ + userDisconnectedIntent: boolean = false; + + // Simulates provider's shouldConnect (affected by disconnect()) + private _providerShouldConnect: boolean = true; + + /** + * Simulates the disconnect() method from HasProvider. + * This sets provider.shouldConnect = false but should NOT + * affect userDisconnectedIntent. + */ + disconnect(): void { + this._providerShouldConnect = false; + // NOTE: userDisconnectedIntent is NOT modified here + } + + /** + * Simulates the connect() method from HasProvider. + */ + connect(): void { + this._providerShouldConnect = true; + } + + /** + * Simulates intent getter (derived from provider.shouldConnect). + */ + get intent(): "connected" | "disconnected" { + return this._providerShouldConnect ? "connected" : "disconnected"; + } +} + +/** + * Simulates LiveView behavior with the userDisconnectedIntent fix. + */ +class MockLiveView { + shouldConnect: boolean; + document: MockDocumentWithUserIntent; + + constructor(document: MockDocumentWithUserIntent, shouldConnect = true) { + this.document = document; + this.shouldConnect = shouldConnect; + } + + /** + * Simulates the toggleConnection() method with the fix applied. + */ + toggleConnection(): void { + this.shouldConnect = !this.shouldConnect; + // THE FIX: Track explicit user disconnect intent + this.document.userDisconnectedIntent = !this.shouldConnect; + if (this.shouldConnect) { + this.document.connect(); + } else { + this.document.disconnect(); + } + } + + /** + * Simulates release() when navigating away. + */ + release(): void { + this.document.disconnect(); + // NOTE: userDisconnectedIntent is NOT modified here + } +} + +/** + * Simulates getViews() creating a new LiveView with the fix applied. + */ +function createLiveViewFromDocument( + doc: MockDocumentWithUserIntent, +): MockLiveView { + // THE FIX: Use userDisconnectedIntent to determine shouldConnect + const shouldConnect = !doc.userDisconnectedIntent; + return new MockLiveView(doc, shouldConnect); +} + +describe("userDisconnectedIntent flag pattern", () => { + describe("Document flag behavior", () => { + let doc: MockDocumentWithUserIntent; + + beforeEach(() => { + doc = new MockDocumentWithUserIntent(); + }); + + it("should default userDisconnectedIntent to false", () => { + expect(doc.userDisconnectedIntent).toBe(false); + }); + + it("should preserve userDisconnectedIntent=true after disconnect()", () => { + doc.userDisconnectedIntent = true; + doc.disconnect(); + expect(doc.userDisconnectedIntent).toBe(true); + }); + + it("should preserve userDisconnectedIntent=false after disconnect()", () => { + expect(doc.userDisconnectedIntent).toBe(false); + doc.disconnect(); + expect(doc.userDisconnectedIntent).toBe(false); + }); + }); + + describe("LiveView toggleConnection behavior", () => { + let doc: MockDocumentWithUserIntent; + let view: MockLiveView; + + beforeEach(() => { + doc = new MockDocumentWithUserIntent(); + view = new MockLiveView(doc); + }); + + it("should set userDisconnectedIntent=true when user disconnects", () => { + expect(doc.userDisconnectedIntent).toBe(false); + view.toggleConnection(); // User clicks to disconnect + expect(doc.userDisconnectedIntent).toBe(true); + expect(view.shouldConnect).toBe(false); + }); + + it("should set userDisconnectedIntent=false when user reconnects", () => { + view.toggleConnection(); // Disconnect + expect(doc.userDisconnectedIntent).toBe(true); + + view.toggleConnection(); // Reconnect + expect(doc.userDisconnectedIntent).toBe(false); + expect(view.shouldConnect).toBe(true); + }); + }); + + describe("Navigation scenario (the bug fix)", () => { + it("should preserve disconnect intent across navigation", () => { + // Setup: Document exists, user opens it + const doc = new MockDocumentWithUserIntent(); + let view = new MockLiveView(doc); + + // User explicitly disconnects + view.toggleConnection(); + expect(doc.userDisconnectedIntent).toBe(true); + expect(view.shouldConnect).toBe(false); + + // User navigates away (release is called) + view.release(); + + // User navigates back (new LiveView is created) + // THIS IS THE BUG FIX: getViews() now uses userDisconnectedIntent + view = createLiveViewFromDocument(doc); + + // New view should respect user's disconnect intent + expect(view.shouldConnect).toBe(false); + expect(doc.userDisconnectedIntent).toBe(true); + }); + + it("should auto-connect when user never explicitly disconnected", () => { + // Setup: Document exists, user opens it + const doc = new MockDocumentWithUserIntent(); + let view = new MockLiveView(doc); + expect(view.shouldConnect).toBe(true); + + // User navigates away without disconnecting + view.release(); + + // User navigates back + view = createLiveViewFromDocument(doc); + + // New view should auto-connect (default behavior) + expect(view.shouldConnect).toBe(true); + expect(doc.userDisconnectedIntent).toBe(false); + }); + + it("should auto-connect after user explicitly reconnects", () => { + const doc = new MockDocumentWithUserIntent(); + let view = new MockLiveView(doc); + + // User disconnects then reconnects + view.toggleConnection(); // Disconnect + view.toggleConnection(); // Reconnect + expect(doc.userDisconnectedIntent).toBe(false); + + // User navigates away + view.release(); + + // User navigates back + view = createLiveViewFromDocument(doc); + + // New view should auto-connect + expect(view.shouldConnect).toBe(true); + }); + }); +}); + +/** + * Integration behavior documentation (verified manually via E2E): + * + * When user clicks disconnect toggle in LiveView: + * 1. LiveView.toggleConnection() is called + * 2. Sets this.shouldConnect = false + * 3. Sets this.document.userDisconnectedIntent = true + * 4. Calls this.document.disconnect() + * + * When user navigates away: + * 1. LiveView.release() is called + * 2. Calls this.document.disconnect() + * 3. userDisconnectedIntent is NOT changed (stays true if user disconnected) + * + * When user navigates back: + * 1. LiveViewManager.getViews() is called + * 2. Creates new LiveView with shouldConnect = !doc.userDisconnectedIntent + * 3. If user had disconnected, new LiveView has shouldConnect = false + * 4. Document stays disconnected as user intended + * + * When user clicks connect toggle: + * 1. LiveView.toggleConnection() is called + * 2. Sets this.shouldConnect = true + * 3. Sets this.document.userDisconnectedIntent = false + * 4. Calls this.document.connect() + * + * E2E verification command: + * python .claude/scripts/obsidian_debug.py --vault relay file-status "test.md" + */ diff --git a/src/Canvas.ts b/src/Canvas.ts index 004f62c..35e8269 100644 --- a/src/Canvas.ts +++ b/src/Canvas.ts @@ -31,6 +31,14 @@ export class Canvas extends HasProvider implements IFile, HasMimeType { _tfile: TFile | null; name: string; userLock: boolean = false; + /** + * Tracks whether the user has explicitly disconnected this canvas via the UI toggle. + * - Default: false (canvases connect by default) + * - Set to true: only via explicit user action in RelayCanvasView.toggleConnection() + * - NOT modified by: disconnect(), release(), network issues, or connection pool limits + * - Used by: LiveViews.getViews() to preserve user's disconnect intent across navigation + */ + userDisconnectedIntent: boolean = false; extension: string; basename: string; vault: Vault; @@ -192,8 +200,9 @@ export class Canvas extends HasProvider implements IFile, HasMimeType { async whenReady(): Promise { const promiseFn = async (): Promise => { const awaitingUpdates = await this.awaitingUpdates(); - if (awaitingUpdates) { + if (awaitingUpdates && !this.userDisconnectedIntent) { // If this is a brand new shared folder, we want to wait for a connection before we start reserving new guids for local files. + // But respect user's explicit disconnect intent. this.log("awaiting updates"); this.connect(); await this.onceConnected(); diff --git a/src/Document.ts b/src/Document.ts index 028fd38..368958e 100644 --- a/src/Document.ts +++ b/src/Document.ts @@ -32,6 +32,14 @@ export class Document extends HasProvider implements IFile, HasMimeType { _tfile: TFile | null; name: string; userLock: boolean = false; + /** + * Tracks whether the user has explicitly disconnected this document via the UI toggle. + * - Default: false (documents connect by default) + * - Set to true: only via explicit user action in LiveView.toggleConnection() + * - NOT modified by: disconnect(), release(), network issues, or connection pool limits + * - Used by: LiveViews.getViews() to preserve user's disconnect intent across navigation + */ + userDisconnectedIntent: boolean = false; extension: string; basename: string; vault: Vault; @@ -318,8 +326,9 @@ export class Document extends HasProvider implements IFile, HasMimeType { async whenReady(): Promise { const promiseFn = async (): Promise => { const awaitingUpdates = await this.awaitingUpdates(); - if (awaitingUpdates) { + if (awaitingUpdates && !this.userDisconnectedIntent) { // If this is a brand new shared folder, we want to wait for a connection before we start reserving new guids for local files. + // But respect user's explicit disconnect intent. this.log("awaiting updates"); this.connect(); await this.onceConnected(); diff --git a/src/LiveViews.ts b/src/LiveViews.ts index 66ec4b6..fb4e718 100644 --- a/src/LiveViews.ts +++ b/src/LiveViews.ts @@ -203,6 +203,8 @@ export class RelayCanvasView implements S3View { toggleConnection() { this.shouldConnect = !this.shouldConnect; + // Track explicit user disconnect intent (persists across navigation) + this.canvas.userDisconnectedIntent = !this.shouldConnect; if (this.shouldConnect) { this.canvas.connect().then((connected) => { if (!connected) { @@ -396,6 +398,8 @@ export class LiveView toggleConnection() { this.shouldConnect = !this.shouldConnect; + // Track explicit user disconnect intent (persists across navigation) + this.document.userDisconnectedIntent = !this.shouldConnect; if (this.shouldConnect) { this.document.connect().then((connected) => { if (!connected) { @@ -871,10 +875,13 @@ export class LiveViewManager { views.push(view); } else if (folder.ready) { const doc = folder.proxy.getDoc(viewFilePath); + // Preserve user's explicit disconnect intent across navigation + const shouldConnect = !doc.userDisconnectedIntent; const view = new LiveView( this, textFileView, doc, + shouldConnect, ); views.push(view); } else { @@ -898,7 +905,14 @@ export class LiveViewManager { } else if (folder.ready) { const canvas = folder.getFile(canvasView.file); if (isCanvas(canvas)) { - const view = new RelayCanvasView(this, canvasView, canvas); + // Preserve user's explicit disconnect intent across navigation + const shouldConnect = !canvas.userDisconnectedIntent; + const view = new RelayCanvasView( + this, + canvasView, + canvas, + shouldConnect, + ); views.push(view); } else { this.log(`Skipping canvas view connection for ${viewFilePath}`); diff --git a/src/plugins/ViewHookPlugin.ts b/src/plugins/ViewHookPlugin.ts index 86ff2bd..e8950bf 100644 --- a/src/plugins/ViewHookPlugin.ts +++ b/src/plugins/ViewHookPlugin.ts @@ -225,7 +225,10 @@ export class ViewHookPlugin extends HasLogging { this.view.previewMode.renderer.set(this.document.text); this.renderAll(); - this.document.connect(); + // Respect user's explicit disconnect intent + if (!this.document.userDisconnectedIntent) { + this.document.connect(); + } this.debug("ViewHookPlugin initialized"); } diff --git a/src/y-codemirror.next/LiveEditPlugin.ts b/src/y-codemirror.next/LiveEditPlugin.ts index 07d617f..25e923c 100644 --- a/src/y-codemirror.next/LiveEditPlugin.ts +++ b/src/y-codemirror.next/LiveEditPlugin.ts @@ -83,7 +83,7 @@ export class LiveCMPluginValue implements PluginValue { if (this.destroyed || !this.editor) { return () => {}; } - + this.banner = new EmbedBanner( this.sourceView, this.editor.dom, @@ -211,7 +211,10 @@ export class LiveCMPluginValue implements PluginValue { }), ); } else { - this.document.connect(); + // Respect user's explicit disconnect intent + if (!this.document.userDisconnectedIntent) { + this.document.connect(); + } } if (this.document.connected) { From 58a670b67e65f816664433e78239b84ce587e26e Mon Sep 17 00:00:00 2001 From: Clark Feusier Date: Wed, 28 Jan 2026 14:54:31 -0800 Subject: [PATCH 8/8] Add dev.sh and .claude to .gitignore (remove old CR chars) --- .gitignore | 48 +++++++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index b7e3ea8..3565746 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,27 @@ -# vscode -.vscode - -# Intellij -*.iml -.idea - -# npm -node_modules - -*.js - -# Exclude sourcemaps -*.map - -# obsidian -data.json - -# Exclude macOS Finder (System Explorer) View States -.DS_Store -vaults/ +# vscode +.vscode + +# Intellij +*.iml +.idea + +# npm +node_modules + +*.js + +# Exclude sourcemaps +*.map + +# obsidian +data.json + +# Exclude macOS Finder (System Explorer) View States +.DS_Store +vaults/ + +# Development +dev.sh + +# Claude Code +.claude/