fix: 外部导入笔记后自动刷新侧栏

Co-Authored-By: Codex <noreply@anthropic.com>
This commit is contained in:
2026-08-13 07:52:50 +08:00
co-authored by Codex
parent 645c04ed01
commit 1838435984
2 changed files with 83 additions and 5 deletions
+58 -5
View File
@@ -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<void>;
// 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
+25
View File
@@ -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,