SilverBullet pivot to become an offline-first PWA (#403)

This commit is contained in:
Zef Hemel
2023-05-23 20:53:53 +02:00
committed by GitHub
parent b256269897
commit 5f484bed57
389 changed files with 4484 additions and 291129 deletions
+1 -1
View File
@@ -55,7 +55,7 @@
<body>
<header>
<h1>Login to <img src="/logo.png" style="height: 1ch;" /> SilverBullet</h1>
<h1>Login to <img src="/.client/logo.png" style="height: 1ch;" /> SilverBullet</h1>
</header>
<form action="/.auth" method="POST">
<input type="hidden" name="refer" value="" />
+11 -68
View File
@@ -1,76 +1,13 @@
import { safeRun } from "../common/util.ts";
import { Editor } from "./editor.tsx";
import { parseYamlSettings, safeRun } from "../common/util.ts";
import { Space } from "../common/spaces/space.ts";
import { HttpSpacePrimitives } from "../common/spaces/http_space_primitives.ts";
import { PlugSpacePrimitives } from "../common/spaces/plug_space_primitives.ts";
import { PageNamespaceHook } from "../common/hooks/page_namespace.ts";
import { SilverBulletHooks } from "../common/manifest.ts";
import { System } from "../plugos/system.ts";
import { BuiltinSettings } from "./types.ts";
import { fulltextSyscalls } from "./syscalls/fulltext.ts";
import { indexerSyscalls } from "./syscalls/index.ts";
import { storeSyscalls } from "./syscalls/store.ts";
import { EventHook } from "../plugos/hooks/event.ts";
import { clientStoreSyscalls } from "./syscalls/clientStore.ts";
import { sandboxFetchSyscalls } from "./syscalls/fetch.ts";
safeRun(async () => {
const httpPrimitives = new HttpSpacePrimitives("");
let settingsPageText = "";
try {
settingsPageText = (
await httpPrimitives.readFile("SETTINGS.md", "utf8")
).data as string;
} catch (e: any) {
console.error("No settings page found", e.message);
}
// Instantiate a PlugOS system for the client
const system = new System<SilverBulletHooks>("client");
// Attach the page namespace hook
const namespaceHook = new PageNamespaceHook();
system.addHook(namespaceHook);
const spacePrimitives = new PlugSpacePrimitives(
httpPrimitives,
namespaceHook,
"client",
);
const serverSpace = new Space(spacePrimitives);
serverSpace.watch();
// Register some web-specific syscall implementations
system.registerSyscalls(
[],
storeSyscalls(serverSpace),
indexerSyscalls(serverSpace),
clientStoreSyscalls(),
fulltextSyscalls(serverSpace),
sandboxFetchSyscalls(serverSpace),
);
console.log("Booting...");
const settings = parseYamlSettings(settingsPageText) as BuiltinSettings;
if (!settings.indexPage) {
settings.indexPage = "index";
}
// Event hook
const eventHook = new EventHook();
system.addHook(eventHook);
console.log("Booting");
const editor = new Editor(
serverSpace,
system,
eventHook,
document.getElementById("sb-root")!,
"",
settings,
);
// @ts-ignore: for convenience
window.editor = editor;
await editor.init();
@@ -84,8 +21,14 @@ if (navigator.serviceWorker) {
.then(() => {
console.log("Service worker registered...");
});
navigator.serviceWorker.ready.then((registration) => {
registration.active!.postMessage({
type: "config",
config: window.silverBulletConfig,
});
});
} else {
console.log(
"No launching service worker (not present, maybe because not running on localhost or over SSL)",
console.warn(
"Not launching service worker, likely because not running from localhost or over HTTPs. This means SilverBullet will not be available offline.",
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { ClickEvent } from "../../plug-api/app_event.ts";
import type { Extension } from "../deps.ts";
import { Editor } from "../editor.tsx";
import type { Editor } from "../editor.tsx";
import { blockquotePlugin } from "./block_quote.ts";
import { admonitionPlugin } from "./admonition.ts";
import { directivePlugin } from "./directive.ts";
@@ -16,7 +16,7 @@ import { fencedCodePlugin } from "./fenced_code.ts";
export function cleanModePlugins(editor: Editor) {
return [
linkPlugin(editor),
linkPlugin(),
directivePlugin(),
blockquotePlugin(),
admonitionPlugin(editor),
+2 -1
View File
@@ -1,5 +1,4 @@
import { EditorView, ViewPlugin, ViewUpdate } from "../deps.ts";
import { safeRun } from "../../plugos/util.ts";
import { maximumAttachmentSize } from "../../common/types.ts";
import { Editor } from "../editor.tsx";
@@ -11,6 +10,7 @@ import {
tables,
taskListItems,
} from "https://cdn.skypack.dev/@joplin/turndown-plugin-gfm@1.0.45";
import { safeRun } from "../../common/util.ts";
const turndownService = new TurndownService({
hr: "---",
codeBlockStyle: "fenced",
@@ -138,6 +138,7 @@ export function attachmentExtension(editor: Editor) {
async function processFileTransfer(payload: File[]) {
const data = await payload[0].arrayBuffer();
// data.byteLength > maximumAttachmentSize;
await saveFile(data!, payload[0].name, payload[0].type);
}
-4
View File
@@ -72,10 +72,6 @@ class IFrameWidget extends WidgetType {
html: widgetContent.html,
script: widgetContent.script,
});
// iframe.contentWindow!.onunload = () => {
// // Unsubscribing from events
// globalThis.removeEventListener("message", messageListener);
// };
} else if (widgetContent.url) {
iframe.contentWindow!.location.href = widgetContent.url;
if (widgetContent.height) {
+3 -7
View File
@@ -7,7 +7,7 @@ import {
} from "../deps.ts";
import { decoratorStateField } from "./util.ts";
import type { Space } from "../../common/spaces/space.ts";
import type { Space } from "../space.ts";
class InlineImageWidget extends WidgetType {
constructor(
@@ -27,12 +27,8 @@ class InlineImageWidget extends WidgetType {
if (this.url.startsWith("http")) {
img.src = this.url;
} else {
// Load the image as a dataURL and inject it into the img's src attribute
this.space.readAttachment(decodeURIComponent(this.url), "dataurl").then(
({ data }) => {
img.src = data as string;
},
);
// This is an attachment image, rewrite the URL a little
img.src = `/.fs/${decodeURIComponent(this.url)}`;
}
img.alt = this.title;
+1 -2
View File
@@ -1,12 +1,11 @@
import { Decoration, syntaxTree } from "../deps.ts";
import { Editor } from "../editor.tsx";
import {
decoratorStateField,
invisibleDecoration,
isCursorInRange,
} from "./util.ts";
export function linkPlugin(editor: Editor) {
export function linkPlugin() {
return decoratorStateField((state) => {
const widgets: any[] = [];
+1 -8
View File
@@ -1,10 +1,4 @@
import {
Decoration,
EditorState,
EditorView,
syntaxTree,
WidgetType,
} from "../deps.ts";
import { Decoration, EditorState, syntaxTree, WidgetType } from "../deps.ts";
import {
decoratorStateField,
invisibleDecoration,
@@ -15,7 +9,6 @@ import { renderMarkdownToHtml } from "../../plugs/markdown/markdown_render.ts";
import { ParseTree } from "$sb/lib/tree.ts";
import { lezerToParseTree } from "../../common/markdown_parser/parse_tree.ts";
import type { Editor } from "../editor.tsx";
import { urlToPathname } from "../../plugos/util.ts";
class TableViewWidget extends WidgetType {
constructor(
+2 -2
View File
@@ -28,7 +28,7 @@ export function cleanWikiLinkPlugin(editor: Editor) {
const [_fullMatch, page, pipePart, alias] = match;
const allPages = editor.space.listPages();
let pageExists = false;
let pageExists = !editor.fullSyncCompleted;
let cleanPage = page;
if (page.includes("@")) {
cleanPage = page.split("@")[0];
@@ -77,7 +77,7 @@ export function cleanWikiLinkPlugin(editor: Editor) {
{
text: linkText,
title: pageExists ? `Navigate to ${page}` : `Create ${page}`,
href: `/${page.replaceAll(" ", "_")}`,
href: `/${page}`,
cssClass: pageExists
? "sb-wiki-link-page"
: "sb-wiki-link-page-missing",
+1 -1
View File
@@ -2,7 +2,7 @@ import { isMacLike } from "../../common/util.ts";
import { FilterList } from "./filter.tsx";
import { CompletionContext, CompletionResult, TerminalIcon } from "../deps.ts";
import { AppCommand } from "../hooks/command.ts";
import { FilterOption } from "../../common/types.ts";
import { FilterOption } from "../types.ts";
export function CommandPalette({
commands,
+18 -60
View File
@@ -5,58 +5,16 @@ import {
useRef,
useState,
} from "../deps.ts";
import { FilterOption } from "../../common/types.ts";
import fuzzysort from "https://esm.sh/fuzzysort@2.0.1";
import { FilterOption } from "../types.ts";
import { FunctionalComponent } from "https://esm.sh/v99/preact@10.11.3/src/index";
import { FeatherProps } from "https://esm.sh/v99/preact-feather@4.2.1/dist/types";
import { MiniEditor } from "./mini_editor.tsx";
function magicSorter(a: FilterOption, b: FilterOption): number {
if (a.orderId && b.orderId) {
return a.orderId < b.orderId ? -1 : 1;
}
if (a.orderId) {
return -1;
}
if (b.orderId) {
return 1;
}
return 0;
}
import { fuzzySearchAndSort } from "./fuzzy_search.ts";
type FilterResult = FilterOption & {
result?: any;
};
function simpleFilter(
pattern: string,
options: FilterOption[],
): FilterOption[] {
const lowerPattern = pattern.toLowerCase();
return options.filter((option) => {
return option.name.toLowerCase().includes(lowerPattern);
});
}
function escapeHtml(unsafe: string): string {
return unsafe
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function fuzzySorter(pattern: string, options: FilterOption[]): FilterResult[] {
return fuzzysort
.go(pattern, options, {
all: true,
key: "name",
})
.map((result: any) => ({ ...result.obj, result: result }))
.sort(magicSorter);
}
export function FilterList({
placeholder,
options,
@@ -88,30 +46,31 @@ export function FilterList({
}) {
const [text, setText] = useState("");
const [matchingOptions, setMatchingOptions] = useState(
fuzzySorter("", options),
fuzzySearchAndSort(options, ""),
);
const [selectedOption, setSelectionOption] = useState(0);
const selectedElementRef = useRef<HTMLDivElement>(null);
function updateFilter(originalPhrase: string) {
const foundExactMatch = false;
const results = fuzzySorter(originalPhrase, options);
const results = fuzzySearchAndSort(options, originalPhrase);
const foundExactMatch = !!results.find((result) =>
result.name === originalPhrase
);
if (allowNew && !foundExactMatch && originalPhrase) {
results.splice(1, 0, {
name: originalPhrase,
hint: newHint,
});
}
setMatchingOptions(results);
// setText(originalPhrase);
setMatchingOptions(results);
setSelectionOption(0);
}
useEffect(() => {
updateFilter(text);
}, [options]);
}, [options, text]);
useEffect(() => {
function closer() {
@@ -147,7 +106,8 @@ export function FilterList({
onSelect(undefined);
}}
onChange={(text) => {
updateFilter(text);
setText(text);
// updateFilter(text);
}}
onKeyUp={(view, e) => {
// This event is triggered after the key has been processed by CM already
@@ -182,7 +142,7 @@ export function FilterList({
const text = view.state.sliceDoc();
if (completePrefix && text === "") {
setText(completePrefix);
updateFilter(completePrefix);
// updateFilter(completePrefix);
return true;
}
break;
@@ -210,7 +170,6 @@ export function FilterList({
setSelectionOption(idx);
}}
onClick={(e) => {
console.log("Selecting", option);
e.stopPropagation();
onSelect(option);
}}
@@ -220,14 +179,13 @@ export function FilterList({
<Icon width={16} height={16} />
</span>
)}
<span
className="sb-name"
dangerouslySetInnerHTML={{
__html: option?.result?.indexes
? fuzzysort.highlight(option.result, "<b>", "</b>")!
: escapeHtml(option.name),
}}
<span className="sb-name" // dangerouslySetInnerHTML={{
// __html: option?.result?.indexes
// ? fuzzysort.highlight(option.result, "<b>", "</b>")!
// : escapeHtml(option.name),
// }}
>
{option.name}
</span>
{option.hint && <span className="sb-hint">{option.hint}</span>}
</div>
+40
View File
@@ -0,0 +1,40 @@
import { FilterOption } from "../types.ts";
import { assertEquals } from "../../test_deps.ts";
import { fuzzySearchAndSort } from "./fuzzy_search.ts";
Deno.test("testFuzzyFilter", () => {
const array: FilterOption[] = [
{ name: "My Company/Hank", orderId: -5 },
{ name: "My Company/Steve Co", orderId: -5 },
{ name: "Other/Steve", orderId: -7 },
{ name: "Steve", orderId: -3 },
];
// Prioritize match in last path part
const result = fuzzySearchAndSort(array, "Co");
assertEquals(result.length, 2);
assertEquals(result[0].name, "My Company/Steve Co");
// Support slash matches
const result2 = fuzzySearchAndSort(array, "Co/St");
assertEquals(result2.length, 1);
assertEquals(result2[0].name, "My Company/Steve Co");
// Find "St" in both, but pioritize based on orderId
const result3 = fuzzySearchAndSort(array, "St");
assertEquals(result3.length, 3);
assertEquals(result3[0].name, "Other/Steve");
const result4 = fuzzySearchAndSort(array, "Steve");
assertEquals(result4[0].name, "Steve");
// const result2 = fuzzySearchAndSort(array, "");
// console.log("Result 2", result2);
// assertEquals(result.length, 3);
// assertEquals(result[0].orderId, 1);
// assertEquals(result[1].name, "Jack");
// assertEquals(result[1].orderId, 2);
// assertEquals(result[2].name, "Jill");
// assertEquals(result[2].orderId, 3);
});
+45
View File
@@ -0,0 +1,45 @@
import { FilterOption } from "../types.ts";
export const fuzzySearchAndSort = (
arr: FilterOption[],
searchPhrase: string,
): FilterOption[] => {
// Prepare regular expression: escape special characters, add '.*' around each character
const safePhrase = searchPhrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // escape special characters
const searchRegex = new RegExp(Array.from(safePhrase).join(".*"), "i"); // 'i' makes it case-insensitive
// Fuzzy matching on name using the regular expression
const filtered = arr.filter((item) => searchRegex.test(item.name));
// Sorting by exact match, whether match is in part after '/', then by orderId
filtered.sort((a, b) => {
const aNamePart = a.name.includes("/")
? a.name.split("/").pop() || ""
: a.name;
const bNamePart = b.name.includes("/")
? b.name.split("/").pop() || ""
: b.name;
const aMatchInPart = searchRegex.test(aNamePart);
const bMatchInPart = searchRegex.test(bNamePart);
// Check for exact match
const aExactMatch = a.name.toLowerCase() === searchPhrase.toLowerCase();
const bExactMatch = b.name.toLowerCase() === searchPhrase.toLowerCase();
if (aExactMatch !== bExactMatch) {
// If one is an exact match and the other is not, prioritize the exact match
return aExactMatch ? -1 : 1;
} else if (aMatchInPart !== bMatchInPart) {
// If one matches in the part after '/' and the other doesn't, prioritize the one that does
return aMatchInPart ? -1 : 1;
} else {
// If both match in the same part of name, prioritize by orderId
const aOrder = a.orderId !== undefined ? a.orderId : Infinity;
const bOrder = b.orderId !== undefined ? b.orderId : Infinity;
return aOrder - bOrder;
}
});
return filtered;
};
+6
View File
@@ -253,6 +253,12 @@ export function MiniEditor(
// Reset the state
view.setState(buildEditorState());
});
} else if (focus) {
// console.log("BLURRING WHILE KEEPING FOCUSE");
// Automatically refocus blurred
if (editorViewRef.current) {
editorViewRef.current.focus();
}
}
// Event may occur again in 500ms
setTimeout(() => {
+2 -2
View File
@@ -1,5 +1,5 @@
import { FilterList } from "./filter.tsx";
import { FilterOption, PageMeta } from "../../common/types.ts";
import { FilterOption, PageMeta } from "../types.ts";
import { CompletionContext, CompletionResult } from "../deps.ts";
export function PageNavigator({
@@ -10,7 +10,7 @@ export function PageNavigator({
darkMode,
currentPage,
}: {
allPages: Set<PageMeta>;
allPages: PageMeta[];
vimMode: boolean;
darkMode: boolean;
onNavigate: (page: string | undefined) => void;
+3 -1
View File
@@ -18,6 +18,7 @@ export type ActionButton = {
export function TopBar({
pageName,
unsavedChanges,
synced,
isLoading,
notifications,
onRename,
@@ -30,6 +31,7 @@ export function TopBar({
}: {
pageName?: string;
unsavedChanges: boolean;
synced: boolean;
isLoading: boolean;
notifications: Notification[];
darkMode: boolean;
@@ -73,7 +75,7 @@ export function TopBar({
}, []);
return (
<div id="sb-top">
<div id="sb-top" className={synced ? undefined : "sb-sync-error"}>
{lhs}
<div className="main">
<div className="inner">
+251 -59
View File
@@ -57,15 +57,14 @@ import {
MDExt,
} from "../common/markdown_parser/markdown_ext.ts";
import buildMarkdown from "../common/markdown_parser/parser.ts";
import { Space } from "../common/spaces/space.ts";
import { markdownSyscalls } from "../common/syscalls/markdown.ts";
import { FilterOption, PageMeta } from "../common/types.ts";
import { isMacLike, safeRun } from "../common/util.ts";
import { Space } from "./space.ts";
import { markdownSyscalls } from "./syscalls/markdown.ts";
import { FilterOption, PageMeta } from "./types.ts";
import { isMacLike, parseYamlSettings, safeRun } from "../common/util.ts";
import { createSandbox } from "../plugos/environments/webworker_sandbox.ts";
import { EventHook } from "../plugos/hooks/event.ts";
import assetSyscalls from "../plugos/syscalls/asset.ts";
import { eventSyscalls } from "../plugos/syscalls/event.ts";
import sandboxSyscalls from "../plugos/syscalls/sandbox.ts";
import { System } from "../plugos/system.ts";
import { cleanModePlugins } from "./cm_plugins/clean.ts";
import { CollabState } from "./cm_plugins/collab.ts";
@@ -101,7 +100,12 @@ import { collabSyscalls } from "./syscalls/collab.ts";
import { editorSyscalls } from "./syscalls/editor.ts";
import { spaceSyscalls } from "./syscalls/space.ts";
import { systemSyscalls } from "./syscalls/system.ts";
import { AppViewState, BuiltinSettings, initialViewState } from "./types.ts";
import {
Action,
AppViewState,
BuiltinSettings,
initialViewState,
} from "./types.ts";
import type {
AppEvent,
@@ -111,8 +115,27 @@ import type {
import { CodeWidgetHook } from "./hooks/code_widget.ts";
import { throttle } from "../common/async_util.ts";
import { readonlyMode } from "./cm_plugins/readonly.ts";
import { PageNamespaceHook } from "../common/hooks/page_namespace.ts";
import { CronHook } from "../plugos/hooks/cron.ts";
import { pageIndexSyscalls } from "./syscalls/index.ts";
import { storeSyscalls } from "../plugos/syscalls/store.dexie_browser.ts";
import { PlugSpacePrimitives } from "../common/spaces/plug_space_primitives.ts";
import { IndexedDBSpacePrimitives } from "../common/spaces/indexeddb_space_primitives.ts";
import { FileMetaSpacePrimitives } from "../common/spaces/file_meta_space_primitives.ts";
import { EventedSpacePrimitives } from "../common/spaces/evented_space_primitives.ts";
import { clientStoreSyscalls } from "./syscalls/clientStore.ts";
import { sandboxFetchSyscalls } from "./syscalls/fetch.ts";
import { shellSyscalls } from "./syscalls/shell.ts";
import { SyncService } from "./sync_service.ts";
import { yamlSyscalls } from "./syscalls/yaml.ts";
import { simpleHash } from "../common/crypto.ts";
import { DexieKVStore } from "../plugos/lib/kv_store.dexie.ts";
import { SyncStatus } from "../common/spaces/sync.ts";
import { HttpSpacePrimitives } from "../common/spaces/http_space_primitives.ts";
import { FallbackSpacePrimitives } from "../common/spaces/fallback_space_primitives.ts";
import { syncSyscalls } from "./syscalls/sync.ts";
const frontMatterRegex = /^---\n(.*?)---\n/ms;
const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/;
class PageState {
constructor(
@@ -123,16 +146,29 @@ class PageState {
const saveInterval = 1000;
declare global {
interface Window {
// Injected via index.html
silverBulletConfig: {
spaceFolderPath: string;
syncEndpoint: string;
};
editor: Editor;
}
}
// TODO: Oh my god, need to refactor this
export class Editor {
readonly commandHook: CommandHook;
readonly slashCommandHook: SlashCommandHook;
openPages = new Map<string, PageState>();
editorView?: EditorView;
viewState: AppViewState;
// deno-lint-ignore ban-types
viewDispatch: Function;
viewState: AppViewState = initialViewState;
viewDispatch: (action: Action) => void = () => {};
space: Space;
pageNavigator: PathPageNavigator;
remoteSpacePrimitives: HttpSpacePrimitives;
pageNavigator?: PathPageNavigator;
eventHook: EventHook;
codeWidgetHook: CodeWidgetHook;
@@ -144,28 +180,94 @@ export class Editor {
}, 1000);
system: System<SilverBulletHooks>;
mdExtensions: MDExt[] = [];
urlPrefix: string;
indexPage: string;
// Track if plugs have been updated since sync cycle
private plugsUpdated = false;
fullSyncCompleted = false;
// Runtime state (that doesn't make sense in viewState)
collabState?: CollabState;
syncService: SyncService;
settings?: BuiltinSettings;
kvStore: DexieKVStore;
constructor(
space: Space,
system: System<SilverBulletHooks>,
eventHook: EventHook,
parent: Element,
urlPrefix: string,
readonly builtinSettings: BuiltinSettings,
) {
this.space = space;
this.system = system;
this.urlPrefix = urlPrefix;
this.viewState = initialViewState;
this.viewDispatch = () => {};
this.indexPage = builtinSettings.indexPage;
const runtimeConfig = window.silverBulletConfig;
this.eventHook = eventHook;
// Instantiate a PlugOS system
const system = new System<SilverBulletHooks>();
this.system = system;
// Generate a semi-unique prefix for the database so not to reuse databases for different space paths
const dbPrefix = "" + simpleHash(runtimeConfig.spaceFolderPath);
// Attach the page namespace hook
const namespaceHook = new PageNamespaceHook();
system.addHook(namespaceHook);
// Event hook
this.eventHook = new EventHook();
system.addHook(this.eventHook);
// Cron hook
const cronHook = new CronHook(system);
system.addHook(cronHook);
const indexSyscalls = pageIndexSyscalls(
`${dbPrefix}_page_index`,
globalThis.indexedDB,
);
this.kvStore = new DexieKVStore(
`${dbPrefix}_store`,
"data",
globalThis.indexedDB,
);
const storeCalls = storeSyscalls(this.kvStore);
// Setup space
this.remoteSpacePrimitives = new HttpSpacePrimitives(
runtimeConfig.syncEndpoint,
runtimeConfig.spaceFolderPath,
true,
);
const plugSpacePrimitives = new PlugSpacePrimitives(
// Using fallback space primitives here to allow (by default) local reads to "fall through" to HTTP when files aren't synced yet
new FallbackSpacePrimitives(
new IndexedDBSpacePrimitives(
`${dbPrefix}_space`,
globalThis.indexedDB,
),
this.remoteSpacePrimitives,
),
namespaceHook,
);
const localSpacePrimitives = new FileMetaSpacePrimitives(
new EventedSpacePrimitives(
plugSpacePrimitives,
this.eventHook,
),
indexSyscalls,
);
this.space = new Space(localSpacePrimitives);
this.space.watch();
this.syncService = new SyncService(
localSpacePrimitives,
this.remoteSpacePrimitives,
this.kvStore,
this.eventHook,
(path) => {
// TODO: At some point we should remove the data.db exception here
return path !== "data.db" && !plugSpacePrimitives.isLikelyHandled(path);
},
);
// Code widget hook
this.codeWidgetHook = new CodeWidgetHook();
@@ -194,11 +296,7 @@ export class Editor {
parent: document.getElementById("sb-editor")!,
});
this.pageNavigator = new PathPageNavigator(
builtinSettings.indexPage,
urlPrefix,
);
// Syscalls available to all plugs
this.system.registerSyscalls(
[],
eventSyscalls(this.eventHook),
@@ -206,9 +304,25 @@ export class Editor {
spaceSyscalls(this),
systemSyscalls(this, this.system),
markdownSyscalls(buildMarkdown(this.mdExtensions)),
sandboxSyscalls(this.system),
assetSyscalls(this.system),
collabSyscalls(this),
yamlSyscalls(),
storeCalls,
indexSyscalls,
syncSyscalls(this.syncService),
// LEGACY
clientStoreSyscalls(storeCalls),
);
// Syscalls that require some additional permissions
this.system.registerSyscalls(
["fetch"],
sandboxFetchSyscalls(this.remoteSpacePrimitives),
);
this.system.registerSyscalls(
["shell"],
shellSyscalls(this.remoteSpacePrimitives),
);
// Make keyboard shortcuts work even when the editor is in read only mode or not focused
@@ -225,13 +339,30 @@ export class Editor {
});
globalThis.addEventListener("touchstart", (ev) => {
// Launch the page picker on a two-finger tap
if (ev.touches.length === 2) {
ev.stopPropagation();
ev.preventDefault();
this.viewDispatch({ type: "start-navigate" });
}
// Launch the command palette using a three-finger tap
if (ev.touches.length > 2) {
if (ev.touches.length === 3) {
ev.stopPropagation();
ev.preventDefault();
this.viewDispatch({ type: "show-palette", context: this.getContext() });
}
});
this.eventHook.addLocalListener("plug:changed", async (fileName) => {
console.log("Plug updated, reloading:", fileName);
system.unload(fileName);
await system.load(
// await this.space.readFile(fileName, "utf8"),
new URL(`/.fs/${fileName}`, location.href),
createSandbox,
);
this.plugsUpdated = true;
});
}
get currentPage(): string | undefined {
@@ -241,27 +372,11 @@ export class Editor {
async init() {
this.focus();
const globalModules: any = await (
await fetch(`${this.urlPrefix}/global.plug.json`)
).json();
this.system.on({
sandboxInitialized: async (sandbox) => {
for (
const [modName, code] of Object.entries(
globalModules.dependencies,
)
) {
await sandbox.loadDependency(modName, code as string);
}
},
});
this.space.on({
pageChanged: (meta) => {
if (this.currentPage === meta.name) {
console.log("Page changed on disk, reloading");
this.flashNotification("Page changed on disk, reloading");
console.log("Page changed elsewhere, reloading");
this.flashNotification("Page changed elsewhere, reloading");
this.reloadPage();
}
},
@@ -273,11 +388,17 @@ export class Editor {
},
});
// Load settings
this.settings = await this.loadSettings();
this.pageNavigator = new PathPageNavigator(
this.settings.indexPage,
);
await this.reloadPlugs();
this.pageNavigator.subscribe(async (pageName, pos: number | string) => {
console.log("Now navigating to", pageName);
if (!this.editorView) {
return;
}
@@ -333,9 +454,70 @@ export class Editor {
this.loadCustomStyles().catch(console.error);
// Kick off background sync
this.syncService.start();
this.eventHook.addLocalListener("sync:success", async (operations) => {
if (operations > 0) {
// Update the page list
await this.space.updatePageList();
}
if (operations !== undefined) {
// "sync:success" is called with a number of operations only from syncSpace(), not from syncing individual pages
this.fullSyncCompleted = true;
}
if (this.plugsUpdated) {
// To register new commands, update editor state based on new plugs
this.rebuildEditorState();
if (operations) {
// Likely initial sync so let's show visually that we're synced now
this.flashNotification(`Synced ${operations} files`, "info");
}
}
// Reset for next sync cycle
this.plugsUpdated = false;
this.viewDispatch({ type: "sync-change", synced: true });
});
this.eventHook.addLocalListener("sync:error", (name) => {
this.viewDispatch({ type: "sync-change", synced: false });
});
this.eventHook.addLocalListener("sync:conflict", (name) => {
this.flashNotification(
`Sync: conflict detected for ${name} - conflict copy created`,
"error",
);
});
this.eventHook.addLocalListener("sync:progress", (status: SyncStatus) => {
this.flashNotification(
`Sync: ${
Math.round(status.filesProcessed / status.totalFiles * 10000) /
100
}% — processed ${status.filesProcessed} out of ${status.totalFiles}`,
"info",
);
});
await this.dispatchAppEvent("editor:init");
}
async loadSettings(): Promise<BuiltinSettings> {
let settingsText: string | undefined;
try {
settingsText = (await this.space.readPage("SETTINGS")).text;
} catch (e: any) {
console.log("No SETTINGS page, falling back to default");
settingsText = "```yaml\nindexPage: index\n```\n";
}
const settings = parseYamlSettings(settingsText!) as BuiltinSettings;
if (!settings.indexPage) {
settings.indexPage = "index";
}
return settings;
}
save(immediate = false): Promise<void> {
return new Promise((resolve, reject) => {
if (this.saveTimeout) {
@@ -397,7 +579,7 @@ export class Editor {
id: id,
});
},
type === "info" ? 2000 : 5000,
type === "info" ? 4000 : 5000,
);
}
@@ -811,8 +993,14 @@ export class Editor {
await this.system.unloadAll();
console.log("(Re)loading plugs");
await Promise.all((await this.space.listPlugs()).map(async (plugName) => {
const { data } = await this.space.readAttachment(plugName, "utf8");
await this.system.load(JSON.parse(data as string), createSandbox);
try {
await this.system.load(
new URL(`/.fs/${plugName}`, location.href),
createSandbox,
);
} catch (e: any) {
console.error("Could not load plug", plugName, "error:", e.message);
}
}));
this.rebuildEditorState();
await this.dispatchAppEvent("plugs:loaded");
@@ -909,7 +1097,7 @@ export class Editor {
newWindow = false,
) {
if (!name) {
name = this.indexPage;
name = this.settings!.indexPage;
}
if (newWindow) {
@@ -919,7 +1107,7 @@ export class Editor {
}
return;
}
await this.pageNavigator.navigate(name, pos, replaceState);
await this.pageNavigator!.navigate(name, pos, replaceState);
}
async loadPage(pageName: string): Promise<boolean> {
@@ -1095,7 +1283,9 @@ export class Editor {
darkMode={viewState.uiOptions.darkMode}
onNavigate={(page) => {
dispatch({ type: "stop-navigate" });
editor.focus();
setTimeout(() => {
editor.focus();
});
if (page) {
safeRun(async () => {
await editor.navigate(page);
@@ -1108,7 +1298,9 @@ export class Editor {
<CommandPalette
onTrigger={(cmd) => {
dispatch({ type: "hide-palette" });
editor.focus();
setTimeout(() => {
editor.focus();
});
if (cmd) {
dispatch({ type: "command-run", command: cmd.command.name });
cmd
@@ -1167,6 +1359,7 @@ export class Editor {
<TopBar
pageName={viewState.currentPage}
notifications={viewState.notifications}
synced={viewState.synced}
unsavedChanges={viewState.unsavedChanges}
isLoading={viewState.isLoading}
vimMode={viewState.uiOptions.vimMode}
@@ -1262,7 +1455,6 @@ export class Editor {
render(container: Element) {
const ViewComponent = this.ViewComponent.bind(this);
// console.log(<ViewComponent />);
preactRender(<ViewComponent />, container);
}
+2 -2
View File
@@ -31,7 +31,7 @@ export class CommandHook extends EventEmitter<CommandHookEvents>
buildAllCommands(system: System<CommandHookT>) {
this.editorCommands.clear();
for (let plug of system.loadedPlugs.values()) {
for (const plug of system.loadedPlugs.values()) {
for (
const [name, functionDef] of Object.entries(
plug.manifest!.functions,
@@ -62,7 +62,7 @@ export class CommandHook extends EventEmitter<CommandHookEvents>
}
validateManifest(manifest: Manifest<CommandHookT>): string[] {
let errors = [];
const errors = [];
for (const [name, functionDef] of Object.entries(manifest.functions)) {
if (!functionDef.command) {
continue;
Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

After

Width:  |  Height:  |  Size: 239 KiB

+26 -16
View File
@@ -6,14 +6,23 @@
<meta name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<base href="/" />
<link rel="apple-touch-icon" href="/.client/logo.png">
<title>SilverBullet</title>
<script>
// Some global variables we need to make this work
Deno = {
args: [],
build: {
arch: "x86_64",
},
core: {
runMicrotasks() {
// console.log("Not supported");
},
setHasTickScheduled() {
// console.log("Not supported");
}
},
env: {
get(key) {
// return undefined;
@@ -23,24 +32,25 @@
AlreadyExists: class extends Error { },
}
};
</script>
<style>
html,
body {
margin: 0;
height: 100%;
padding: 0;
width: 100%;
overflow: hidden;
window.silverBulletConfig = {
// These {{VARIABLES}} are replaced by http_server.ts
spaceFolderPath: "{{SPACE_PATH}}",
syncEndpoint: "{{SYNC_ENDPOINT}}",
};
// But in case these variables aren't replaced by the server, fall back fully static mode (no sync)
if (window.silverBulletConfig.spaceFolderPath.includes("{{")) {
window.silverBulletConfig = {
spaceFolderPath: "",
syncEndpoint: "/.fs"
};
}
</style>
</script>
<style id="custom-styles">
</style>
<link rel="stylesheet" href="/main.css" />
<script type="module" src="/client.js"></script>
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/x-icon" href="/favicon.png" />
<link rel="stylesheet" href="/.client/main.css" />
<script type="module" src="/.client/client.js"></script>
<link rel="manifest" href="/.client/manifest.json" />
<link rel="icon" type="image/x-icon" href="/.client/favicon.png" />
</head>
<body>
+3 -3
View File
@@ -3,15 +3,15 @@
"name": "SilverBullet",
"icons": [
{
"src": "/logo.png",
"src": "/.client/logo-dock.png",
"type": "image/png",
"sizes": "1024x1024"
"sizes": "512x512"
}
],
"capture_links": "new-client",
"start_url": "/",
"display": "standalone",
"scope": "/",
"theme_color": "#000",
"theme_color": "#e1e1e1",
"description": "Markdown as a platform"
}
+2 -2
View File
@@ -1,11 +1,11 @@
import { safeRun } from "../common/util.ts";
function encodePageUrl(name: string): string {
return name.replaceAll(" ", "_");
return name;
}
function decodePageUrl(url: string): string {
return url.replaceAll("_", " ");
return url;
}
export class PathPageNavigator {
+9 -6
View File
@@ -16,12 +16,10 @@ export default function reducer(
return {
...state,
isLoading: false,
allPages: new Set(
[...state.allPages].map((pageMeta) =>
pageMeta.name === action.meta.name
? { ...pageMeta, lastOpened: Date.now() }
: pageMeta
),
allPages: state.allPages.map((pageMeta) =>
pageMeta.name === action.meta.name
? { ...pageMeta, lastOpened: Date.now() }
: pageMeta
),
currentPage: action.meta.name,
currentPageMeta: action.meta,
@@ -36,6 +34,11 @@ export default function reducer(
...state,
unsavedChanges: false,
};
case "sync-change":
return {
...state,
synced: action.synced,
};
case "start-navigate":
return {
...state,
+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="icon" type="image/x-icon" href="/favicon.png" />
<title>Reset SilverBullet</title>
<style>
html,
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
border: 0;
margin: 0;
}
footer {
margin-top: 10px;
}
header {
background-color: #e1e1e1;
border-bottom: #cacaca 1px solid;
}
h1 {
margin: 0;
margin: 0 auto;
max-width: 800px;
padding: 8px;
font-size: 28px;
font-weight: normal;
}
form {
max-width: 800px;
margin: 0 auto;
padding: 10px;
}
input {
font-size: 18px;
}
form>div {
margin-bottom: 5px;
}
.error-message {
color: red;
}
</style>
</head>
<body>
<header>
<h1>Reset page</h1>
</header>
<button onclick="resetAll()">Flush everything</button>
<button onclick="javascript:location='/'">Back</button>
<script>
function resetAll() {
if (indexedDB.databases) {
// get a list of all existing IndexedDB databases
indexedDB.databases().then((databases) => {
// loop through the list and delete each database
return Promise.all(
databases.map((database) => {
console.log("Now deleting", database.name);
return new Promise((resolve) => {
return indexedDB.deleteDatabase(database.name).onsuccess = resolve;
});
})
);
}).then(() => {
alert("All IndexedDB databases deleted");
});
}
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then((registration) => {
registration.active.postMessage({ type: 'flushCache' });
});
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'cacheFlushed') {
console.log('Cache flushed');
navigator.serviceWorker.getRegistrations().then((registrations) => {
for (const registration of registrations) {
registration.unregister();
alert("Service worker unregistered");
}
});
}
});
}
}
</script>
</body>
</html>
+159 -2
View File
@@ -1,3 +1,160 @@
globalThis.addEventListener("fetch", function () {
return;
import Dexie from "https://esm.sh/v120/dexie@3.2.2/dist/dexie.js";
import { mime } from "https://deno.land/x/mimetypes@v1.0.0/mod.ts";
import type { FileContent } from "../common/spaces/indexeddb_space_primitives.ts";
import { simpleHash } from "../common/crypto.ts";
import { clientStore } from "../plug-api/silverbullet-syscall/mod.ts";
const CACHE_NAME = "{{CACHE_NAME}}";
const precacheFiles = Object.fromEntries([
"/",
"/.client/reset.html",
"/.client/client.js",
"/.client/favicon.png",
"/.client/iAWriterMonoS-Bold.woff2",
"/.client/iAWriterMonoS-BoldItalic.woff2",
"/.client/iAWriterMonoS-Italic.woff2",
"/.client/iAWriterMonoS-Regular.woff2",
"/.client/logo.png",
"/.client/logo-dock.png",
"/.client/main.css",
"/.client/manifest.json",
].map((path) => [path, path + "?v=" + CACHE_NAME, path])); // Cache busting
self.addEventListener("install", (event: any) => {
console.log("[Service worker]", "Installing service worker...");
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log(
"[Service worker]",
"Now pre-caching client files",
);
return cache.addAll(Object.values(precacheFiles)).then(() => {
console.log(
"[Service worker]",
Object.keys(precacheFiles).length,
"client files cached",
);
// @ts-ignore: No need to wait
self.skipWaiting();
});
}),
);
});
self.addEventListener("activate", (event: any) => {
console.log("[Service worker]", "Activating new service worker!!!");
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log("[Service worker]", "Removing old cache", cacheName);
return caches.delete(cacheName);
}
}),
).then(() => {
// Let's activate ourselves for all existing clients
// @ts-ignore: No need to wait, clients is a serviceworker thing
return clients.claim();
});
}),
);
});
let db: Dexie | undefined;
let fileContentTable: Dexie.Table<FileContent, string> | undefined;
self.addEventListener("fetch", (event: any) => {
const url = new URL(event.request.url);
// Use the custom cache key if available, otherwise use the request URL
const cacheKey = precacheFiles[url.pathname] || event.request.url;
event.respondWith(
// Try the static (client) file cache first
caches.match(cacheKey)
.then((response) => {
// Return the cached response if found
if (response) {
return response;
}
const requestUrl = new URL(event.request.url);
const pathname = requestUrl.pathname;
// If this is a /.fs request, this can either be a plug worker load or an attachment load
if (pathname.startsWith("/.fs")) {
if (fileContentTable && !event.request.headers.has("x-sync-mode")) {
console.log(
"Attempting to serve file from locally synced space:",
pathname,
);
// Don't fetch from DB when in sync mode (because then updates won't sync)
const path = decodeURIComponent(
requestUrl.pathname.slice("/.fs/".length),
);
return fileContentTable.get(path).then(
(data) => {
if (data) {
console.log("Serving from space", path);
return new Response(data.data, {
headers: {
"Content-type": mime.getType(path) ||
"application/octet-stream",
},
});
} else {
console.error(
"Did not find file in locally synced space",
path,
);
return new Response("Not found", {
status: 404,
});
}
},
);
} else {
// Just fetch the file directly
return fetch(event.request);
}
} else if (pathname !== "/.auth") {
// Must be a page URL, let's serve index.html which will handle it
return caches.match(precacheFiles["/"]).then((response) => {
// This shouldnt't happen, index.html not in the cache for some reason
return response || fetch(event.request);
});
} else {
return fetch(event.request);
}
}),
);
});
self.addEventListener("message", (event: any) => {
if (event.data.type === "flushCache") {
caches.delete(CACHE_NAME)
.then(() => {
console.log("[Service worker]", "Cache deleted");
db?.close();
event.source.postMessage({ type: "cacheFlushed" });
});
}
if (event.data.type === "config") {
const spaceFolderPath = event.data.config.spaceFolderPath;
const dbPrefix = "" + simpleHash(spaceFolderPath);
// Setup space
db = new Dexie(`${dbPrefix}_space`, {
indexedDB: globalThis.indexedDB,
});
db.version(1).stores({
fileMeta: "name",
fileContent: "name",
});
fileContentTable = db.table<FileContent, string>("fileContent");
}
});
+310
View File
@@ -0,0 +1,310 @@
import { SpacePrimitives } from "../common/spaces/space_primitives.ts";
import { FileMeta } from "../common/types.ts";
import { EventEmitter } from "../plugos/event.ts";
import { plugPrefix } from "../common/spaces/constants.ts";
import { safeRun } from "../common/util.ts";
import {
base64DecodeDataUrl,
base64EncodedDataUrl,
} from "../plugos/asset_bundle/base64.ts";
import { mime } from "./deps.ts";
import { AttachmentMeta, PageMeta } from "./types.ts";
export type FileEncoding = "utf8" | "arraybuffer" | "dataurl";
export type FileData = ArrayBuffer | string;
export type SpaceEvents = {
pageCreated: (meta: PageMeta) => void;
pageChanged: (meta: PageMeta) => void;
pageDeleted: (name: string) => void;
pageListUpdated: (pages: PageMeta[]) => void;
};
const pageWatchInterval = 5000;
export class Space extends EventEmitter<SpaceEvents> {
pageMetaCache = new Map<string, PageMeta>();
// We do watch files in the background to detect changes
// This set of pages should only ever contain 1 page
watchedPages = new Set<string>();
watchInterval?: number;
private initialPageListLoad = true;
private saving = false;
constructor(readonly spacePrimitives: SpacePrimitives) {
super();
}
// // Filesystem interface implementation
// async readFile(path: string, encoding: "dataurl" | "utf8"): Promise<string> {
// return (await this.spacePrimitives.readFile(path, encoding)).data as string;
// }
// getFileMeta(path: string): Promise<FileMeta> {
// return this.spacePrimitives.getFileMeta(path);
// }
// writeFile(
// path: string,
// text: string,
// encoding: "dataurl" | "utf8",
// ): Promise<FileMeta> {
// return this.spacePrimitives.writeFile(path, encoding, text);
// }
// deleteFile(path: string): Promise<void> {
// return this.spacePrimitives.deleteFile(path);
// }
// async listFiles(path: string): Promise<FileMeta[]> {
// return (await this.spacePrimitives.fetchFileList()).filter((f) =>
// f.name.startsWith(path)
// );
// }
// // The more domain-specific methods
public async updatePageList() {
const newPageList = await this.fetchPageList();
const deletedPages = new Set<string>(this.pageMetaCache.keys());
newPageList.forEach((meta) => {
const pageName = meta.name;
const oldPageMeta = this.pageMetaCache.get(pageName);
const newPageMeta: PageMeta = { ...meta };
if (
!oldPageMeta &&
(pageName.startsWith(plugPrefix) || !this.initialPageListLoad)
) {
this.emit("pageCreated", newPageMeta);
} else if (
oldPageMeta &&
oldPageMeta.lastModified !== newPageMeta.lastModified
) {
this.emit("pageChanged", newPageMeta);
}
// Page found, not deleted
deletedPages.delete(pageName);
// Update in cache
this.pageMetaCache.set(pageName, newPageMeta);
});
for (const deletedPage of deletedPages) {
this.pageMetaCache.delete(deletedPage);
this.emit("pageDeleted", deletedPage);
}
this.emit("pageListUpdated", this.listPages());
this.initialPageListLoad = false;
}
async deletePage(name: string): Promise<void> {
await this.getPageMeta(name); // Check if page exists, if not throws Error
await this.spacePrimitives.deleteFile(`${name}.md`);
this.pageMetaCache.delete(name);
this.emit("pageDeleted", name);
this.emit("pageListUpdated", [...this.pageMetaCache.values()]);
}
async getPageMeta(name: string): Promise<PageMeta> {
const oldMeta = this.pageMetaCache.get(name);
const newMeta = fileMetaToPageMeta(
await this.spacePrimitives.getFileMeta(`${name}.md`),
);
if (oldMeta) {
if (oldMeta.lastModified !== newMeta.lastModified) {
// Changed on disk, trigger event
this.emit("pageChanged", newMeta);
}
}
return this.metaCacher(name, newMeta);
}
listPages(): PageMeta[] {
return [...new Set(this.pageMetaCache.values())];
}
async listPlugs(): Promise<string[]> {
const files = await this.spacePrimitives.fetchFileList();
return files
.filter((fileMeta) =>
fileMeta.name.startsWith(plugPrefix) &&
fileMeta.name.endsWith(".plug.js")
)
.map((fileMeta) => fileMeta.name);
}
async readPage(name: string): Promise<{ text: string; meta: PageMeta }> {
const pageData = await this.spacePrimitives.readFile(`${name}.md`);
const previousMeta = this.pageMetaCache.get(name);
const newMeta = fileMetaToPageMeta(pageData.meta);
if (previousMeta) {
if (previousMeta.lastModified !== newMeta.lastModified) {
// Page changed since last cached metadata, trigger event
this.emit("pageChanged", newMeta);
}
}
const meta = this.metaCacher(name, newMeta);
return {
text: new TextDecoder().decode(pageData.data),
meta: meta,
};
}
async writePage(
name: string,
text: string,
selfUpdate?: boolean,
): Promise<PageMeta> {
try {
this.saving = true;
const pageMeta = fileMetaToPageMeta(
await this.spacePrimitives.writeFile(
`${name}.md`,
new TextEncoder().encode(text),
selfUpdate,
),
);
if (!selfUpdate) {
this.emit("pageChanged", pageMeta);
}
return this.metaCacher(name, pageMeta);
} finally {
this.saving = false;
}
}
async fetchPageList(): Promise<PageMeta[]> {
return (await this.spacePrimitives.fetchFileList())
.filter((fileMeta) => fileMeta.name.endsWith(".md"))
.map(fileMetaToPageMeta);
}
async fetchAttachmentList(): Promise<AttachmentMeta[]> {
return (await this.spacePrimitives.fetchFileList()).filter(
(fileMeta) =>
!fileMeta.name.endsWith(".md") &&
!fileMeta.name.endsWith(".plug.js"),
);
}
/**
* Reads an attachment
* @param name path of the attachment
* @param encoding how the return value is expected to be encoded
* @returns
*/
async readAttachment(
name: string,
encoding: FileEncoding,
): Promise<{ data: FileData; meta: AttachmentMeta }> {
const { data, meta } = await this.spacePrimitives.readFile(name);
switch (encoding) {
case "arraybuffer":
return { data, meta };
case "dataurl":
return {
data: base64EncodedDataUrl(
mime.getType(name) || "application/octet-stream",
data,
),
meta,
};
case "utf8":
return {
data: new TextDecoder().decode(data),
meta,
};
}
}
getAttachmentMeta(name: string): Promise<AttachmentMeta> {
return this.spacePrimitives.getFileMeta(name);
}
writeAttachment(
name: string,
encoding: FileEncoding,
data: FileData,
selfUpdate?: boolean | undefined,
): Promise<AttachmentMeta> {
switch (encoding) {
case "arraybuffer":
return this.spacePrimitives.writeFile(
name,
data as Uint8Array,
selfUpdate,
);
case "dataurl":
return this.spacePrimitives.writeFile(
name,
base64DecodeDataUrl(data as string),
selfUpdate,
);
case "utf8":
return this.spacePrimitives.writeFile(
name,
new TextEncoder().encode(data as string),
selfUpdate,
);
}
}
deleteAttachment(name: string): Promise<void> {
return this.spacePrimitives.deleteFile(name);
}
// Even though changes coming from a sync cycle will immediately trigger a reload
// there are scenarios in which other tabs run the sync, so we have to poll for changes
watch() {
if (this.watchInterval) {
clearInterval(this.watchInterval);
}
this.watchInterval = setInterval(() => {
safeRun(async () => {
if (this.saving) {
return;
}
for (const pageName of this.watchedPages) {
const oldMeta = this.pageMetaCache.get(pageName);
if (!oldMeta) {
// No longer in cache, meaning probably deleted let's unwatch
this.watchedPages.delete(pageName);
continue;
}
// This seems weird, but simply fetching it will compare to local cache and trigger an event if necessary
await this.getPageMeta(pageName);
}
});
}, pageWatchInterval);
this.updatePageList().catch(console.error);
}
unwatch() {
if (this.watchInterval) {
clearInterval(this.watchInterval);
}
}
watchPage(pageName: string) {
this.watchedPages.add(pageName);
}
unwatchPage(pageName: string) {
this.watchedPages.delete(pageName);
}
private metaCacher(name: string, meta: PageMeta): PageMeta {
if (meta.lastModified !== 0) {
// Don't cache metadata for pages with a 0 lastModified timestamp (usualy dynamically generated pages)
this.pageMetaCache.set(name, meta);
}
return meta;
}
}
function fileMetaToPageMeta(fileMeta: FileMeta): PageMeta {
return {
...fileMeta,
name: fileMeta.name.substring(0, fileMeta.name.length - 3),
} as PageMeta;
}
+13 -4
View File
@@ -4,32 +4,41 @@
@font-face {
font-family: "iA-Mono";
src: url("/iAWriterMonoS-Regular.woff2");
src: url("/.client/iAWriterMonoS-Regular.woff2");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "iA-Mono";
src: url("/iAWriterMonoS-Bold.woff2");
src: url("/.client/iAWriterMonoS-Bold.woff2");
font-weight: bold;
font-style: normal;
}
@font-face {
font-family: "iA-Mono";
src: url("/iAWriterMonoS-Italic.woff2");
src: url("/.client/iAWriterMonoS-Italic.woff2");
font-weight: normal;
font-style: italic;
}
@font-face {
font-family: "iA-Mono";
src: url("/iAWriterMonoS-BoldItalic.woff2");
src: url("/.client/iAWriterMonoS-BoldItalic.woff2");
font-weight: bold;
font-style: italic;
}
html,
body {
margin: 0;
height: 100%;
padding: 0;
width: 100%;
overflow: hidden;
}
#sb-root {
display: flex;
flex-direction: column;
+20 -11
View File
@@ -14,6 +14,10 @@
border-bottom: #cacaca 1px solid;
}
#sb-top.sb-sync-error {
background-color: #fdf8cb;
}
.sb-panel {
border-left: 1px solid #eee;
@@ -131,13 +135,14 @@
.cm-panels-bottom {
background-color: #e1e1e1;
border-top: #cacaca 1px solid;
.cm-vim-panel {
padding: 0 20px;
max-width: var(--editor-width);
margin: auto;
}
background-color: #e1e1e1;
border-top: #cacaca 1px solid;
.cm-vim-panel {
padding: 0 20px;
max-width: var(--editor-width);
margin: auto;
}
}
.cm-editor .cm-tooltip-autocomplete {
@@ -563,6 +568,11 @@ html[data-theme="dark"] {
color: #fff;
}
#sb-top.sb-sync-error {
background-color: #622626;
}
.sb-directive-start {
background-color: rgb(38, 38, 38) !important;
}
@@ -588,9 +598,7 @@ html[data-theme="dark"] {
color: #c7c7c7;
}
.sb-meta {
}
.sb-meta {}
.sb-modal-box,
/* duplicating the class name to increase specificity */
@@ -613,6 +621,7 @@ html[data-theme="dark"] {
border-top: rgb(62, 62, 62) 1px solid;
background: rgb(38, 38, 38);
color: white !important;
.cm-vim-panel {
max-width: var(--editor-width);
margin: auto;
@@ -671,4 +680,4 @@ html[data-theme="dark"] {
}
}
}
}
+243
View File
@@ -0,0 +1,243 @@
import type { SpacePrimitives } from "../common/spaces/space_primitives.ts";
import {
SpaceSync,
SyncStatus,
SyncStatusItem,
} from "../common/spaces/sync.ts";
import { EventHook } from "../plugos/hooks/event.ts";
import { KVStore } from "../plugos/lib/kv_store.ts";
// Keeps the current sync snapshot
const syncSnapshotKey = "syncSnapshot";
// Keeps the start time of an ongoing sync, is reset once the sync is done
const syncStartTimeKey = "syncStartTime";
// Keeps the last time an activity was registered, used to detect if a sync is still alive and whether a new one should be started already
const syncLastActivityKey = "syncLastActivity";
// maximum time between two activities before we consider a sync crashed
const syncMaxIdleTimeout = 1000 * 20; // 20s
// How often to sync the whole space
const syncInterval = 10 * 1000; // Every 10s
/**
* The SyncService primarily wraps the SpaceSync engine but also coordinates sync between
* different browser tabs. It is using the KVStore to keep track of sync state.
*/
export class SyncService {
spaceSync: SpaceSync;
lastReportedSyncStatus = Date.now();
constructor(
private localSpacePrimitives: SpacePrimitives,
private remoteSpace: SpacePrimitives,
private kvStore: KVStore,
private eventHook: EventHook,
private isSyncCandidate: (path: string) => boolean,
) {
this.spaceSync = new SpaceSync(
this.localSpacePrimitives,
this.remoteSpace!,
{
conflictResolver: this.plugAwareConflictResolver.bind(this),
isSyncCandidate: this.isSyncCandidate,
onSyncProgress: (status) => {
this.registerSyncProgress(status).catch(console.error);
},
},
);
eventHook.addLocalListener("editor:pageLoaded", async (name) => {
await this.syncFile(`${name}.md`);
});
eventHook.addLocalListener("page:saved", async (name) => {
await this.syncFile(`${name}.md`);
});
}
async isSyncing(): Promise<boolean> {
const startTime = await this.kvStore.get(syncStartTimeKey);
if (!startTime) {
return false;
}
// Sync is running, but is it still alive?
const lastActivity = await this.kvStore.get(syncLastActivityKey)!;
if (Date.now() - lastActivity > syncMaxIdleTimeout) {
// It's been too long since the last activity, let's consider this one crashed and
// reset the sync start state
await this.kvStore.del(syncStartTimeKey);
return false;
}
return true;
}
async hasInitialSyncCompleted(): Promise<boolean> {
// Initial sync has happened when sync progress has been reported at least once, but the syncStartTime has been reset (which happens after sync finishes)
return !!(!(await this.kvStore.get(syncStartTimeKey)) &&
(await this.kvStore.get(syncLastActivityKey)));
}
async registerSyncStart(): Promise<void> {
// Assumption: this is called after an isSyncing() check
await this.kvStore.set(syncStartTimeKey, Date.now());
await this.kvStore.set(syncLastActivityKey, Date.now());
}
async registerSyncProgress(status?: SyncStatus): Promise<void> {
// Emit a sync event at most every 10s
if (status && this.lastReportedSyncStatus < Date.now() - 10000) {
this.eventHook.dispatchEvent("sync:progress", status);
this.lastReportedSyncStatus = Date.now();
await this.saveSnapshot(status.snapshot);
}
await this.kvStore.set(syncLastActivityKey, Date.now());
}
async registerSyncStop(): Promise<void> {
await this.registerSyncProgress();
await this.kvStore.del(syncStartTimeKey);
}
async getSnapshot(): Promise<Map<string, SyncStatusItem>> {
const snapshot = (await this.kvStore.get(syncSnapshotKey)) || {};
return new Map<string, SyncStatusItem>(
Object.entries(snapshot),
);
}
start() {
this.syncSpace().catch(
console.error,
);
setInterval(async () => {
try {
const lastActivity = (await this.kvStore.get(syncLastActivityKey)) || 0;
if (lastActivity && Date.now() - lastActivity > syncInterval) {
// It's been a while since the last activity, let's sync the whole space
// The reason to do this check is that there may be multiple tabs open each with their sync cycle
await this.syncSpace();
}
} catch (e: any) {
console.error(e);
}
}, syncInterval / 2); // check every half the sync cycle because actually running the sync takes some time therefore we don't want to wait for the full cycle
}
async syncSpace(): Promise<number> {
if (await this.isSyncing()) {
console.log("Already syncing");
return 0;
}
await this.registerSyncStart();
let operations = 0;
const snapshot = await this.getSnapshot();
try {
operations = await this.spaceSync!.syncFiles(snapshot);
this.eventHook.dispatchEvent("sync:success", operations);
} catch (e: any) {
this.eventHook.dispatchEvent("sync:error", e.message);
console.error("Sync error", e);
}
await this.saveSnapshot(snapshot);
await this.registerSyncStop();
return operations;
}
async syncFile(name: string) {
if (await this.isSyncing()) {
// console.log("Already syncing");
return;
}
if (!this.isSyncCandidate(name)) {
return;
}
await this.registerSyncStart();
console.log("Syncing file", name);
const snapshot = await this.getSnapshot();
try {
let localHash: number | undefined = undefined;
let remoteHash: number | undefined = undefined;
try {
localHash =
(await this.localSpacePrimitives.getFileMeta(name)).lastModified;
} catch {
// Not present
}
try {
// This is wasteful, but Netlify (silverbullet.md) doesn't support OPTIONS call (404s) so we'll just fetch the whole file
const { meta } = await this.remoteSpace!.readFile(name);
remoteHash = meta.lastModified;
} catch (e: any) {
if (e.message === "Not found") {
// File doesn't exist remotely, that's ok
} else {
throw e;
}
}
await this.spaceSync!.syncFile(snapshot, name, localHash, remoteHash);
this.eventHook.dispatchEvent("sync:success");
} catch (e: any) {
this.eventHook.dispatchEvent("sync:error", e.message);
console.error("Sync error", e);
}
await this.saveSnapshot(snapshot);
await this.registerSyncStop();
}
async saveSnapshot(snapshot: Map<string, SyncStatusItem>) {
await this.kvStore.set(syncSnapshotKey, Object.fromEntries(snapshot));
}
public async plugAwareConflictResolver(
name: string,
snapshot: Map<string, SyncStatusItem>,
primary: SpacePrimitives,
secondary: SpacePrimitives,
): Promise<number> {
if (!name.startsWith("_plug/")) {
const operations = await SpaceSync.primaryConflictResolver(
name,
snapshot,
primary,
secondary,
);
if (operations > 0) {
// Something happened -> conflict copy generated, let's report it
await this.eventHook.dispatchEvent("sync:conflict", name);
}
return operations;
}
console.log(
"[sync]",
"Conflict in plug",
name,
"will pick the version from secondary and be done with it.",
);
const fileMeta = await primary.getFileMeta(name);
// Read file from secondary
const { data } = await secondary.readFile(
name,
);
// Write file to primary
const newMeta = await primary.writeFile(
name,
data,
false,
fileMeta.lastModified,
);
// Update snapshot
snapshot.set(name, [
newMeta.lastModified,
fileMeta.lastModified,
]);
return 1;
}
}
+4 -3
View File
@@ -1,9 +1,10 @@
import { proxySyscalls } from "../../plugos/syscalls/transport.ts";
import { SysCallMapping } from "../../plugos/system.ts";
import { storeSyscalls } from "../../plugos/syscalls/store.dexie_browser.ts";
export function clientStoreSyscalls(): SysCallMapping {
const storeCalls = storeSyscalls("local", "localData");
// DEPRECATED, use store directly
export function clientStoreSyscalls(
storeCalls: SysCallMapping,
): SysCallMapping {
return proxySyscalls(
["clientStore.get", "clientStore.set", "clientStore.delete"],
(ctx, name, ...args) => {
+1 -1
View File
@@ -1,7 +1,7 @@
import { Editor } from "../editor.tsx";
import { EditorView, Transaction, Vim, vimGetCm } from "../deps.ts";
import { SysCallMapping } from "../../plugos/system.ts";
import { FilterOption } from "../../common/types.ts";
import type { FilterOption } from "../types.ts";
export function editorSyscalls(editor: Editor): SysCallMapping {
const syscalls: SysCallMapping = {
+34 -9
View File
@@ -1,12 +1,37 @@
import type { SysCallMapping } from "../../plugos/system.ts";
import { proxySyscalls } from "../../plugos/syscalls/transport.ts";
import type { Space } from "../../common/spaces/space.ts";
import type { HttpSpacePrimitives } from "../../common/spaces/http_space_primitives.ts";
import {
performLocalFetch,
ProxyFetchRequest,
ProxyFetchResponse,
} from "../../common/proxy_fetch.ts";
export function sandboxFetchSyscalls(space: Space): SysCallMapping {
return proxySyscalls(
[
"sandboxFetch.fetch",
],
(ctx, name, ...args) => space.proxySyscall(ctx.plug, name, args),
);
export function sandboxFetchSyscalls(
httpSpacePrimitives?: HttpSpacePrimitives,
): SysCallMapping {
return {
"sandboxFetch.fetch": async (
_ctx,
url: string,
options: ProxyFetchRequest,
): Promise<ProxyFetchResponse> => {
// console.log("Got sandbox fetch ", url);
if (!httpSpacePrimitives) {
// No SB server to proxy the fetch available so let's execute the request directly
return performLocalFetch(url, options);
}
const resp = httpSpacePrimitives.authenticatedFetch(
httpSpacePrimitives.url,
{
method: "POST",
body: JSON.stringify({
operation: "fetch",
url,
options,
}),
},
);
return (await resp).json();
},
};
}
-10
View File
@@ -1,10 +0,0 @@
import { SysCallMapping } from "../../plugos/system.ts";
import { proxySyscalls } from "../../plugos/syscalls/transport.ts";
import { Space } from "../../common/spaces/space.ts";
export function fulltextSyscalls(space: Space): SysCallMapping {
return proxySyscalls(
["fulltext.search", "fulltext.delete", "fulltext.index"],
(ctx, name, ...args) => space.proxySyscall(ctx.plug, name, args),
);
}
+61 -14
View File
@@ -1,16 +1,63 @@
import { SysCallMapping } from "../../plugos/system.ts";
import { proxySyscalls } from "../../plugos/syscalls/transport.ts";
import { Space } from "../../common/spaces/space.ts";
import type { SysCallMapping } from "../../plugos/system.ts";
import Dexie from "dexie";
export function indexerSyscalls(space: Space): SysCallMapping {
return proxySyscalls(
[
"index.queryPrefix",
"index.get",
"index.set",
"index.batchSet",
"index.delete",
],
(ctx, name, ...args) => space.proxySyscall(ctx.plug, name, args),
);
type Item = {
page: string;
key: string;
value: any;
};
export type KV = {
key: string;
value: any;
};
export function pageIndexSyscalls(
dbName: string,
indexedDB?: any,
): SysCallMapping {
const db = new Dexie(dbName, {
indexedDB,
});
db.version(1).stores({
"index": "[page+key], page, key",
});
const items = db.table<Item, { key: string; page: string }>("index");
const apiObj: SysCallMapping = {
"index.set": (_ctx, page: string, key: string, value: any) => {
return items.put({ page, key, value });
},
"index.batchSet": async (_ctx, page: string, kvs: KV[]) => {
// await items.bulkPut(kvs);
if (kvs.length === 0) {
return;
}
const values = kvs.flatMap((kv) => ({
page,
key: kv.key,
value: kv.value,
}));
await items.bulkPut(values);
},
"index.delete": (_ctx, page: string, key: string) => {
return items.delete({ page, key });
},
"index.get": async (_ctx, page: string, key: string) => {
return (await items.get({ page, key }))?.value;
},
"index.queryPrefix": (_ctx, prefix: string) => {
return items.where("key").startsWith(prefix).toArray();
},
"index.clearPageIndexForPage": async (ctx, page: string) => {
await apiObj["index.deletePrefixForPage"](ctx, page, "");
},
"index.deletePrefixForPage": (_ctx, page: string, prefix: string) => {
return items.where({ page }).and((it) => it.key.startsWith(prefix))
.delete();
},
"index.clearPageIndex": () => {
return items.clear();
},
};
return apiObj;
}
+12
View File
@@ -0,0 +1,12 @@
import { SysCallMapping } from "../../plugos/system.ts";
import { parse } from "../../common/markdown_parser/parse_tree.ts";
import { Language } from "../deps.ts";
import type { ParseTree } from "$sb/lib/tree.ts";
export function markdownSyscalls(lang: Language): SysCallMapping {
return {
"markdown.parseMarkdown": (_ctx, text: string): ParseTree => {
return parse(lang, text);
},
};
}
+34
View File
@@ -0,0 +1,34 @@
import { HttpSpacePrimitives } from "../../common/spaces/http_space_primitives.ts";
import { SysCallMapping } from "../../plugos/system.ts";
export function shellSyscalls(
httpSpacePrimitives?: HttpSpacePrimitives,
): SysCallMapping {
return {
"shell.run": async (
_ctx,
cmd: string,
args: string[],
): Promise<{ stdout: string; stderr: string; code: number }> => {
if (!httpSpacePrimitives) {
throw new Error("Not supported in fully local mode");
}
const resp = httpSpacePrimitives.authenticatedFetch(
httpSpacePrimitives.url,
{
method: "POST",
body: JSON.stringify({
operation: "shell",
cmd,
args,
}),
},
);
const { code, stderr, stdout } = await (await resp).json();
if (code !== 0) {
throw new Error(stderr);
}
return { code, stderr, stdout };
},
};
}
+62 -13
View File
@@ -1,19 +1,68 @@
import { Editor } from "../editor.tsx";
import { SysCallMapping } from "../../plugos/system.ts";
import commonSpaceSyscalls from "../../common/syscalls/space.ts";
import { AttachmentMeta, PageMeta } from "../types.ts";
import { FileData, FileEncoding } from "../space.ts";
export function spaceSyscalls(editor: Editor): SysCallMapping {
const syscalls = commonSpaceSyscalls(editor.space);
syscalls["space.deletePage"] = async (_ctx, name: string) => {
// If we're deleting the current page, navigate to the index page
if (editor.currentPage === name) {
await editor.navigate("");
}
// Remove page from open pages in editor
editor.openPages.delete(name);
console.log("Deleting page");
await editor.space.deletePage(name);
const space = editor.space;
return {
"space.listPages": (): Promise<PageMeta[]> => {
return space.fetchPageList();
},
"space.readPage": async (
_ctx,
name: string,
): Promise<string> => {
return (await space.readPage(name)).text;
},
"space.getPageMeta": (_ctx, name: string): Promise<PageMeta> => {
return space.getPageMeta(name);
},
"space.writePage": (
_ctx,
name: string,
text: string,
): Promise<PageMeta> => {
return space.writePage(name, text);
},
"space.deletePage": async (_ctx, name: string) => {
// If we're deleting the current page, navigate to the index page
if (editor.currentPage === name) {
await editor.navigate("");
}
// Remove page from open pages in editor
editor.openPages.delete(name);
console.log("Deleting page");
await editor.space.deletePage(name);
},
"space.listPlugs": (): Promise<string[]> => {
return space.listPlugs();
},
"space.listAttachments": async (): Promise<AttachmentMeta[]> => {
return await space.fetchAttachmentList();
},
"space.readAttachment": async (
_ctx,
name: string,
): Promise<FileData> => {
return (await space.readAttachment(name, "dataurl")).data;
},
"space.getAttachmentMeta": async (
_ctx,
name: string,
): Promise<AttachmentMeta> => {
return await space.getAttachmentMeta(name);
},
"space.writeAttachment": async (
_ctx,
name: string,
encoding: FileEncoding,
data: string,
): Promise<AttachmentMeta> => {
return await space.writeAttachment(name, encoding, data);
},
"space.deleteAttachment": async (_ctx, name: string) => {
await space.deleteAttachment(name);
},
};
return syscalls;
}
-18
View File
@@ -1,18 +0,0 @@
import { SysCallMapping } from "../../plugos/system.ts";
import { proxySyscalls } from "../../plugos/syscalls/transport.ts";
import { Space } from "../../common/spaces/space.ts";
export function storeSyscalls(space: Space): SysCallMapping {
return proxySyscalls(
[
"store.queryPrefix",
"store.get",
"store.has",
"store.set",
"store.batchSet",
"store.delete",
"store.deletePrefix",
],
(ctx, name, ...args) => space.proxySyscall(ctx.plug, name, args),
);
}
+13
View File
@@ -0,0 +1,13 @@
import { SysCallMapping } from "../../plugos/system.ts";
import { SyncService } from "../sync_service.ts";
export function syncSyscalls(syncService: SyncService): SysCallMapping {
return {
"sync.isSyncing": (): Promise<boolean> => {
return syncService.isSyncing();
},
"sync.hasInitialSyncCompleted": (): Promise<boolean> => {
return syncService.hasInitialSyncCompleted();
},
};
}
+2 -9
View File
@@ -28,13 +28,9 @@ export function systemSyscalls(
}
name = functionName;
}
if (env === "client") {
return plug.invoke(name, args);
}
return editor.space.invokeFunction(plug, env, name, args);
return plug.invoke(name, args);
},
"system.invokeCommand": (ctx, name: string) => {
"system.invokeCommand": (_ctx, name: string) => {
return editor.runCommandByName(name);
},
"system.listCommands": (): { [key: string]: CommandDef } => {
@@ -47,9 +43,6 @@ export function systemSyscalls(
"system.reloadPlugs": () => {
return editor.reloadPlugs();
},
"sandbox.getServerLogs": (ctx) => {
return editor.space.proxySyscall(ctx.plug, "sandbox.getLogs", []);
},
"system.getEnv": () => {
return system.env;
},
+13
View File
@@ -0,0 +1,13 @@
import { SysCallMapping } from "../../plugos/system.ts";
import { YAML } from "../deps.ts";
export function yamlSyscalls(): SysCallMapping {
return {
"yaml.parse": (_ctx, text: string): any => {
return YAML.parse(text);
},
"yaml.stringify": (_ctx, obj: any): string => {
return YAML.stringify(obj);
},
};
}
+28 -4
View File
@@ -1,5 +1,26 @@
import { AppCommand } from "./hooks/command.ts";
import { FilterOption, PageMeta } from "../common/types.ts";
export type PageMeta = {
name: string;
lastModified: number;
lastOpened?: number;
perm: "ro" | "rw";
} & Record<string, any>;
export type AttachmentMeta = {
name: string;
contentType: string;
lastModified: number;
size: number;
perm: "ro" | "rw";
};
// Used by FilterBox
export type FilterOption = {
name: string;
orderId?: number;
hint?: string;
} & Record<string, any>;
export type Notification = {
id: number;
@@ -28,8 +49,9 @@ export type AppViewState = {
showCommandPalette: boolean;
showCommandPaletteContext?: string;
unsavedChanges: boolean;
synced: boolean;
panels: { [key: string]: PanelConfig };
allPages: Set<PageMeta>;
allPages: PageMeta[];
commands: Map<string, AppCommand>;
notifications: Notification[];
recentCommands: Map<string, Date>;
@@ -65,6 +87,7 @@ export const initialViewState: AppViewState = {
showPageNavigator: false,
showCommandPalette: false,
unsavedChanges: false,
synced: true,
uiOptions: {
vimMode: false,
darkMode: false,
@@ -76,7 +99,7 @@ export const initialViewState: AppViewState = {
bhs: {},
modal: {},
},
allPages: new Set(),
allPages: [],
commands: new Map(),
recentCommands: new Map(),
notifications: [],
@@ -94,9 +117,10 @@ export const initialViewState: AppViewState = {
export type Action =
| { type: "page-loaded"; meta: PageMeta }
| { type: "page-loading"; name: string }
| { type: "pages-listed"; pages: Set<PageMeta> }
| { type: "pages-listed"; pages: PageMeta[] }
| { type: "page-changed" }
| { type: "page-saved" }
| { type: "sync-change"; synced: boolean }
| { type: "start-navigate" }
| { type: "stop-navigate" }
| {