SilverBullet pivot to become an offline-first PWA (#403)
This commit is contained in:
+1
-1
@@ -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
@@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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,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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"] {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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
@@ -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" }
|
||||
| {
|
||||
|
||||
Reference in New Issue
Block a user