File watching and various tweaks

pull/3/head
Zef Hemel 2022-02-25 15:34:00 +01:00
parent 0527430626
commit 2986c2c231
21 changed files with 264 additions and 119 deletions

View File

@ -13,7 +13,8 @@
"requiredContext": {}
},
"Insert Current Date": {
"invoke": "insert_nice_date"
"invoke": "insert_nice_date",
"slashCommand": "/insert-today"
},
"Toggle : Heading 1": {
"invoke": "toggle_h1",

View File

@ -6,24 +6,31 @@ import { oakCors } from "https://deno.land/x/cors@v1.2.0/mod.ts";
import { readAll } from "https://deno.land/std@0.126.0/streams/mod.ts";
import { exists } from "https://deno.land/std@0.126.0/fs/mod.ts";
type NuggetMeta = {
name: string;
lastModified: number;
};
const fsPrefix = "/fs";
const nuggetsPath = "../nuggets";
const fsRouter = new Router();
fsRouter.use(oakCors());
fsRouter.use(oakCors({ methods: ["OPTIONS", "GET", "PUT", "POST"] }));
fsRouter.get("/", async (context) => {
const localPath = nuggetsPath;
let fileNames: string[] = [];
let fileNames: NuggetMeta[] = [];
for await (const dirEntry of Deno.readDir(localPath)) {
if (dirEntry.isFile) {
fileNames.push(
dirEntry.name.substring(
const stat = await Deno.stat(`${localPath}/${dirEntry.name}`);
fileNames.push({
name: dirEntry.name.substring(
0,
dirEntry.name.length - path.extname(dirEntry.name).length
)
);
),
lastModified: stat.mtime?.getTime()!,
});
}
}
context.response.body = JSON.stringify(fileNames);
@ -33,7 +40,9 @@ fsRouter.get("/:nugget", async (context) => {
const nuggetName = context.params.nugget;
const localPath = `${nuggetsPath}/${nuggetName}.md`;
try {
const stat = await Deno.stat(localPath);
const text = await Deno.readTextFile(localPath);
context.response.headers.set("Last-Modified", "" + stat.mtime?.getTime());
context.response.body = text;
} catch (e) {
context.response.status = 404;
@ -46,7 +55,9 @@ fsRouter.options("/:nugget", async (context) => {
try {
const stat = await Deno.stat(localPath);
context.response.headers.set("Content-length", `${stat.size}`);
context.response.headers.set("Last-Modified", "" + stat.mtime?.getTime());
} catch (e) {
// For CORS
context.response.status = 200;
context.response.body = "";
}
@ -69,8 +80,10 @@ fsRouter.put("/:nugget", async (context) => {
const text = await readAll(result.value);
file.write(text);
file.close();
const stat = await Deno.stat(localPath);
console.log("Wrote to", localPath);
context.response.status = existingNugget ? 200 : 201;
context.response.headers.set("Last-Modified", "" + stat.mtime?.getTime());
context.response.body = "OK";
});

15
webapp/src/boot.ts Normal file
View File

@ -0,0 +1,15 @@
import { Editor } from "./editor";
import { HttpFileSystem } from "./fs";
import { safeRun } from "./util";
let editor = new Editor(
new HttpFileSystem(`http://${location.hostname}:2222/fs`),
document.getElementById("root")!
);
safeRun(async () => {
await editor.init();
});
// @ts-ignore
window.editor = editor;

View File

@ -1,4 +1,5 @@
import { AppCommand } from "../types";
import { isMacLike } from "../util";
import { FilterList, Option } from "./filter";
export function CommandPalette({
@ -9,8 +10,12 @@ export function CommandPalette({
onTrigger: (command: AppCommand | undefined) => void;
}) {
let options: Option[] = [];
const isMac = isMacLike();
for (let [name, def] of commands.entries()) {
options.push({ name: name });
options.push({
name: name,
hint: isMac && def.command.mac ? def.command.mac : def.command.key,
});
}
console.log("Commands", options);
return (

View File

@ -2,26 +2,28 @@ import React, { useEffect, useRef, useState } from "react";
export interface Option {
name: string;
orderId?: number;
hint?: string;
}
function magicSorter(a: Option, b: Option): number {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return -1;
} else {
return 1;
if (a.orderId && b.orderId) {
return a.orderId < b.orderId ? -1 : 1;
}
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
}
export function FilterList({
placeholder,
options,
onSelect,
onKeyPress,
allowNew = false,
newHint,
}: {
placeholder: string;
options: Option[];
onKeyPress?: (key: string, currentText: string) => void;
onSelect: (option: Option | undefined) => void;
allowNew?: boolean;
newHint?: string;
@ -75,7 +77,6 @@ export function FilterList({
document.addEventListener("click", closer);
return () => {
console.log("Unsubscribing");
document.removeEventListener("click", closer);
};
}, []);
@ -90,6 +91,9 @@ export function FilterList({
onChange={filter}
onKeyDown={(e: React.KeyboardEvent) => {
console.log("Key up", e.key);
if (onKeyPress) {
onKeyPress(e.key, text);
}
switch (e.key) {
case "ArrowUp":
setSelectionOption(Math.max(0, selectedOption - 1));

View File

@ -1,14 +1,16 @@
import { NuggetMeta } from "../types";
export function NavigationBar({
currentNugget,
onClick,
}: {
currentNugget?: string;
currentNugget?: NuggetMeta;
onClick: () => void;
}) {
return (
<div id="top">
<div className="current-nugget" onClick={onClick}>
» {currentNugget}
» {currentNugget?.name}
</div>
</div>
);

View File

@ -11,7 +11,11 @@ export function NuggetNavigator({
return (
<FilterList
placeholder=""
options={allNuggets}
options={allNuggets.map((meta) => ({
...meta,
// Order by last modified date in descending order
orderId: -meta.lastModified.getTime(),
}))}
allowNew={true}
newHint="Create nugget"
onSelect={(opt) => {

View File

@ -1,5 +1,6 @@
import {
autocompletion,
Completion,
CompletionContext,
completionKeymap,
CompletionResult,
@ -11,12 +12,12 @@ import { indentOnInput, syntaxTree } from "@codemirror/language";
import { bracketMatching } from "@codemirror/matchbrackets";
import { searchKeymap } from "@codemirror/search";
import { EditorState, StateField, Transaction } from "@codemirror/state";
import { KeyBinding } from "@codemirror/view";
import {
drawSelection,
dropCursor,
EditorView,
highlightSpecialChars,
KeyBinding,
keymap,
} from "@codemirror/view";
import React, { useEffect, useReducer } from "react";
@ -28,12 +29,12 @@ import { CommandPalette } from "./components/command_palette";
import { NavigationBar } from "./components/navigation_bar";
import { NuggetNavigator } from "./components/nugget_navigator";
import { StatusBar } from "./components/status_bar";
import { FileSystem, HttpFileSystem } from "./fs";
import { FileSystem } from "./fs";
import { lineWrapper } from "./lineWrapper";
import { markdown } from "./markdown";
import customMarkDown from "./parser";
import { BrowserSystem } from "./plugins/browser_system";
import { Manifest } from "./plugins/types";
import { Manifest, slashCommandRegexp } from "./plugins/types";
import reducer from "./reducer";
import customMarkdownStyle from "./style";
import dbSyscalls from "./syscalls/db.localstorage";
@ -44,19 +45,24 @@ import {
AppViewState,
CommandContext,
initialViewState,
NuggetMeta,
} from "./types";
import { safeRun } from "./util";
class NuggetState {
editorState: EditorState;
scrollTop: number;
meta: NuggetMeta;
constructor(editorState: EditorState, scrollTop: number) {
constructor(editorState: EditorState, scrollTop: number, meta: NuggetMeta) {
this.editorState = editorState;
this.scrollTop = scrollTop;
this.meta = meta;
}
}
const watchInterval = 5000;
export class Editor {
editorView?: EditorView;
viewState: AppViewState;
@ -78,6 +84,7 @@ export class Editor {
parent: document.getElementById("editor")!,
});
this.addListeners();
this.watch();
}
async init() {
@ -93,15 +100,15 @@ export class Editor {
await system.bootServiceWorker();
console.log("Now loading core plugin");
let mainCartridge = await system.load("core", coreManifest as Manifest);
let mainPlugin = await system.load("core", coreManifest as Manifest);
this.editorCommands = new Map<string, AppCommand>();
const cmds = mainCartridge.manifest!.commands;
const cmds = mainPlugin.manifest!.commands;
for (let name in cmds) {
let cmd = cmds[name];
this.editorCommands.set(name, {
command: cmd,
run: async (arg: CommandContext): Promise<any> => {
return await mainCartridge.invoke(cmd.invoke, [arg]);
return await mainPlugin.invoke(cmd.invoke, [arg]);
},
});
}
@ -111,7 +118,7 @@ export class Editor {
});
}
get currentNugget(): string | undefined {
get currentNugget(): NuggetMeta | undefined {
return this.viewState.currentNugget;
}
@ -146,7 +153,10 @@ export class Editor {
bracketMatching(),
closeBrackets(),
autocompletion({
override: [this.nuggetCompleter.bind(this)],
override: [
this.nuggetCompleter.bind(this),
this.commandCompleter.bind(this),
],
}),
EditorView.lineWrapping,
lineWrapper([
@ -176,15 +186,10 @@ export class Editor {
run: commands.insertMarker("_"),
},
{
key: "Ctrl-s",
mac: "Cmd-s",
run: (target: EditorView): boolean => {
Promise.resolve()
.then(async () => {
console.log("Saving");
await this.save();
})
.catch((e) => console.error(e));
key: "Ctrl-e",
mac: "Cmd-e",
run: (): boolean => {
window.open(location.href, "_blank")!.focus();
return true;
},
},
@ -200,7 +205,9 @@ export class Editor {
key: "Ctrl-.",
mac: "Cmd-.",
run: (target): boolean => {
this.viewDispatch({ type: "show-palette" });
this.viewDispatch({
type: "show-palette",
});
return true;
},
},
@ -220,12 +227,10 @@ export class Editor {
}
nuggetCompleter(ctx: CompletionContext): CompletionResult | null {
let prefix = ctx.matchBefore(/\[\[\w*/);
let prefix = ctx.matchBefore(/\[\[[\w\s]*/);
if (!prefix) {
return null;
}
// TODO: Lots of optimization potential here
// TODO: put something in the cm-completionIcon-nugget style
return {
from: prefix.from + 2,
options: this.viewState.allNuggets.map((nuggetMeta) => ({
@ -235,6 +240,39 @@ export class Editor {
};
}
commandCompleter(ctx: CompletionContext): CompletionResult | null {
let prefix = ctx.matchBefore(slashCommandRegexp);
if (!prefix) {
return null;
}
let options: Completion[] = [];
for (let [name, def] of this.viewState.commands) {
if (!def.command.slashCommand) {
continue;
}
options.push({
label: def.command.slashCommand,
detail: name,
apply: () => {
this.editorView?.dispatch({
changes: {
from: prefix!.from,
to: ctx.pos,
insert: "",
},
});
safeRun(async () => {
def.run(buildContext(def, this));
});
},
});
}
return {
from: prefix.from + 1,
options: options,
};
}
update(value: null, transaction: Transaction): null {
if (transaction.docChanged) {
this.viewDispatch({
@ -276,22 +314,26 @@ export class Editor {
return;
}
// Write to file system
const created = await this.fs.writeNugget(
this.currentNugget,
let nuggetMeta = await this.fs.writeNugget(
this.currentNugget.name,
editorState.sliceDoc()
);
// Update in open nugget cache
this.openNuggets.set(
this.currentNugget,
new NuggetState(editorState, this.editorView!.scrollDOM.scrollTop)
this.currentNugget.name,
new NuggetState(
editorState,
this.editorView!.scrollDOM.scrollTop,
nuggetMeta
)
);
// Dispatch update to view
this.viewDispatch({ type: "nugget-saved" });
this.viewDispatch({ type: "nugget-saved", meta: nuggetMeta });
// If a new nugget was created, let's refresh the nugget list
if (created) {
if (nuggetMeta.created) {
await this.loadNuggetList();
}
}
@ -304,6 +346,34 @@ export class Editor {
});
}
watch() {
setInterval(() => {
safeRun(async () => {
if (!this.currentNugget) {
return;
}
const currentNuggetName = this.currentNugget.name;
let newNuggetMeta = await this.fs.getMeta(currentNuggetName);
if (
this.currentNugget.lastModified.getTime() <
newNuggetMeta.lastModified.getTime()
) {
console.log("File changed on disk, reloading");
let nuggetData = await this.fs.readNugget(currentNuggetName);
this.openNuggets.set(
newNuggetMeta.name,
new NuggetState(
this.createEditorState(nuggetData.text),
0,
newNuggetMeta
)
);
await this.loadNugget(currentNuggetName);
}
});
}, watchInterval);
}
focus() {
this.editorView!.focus();
}
@ -323,25 +393,33 @@ export class Editor {
return;
}
let nuggetState = this.openNuggets.get(nuggetName);
if (!nuggetState) {
let text = await this.fs.readNugget(nuggetName);
nuggetState = new NuggetState(this.createEditorState(text), 0);
}
this.openNuggets.set(nuggetName, nuggetState!);
this.editorView!.setState(nuggetState!.editorState);
this.editorView.scrollDOM.scrollTop = nuggetState!.scrollTop;
this.viewDispatch({
type: "nugget-loaded",
name: nuggetName,
});
await this.loadNugget(nuggetName);
})
.catch((e) => {
console.error(e);
});
}
async loadNugget(nuggetName: string) {
let nuggetState = this.openNuggets.get(nuggetName);
if (!nuggetState) {
let nuggetData = await this.fs.readNugget(nuggetName);
nuggetState = new NuggetState(
this.createEditorState(nuggetData.text),
0,
nuggetData.meta
);
this.openNuggets.set(nuggetName, nuggetState!);
}
this.editorView!.setState(nuggetState!.editorState);
this.editorView!.scrollDOM.scrollTop = nuggetState!.scrollTop;
this.viewDispatch({
type: "nugget-loaded",
meta: nuggetState.meta,
});
}
addListeners() {
this.$hashChange = this.hashChange.bind(this);
window.addEventListener("hashchange", this.$hashChange);
@ -380,7 +458,7 @@ export class Editor {
useEffect(() => {
if (viewState.currentNugget) {
document.title = viewState.currentNugget;
document.title = viewState.currentNugget.name;
}
}, [viewState.currentNugget]);
@ -437,19 +515,3 @@ export class Editor {
ReactDOM.render(<ViewComponent />, container);
}
}
let ed = new Editor(
new HttpFileSystem("http://localhost:2222/fs"),
document.getElementById("root")!
);
ed.loadPlugins().catch((e) => {
console.error(e);
});
safeRun(async () => {
await ed.init();
});
// @ts-ignore
window.editor = ed;

View File

@ -2,9 +2,9 @@ import { NuggetMeta } from "./types";
export interface FileSystem {
listNuggets(): Promise<NuggetMeta[]>;
readNugget(name: string): Promise<string>;
// @return whether a new nugget was created for this
writeNugget(name: string, text: string): Promise<boolean>;
readNugget(name: string): Promise<{ text: string; meta: NuggetMeta }>;
writeNugget(name: string, text: string): Promise<NuggetMeta>;
getMeta(name: string): Promise<NuggetMeta>;
}
export class HttpFileSystem implements FileSystem {
@ -17,20 +17,43 @@ export class HttpFileSystem implements FileSystem {
method: "GET",
});
return (await req.json()).map((name: string) => ({ name }));
return (await req.json()).map((meta: any) => ({
name: meta.name,
lastModified: new Date(meta.lastModified),
}));
}
async readNugget(name: string): Promise<string> {
async readNugget(name: string): Promise<{ text: string; meta: NuggetMeta }> {
let req = await fetch(`${this.url}/${name}`, {
method: "GET",
});
return await req.text();
return {
text: await req.text(),
meta: {
lastModified: new Date(+req.headers.get("Last-Modified")!),
name: name,
},
};
}
async writeNugget(name: string, text: string): Promise<boolean> {
async writeNugget(name: string, text: string): Promise<NuggetMeta> {
let req = await fetch(`${this.url}/${name}`, {
method: "PUT",
body: text,
});
// 201 (Created) means a new nugget was created
return req.status === 201;
return {
lastModified: new Date(+req.headers.get("Last-Modified")!),
name: name,
created: req.status === 201,
};
}
async getMeta(name: string): Promise<NuggetMeta> {
let req = await fetch(`${this.url}/${name}`, {
method: "OPTIONS",
});
return {
name: name,
lastModified: new Date(+req.headers.get("Last-Modified")!),
};
}
}

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<title>Nugget</title>
<link rel="stylesheet" href="styles.css" />
<script type="module" src="editor.tsx"></script>
<script type="module" src="boot.ts"></script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
</head>

View File

@ -66,13 +66,13 @@ self.addEventListener("fetch", (event: any) => {
return await handlePut(req, path);
}
let [cartridgeName, resourceType, functionName] = path.split("/");
let [pluginName, resourceType, functionName] = path.split("/");
let manifest = await getManifest(cartridgeName);
let manifest = await getManifest(pluginName);
if (!manifest) {
// console.log("Ain't got", cartridgeName);
return new Response(`Cartridge not loaded: ${cartridgeName}`, {
// console.log("Ain't got", pluginName);
return new Response(`Plugin not loaded: ${pluginName}`, {
status: 404,
});
}

View File

@ -1,8 +1,8 @@
import { CartridgeLoader, System } from "./runtime";
import { PluginLoader, System } from "./runtime";
import { Manifest } from "./types";
import { sleep } from "../util";
export class BrowserLoader implements CartridgeLoader {
export class BrowserLoader implements PluginLoader {
readonly pathPrefix: string;
constructor(pathPrefix: string) {

View File

@ -1,10 +1,10 @@
import { Manifest } from "./types";
export class SyscallContext {
public cartridge: Cartridge;
public plugin: Plugin;
constructor(cartridge: Cartridge) {
this.cartridge = cartridge;
constructor(Plugin: Plugin) {
this.plugin = Plugin;
}
}
@ -19,9 +19,9 @@ export class FunctionWorker {
private initCallback: any;
private invokeResolve?: (result?: any) => void;
private invokeReject?: (reason?: any) => void;
private cartridge: Cartridge;
private plugin: Plugin;
constructor(cartridge: Cartridge, pathPrefix: string, name: string) {
constructor(plugin: Plugin, pathPrefix: string, name: string) {
// this.worker = new Worker(new URL("function_worker.ts", import.meta.url), {
// type: "classic",
// });
@ -40,7 +40,7 @@ export class FunctionWorker {
this.inited = new Promise((resolve) => {
this.initCallback = resolve;
});
this.cartridge = cartridge;
this.plugin = plugin;
}
async onmessage(evt: MessageEvent) {
@ -51,8 +51,8 @@ export class FunctionWorker {
this.initCallback();
break;
case "syscall":
const ctx = new SyscallContext(this.cartridge);
let result = await this.cartridge.system.syscall(
const ctx = new SyscallContext(this.plugin);
let result = await this.plugin.system.syscall(
ctx,
data.name,
data.args
@ -92,11 +92,11 @@ export class FunctionWorker {
}
}
export interface CartridgeLoader {
export interface PluginLoader {
load(name: string, manifest: Manifest): Promise<void>;
}
export class Cartridge {
export class Plugin {
pathPrefix: string;
system: System;
private runningFunctions: Map<string, FunctionWorker>;
@ -112,7 +112,7 @@ export class Cartridge {
async load(manifest: Manifest) {
this.manifest = manifest;
await this.system.cartridgeLoader.load(this.name, manifest);
await this.system.pluginLoader.load(this.name, manifest);
await this.dispatchEvent("load");
}
@ -149,15 +149,15 @@ export class Cartridge {
}
export class System {
protected cartridges: Map<string, Cartridge>;
protected plugins: Map<string, Plugin>;
protected pathPrefix: string;
registeredSyscalls: SysCallMapping;
cartridgeLoader: CartridgeLoader;
pluginLoader: PluginLoader;
constructor(cartridgeLoader: CartridgeLoader, pathPrefix: string) {
this.cartridgeLoader = cartridgeLoader;
constructor(PluginLoader: PluginLoader, pathPrefix: string) {
this.pluginLoader = PluginLoader;
this.pathPrefix = pathPrefix;
this.cartridges = new Map<string, Cartridge>();
this.plugins = new Map<string, Plugin>();
this.registeredSyscalls = {};
}
@ -184,16 +184,16 @@ export class System {
return Promise.resolve(callback(ctx, ...args));
}
async load(name: string, manifest: Manifest): Promise<Cartridge> {
const cartridge = new Cartridge(this, this.pathPrefix, name);
await cartridge.load(manifest);
this.cartridges.set(name, cartridge);
return cartridge;
async load(name: string, manifest: Manifest): Promise<Plugin> {
const plugin = new Plugin(this, this.pathPrefix, name);
await plugin.load(manifest);
this.plugins.set(name, plugin);
return plugin;
}
async stop(): Promise<void[]> {
return Promise.all(
Array.from(this.cartridges.values()).map((cartridge) => cartridge.stop())
Array.from(this.plugins.values()).map((plugin) => plugin.stop())
);
}
}

View File

@ -8,6 +8,8 @@ export interface Manifest {
};
}
export const slashCommandRegexp = /\/[\w\-]*/;
export interface CommandDef {
// Function name to invoke
invoke: string;
@ -15,6 +17,11 @@ export interface CommandDef {
// Bind to keyboard shortcut
key?: string;
mac?: string;
// If to show in slash invoked menu and if so, with what label
// should match slashCommandRegexp
slashCommand?: string;
// Required context to be passed in as function arguments
requiredContext?: {
text?: boolean;

View File

@ -9,12 +9,13 @@ export default function reducer(
case "nugget-loaded":
return {
...state,
currentNugget: action.name,
currentNugget: action.meta,
isSaved: true,
};
case "nugget-saved":
return {
...state,
currentNugget: action.meta,
isSaved: true,
};
case "nugget-updated":

View File

@ -177,8 +177,8 @@ body {
border: #333 1px solid;
z-index: 1000;
position: absolute;
left: 8px;
top: 8px;
left: 25px;
top: 10px;
right: 10px;
}

View File

@ -2,6 +2,6 @@ import { SyscallContext } from "../plugins/runtime";
export default {
"event.publish": async (ctx: SyscallContext, name: string, data: any) => {
await ctx.cartridge.dispatchEvent(name, data);
await ctx.plugin.dispatchEvent(name, data);
},
};

View File

@ -8,7 +8,7 @@ window.addEventListener("message", async (event) => {
let data = messageEvent.data;
if (data.type === "iframe_event") {
// @ts-ignore
window.mainCartridge.dispatchEvent(data.data.event, data.data.data);
window.mainPlugin.dispatchEvent(data.data.event, data.data.data);
}
});

View File

@ -2,6 +2,8 @@ import { CommandDef } from "./plugins/types";
export type NuggetMeta = {
name: string;
lastModified: Date;
created?: boolean;
};
export type CommandContext = {
@ -14,7 +16,7 @@ export type AppCommand = {
};
export type AppViewState = {
currentNugget?: string;
currentNugget?: NuggetMeta;
isSaved: boolean;
showNuggetNavigator: boolean;
showCommandPalette: boolean;
@ -31,8 +33,8 @@ export const initialViewState: AppViewState = {
};
export type Action =
| { type: "nugget-loaded"; name: string }
| { type: "nugget-saved" }
| { type: "nugget-loaded"; meta: NuggetMeta }
| { type: "nugget-saved"; meta: NuggetMeta }
| { type: "nugget-updated" }
| { type: "nuggets-listed"; nuggets: NuggetMeta[] }
| { type: "start-navigate" }

View File

@ -21,3 +21,7 @@ export function sleep(ms: number): Promise<void> {
}, ms);
});
}
export function isMacLike() {
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
}

2
webapp/src/watcher.ts Normal file
View File

@ -0,0 +1,2 @@
import { Editor } from "./editor";
import { safeRun } from "./util";