Files
plainleaf/client/client.ts
T

1257 lines
40 KiB
TypeScript

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 { 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<unknown>;
evalLuaScript?: (script: string) => Promise<unknown>;
};
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<void>;
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<void>((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<any[]> {
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<void> {
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<T>(
tag: string,
query: LuaCollectionQuery,
scopedVariables?: Record<string, any>,
): Promise<T[]> {
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<PageMeta>("page", {});
// Overlay augmented meta values
await this.pageMetaAugmenter.augmentObjectArray(allPages, "ref");
// Fetch aspiring pages
const aspiringPageNames = await this.queryLuaObjects<string>(
"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<DocumentMeta>(
"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<CompletionResult | SlashCompletions | null> {
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<CompletionResult | null> {
return this.completeWithEvent(
context,
"editor:complete",
) as Promise<CompletionResult | null>;
}
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<StyleObject>("space-style", {
objectVariable: "_",
orderBy: [
{
expr: parseExpressionString("_.priority"),
desc: true,
},
],
});
if (!spaceStyles) {
return;
}
// Prepare separate <style> tag per custom style (for robustness)
const customStylesContent = spaceStyles
.map((s) => `<style>${s.style}</style>`)
.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<string, Command> {
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(
"A new version of SilverBullet client is available. A reload or two is required to update.",
"warning",
{
timeout: 0,
actions: [{ name: "Reload", run: () => location.reload() }],
},
);
}
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<void>((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);
}
}