From 3f4ddcbf80babf2705c2dd217429cab348b57e03 Mon Sep 17 00:00:00 2001 From: "shi.changliang" Date: Fri, 5 Jun 2026 16:35:33 +0800 Subject: [PATCH] Coalesce auto sync notifications --- src/main.test.ts | 19 ++++++++ src/main.ts | 119 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 112 insertions(+), 26 deletions(-) diff --git a/src/main.test.ts b/src/main.test.ts index 11ea80d..a919dd7 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -81,6 +81,22 @@ describe("auto sync scheduling", () => { expect(plugin.syncNow).toHaveBeenCalledTimes(1); }); + test("keeps file-event suppression active briefly after sync finishes", async () => { + const plugin = createPlugin(); + + plugin.startFileEventSuppression(); + expect(plugin.suppressFileSyncEvents).toBe(true); + + plugin.finishFileEventSuppression(); + expect(plugin.suppressFileSyncEvents).toBe(true); + + await vi.advanceTimersByTimeAsync(999); + expect(plugin.suppressFileSyncEvents).toBe(true); + + await vi.advanceTimersByTimeAsync(1); + expect(plugin.suppressFileSyncEvents).toBe(false); + }); + test("runs one follow-up sync when triggered during an active sync", async () => { const plugin = createPlugin(); let finishSync: (() => void) | undefined; @@ -236,6 +252,9 @@ type TestPlugin = { saveSettings: ReturnType Promise>>; queueAutoSync: (delayMs?: number) => void; runAutoSync: () => Promise; + startFileEventSuppression: () => void; + finishFileEventSuppression: () => void; + suppressFileSyncEvents: boolean; syncNow: ReturnType Promise>>; }; diff --git a/src/main.ts b/src/main.ts index fe9836a..58e9a48 100644 --- a/src/main.ts +++ b/src/main.ts @@ -56,12 +56,19 @@ const DEFAULT_SETTINGS: ObsyncSettings = { }; const AUTO_SYNC_DEBOUNCE_MS = 2_000; +const FILE_EVENT_SUPPRESSION_MS = 1_000; + +type SyncTrigger = "manual" | "auto"; export default class ObsyncPlugin extends Plugin { settings: ObsyncSettings = DEFAULT_SETTINGS; private autoSyncTimer: number | null = null; private autoSyncRunning = false; private autoSyncQueued = false; + private syncRunning = false; + private suppressFileSyncEvents = false; + private suppressFileSyncTimer: number | null = null; + private activeNotice: Notice | null = null; private statusBarItem: HTMLElement | null = null; async onload(): Promise { @@ -74,12 +81,12 @@ export default class ObsyncPlugin extends Plugin { id: "manual-sync", name: "立即同步", callback: async () => { - await this.syncNow(); + await this.syncNow("manual"); }, }); const syncRibbonIcon = this.addRibbonIcon("refresh-cw", "Obsync 立即同步", async () => { - await this.syncNow(); + await this.syncNow("manual"); }); syncRibbonIcon.classList.add("obsync-ribbon-sync"); this.app.workspace.onLayoutReady(() => { @@ -107,27 +114,51 @@ export default class ObsyncPlugin extends Plugin { this.registerAutoSyncTriggers(); } - async syncNow(): Promise { - await this.runConfiguredOperation("同步中...", "Obsync 同步失败", async () => { - const store = this.createObjectStore(); - const result = await syncOnce({ - vault: new ObsidianVaultIO(this.app), - store, - state: this.settings.syncState, - deviceName: this.settings.deviceName || this.settings.deviceId, - deviceId: this.settings.deviceId, - now: () => Date.now(), - }); + async syncNow(trigger: SyncTrigger = "manual"): Promise { + if (this.syncRunning) { + this.autoSyncQueued = true; - await this.saveSettings(); - - if (result.locked) { - new Notice("另一台设备正在同步,本次已跳过。"); - return; + if (trigger === "manual") { + this.showNotice("Obsync 正在同步,稍后会再同步一次。"); } - new Notice(`Obsync 同步完成:上传 ${result.uploaded},下载 ${result.downloaded},冲突 ${result.conflicts}。`); - }); + return; + } + + this.syncRunning = true; + this.startFileEventSuppression(); + + try { + await this.runConfiguredOperation("同步中...", "Obsync 同步失败", async () => { + const store = this.createObjectStore(); + const result = await syncOnce({ + vault: new ObsidianVaultIO(this.app), + store, + state: this.settings.syncState, + deviceName: this.settings.deviceName || this.settings.deviceId, + deviceId: this.settings.deviceId, + now: () => Date.now(), + }); + + await this.saveSettings(); + + if (result.locked) { + if (trigger === "manual") { + this.showNotice("另一台设备正在同步,本次已跳过。"); + } + return; + } + + if (trigger === "manual") { + this.showNotice(`Obsync 同步完成:上传 ${result.uploaded},下载 ${result.downloaded},冲突 ${result.conflicts}。`); + } + }, { + notifyMissingConfig: trigger === "manual", + }); + } finally { + this.syncRunning = false; + this.finishFileEventSuppression(); + } } private moveRibbonIconToBottom(iconEl: HTMLElement): void { @@ -152,6 +183,10 @@ export default class ObsyncPlugin extends Plugin { const queueFileSync = (file: TAbstractFile) => { if (file instanceof TFile) { + if (this.suppressFileSyncEvents) { + return; + } + this.queueAutoSync(); } }; @@ -190,7 +225,7 @@ export default class ObsyncPlugin extends Plugin { this.autoSyncRunning = true; try { - await this.syncNow(); + await this.syncNow("auto"); } finally { this.autoSyncRunning = false; @@ -210,19 +245,26 @@ export default class ObsyncPlugin extends Plugin { const result = await releaseDeletedContent({ store: this.createObjectStore(), now: () => Date.now() }); if (result.locked) { - new Notice("另一台设备正在同步,本次释放空间已跳过。"); + this.showNotice("另一台设备正在同步,本次释放空间已跳过。"); return; } this.pruneLocalDeletedState(); await this.saveSettings(); - new Notice(`Obsync 已释放:清理删除记录 ${result.deletedTombstones},删除 Blob ${result.deletedBlobs}。`); + this.showNotice(`Obsync 已释放:清理删除记录 ${result.deletedTombstones},删除 Blob ${result.deletedBlobs}。`); }); } - private async runConfiguredOperation(progressText: string, failurePrefix: string, operation: () => Promise): Promise { + private async runConfiguredOperation( + progressText: string, + failurePrefix: string, + operation: () => Promise, + options: { notifyMissingConfig?: boolean } = {}, + ): Promise { if (!this.hasConnectionSettings()) { - new Notice("请先填写端点、Bucket、Access Key ID 和 Secret Access Key。"); + if (options.notifyMissingConfig ?? true) { + this.showNotice("请先填写端点、Bucket、Access Key ID 和 Secret Access Key。"); + } return; } @@ -232,12 +274,37 @@ export default class ObsyncPlugin extends Plugin { await operation(); } catch (error) { const message = error instanceof Error ? error.message : String(error); - new Notice(`${failurePrefix}:${message}`); + this.showNotice(`${failurePrefix}:${message}`); } finally { this.clearOperationStatus(); } } + private startFileEventSuppression(): void { + if (this.suppressFileSyncTimer !== null) { + window.clearTimeout(this.suppressFileSyncTimer); + this.suppressFileSyncTimer = null; + } + + this.suppressFileSyncEvents = true; + } + + private finishFileEventSuppression(): void { + if (this.suppressFileSyncTimer !== null) { + window.clearTimeout(this.suppressFileSyncTimer); + } + + this.suppressFileSyncTimer = window.setTimeout(() => { + this.suppressFileSyncTimer = null; + this.suppressFileSyncEvents = false; + }, FILE_EVENT_SUPPRESSION_MS); + } + + private showNotice(message: string): void { + this.activeNotice?.hide(); + this.activeNotice = new Notice(message); + } + private setOperationStatus(text: string): void { this.statusBarItem?.classList.remove("is-hidden"); this.statusBarItem?.setText(text);