+58
-5
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user