diff --git a/client/client.ts b/client/client.ts index 8bd1045a..d573b883 100644 --- a/client/client.ts +++ b/client/client.ts @@ -7,7 +7,7 @@ import { syntaxTree } from "@codemirror/language"; import type { Compartment, EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import type { SyntaxNode } from "@lezer/common"; -import { jitter } from "@silverbulletmd/silverbullet/lib/async"; +import { jitter, sleep } from "@silverbulletmd/silverbullet/lib/async"; import { deriveDbName } from "@silverbulletmd/silverbullet/lib/crypto"; import { encodePageURI, @@ -142,6 +142,12 @@ export class Client { public pageListLoaded: boolean = false; // Guard so we only fire the loading→ready editor:reloadState once. private widgetReadyDispatched: boolean = false; + // Tracks external Markdown changes independently from the queue's repeated + // idle notifications. One coordinator waits for indexing to drain and + // coalesces a burst of file changes into the minimum number of refreshes. + private pageListRefreshRevision = 0; + private pageListRefreshedRevision = 0; + private pageListRefreshTask?: Promise; // Resolves once the widget-ready transition has been dispatched and the // resulting editor state rebuild has settled. Headless tests wait on // this to avoid typing into the editor mid-rebuild. @@ -231,10 +237,9 @@ export class Client { }); // If widget rendering is still gated waiting on a full index, the - // initial-index handler in ObjectIndex may have unsubscribed or - // never fired for this client. After any indexing pass empties the - // queue, refresh the page list cache so its index-backed branch - // can flip pageListLoaded and unblock widget rendering. + // initial-index handler in ObjectIndex may have unsubscribed or never + // fired for this client. External changes after boot are handled by the + // coalesced refresh coordinator started from the file event listeners. this.eventHook.addLocalListener("mq:emptyQueue:indexQueue", async () => { if (this.widgetReadyDispatched) return; if ( @@ -393,6 +398,19 @@ export class Client { console.log("Queueing index for", name); await this.objectIndex.clearFileIndex(name); await this.mq.send("indexQueue", name); + if (name.endsWith(".md")) { + this.requestPageListRefresh(); + } + }); + + this.eventHook.addLocalListener("file:deleted", async (name: string) => { + if (name.endsWith(".md")) { + // ObjectIndex also clears this through its own listener. Awaiting the + // idempotent operation here makes the following refresh independent + // of listener completion order. + await this.objectIndex.clearFileIndex(name); + this.requestPageListRefresh(); + } }); const space = new Space( @@ -670,6 +688,41 @@ export class Client { void this.space.spacePrimitives.fetchFileList(); } + private requestPageListRefresh() { + this.pageListRefreshRevision++; + this.ensurePageListRefresh(); + } + + private ensurePageListRefresh() { + if (this.pageListRefreshTask) return; + + this.pageListRefreshTask = (async () => { + while (this.pageListRefreshedRevision < this.pageListRefreshRevision) { + const pendingRevision = this.pageListRefreshRevision; + // A file-list comparison reports each changed file separately. Wait + // briefly for that producer to go quiet so a large import refreshes + // the sidebar once instead of once per page. + await sleep(100); + if (pendingRevision !== this.pageListRefreshRevision) continue; + await this.mq.awaitEmptyQueue("indexQueue"); + if (pendingRevision !== this.pageListRefreshRevision) continue; + const indexedRevision = this.pageListRefreshRevision; + await this.updatePageListCache(); + this.pageListRefreshedRevision = indexedRevision; + } + })(); + void this.pageListRefreshTask + .catch((error) => console.error("Failed to refresh page list", error)) + .finally(() => { + this.pageListRefreshTask = undefined; + // A request can land after the worker's final loop condition but + // before this cleanup callback. Make sure that edge starts a worker. + if (this.pageListRefreshedRevision < this.pageListRefreshRevision) { + this.ensurePageListRefresh(); + } + }); + } + /** * If we just transitioned from "loading" to "ready" for widget * rendering, dispatch editor:reloadState so the editor rebuilds and diff --git a/e2e/external-edit.test.ts b/e2e/external-edit.test.ts index ed39ec0e..6bc70b7b 100644 --- a/e2e/external-edit.test.ts +++ b/e2e/external-edit.test.ts @@ -394,6 +394,31 @@ test("polling still delivers external edits when /.events is unavailable", async expect(eventsRequestCount).toBe(1); }); +test("external Markdown folders appear in the open sidebar after indexing", async ({ + sbPage, + sbServer, +}) => { + const importedFolder = "极空间记事本"; + const sidebar = sbPage.getByRole("complementary", { + name: "Plainleaf 导航", + }); + await expect(sidebar.getByText(importedFolder, { exact: true })).toHaveCount( + 0, + ); + + await mkdir(join(sbServer.spaceDir, importedFolder), { recursive: true }); + await writeFile( + join(sbServer.spaceDir, importedFolder, "导入示例.md"), + "# 导入示例\n", + ); + + // The regular file-list scan detects and indexes the new file. Once that + // batch drains, the page-list cache must refresh without a browser reload. + await expect( + sidebar.getByText(importedFolder, { exact: true }), + ).toBeVisible({ timeout: 15_000 }); +}); + test("reconnects with backoff after a transient connection failure", async ({ page, sbServer,