And also bumps index version to force reindex (due to 'relation' introduction and 'link' elimination)
1198 lines
38 KiB
TypeScript
1198 lines
38 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 { publicVersion } from "../public_version.ts";
|
|
import { ClientSystem } from "./client_system.ts";
|
|
import {
|
|
buildMarkdownLanguageExtension,
|
|
createEditorState,
|
|
isValidEditor,
|
|
} from "./codemirror/editor_state.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 { Space } from "./space.ts";
|
|
import { evalStatement } from "./space_lua/eval.ts";
|
|
import { parseExpressionString, parse 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";
|
|
import { handleObjectsRequest } from "./runtime_api/objects_api.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 and invoke the objects API in the live client.
|
|
export type SBRuntime = {
|
|
headless?: boolean;
|
|
ready?: boolean;
|
|
evalLua?: (expr: string) => Promise<unknown>;
|
|
evalLuaScript?: (script: string) => Promise<unknown>;
|
|
objectsAPI?: (reqJson: string) => Promise<string>;
|
|
};
|
|
|
|
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;
|
|
|
|
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 check for an up-to-date index immediately
|
|
if (this.bootConfig.disableServiceWorker) {
|
|
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;
|
|
|
|
let lastSaveTimestamp: number | undefined;
|
|
|
|
const updateLastSaveTimestamp = () => {
|
|
lastSaveTimestamp = Date.now();
|
|
};
|
|
|
|
this.eventHook.addLocalListener(
|
|
"editor:pageSaving",
|
|
updateLastSaveTimestamp,
|
|
);
|
|
|
|
this.eventHook.addLocalListener(
|
|
"editor:documentSaving",
|
|
updateLastSaveTimestamp,
|
|
);
|
|
|
|
this.eventHook.addLocalListener(
|
|
"file:changed",
|
|
(path: string, oldHash: number, newHash: number) => {
|
|
// Only reload when watching the current page or document (to avoid reloading when switching pages)
|
|
if (
|
|
this.space.watchInterval &&
|
|
this.currentPath() === path &&
|
|
// Avoid reloading if the page was just saved (5s window)
|
|
(!lastSaveTimestamp || lastSaveTimestamp < Date.now() - 5000) &&
|
|
// Avoid reloading if the previous hash was undefined (first load)
|
|
oldHash !== undefined
|
|
) {
|
|
console.log(
|
|
"Page changed elsewhere, reloading. Old hash",
|
|
oldHash,
|
|
"new hash",
|
|
newHash,
|
|
);
|
|
this.ui.flashNotification(
|
|
"Page or 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();
|
|
}
|
|
|
|
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;
|
|
|
|
globalThis.sbRuntime.objectsAPI = async (
|
|
reqJson: string,
|
|
): Promise<string> => {
|
|
try {
|
|
const req = JSON.parse(reqJson);
|
|
const scriptEnv = new LuaEnv(spaceLuaEnv.env);
|
|
const tl = new LuaEnv();
|
|
tl.setLocal("_GLOBAL", spaceLuaEnv.env);
|
|
const sf = new LuaStackFrame(tl, null);
|
|
const response = await handleObjectsRequest(
|
|
{ objectIndex: this.objectIndex, env: scriptEnv, stackFrame: sf },
|
|
req,
|
|
);
|
|
return JSON.stringify(response);
|
|
} catch (e: unknown) {
|
|
return JSON.stringify({
|
|
ok: false,
|
|
code: "internal_error",
|
|
error: e instanceof Error ? e.message : String(e),
|
|
});
|
|
}
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
return currentResult;
|
|
}
|
|
|
|
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" };
|
|
}
|
|
|
|
async navigate(ref: Ref | null, replaceState = false, newWindow = false) {
|
|
ref ??= this.getIndexRef();
|
|
|
|
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);
|
|
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 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);
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
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);
|
|
}
|
|
}
|