import type { CompletionContext, CompletionResult, } from "@codemirror/autocomplete"; import { history } from "@codemirror/commands"; 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 { deriveDbName } from "@silverbulletmd/silverbullet/lib/crypto"; import { encodePageURI, encodeRef, getNameFromPath, isMarkdownPath, type Path, parseToRef, type Ref, } from "@silverbulletmd/silverbullet/lib/ref"; import type { AppEvent, ClickEvent, CompleteEvent, EnrichedClickEvent, SlashCompletions, } from "@silverbulletmd/silverbullet/type/client"; import type { DocumentMeta, FileMeta, PageMeta, } from "@silverbulletmd/silverbullet/type/index"; import type { StyleObject } from "../plugs/index/space_style.ts"; import type { ResolveAnchorResult } from "../plugs/index/types.ts"; import { version as publicVersion } from "../version.json"; import { ClientSystem } from "./client_system.ts"; import { buildMarkdownLanguageExtension, createEditorState, isValidEditor, } from "./codemirror/editor_state.ts"; import { withCompletionInfo } from "./codemirror/completion_info.ts"; import type { Config } from "./config.ts"; import { ContentManager } from "./content_manager.ts"; import { Augmenter } from "./data/data_augmenter.ts"; import { DataStore } from "./data/datastore.ts"; import { EncryptedKvPrimitives } from "./data/encrypted_kv_primitives.ts"; import { IndexedDBKvPrimitives } from "./data/indexeddb_kv_primitives.ts"; import type { KvPrimitives } from "./data/kv_primitives.ts"; import { DataStoreMQ } from "./data/mq.datastore.ts"; import { ObjectIndex } from "./data/object_index.ts"; import { MainUI } from "./editor_ui.tsx"; import { PathPageNavigator, parseRefFromURI } from "./navigator.ts"; import { EventHook } from "./plugos/hooks/event.ts"; import { RealtimeEvents } from "./realtime_events.ts"; import { reloadWithUpdatedServiceWorker } from "./service_worker/util.ts"; import { Space } from "./space.ts"; import { evalStatement } from "./space_lua/eval.ts"; import { parseExpressionString, parseBlock as parseLua, } from "./space_lua/parse.ts"; import type { LuaCollectionQuery } from "./space_lua/query_collection.ts"; import { LuaEnv, LuaRuntimeError, LuaStackFrame, luaValueToJS, } from "./space_lua/runtime.ts"; import { resolveASTReference } from "./space_lua.ts"; import { CheckedSpacePrimitives } from "./spaces/checked_space_primitives.ts"; import { fsEndpoint } from "./spaces/constants.ts"; import { EventedSpacePrimitives } from "./spaces/evented_space_primitives.ts"; import { HttpSpacePrimitives } from "./spaces/http_space_primitives.ts"; import type { Command } from "./types/command.ts"; import type { AppViewState, BootConfig, ServiceWorkerSourceMessage, ServiceWorkerTargetMessage, } from "./types/ui.ts"; import { WidgetCache } from "./widget_cache.ts"; // Fetch the file list ever so often, this will implicitly kick off a snapshot comparison resulting in the indexing of changed pages const fetchFileListInterval = 10000; // Runtime API bridge: written by the client when running headless to evaluate Lua in the live client. export type SBRuntime = { headless?: boolean; ready?: boolean; evalLua?: (expr: string) => Promise; evalLuaScript?: (script: string) => Promise; }; declare global { var client: Client; var sbRuntime: SBRuntime; } // TODO: Clean this up, this has become a god class... export class Client { // Event bus used to communicate between components eventHook: EventHook; space!: Space; clientSystem!: ClientSystem; eventedSpacePrimitives!: EventedSpacePrimitives; httpSpacePrimitives!: HttpSpacePrimitives; realtimeEvents?: RealtimeEvents; ui!: MainUI; ds!: DataStore; mq!: DataStoreMQ; // Used to store additional pageMeta outside the page index itself persistent between client runs (specifically: lastOpened) pageMetaAugmenter!: Augmenter; // Used to store additional command data outside the objects themselves persistent between client rusn (specifically: lastRun) commandAugmenter!: Augmenter; // CodeMirror editor editorView!: EditorView; commandKeyHandlerCompartment?: Compartment; vimCompartment?: Compartment; indentUnitCompartment?: Compartment; undoHistoryCompartment?: Compartment; markdownLanguageCompartment?: Compartment; // Content manager: handles page/document loading, saving, and editor mode switching contentManager!: ContentManager; // Track if plugs have been updated since sync cycle fullSyncCompleted = false; private versionMismatchNotified = false; // True once we've confirmed the server reports the same publicVersion as // this client bundle and the plugs the server is shipping are aligned with the running build. private versionsInSync = false; // Set to true once the system is ready (plugs loaded) public systemReady: boolean = false; // Set to true once the object index has been fully built at least once public fullIndexCompleted: boolean = false; // Set to true once viewState.allPages has been populated from the index public pageListLoaded: boolean = false; // Guard so we only fire the loading→ready editor:reloadState once. private widgetReadyDispatched: boolean = false; // 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. public widgetsReady!: Promise; private resolveWidgetsReady!: () => void; private pageNavigator!: PathPageNavigator; private onLoadRef: Ref; dbPrefix?: string; syncMode = false; // Widget and image height caching widgetCache!: WidgetCache; objectIndex!: ObjectIndex; constructor( private parent: Element, public bootConfig: BootConfig, readonly config: Config, ) { this.eventHook = new EventHook(this.config); // The third case should only ever happen when the user provides an invalid index env variable this.onLoadRef = parseRefFromURI() || this.getIndexRef(); this.widgetsReady = new Promise((resolve) => { this.resolveWidgetsReady = resolve; }); } /** * Initialize the client * This is a separated from the constructor to allow for async initialization */ async init(encryptionKey?: CryptoKey) { const dbName = await deriveDbName( "data", this.bootConfig.spaceFolderPath, document.baseURI.replace(/\/$/, ""), encryptionKey, ); // Setup the KV (database) let kvPrimitives: KvPrimitives = new IndexedDBKvPrimitives(dbName); await (kvPrimitives as IndexedDBKvPrimitives).init(); console.log("Using IndexedDB database", dbName); // See if we need to encrypt this if (encryptionKey) { kvPrimitives = new EncryptedKvPrimitives(kvPrimitives, encryptionKey); await (kvPrimitives as EncryptedKvPrimitives).init(); console.log("Enabled client-side encryption"); } // Wrap it in a datastore this.ds = new DataStore(kvPrimitives); this.pageMetaAugmenter = new Augmenter(this.ds, ["aug", "pageMeta"]); this.commandAugmenter = new Augmenter(this.ds, ["aug", "command"]); // Setup message queue on top of that this.mq = new DataStoreMQ(this.ds, this.eventHook); this.widgetCache = new WidgetCache(this.ds); this.contentManager = new ContentManager(this); this.objectIndex = new ObjectIndex( this.ds, this.config, this.eventHook, this.mq, ); // Seed the full-index readiness flag from persistent state so a // warm reload doesn't unnecessarily flash loading spinners. A stale // stored version (older than `desiredIndexVersion`) still counts as // ready: the entries are queryable while `ensureFullIndex` waits for // sync + version confirmation. See `ObjectIndex.isIndexAvailable`. this.fullIndexCompleted = await this.objectIndex.isIndexAvailable(); // After the initial index completes (object_index.ts dispatches this), // mark the flag and — if the page list cache previously took the // fallback branch (so `pageListLoaded` stayed false) — re-run it // so it reflects the now-indexed, transform-applied values. The // editor itself will be rebuilt by client_system's listener for // the same event; we don't need to dispatch a fresh one here. this.eventHook.addLocalListener("editor:reloadState", () => { this.fullIndexCompleted = true; if (!this.pageListLoaded) { this.updatePageListCache().catch(console.error); } }); // 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. this.eventHook.addLocalListener("mq:emptyQueue:indexQueue", async () => { if (this.widgetReadyDispatched) return; if ( !this.pageListLoaded && (await this.objectIndex.hasFullIndexCompleted()) ) { this.fullIndexCompleted = true; await this.updatePageListCache(); } }); // Instantiate a PlugOS system this.clientSystem = new ClientSystem( this, this.mq, this.ds, this.eventHook, this.objectIndex, this.bootConfig.readOnly, ); this.initSpace(); this.ui = new MainUI(this); this.ui.render(this.parent); this.editorView = new EditorView({ state: createEditorState(this, "", "", true), parent: document.getElementById("sb-editor")!, }); this.focus(); this.clientSystem.init(); if (this.bootConfig.performWipe) { if (confirm("Are you sure you want to wipe the client?")) { await this.wipeClient(); alert("Wipe done. Please reload the page or navigate away."); return; } } if (this.bootConfig.performReset) { if ( confirm( "Are you sure you want to reset the client? This will wipe all local data and re-sync everything.", ) ) { await this.wipeClient(); location.reload(); return; } } await this.widgetCache.load(); // Let's ping the remote space to ensure we're authenticated properly, if not will result in a redirect to auth page try { await this.httpSpacePrimitives.ping(); } catch (e: any) { if (e.message === "Not authenticated") { console.warn("Not authenticated, redirecting to auth page"); return; } console.warn( "Could not reach remote server, we're offline or the server is down", e, ); } // Load plugs await this.loadPlugs(); await this.clientSystem.loadLuaScripts(); await this.initNavigator(); // await this.initSync(); await this.eventHook.dispatchEvent("system:ready"); this.systemReady = true; this.maybeDispatchWidgetsReady(); // When the service worker is disabled (desktop app / headless) there's no // SW proxy performing the `server-version` handshake that normally sets // `versionsInSync`. The bundled client and server are a matching set, so // treat versions as in sync. This both lets us check the index immediately // AND lets `space-sync-complete` signals — bridged in from the native sync // engine — drive the version-bump reindex after each sync cycle (otherwise // the gate in `handleServiceWorkerMessage` would stay permanently closed). if (this.bootConfig.disableServiceWorker) { this.versionsInSync = true; void this.objectIndex.ensureFullIndex(this.space); } this.initHeadlessRuntime(); // Load space snapshot and enable events await this.eventedSpacePrimitives.enable(); // Kick off a cron event interval setInterval(() => { void this.dispatchAppEvent("cron:secondPassed"); }, 1000); // We can load custom styles async this.loadCustomStyles().catch(console.error); await this.dispatchAppEvent("editor:init"); // Reset Undo History after editor initialization. client.editorView.dispatch({ effects: client.undoHistoryCompartment?.reconfigure([]), }); client.editorView.dispatch({ effects: client.undoHistoryCompartment?.reconfigure([history()]), }); // Asynchronously update caches this.updatePageListCache().catch(console.error); this.updateDocumentListCache().catch(console.error); } initSpace() { this.httpSpacePrimitives = new HttpSpacePrimitives( document.baseURI.replace(/\/*$/, "") + fsEndpoint, this.bootConfig.spaceFolderPath, (message, actionOrRedirectHeader) => { alert(message); if (actionOrRedirectHeader === "reload") { location.reload(); } else { location.href = actionOrRedirectHeader; } }, // Bearer token injected by an embedder (e.g. the Tauri App) as // `globalThis.silverbullet.bearerToken` before the bundle loads. // Undefined in a normal browser deployment, which leaves // HttpSpacePrimitives in its default cookie-only auth mode. (globalThis as { silverbullet?: { bearerToken?: string } }).silverbullet ?.bearerToken, ); this.eventedSpacePrimitives = new EventedSpacePrimitives( new CheckedSpacePrimitives( this.httpSpacePrimitives, this.bootConfig.readOnly, ), this.eventHook, this.ds, ); // Kick off a regular file listing request to trigger events setInterval(() => { void this.eventedSpacePrimitives.fetchFileList(); }, fetchFileListInterval + jitter()); this.eventHook.addLocalListener("file:changed", async (name: string) => { console.log("Queueing index for", name); await this.objectIndex.clearFileIndex(name); await this.mq.send("indexQueue", name); }); const space = new Space( this.eventedSpacePrimitives, this.eventHook, (name, page) => this.clientSystem.localSyscall("index.resolveAnchor", [name, page]), ); this.space = space; this.eventHook.addLocalListener( "file:changed", (path: string, oldHash: number, _newHash: number, ownWrite: boolean) => { if ( !this.space.watchInterval || this.currentPath() !== path || oldHash === undefined || // our own save landing, not somebody else's edit ownWrite ) { return; } if (isMarkdownPath(path)) { this.contentManager.reloadPageContent().catch(console.error); } else { // Documents (non-markdown) keep the reload path this.ui.flashNotification("Document changed elsewhere, reloading"); void this.reloadEditor(); } }, ); // Caching a list of known files for the wiki_link highlighter (that checks if a file exists) // And keeping it up to date as we go this.eventHook.addLocalListener("file:changed", (fileName: string) => { // Make sure this file is in the list of known pages this.clientSystem.allKnownFiles.add(fileName); }); this.eventHook.addLocalListener("file:deleted", (fileName: string) => { this.clientSystem.allKnownFiles.delete(fileName); }); this.eventHook.addLocalListener("file:listed", (allFiles: FileMeta[]) => { // Update list of known pages this.clientSystem.allKnownFiles.clear(); allFiles.forEach((f) => { this.clientSystem.allKnownFiles.add(f.name); }); this.clientSystem.knownFilesLoaded = true; }); this.space.watch(); this.realtimeEvents = new RealtimeEvents({ probeFile: (name) => this.eventedSpacePrimitives.getFileMeta(name), syncFile: (name) => this.clientSystem .localSyscall("sync.performFileSync", [name]) .catch((e) => console.warn("[realtime] sync nudge failed", e)), syncSpace: () => this.clientSystem .localSyscall("sync.performSpaceSync", []) .catch((e) => console.warn("[realtime] sync nudge failed", e)), refreshFileList: () => this.eventedSpacePrimitives.fetchFileListWhenIdle(), serviceWorkerActive: () => !!globalThis.navigator?.serviceWorker?.controller, }); this.realtimeEvents.start( `${document.baseURI.replace(/\/*$/, "")}/.events`, ); } currentPath(): Path { return this.ui.viewState.current?.path || this.onLoadRef.path; } currentName(): string { return getNameFromPath( this.ui.viewState.current?.path || this.onLoadRef.path, ); } currentPageMeta(): PageMeta | undefined { return this.ui.viewState.current?.meta; } dispatchAppEvent(name: AppEvent, ...args: any[]): Promise { return this.eventHook.dispatchEvent(name, ...args); } dispatchClickEvent(clickEvent: ClickEvent) { const editorState = this.editorView.state; const sTree = syntaxTree(editorState); const currentNode = sTree.resolveInner(clickEvent.pos); const parentNodes: string[] = this.extractParentNodes( editorState, currentNode, ); const enrichedEvent: EnrichedClickEvent = { ...clickEvent, parentNodes, }; return this.dispatchAppEvent("page:click", enrichedEvent); } save(immediate = false): Promise { return this.contentManager.save(immediate); } reportError(e: any, context: string = "") { console.error(`Error during ${context}:`, e); // Don't show flash notifications for expected operational errors if ( e.message === "Offline" || e.name === "AbortError" || e.name === "TimeoutError" ) { return; } if (e instanceof LuaRuntimeError) { this.ui.flashNotification(`Lua error: ${e.message}`, "error"); const origin = resolveASTReference(e.sf.astCtx!); if (origin) { void client.navigate(origin); } } else { this.ui.flashNotification(`Error: ${e.message}`, "error"); } } startPageNavigate(mode: "page" | "meta" | "document" | "all") { // Then show the page navigator this.ui.viewDispatch({ type: "start-navigate", mode }); // And update the page list cache asynchronously this.updatePageListCache().catch(console.error); this.updateDocumentListCache().catch(console.error); } queryLuaObjects( tag: string, query: LuaCollectionQuery, scopedVariables?: Record, ): Promise { return this.objectIndex.queryLuaObjects( this.clientSystem.spaceLuaEnv.env, tag, query, scopedVariables, ); } /** * In headless mode, expose Lua eval functions on globalThis for CDP access * and signal readiness once the full index is complete. */ private initHeadlessRuntime() { if (!globalThis.sbRuntime.headless) { return; } console.log("[RuntimeAPI] Headless mode, exposing eval functions"); const spaceLuaEnv = this.clientSystem.spaceLuaEnv; const evalLuaCode = async (code: string) => { const ast = parseLua(code); const scriptEnv = new LuaEnv(spaceLuaEnv.env); const tl = new LuaEnv(); tl.setLocal("_GLOBAL", spaceLuaEnv.env); const sf = new LuaStackFrame(tl, ast.ctx); const result = await evalStatement(ast, scriptEnv, sf); const returnValue = result && typeof result === "object" && "ctrl" in result && result.ctrl === "return" && Array.isArray(result.values) ? result.values[0] : result; return (await Promise.resolve(luaValueToJS(returnValue, sf))) ?? null; }; globalThis.sbRuntime.evalLua = (expr: string) => evalLuaCode(`return ${expr}`); globalThis.sbRuntime.evalLuaScript = evalLuaCode; // Signal readiness after widgets are fully ready (index complete + // editor state rebuild settled). Waiting on the widget-ready // transition ensures tests don't type into the editor mid-rebuild. void this.widgetsReady.then(() => { console.log( "[RuntimeAPI] Ready (eval functions exposed + widgets ready)", ); globalThis.sbRuntime.ready = true; }); } async updatePageListCache() { console.log("Updating page list cache"); // Use the looser `isIndexAvailable` check: stale-but-present index // entries are still queryable, so we can take the index branch. const indexAvailable = await this.objectIndex.isIndexAvailable(); let allPages: PageMeta[] = []; if (indexAvailable) { console.log("Initial index complete, loading full page list via index."); // Fetch indexed pages allPages = await this.queryLuaObjects("page", {}); // Overlay augmented meta values await this.pageMetaAugmenter.augmentObjectArray(allPages, "ref"); // Fetch aspiring pages const aspiringPageNames = await this.queryLuaObjects( "aspiring-page", { select: parseExpressionString("name"), distinct: true }, ); // Fetch any augmented page meta data (for now only lastOpened) // this.clientSystem.ds.query({prefix: }) // Map and push aspiring pages directly into allPages allPages.push( ...aspiringPageNames.map( (name): PageMeta => ({ ref: name, tag: "page", _isAspiring: true, name: name, created: "", // Aspiring pages don't have timestamps yet lastModified: "", // Aspiring pages don't have timestamps yet perm: "rw", }), ), ); } else { console.log( "Initial sync not complete or index plug not loaded. Fetching page list directly using space.fetchPageList().", ); try { // Call fetchPageList directly allPages = await this.space.fetchPageList(); // Let's do some heuristic-based post processing for (const page of allPages) { // These are _mostly_ meta pages, let's add a tag for them if (page.name.startsWith("Library/")) { page.tags = ["meta"]; } } } catch (e) { console.error("Failed to list pages directly from space:", e); // Handle error, maybe show notification or leave list empty this.ui.flashNotification( "Could not fetch page list directly.", "error", ); } } this.ui.viewDispatch({ type: "update-page-list", allPages: allPages, }); // Only flip the readiness flag if allPages reflects the indexed, // transform-applied values (the index branch). The fallback branch // produces raw page meta without pageDecoration, so we keep // showing loading widgets until the index is back. if (indexAvailable) { this.pageListLoaded = true; this.maybeDispatchWidgetsReady(); } // Async kick-off file listing to bring listing up to date void this.space.spacePrimitives.fetchFileList(); } /** * If we just transitioned from "loading" to "ready" for widget * rendering, dispatch editor:reloadState so the editor rebuilds and * the loading placeholders get replaced with real widgets. Fires at * most once per session. */ public maybeDispatchWidgetsReady() { if (this.widgetReadyDispatched) return; if ( this.systemReady && this.clientSystem.scriptsLoaded && this.fullIndexCompleted && this.pageListLoaded ) { this.widgetReadyDispatched = true; this.rebuildEditorState(); this.resolveWidgetsReady(); } } async updateDocumentListCache() { console.log("Updating document list cache"); const allDocuments = await this.queryLuaObjects( "document", {}, ); this.ui.viewDispatch({ type: "update-document-list", allDocuments: allDocuments, }); } async startCommandPalette() { const commands = this.ui.viewState.commands; await this.commandAugmenter.augmentObjectMap(commands); this.ui.viewDispatch({ type: "show-palette", commands, context: client.getContext(), }); } /** * Saves when a command was last run to the datastore for command palette ordering */ async registerCommandRun(name: string) { await this.commandAugmenter.setAugmentation(name, { lastRun: Date.now(), }); } async loadPlugs() { await this.clientSystem.reloadPlugsFromSpace(this.space); await this.dispatchAppEvent("plugs:loaded"); } reconfigureLanguage() { if (this.markdownLanguageCompartment) { this.editorView.dispatch({ effects: this.markdownLanguageCompartment.reconfigure( buildMarkdownLanguageExtension(this), ), }); } } rebuildEditorState() { const editorView = this.editorView; // Preserve selection + scroll across the rebuild — this fires on // widget loading→ready transitions after the editor is already // interactive, so a reset to pos 0 / scrollTop 0 is jarring. const previousSelection = editorView.state.selection; const previousScrollTop = editorView.scrollDOM.scrollTop; let cursorWasVisible = false; try { const block = editorView.lineBlockAt(previousSelection.main.head); const scrollBottom = previousScrollTop + editorView.scrollDOM.clientHeight; cursorWasVisible = block.bottom > previousScrollTop && block.top < scrollBottom; } catch { // fall back to no-scroll } editorView.setState( createEditorState( this, this.currentName(), editorView.state.sliceDoc(), this.currentPageMeta()?.perm === "ro", previousSelection, ), ); editorView.scrollDOM.scrollTop = previousScrollTop; if (cursorWasVisible) { editorView.dispatch({ effects: EditorView.scrollIntoView(previousSelection.main.head), }); } } // Code completion support async completeWithEvent( context: CompletionContext, eventName: AppEvent, ): Promise { const editorState = context.state; const selection = editorState.selection.main; const line = editorState.doc.lineAt(selection.from); const linePrefix = line.text.slice(0, selection.from - line.from); // Build up list of parent nodes, some completions need this const sTree = syntaxTree(editorState); const currentNode = sTree.resolveInner(editorState.selection.main.from); const parentNodes: string[] = this.extractParentNodes( editorState, currentNode, ); // Dispatch the event const results = await this.dispatchAppEvent(eventName, { pageName: this.currentName(), linePrefix, pos: selection.from, parentNodes, } as CompleteEvent); // Merge results let currentResult: CompletionResult | null = null; for (const result of results) { if (!result) { continue; } if (currentResult) { // Let's see if we can merge results if (currentResult.from !== result.from) { console.error( "Got completion results from multiple sources with different `from` locators, cannot deal with that", ); console.error( "Previously had", currentResult, "now also got", result, ); return null; } else { // Merge currentResult = { from: result.from, options: [...currentResult.options, ...result.options], }; } } else { currentResult = result; } } if (!currentResult) { return null; } return { ...currentResult, options: currentResult.options.map(withCompletionInfo), }; } isReadOnlyMode(): boolean { return this.bootConfig.readOnly || this.currentPageMeta()?.perm === "ro"; } public extractParentNodes(editorState: EditorState, currentNode: SyntaxNode) { const parentNodes: string[] = []; if (currentNode) { let node: SyntaxNode | null = currentNode; do { if (["FencedCode", "FrontMatter"].includes(node.name)) { const body = editorState.sliceDoc(node.from + 3, node.to - 3); parentNodes.push(`${node.name}:${body}`); } else if (node.name === "LuaDirective") { const body = editorState.sliceDoc(node.from + 2, node.to - 1); parentNodes.push(`${node.name}:${body}`); } else { parentNodes.push(node.name); } node = node.parent; } while (node); } return parentNodes; } editorComplete(context: CompletionContext): Promise { return this.completeWithEvent( context, "editor:complete", ) as Promise; } reloadEditor() { return this.contentManager.reloadEditor(); } // Focus the editor focus() { const viewState = this.ui.viewState; if ( [ viewState.showCommandPalette, viewState.showPageNavigator, viewState.showFilterBox, viewState.showConfirm, viewState.showPrompt, ].some(Boolean) ) { // console.log("not focusing"); // Some other modal UI element is visible, don't focus editor now return; } if (this.contentManager.isDocumentEditor()) { this.contentManager.documentEditor.focus(); } else { this.editorView.focus(); } } getIndexRef(): Ref { return parseToRef(this.bootConfig.indexPage) || { path: "index.md" }; } /** * Navigates the client to a particular ref (ingoring previous state) */ navigate(ref: Ref | null, replaceState = false, newWindow = false) { return this._navigate(ref, replaceState, newWindow, false); } /** * Opens a particular ref in the client restoring previous state if available */ open(ref: Ref | null, replaceState = false, newWindow = false) { return this._navigate(ref, replaceState, newWindow, true); } private async _navigate( ref: Ref | null, replaceState = false, newWindow = false, restore = false, ) { ref ??= this.getIndexRef(); // Resolve $-anchor refs into a concrete page + position. The page // navigator only knows about position/header/linecolumn details, so // we have to translate here. if (ref.details?.type === "anchor") { const anchorName = ref.details.name; const pageFilter = ref.path ? ref.path.endsWith(".md") ? ref.path.slice(0, -3) : ref.path : undefined; const result: ResolveAnchorResult = await this.clientSystem.localSyscall( "index.resolveAnchor", [anchorName, pageFilter], ); if (!result.ok) { if (result.reason === "missing") { this.ui.flashNotification( `Anchor not found: $${anchorName}`, "error", ); } else { const pages = result.hits.map((h) => h.page).join(", "); this.ui.flashNotification( `Duplicate anchor $${anchorName} on pages: ${pages}`, "error", ); } return; } ref = { ...ref, path: `${result.page}.md`, details: { type: "position", pos: result.range[0] }, }; } if (newWindow) { console.log( "Navigating to new page in new window", `${document.baseURI}${encodePageURI(encodeRef(ref))}`, ); const win = globalThis.open( `${document.baseURI}${encodePageURI(encodeRef(ref))}`, "_blank", ); if (win) { win.focus(); } return; } await this.pageNavigator!.navigate(ref, replaceState, restore); this.focus(); } openUrl(url: string, existingWindow = false) { if (!existingWindow) { const win = globalThis.open(url, "_blank"); if (win) { win.focus(); } } else { location.href = url; } } async loadCustomStyles() { if (this.bootConfig.disableSpaceStyle) { console.warn("Not loading custom styles, since space style is disabled"); return; } if (!(await this.objectIndex.isIndexAvailable())) { console.warn( "Not loading custom styles, since no index is available yet", ); return; } const spaceStyles = await this.queryLuaObjects("space-style", { objectVariable: "_", orderBy: [ { expr: parseExpressionString("_.priority"), desc: true, }, ], }); if (!spaceStyles) { return; } // Prepare separate `) .join("\n\n"); this.ui.viewDispatch({ type: "set-ui-option", key: "customStyles", value: customStylesContent, }); document.getElementById("custom-styles")!.innerHTML = customStylesContent; } async runCommandByName(name: string, args?: any[]) { const cmd = this.ui.viewState.commands.get(name); if (cmd) { if (args) { await cmd.run!(args); } else { await cmd.run!(); } } else { throw new Error(`Command ${name} not found`); } } getCommandsByContext(state: AppViewState): Map { const currentEditor = client.contentManager.documentEditor?.name; const readOnly = this.isReadOnlyMode(); const commands = new Map(state.commands); for (const [k, v] of state.commands.entries()) { if ( v.contexts && (!state.showCommandPaletteContext || !v.contexts.includes(state.showCommandPaletteContext)) ) { commands.delete(k); } const requiredEditor = v.requireEditor; if (!isValidEditor(currentEditor, requiredEditor)) { commands.delete(k); } // Hide write-mode commands when the current page (or space) is read-only. // CommandHook only filters on the space-wide read-only flag, so per-page // read-only pages would otherwise still expose "rw" commands here. if (readOnly && v.requireMode === "rw") { commands.delete(k); } } return commands; } getContext(): string | undefined { const state = this.editorView.state; const selection = state.selection.main; if (selection.empty) { return syntaxTree(state).resolveInner(selection.from).type.name; } return; } async handleServiceWorkerMessage(message: ServiceWorkerSourceMessage) { switch (message.type) { case "space-sync-complete": { const isFirstSync = !this.fullSyncCompleted; this.fullSyncCompleted = true; // Only trigger a version-bump reindex once we've also confirmed the // server is on the same publicVersion as this client — otherwise the // reindex could run against stale plug code that's about to be // replaced by the in-progress upgrade. if (this.versionsInSync) { void this.objectIndex.ensureFullIndex(this.space); } if (isFirstSync && message.operations > 0) { // First sync pulled new content — reload the current page // (it may have been empty because the file didn't exist locally yet) void this.reloadEditor(); // Re-evaluate CONFIG and space scripts now that sync has pulled them void this.clientSystem.reloadState(); } break; } case "online-status": { this.ui.viewDispatch({ type: "online-status-change", isOnline: message.isOnline, }); break; } case "auth-error": { alert(message.message); if ( message.actionOrRedirectHeader && message.actionOrRedirectHeader !== "reload" ) { location.href = message.actionOrRedirectHeader; } else { location.reload(); } break; } case "server-version": { if (message.serverVersion === publicVersion) { const wasInSync = this.versionsInSync; this.versionsInSync = true; // If sync already completed before we learned versions were aligned, // kick off the deferred reindex check now. if (!wasInSync && this.fullSyncCompleted) { void this.objectIndex.ensureFullIndex(this.space); } } else if (!this.versionMismatchNotified) { this.versionMismatchNotified = true; this.ui.flashNotification( "Plainleaf 已更新,请刷新页面以使用新版本。", "warning", { timeout: 0, actions: [ { name: "立即更新", run: () => void reloadWithUpdatedServiceWorker(), }, ], }, ); } break; } } // Also dispatch it on the event hook for any other listeners await this.eventHook.dispatchEvent( `service-worker:${message.type}`, message, ); } private async initNavigator() { this.pageNavigator = new PathPageNavigator(this); this.pageNavigator.subscribe(async (locationState) => { console.log(`Now navigating to ${encodeRef(locationState)}`); if (isMarkdownPath(locationState.path)) { await this.contentManager.loadPage(locationState); } else { await this.contentManager.loadDocumentEditor(locationState); } // Persist this page as the last opened page, we'll use this for cold start PWA loads await this.ds.set(["client", "lastOpenedPath"], locationState.path); }); // Initial navigation let ref = this.onLoadRef; if (ref.details?.type === "header" && ref.details.header === "boot") { const path = (await this.ds.get(["client", "lastOpenedPath"])) as Path; if (path) { console.log("Navigating to last opened page", getNameFromPath(path)); ref = { path }; } else { // Strip the #boot detail — it's not a real header delete ref.details; } } await this.navigate(ref, true); console.log("Focusing editor"); this.focus(); } async wipeClient() { // Clean out _other_ IndexedDB databases console.log("Wiping IndexedDB databses not connected to this space..."); const dbName = (this.ds.kv as any).dbName; const suffix = dbName.replace("sb_data", ""); if (indexedDB.databases) { const allDbs = await indexedDB.databases(); for (const db of allDbs) { if (!db.name?.endsWith(suffix)) { console.log("Deleting database", db.name); indexedDB.deleteDatabase(db.name!); } } } // Instructe service worker to wipe if (navigator.serviceWorker?.controller) { // We will attempt to unregister the service worker, best effort await new Promise((resolve) => { navigator.serviceWorker.addEventListener("message", async (e: any) => { const message: ServiceWorkerSourceMessage = e.data; if (message.type === "dataWiped") { console.log( "Got data wipe confirm, uninstalling service worker now", ); const registrations = await navigator.serviceWorker.getRegistrations(); for (const registration of registrations) { await registration.unregister(); } console.log("Unregistered all service workers"); resolve(); } }); // Send wipe request navigator.serviceWorker.getRegistration().then((registration) => { console.log( "Sending data wipe request to service worker", registration, ); registration?.active?.postMessage({ type: "wipe-data", } as ServiceWorkerTargetMessage); }); }); } else { console.info( "Service workers not enabled (no HTTPS?), so not unregistering.", ); } console.log("Stopping all systems"); this.space.unwatch(); this.realtimeEvents?.stop(); console.log("Clearing data store"); await this.ds.kv.clear(); console.log("Clearing complete."); } public async postServiceWorkerMessage(message: ServiceWorkerTargetMessage) { const registration = await navigator.serviceWorker.getRegistration(); if (!registration?.active) { console.warn("No active service worker, skipping message:", message.type); return; } registration.active.postMessage(message); } }