Files
plainleaf/client/client.ts
T
rktjmpandGitHub 8754b685ba Ensure command keybindings work reliably in any vim-mode (#1821)
When the vim extension is enabled and in "normal" mode, some default
bindings collide with Silverbullets commands (eg: `ctrl-p` - Share,
`ctrl-o` - Open Document, `ctrl-e` - Export, `ctrl-g h` - Home). These
vim bindings are not always functional (they may appear to do nothing
depending on vims state). This gives the impression that Silverbullet is
bugged and wont trigger commands. In "insert" mode, most bindings are
passed through correctly with some exceptions (eg: `ctrl-o`).

By splitting the command keybindings into their own keymap extension and
loading it before the vim extension, we can ensure Silverbullet commands
always trigger.

This does mean some vim bindings are overshadowed, but if a binding
triggers a Silverbullet command, its generally obvious that *something*
happened and the user should be able to recognise why the vim binding
failed and then rebind the command or vim mapping.
2026-02-06 16:12:02 +01:00

1622 lines
47 KiB
TypeScript

import type {
CompletionContext,
CompletionResult,
} from "@codemirror/autocomplete";
import type { Compartment, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { syntaxTree } from "@codemirror/language";
import { history, isolateHistory } from "@codemirror/commands";
import type { SyntaxNode } from "@lezer/common";
import { Space } from "./space.ts";
import type {
AppEvent,
ClickEvent,
CompleteEvent,
EnrichedClickEvent,
FilterOption,
SlashCompletions,
} from "@silverbulletmd/silverbullet/type/client";
import { EventHook } from "./plugos/hooks/event.ts";
import type { Command } from "./types/command.ts";
import {
type LocationState,
parseRefFromURI,
PathPageNavigator,
} from "./navigator.ts";
import type {
AppViewState,
BootConfig,
ServiceWorkerSourceMessage,
ServiceWorkerTargetMessage,
} from "./types/ui.ts";
import type {
PageCreatingContent,
PageCreatingEvent,
} from "@silverbulletmd/silverbullet/type/event";
import type { StyleObject } from "../plugs/index/space_style.ts";
import { jitter, throttle } from "@silverbulletmd/silverbullet/lib/async";
import { EventedSpacePrimitives } from "./spaces/evented_space_primitives.ts";
import { HttpSpacePrimitives } from "./spaces/http_space_primitives.ts";
import {
encodePageURI,
encodeRef,
getNameFromPath,
getOffsetFromHeader,
getOffsetFromLineColumn,
isMarkdownPath,
parseToRef,
type Path,
type Ref,
} from "@silverbulletmd/silverbullet/lib/ref";
import { ClientSystem } from "./client_system.ts";
import { createEditorState, isValidEditor } from "./codemirror/editor_state.ts";
import { MainUI } from "./editor_ui.tsx";
import { DataStore } from "./data/datastore.ts";
import { IndexedDBKvPrimitives } from "./data/indexeddb_kv_primitives.ts";
import { DataStoreMQ } from "./data/mq.datastore.ts";
import { LimitedMap } from "@silverbulletmd/silverbullet/lib/limited_map";
import { fsEndpoint } from "./spaces/constants.ts";
import { diffAndPrepareChanges } from "./codemirror/cm_util.ts";
import { DocumentEditor } from "./document_editor.ts";
import { parseExpressionString } from "./space_lua/parse.ts";
import type { Config } from "./config.ts";
import type {
DocumentMeta,
FileMeta,
PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import { parseMarkdown } from "./markdown_parser/parser.ts";
import { CheckedSpacePrimitives } from "./spaces/checked_space_primitives.ts";
import {
notFoundError,
offlineError,
} from "@silverbulletmd/silverbullet/constants";
import { Augmenter } from "./data/data_augmenter.ts";
import { EncryptedKvPrimitives } from "./data/encrypted_kv_primitives.ts";
import type { KvPrimitives } from "./data/kv_primitives.ts";
import { deriveDbName } from "@silverbulletmd/silverbullet/lib/crypto";
import { LuaRuntimeError } from "./space_lua/runtime.ts";
import { resolveASTReference } from "./space_lua.ts";
import { ObjectIndex } from "./data/object_index.ts";
import type { LuaCollectionQuery } from "./space_lua/query_collection.ts";
const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/;
const autoSaveInterval = 1000;
// 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;
declare global {
var client: Client;
}
type WidgetCacheItem = {
html: string;
block?: boolean;
copyContent?: string;
};
// 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;
indentUnitCompartment?: Compartment;
undoHistoryCompartment?: Compartment;
// Document editor
documentEditor: DocumentEditor | null = null;
saveTimeout?: number;
debouncedUpdateEvent = throttle(() => {
this.eventHook
.dispatchEvent("editor:updated")
.catch((e) => console.error("Error dispatching editor:updated event", e));
}, 1000);
// Track if plugs have been updated since sync cycle
fullSyncCompleted = false;
// Set to true once the system is ready (plugs loaded)
public systemReady: boolean = false;
private pageNavigator!: PathPageNavigator;
private onLoadRef: Ref;
// Progress circle handling
private progressTimeout?: number;
// Widget and image height caching
private widgetCache = new LimitedMap<WidgetCacheItem>(100); // bodyText -> WidgetCacheItem
debouncedWidgetCacheFlush = throttle(() => {
this.ds.set(["cache", "widgets"], this.widgetCache.toJSON())
.catch(
console.error,
);
}, 2000);
private widgetHeightCache = new LimitedMap<number>(1000); // bodytext -> height
debouncedWidgetHeightCacheFlush = throttle(() => {
this.ds.set(
["cache", "widgetHeight"],
this.widgetHeightCache.toJSON(),
)
.catch(
console.error,
);
}, 2000);
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();
}
/**
* 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.objectIndex = new ObjectIndex(
this.ds,
this.config,
this.eventHook,
this.mq,
);
// 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.loadCaches();
// 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;
// Load space snapshot and enable events
await this.eventedSpacePrimitives.enable();
// Kick off a cron event interval
setInterval(() => {
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;
}
},
);
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(() => {
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,
);
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.flashNotification(
"Page or document changed elsewhere, reloading",
);
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 the current page
save(immediate = false): Promise<void> {
return new Promise((resolve, reject) => {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(
() => {
if (
!this.ui.viewState.unsavedChanges ||
this.isReadOnlyMode()
) {
// No unsaved changes, or read-only mode, not gonna save
return resolve();
}
if (this.isDocumentEditor()) {
console.log("Requesting save for document", this.currentPath());
this.dispatchAppEvent(
"editor:documentSaving",
this.currentPath(),
);
// Only thing we can really do is request a save
this.documentEditor.requestSave();
return resolve();
} else {
console.log("Saving page", this.currentPath());
this.dispatchAppEvent(
"editor:pageSaving",
this.currentName(),
);
this.space
.writePage(
this.currentName(),
this.editorView.state.sliceDoc(0),
)
.then(async (meta) => {
this.ui.viewDispatch({ type: "page-saved" });
await this.dispatchAppEvent(
"editor:pageSaved",
this.currentName(),
meta,
);
// At this all the essential stuff is done, let's proceed
resolve();
// In the background we'll fetch any enriched meta data, if any
const enrichedMeta = await this.objectIndex.getObjectByRef<
PageMeta
>(
this.currentName(),
"page",
this.currentName(),
);
if (enrichedMeta) {
this.ui.viewDispatch({
type: "update-current-page-meta",
meta: enrichedMeta,
});
// Trigger editor re-render to update Lua widgets with the new metadata
this.editorView.dispatch({});
}
})
.catch((e) => {
this.flashNotification(
"Could not save page, retrying again in 10 seconds",
"error",
);
this.saveTimeout = setTimeout(this.save.bind(this), 10000);
reject(e);
});
}
},
immediate ? 0 : autoSaveInterval,
);
});
}
flashNotification(message: string, type: "info" | "error" = "info") {
const id = Math.floor(Math.random() * 1000000);
this.ui.viewDispatch({
type: "show-notification",
notification: {
id,
type,
message,
date: new Date(),
},
});
setTimeout(
() => {
this.ui.viewDispatch({
type: "dismiss-notification",
id: id,
});
},
type === "info" ? 4000 : 5000,
);
}
reportError(e: any, context: string = "") {
console.error(`Error during ${context}:`, e);
if (e instanceof LuaRuntimeError) {
this.flashNotification(`Lua error: ${e.message}`, "error");
const origin = resolveASTReference(e.sf.astCtx!);
if (origin) {
client.navigate(origin);
}
} else {
this.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,
);
}
async updatePageListCache() {
console.log("Updating page list cache");
// Check if the initial sync has been completed
const initialIndexCompleted = await this.objectIndex
.hasFullIndexCompleted();
let allPages: PageMeta[] = [];
if (initialIndexCompleted) {
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.flashNotification(
"Could not fetch page list directly.",
"error",
);
}
}
this.ui.viewDispatch({
type: "update-page-list",
allPages: allPages,
});
// Async kick-off file listing to bring listing up to date
this.space.spacePrimitives.fetchFileList();
}
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(),
});
}
showProgress(progressPercentage?: number, progressType?: "sync" | "index") {
// console.log("Showing progress", progressPercentage, progressType);
this.ui.viewDispatch({
type: "set-progress",
progressPercentage,
progressType,
});
if (this.progressTimeout) {
clearTimeout(this.progressTimeout);
}
this.progressTimeout = setTimeout(
() => {
this.ui.viewDispatch({
type: "set-progress",
});
},
5000,
);
}
// Various UI elements
filterBox(
label: string,
options: FilterOption[],
helpText = "",
placeHolder = "",
): Promise<FilterOption | undefined> {
return new Promise((resolve) => {
this.ui.viewDispatch({
type: "show-filterbox",
label,
options,
placeHolder,
helpText,
onSelect: (option: any) => {
this.ui.viewDispatch({ type: "hide-filterbox" });
this.focus();
resolve(option);
},
});
});
}
prompt(
message: string,
defaultValue = "",
): Promise<string | undefined> {
return new Promise((resolve) => {
this.ui.viewDispatch({
type: "show-prompt",
message,
defaultValue,
callback: (value: string | undefined) => {
this.ui.viewDispatch({ type: "hide-prompt" });
this.focus();
resolve(value);
},
});
});
}
confirm(
message: string,
): Promise<boolean> {
return new Promise((resolve) => {
this.ui.viewDispatch({
type: "show-confirm",
message,
callback: (value: boolean) => {
this.ui.viewDispatch({ type: "hide-confirm" });
this.focus();
resolve(value);
},
});
});
}
async loadPlugs() {
await this.clientSystem.reloadPlugsFromSpace(this.space);
await this.dispatchAppEvent("plugs:loaded");
}
rebuildEditorState() {
const editorView = this.editorView;
editorView.setState(
createEditorState(
this,
this.currentName(),
editorView.state.sliceDoc(),
this.currentPageMeta()?.perm === "ro",
),
);
}
// 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
>;
}
async reloadEditor() {
if (!this.systemReady) return;
console.log("Reloading editor");
clearTimeout(this.saveTimeout);
try {
if (isMarkdownPath(this.currentPath())) {
await this.loadPage({ path: this.currentPath() }, false);
} else {
await this.loadDocumentEditor({ path: this.currentPath() });
}
} catch {
console.log(this.currentPath());
console.error("There was an error during reload");
}
}
// 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.isDocumentEditor()) {
this.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();
}
async loadDocumentEditor(locationState: LocationState) {
const path = locationState.path;
if (isMarkdownPath(path)) throw Error("This is a markdown path");
const previousPath = this.ui.viewState.current?.path;
const loadingDifferentPath = previousPath
? (previousPath !== path)
// Always load as different editor if editor is loaded from scratch
: true;
if (previousPath) {
this.space.unwatchFile(previousPath);
await this.save(true);
}
// This can throw, but that will be catched and handled upstream.
const doc = await this.space.readDocument(path);
// Create the document editor if it doesn't already exist
if (
!this.isDocumentEditor() ||
this.documentEditor.extension !== doc.meta.extension
) {
try {
await this.switchToDocumentEditor(doc.meta.extension);
} catch (e: any) {
// If there is no document editor we will open the file raw
if (e.message.includes("Couldn't find")) {
this.openUrl(fsEndpoint + "/" + path, !previousPath);
}
throw e;
}
if (!this.isDocumentEditor()) {
throw new Error("Problem setting up document editor");
}
}
this.documentEditor!.openFile(doc.data, doc.meta, locationState.details);
this.space.watchFile(path);
this.ui.viewDispatch({
type: "document-editor-loaded",
meta: doc.meta,
path: path,
});
this.eventHook.dispatchEvent(
loadingDifferentPath
? "editor:documentLoaded"
: "editor:documentReloaded",
path,
previousPath,
).catch(console.error);
}
async loadPage(
locationState: LocationState,
navigateWithinPage: boolean = true,
) {
const path = locationState.path;
if (!isMarkdownPath(path)) throw Error("This is not a markdown path");
const previousPath = this.ui.viewState.current?.path;
const loadingDifferentPath = previousPath
? (previousPath !== path)
// Always load as different page if page is loaded from scratch
: true;
const pageName = getNameFromPath(path);
if (previousPath) {
this.space.unwatchFile(previousPath);
await this.save(true);
}
// Fetch next page to open
let doc;
let markerIndex = -1;
try {
doc = await this.space.readPage(pageName);
} catch (e: any) {
if (
e.message !== notFoundError.message &&
e.message !== offlineError.message
) {
// If the error is not a "not found" or "offline" error, rethrow it
throw e;
}
if (e.message === offlineError.message) {
console.info(
"Currently offline, will assume page doesn't exist:",
pageName,
);
}
// Scenarios:
// 1. We got a not found error -> Create an empty page
// 2. We got a offline error (which meant that the service worker didn't locally retrieve the page either so likely it doesn't exist) -> Create a new page
// Either way... we create an empty page!
console.log(`Page doesn't exist, creating new page: ${pageName}`);
// Mock up the page. We won't yet safe it, because the user may not even
// want to create that page
doc = {
text: "",
meta: {
ref: pageName,
tags: ["page"],
name: pageName,
lastModified: "",
created: "",
perm: "rw",
} as PageMeta,
};
// Let's dispatch a editor:pageCreating event to see if anybody wants to do something before the page is created
const results = await this.dispatchAppEvent(
"editor:pageCreating",
{ name: pageName } as PageCreatingEvent,
) as PageCreatingContent[];
if (results.length === 1) {
doc.text = results[0].text;
doc.meta.perm = results[0].perm;
// check for |^| and remove it; record position to place cursor later
const cursorMarker = "|^|";
const idx = doc.text.indexOf(cursorMarker);
if (idx !== -1) {
markerIndex = idx;
doc.text = doc.text.slice(0, idx) +
doc.text.slice(idx + cursorMarker.length);
}
} else if (results.length > 1) {
console.error(
"Multiple responses for editor:pageCreating event, this is not supported",
);
}
}
// This could create an invalid editor state, but that doesn't matter, we'll update it later
this.switchToPageEditor();
await this.pageMetaAugmenter.setAugmentation(pageName, {
lastOpened: Date.now(),
});
this.ui.viewDispatch({
type: "page-loaded",
meta: doc.meta,
path: path,
});
// Fetch the meta which includes the possibly indexed stuff, like page
// decorations
if (await this.objectIndex.hasFullIndexCompleted()) {
try {
const enrichedMeta = await this.objectIndex.getObjectByRef<PageMeta>(
pageName,
"page",
pageName,
) ?? doc.meta;
const body = document.body;
body.removeAttribute("class");
if (enrichedMeta.pageDecoration?.cssClasses) {
body.className = enrichedMeta.pageDecoration.cssClasses
.join(" ")
.replaceAll(/[^a-zA-Z0-9-_ ]/g, "");
}
this.ui.viewDispatch({
type: "update-current-page-meta",
meta: enrichedMeta,
});
// Trigger editor re-render to update Lua widgets with the new metadata
this.editorView.dispatch({});
} catch (e: any) {
console.log(
`There was an error trying to fetch enriched metadata: ${e.message}`,
);
}
}
// When loading a different page OR if the page is read-only (in which case we don't want to apply local patches, because there's no point)
if (loadingDifferentPath || doc.meta.perm === "ro") {
const editorState = createEditorState(
this,
pageName,
doc.text,
doc.meta.perm === "ro",
);
this.editorView.setState(editorState);
} else {
// Just apply minimal patches so that the cursor is preserved
this.setEditorText(doc.text, true);
}
this.space.watchFile(path);
if (navigateWithinPage) {
// Setup scroll position, cursor position, etc
try {
this.navigateWithinPage(locationState);
} catch {
// We don't really care if this fails.
}
}
// Note: these events are dispatched asynchronously deliberately (not waiting for results)
this.eventHook.dispatchEvent(
loadingDifferentPath ? "editor:pageLoaded" : "editor:pageReloaded",
pageName,
previousPath ? getNameFromPath(previousPath) : undefined,
).catch(console.error);
// If a cursor marker was found for a newly-created page, place the
// cursor there now (after navigateWithinPage so it doesn't get
// overwritten by default positioning).
if (markerIndex !== -1) {
try {
const pos = Math.max(
0,
Math.min(markerIndex, this.editorView.state.doc.length),
);
this.editorView.dispatch({
selection: { anchor: pos },
effects: [EditorView.scrollIntoView(pos, { y: "center" })],
});
this.editorView.focus();
} catch (e) {
console.error("Failed to set cursor at cursor marker:", e);
}
}
}
isDocumentEditor(): this is { documentEditor: DocumentEditor } & this {
return this.documentEditor !== null;
}
switchToPageEditor() {
if (!this.isDocumentEditor()) return;
// Deliberately not awaiting this function as destroying & last-save can be handled in the background
this.documentEditor.destroy();
// @ts-ignore: This is there the hacked type-guard from isDocumentEditor fails
this.documentEditor = null;
this.rebuildEditorState();
document.getElementById("sb-editor")!.classList.remove("hide-cm");
}
async switchToDocumentEditor(extension: string) {
if (this.documentEditor) {
// Deliberately not awaiting this function as destroying & last-save can be handled in the background
this.documentEditor.destroy();
}
// This is probably not the best way to hide the codemirror editor, but it works
document.getElementById("sb-editor")!.classList.add("hide-cm");
this.documentEditor = new DocumentEditor(
document.getElementById("sb-editor")!,
this,
(path, content) => {
this.space
.writeDocument(path, content)
.then(async (meta) => {
this.ui.viewDispatch({ type: "document-editor-saved" });
await this.dispatchAppEvent(
"editor:documentSaved",
path,
meta,
);
})
.catch(() => {
this.flashNotification(
"Could not save document, retrying again in 10 seconds",
"error",
);
this.saveTimeout = setTimeout(this.save.bind(this), 10000);
});
},
);
await this.documentEditor.init(extension);
// We have to rebuild the editor state here to update the keymap correctly
// This is a little hacky but any other solution would pose a larger rewrite
this.rebuildEditorState();
this.editorView.contentDOM.blur();
}
setEditorText(newText: string, shouldIsolateHistory = false) {
const currentText = this.editorView.state.sliceDoc();
const allChanges = diffAndPrepareChanges(currentText, newText);
client.editorView.dispatch({
changes: allChanges,
annotations: shouldIsolateHistory ? isolateHistory.of("full") : undefined,
});
}
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.hasFullIndexCompleted()) {
console.warn(
"Not loading custom styles, since full indexing has not completed 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.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 loadCaches() {
const [widgetHeightCache, widgetCache] = await this
.ds.batchGet([[
"cache",
"widgetHeight",
], ["cache", "widgets"]]);
this.widgetHeightCache = new LimitedMap(1000, widgetHeightCache || {});
this.widgetCache = new LimitedMap(100, widgetCache || {});
}
setCachedWidgetHeight(bodyText: string, height: number) {
this.widgetHeightCache.set(bodyText, height);
this.debouncedWidgetHeightCacheFlush();
}
getCachedWidgetHeight(bodyText: string): number {
return this.widgetHeightCache.get(bodyText) ?? -1;
}
setWidgetCache(key: string, cacheItem: WidgetCacheItem) {
this.widgetCache.set(key, cacheItem);
this.debouncedWidgetCacheFlush();
}
getWidgetCache(key: string): WidgetCacheItem | undefined {
return this.widgetCache.get(key);
}
async handleServiceWorkerMessage(message: ServiceWorkerSourceMessage) {
switch (message.type) {
case "space-sync-complete": {
this.fullSyncCompleted = true;
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;
}
}
// Also dispatch it on the event hook for any other listeners
await this.eventHook.dispatchEvent(
`service-worker:${message.type}`,
message,
);
}
private navigateWithinPage(pageState: LocationState) {
if (!isMarkdownPath(pageState.path)) return;
// We can't use getOffsetFromRef here, because it is asyncronous.
let pos: number | undefined = undefined;
// Don't use getOffsetFromRef, so we can show error messages
if (pageState.details?.type === "header") {
const pageText = this.editorView.state.sliceDoc();
pos = getOffsetFromHeader(
parseMarkdown(pageText),
pageState.details.header,
);
if (pos === -1) {
this.flashNotification(
`Could not find header "${pageState.details.header}"`,
"error",
);
pos = undefined;
}
} else if (pageState.details?.type === "position") {
pos = Math.max(
0,
Math.min(pageState.details.pos, this.editorView.state.doc.length),
);
} else if (pageState.details?.type === "linecolumn") {
const pageText = this.editorView.state.sliceDoc();
pos = getOffsetFromLineColumn(
pageText,
pageState.details.line,
pageState.details.column,
);
}
if (pos !== undefined) {
this.editorView.dispatch({
selection: { anchor: pos },
effects: EditorView.scrollIntoView(pos, {
y: "start",
yMargin: 5,
}),
});
// If a position was specified, we bail out and ignore any cached state
return;
}
let adjustedPosition = false;
// Was a particular scroll position persisted?
if (pageState.scrollTop && pageState.scrollTop > 0) {
setTimeout(() => {
this.editorView.scrollDOM.scrollTop = pageState.scrollTop!;
});
adjustedPosition = true;
}
// Was a particular cursor/selection set?
if (pageState.selection?.anchor) {
this.editorView.dispatch({
selection: pageState.selection,
});
adjustedPosition = true;
}
// If not: just put the cursor at the top of the page, right after the frontmatter
if (!adjustedPosition) {
// Somewhat ad-hoc way to determine if the document contains frontmatter and if so, putting the cursor _after it_.
const pageText = this.editorView.state.sliceDoc();
// Default the cursor to be at position 0
let initialCursorPos = 0;
const match = frontMatterRegex.exec(pageText);
if (match) {
// Frontmatter found, put cursor after it
initialCursorPos = match[0].length;
}
// By default scroll to the top
this.editorView.scrollDOM.scrollTop = 0;
this.editorView.dispatch({
selection: { anchor: initialCursorPos },
// And then scroll down if required
scrollIntoView: true,
});
}
}
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.loadPage(locationState);
} else {
await this.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 };
}
}
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) {
throw new Error("No active service worker to post message to");
}
registration?.active?.postMessage(message);
}
}