1
0

Tons of work

This commit is contained in:
Zef Hemel 2022-03-14 10:07:38 +01:00
parent da4bf4a9ab
commit 1984d8eefe
27 changed files with 3644 additions and 690 deletions

13
plugbox/jest.config.js Normal file
View File

@ -0,0 +1,13 @@
export default {
extensionsToTreatAsEsm: [".ts"],
preset: "ts-jest/presets/default-esm", // or other ESM presets
globals: {
"ts-jest": {
useESM: true,
tsconfig: "<rootDir>/tsconfig.json",
},
},
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
};

View File

@ -7,17 +7,30 @@
"plugbox-bundle": "./bin/plugbox-bundle.mjs" "plugbox-bundle": "./bin/plugbox-bundle.mjs"
}, },
"scripts": { "scripts": {
"check": "tsc --noEmit" "check": "tsc --noEmit",
"test": "jest",
"build-worker": "tsc src/node_worker.ts --outDir dist --module nodenext"
}, },
"dependencies": { "dependencies": {
"esbuild": "^0.14.24", "esbuild": "^0.14.24",
"idb": "^7.0.0", "idb": "^7.0.0",
"typescript": ">=3.0.0", "typescript": "^4.7.0-dev.20220313",
"vm2": "^3.9.9", "vm2": "^3.9.9",
"yargs": "^17.3.1" "yargs": "^17.3.1"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^27.5.1",
"@types/jest": "^27.4.1",
"@types/node": "^17.0.21", "@types/node": "^17.0.21",
"@types/yargs": "^17.0.9" "@types/yargs": "^17.0.9",
"buffer": "^6.0.3",
"events": "^3.3.0",
"jest": "^27.5.1",
"parcel": "^2.3.2",
"parceljs": "^0.0.1",
"path-browserify": "^1.0.1",
"ts-jest": "^27.1.3",
"util": "^0.12.4",
"vm-browserify": "^1.1.2"
} }
} }

View File

@ -0,0 +1,92 @@
import { ControllerMessage, WorkerMessage } from "./types";
import { System, Sandbox } from "./runtime";
import { Worker } from "worker_threads";
function wrapScript(code: string): string {
return `${code}["default"]`;
}
export class NodeSandbox implements Sandbox {
worker: Worker;
private reqId = 0;
outstandingInits = new Map<string, () => void>();
outstandingInvocations = new Map<
number,
{ resolve: (result: any) => void; reject: (e: any) => void }
>();
loadedFunctions = new Set<string>();
constructor(readonly system: System<any>, workerScript: string) {
this.worker = new Worker(workerScript);
this.worker.on("message", this.onmessage.bind(this));
}
isLoaded(name: string): boolean {
return this.loadedFunctions.has(name);
}
async load(name: string, code: string): Promise<void> {
this.worker.postMessage({
type: "load",
name: name,
code: code,
} as WorkerMessage);
return new Promise((resolve) => {
this.loadedFunctions.add(name);
this.outstandingInits.set(name, resolve);
});
}
async onmessage(data: ControllerMessage) {
// let data = evt.data;
// let data = JSON.parse(msg) as ControllerMessage;
switch (data.type) {
case "inited":
let initCb = this.outstandingInits.get(data.name!);
initCb && initCb();
this.outstandingInits.delete(data.name!);
break;
case "syscall":
let result = await this.system.syscall(data.name!, data.args!);
this.worker.postMessage({
type: "syscall-response",
id: data.id,
data: result,
} as WorkerMessage);
break;
case "result":
let resultCb = this.outstandingInvocations.get(data.id!);
this.outstandingInvocations.delete(data.id!);
resultCb && resultCb.resolve(data.result);
break;
case "error":
let errCb = this.outstandingInvocations.get(data.result.id!);
this.outstandingInvocations.delete(data.id!);
errCb && errCb.reject(data.reason);
break;
default:
console.error("Unknown message type", data);
}
}
async invoke(name: string, args: any[]): Promise<any> {
this.reqId++;
this.worker.postMessage({
type: "invoke",
id: this.reqId,
name,
args,
});
return new Promise((resolve, reject) => {
this.outstandingInvocations.set(this.reqId, { resolve, reject });
});
}
stop() {
this.worker.terminate();
}
}

View File

@ -0,0 +1,88 @@
import { VM, VMScript } from "vm2";
import { parentPort } from "worker_threads";
let loadedFunctions = new Map();
let pendingRequests = new Map();
let reqId = 0; // Syscall request ID
let vm = new VM({
sandbox: {
console: console,
syscall: (name: string, args: any[]) => {
return new Promise((resolve, reject) => {
reqId++;
pendingRequests.set(reqId, resolve);
parentPort!.postMessage({
type: "syscall",
id: reqId,
name,
// TODO: Figure out why this is necessary (to avoide a CloneError)
args: JSON.parse(JSON.stringify(args)),
});
});
},
},
});
function wrapScript(code: string) {
return `${code}["default"]`;
}
function safeRun(fn: () => Promise<any>) {
fn().catch((e) => {
console.error(e);
});
}
parentPort!.on("message", (data) => {
safeRun(async () => {
switch (data.type) {
case "load":
console.log("Booting", data.name);
loadedFunctions.set(data.name, new VMScript(wrapScript(data.code)));
parentPort!.postMessage({
type: "inited",
name: data.name,
});
break;
case "invoke":
let fn = loadedFunctions.get(data.name);
if (!fn) {
throw new Error(`Function not loaded: ${data.name}`);
}
try {
let r = vm.run(fn);
let result = await Promise.resolve(r(...data.args));
parentPort!.postMessage({
type: "result",
id: data.id,
result: result,
});
} catch (e: any) {
parentPort!.postMessage({
type: "error",
id: data.id,
reason: e.message,
});
throw e;
}
break;
case "syscall-response":
let syscallId = data.id;
const lookup = pendingRequests.get(syscallId);
if (!lookup) {
console.log(
"Current outstanding requests",
pendingRequests,
"looking up",
syscallId
);
throw Error("Invalid request id");
}
pendingRequests.delete(syscallId);
lookup(data.data);
break;
}
});
});

View File

@ -0,0 +1,47 @@
import { NodeSandbox } from "./node_sandbox";
import { System } from "./runtime";
import { test, expect } from "@jest/globals";
test("Run a Node sandbox", async () => {
let system = new System();
system.registerSyscalls({
addNumbers: (a, b) => {
return a + b;
},
});
let plug = await system.load(
"test",
{
functions: {
addTen: {
code: `(() => {
return {
default: (n) => {
return n + 10;
}
};
})()`,
},
addNumbersSyscall: {
code: `(() => {
return {
default: async (a, b) => {
return await(syscall("addNumbers", [a, b]));
}
};
})()`,
},
},
hooks: {
events: {},
},
},
new NodeSandbox(system, __dirname + "/../dist/node_worker.js")
);
expect(await plug.invoke("addTen", [10])).toBe(20);
for (let i = 0; i < 100; i++) {
expect(await plug.invoke("addNumbersSyscall", [10, i])).toBe(10 + i);
}
console.log(plug.sandbox);
await system.stop();
});

View File

@ -1,9 +1,8 @@
import { Manifest } from "./types"; import { Manifest } from "./types";
import { WebworkerSandbox } from "./worker_sandbox"; // import { WebworkerSandbox } from "./worker_sandbox";
interface SysCallMapping { interface SysCallMapping {
// TODO: Better typing [key: string]: (...args: any) => Promise<any> | any;
[key: string]: any;
} }
export interface Sandbox { export interface Sandbox {
@ -13,19 +12,14 @@ export interface Sandbox {
stop(): void; stop(): void;
} }
export interface PlugLoader<HookT> {
load(name: string, manifest: Manifest<HookT>): Promise<void>;
}
export class Plug<HookT> { export class Plug<HookT> {
system: System<HookT>; system: System<HookT>;
// private runningFunctions: Map<string, FunctionWorker>; sandbox: Sandbox;
functionWorker: WebworkerSandbox;
public manifest?: Manifest<HookT>; public manifest?: Manifest<HookT>;
constructor(system: System<HookT>, name: string) { constructor(system: System<HookT>, name: string, sandbox: Sandbox) {
this.system = system; this.system = system;
this.functionWorker = new WebworkerSandbox(this); this.sandbox = sandbox;
} }
async load(manifest: Manifest<HookT>) { async load(manifest: Manifest<HookT>) {
@ -34,13 +28,11 @@ export class Plug<HookT> {
} }
async invoke(name: string, args: Array<any>): Promise<any> { async invoke(name: string, args: Array<any>): Promise<any> {
if (!this.functionWorker.isLoaded(name)) { if (!this.sandbox.isLoaded(name)) {
await this.functionWorker.load( await this.sandbox.load(name, this.manifest!.functions[name].code!);
name,
this.manifest!.functions[name].code!
);
} }
return await this.functionWorker.invoke(name, args); console.log("Loaded", name);
return await this.sandbox.invoke(name, args);
} }
async dispatchEvent(name: string, data?: any): Promise<any[]> { async dispatchEvent(name: string, data?: any): Promise<any[]> {
@ -58,18 +50,13 @@ export class Plug<HookT> {
} }
async stop() { async stop() {
this.functionWorker.stop(); this.sandbox.stop();
} }
} }
export class System<HookT> { export class System<HookT> {
protected plugs: Map<string, Plug<HookT>>; protected plugs = new Map<string, Plug<HookT>>();
registeredSyscalls: SysCallMapping; registeredSyscalls: SysCallMapping = {};
constructor() {
this.plugs = new Map<string, Plug<HookT>>();
this.registeredSyscalls = {};
}
registerSyscalls(...registrationObjects: SysCallMapping[]) { registerSyscalls(...registrationObjects: SysCallMapping[]) {
for (const registrationObject of registrationObjects) { for (const registrationObject of registrationObjects) {
@ -90,8 +77,12 @@ export class System<HookT> {
return Promise.resolve(callback(...args)); return Promise.resolve(callback(...args));
} }
async load(name: string, manifest: Manifest<HookT>): Promise<Plug<HookT>> { async load(
const plug = new Plug(this, name); name: string,
manifest: Manifest<HookT>,
sandbox: Sandbox
): Promise<Plug<HookT>> {
const plug = new Plug(this, name, sandbox);
await plug.load(manifest); await plug.load(manifest);
this.plugs.set(name, plug); this.plugs.set(name, plug);
return plug; return plug;

View File

@ -1,12 +1,13 @@
declare global {
function syscall(id: number, name: string, args: any[]): Promise<any>;
}
import { ControllerMessage, WorkerMessage, WorkerMessageType } from "./types"; import { ControllerMessage, WorkerMessage, WorkerMessageType } from "./types";
import { safeRun } from "./util"; import { safeRun } from "./util";
let loadedFunctions = new Map<string, Function>(); let loadedFunctions = new Map<string, Function>();
let pendingRequests = new Map<number, (result: unknown) => void>(); let pendingRequests = new Map<number, (result: unknown) => void>();
declare global {
function syscall(id: number, name: string, args: any[]): Promise<any>;
}
self.syscall = async (id: number, name: string, args: any[]) => { self.syscall = async (id: number, name: string, args: any[]) => {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
pendingRequests.set(id, resolve); pendingRequests.set(id, resolve);
@ -19,22 +20,6 @@ self.syscall = async (id: number, name: string, args: any[]) => {
}); });
}; };
self.addEventListener("result", (event) => {
let customEvent = event as CustomEvent;
self.postMessage({
type: "result",
result: customEvent.detail,
});
});
self.addEventListener("app-error", (event) => {
let customEvent = event as CustomEvent;
self.postMessage({
type: "error",
reason: customEvent.detail,
});
});
function wrapScript(code: string): string { function wrapScript(code: string): string {
return `const fn = ${code}; return `const fn = ${code};
return fn["default"].apply(null, arguments);`; return fn["default"].apply(null, arguments);`;

View File

@ -1,5 +1,5 @@
import { ControllerMessage, WorkerMessage } from "./types"; import { ControllerMessage, WorkerMessage } from "./types";
import { Plug, Sandbox } from "./runtime"; import { Plug, Sandbox, System } from "./runtime";
export class WebworkerSandbox implements Sandbox { export class WebworkerSandbox implements Sandbox {
private worker: Worker; private worker: Worker;
@ -12,7 +12,7 @@ export class WebworkerSandbox implements Sandbox {
>(); >();
private loadedFunctions = new Set<string>(); private loadedFunctions = new Set<string>();
constructor(readonly plug: Plug<any>) { constructor(readonly system: System<any>) {
this.worker = new Worker(new URL("sandbox_worker.ts", import.meta.url), { this.worker = new Worker(new URL("sandbox_worker.ts", import.meta.url), {
type: "module", type: "module",
}); });
@ -46,7 +46,7 @@ export class WebworkerSandbox implements Sandbox {
this.outstandingInits.delete(data.name!); this.outstandingInits.delete(data.name!);
break; break;
case "syscall": case "syscall":
let result = await this.plug.system.syscall(data.name!, data.args!); let result = await this.system.syscall(data.name!, data.args!);
this.worker.postMessage({ this.worker.postMessage({
type: "syscall-response", type: "syscall-response",
@ -56,10 +56,12 @@ export class WebworkerSandbox implements Sandbox {
break; break;
case "result": case "result":
let resultCb = this.outstandingInvocations.get(data.id!); let resultCb = this.outstandingInvocations.get(data.id!);
this.outstandingInvocations.delete(data.id!);
resultCb && resultCb.resolve(data.result); resultCb && resultCb.resolve(data.result);
break; break;
case "error": case "error":
let errCb = this.outstandingInvocations.get(data.result.id!); let errCb = this.outstandingInvocations.get(data.result.id!);
this.outstandingInvocations.delete(data.id!);
errCb && errCb.reject(data.reason); errCb && errCb.reject(data.reason);
break; break;
default: default:

View File

@ -4,7 +4,7 @@
"target": "esnext", "target": "esnext",
"strict": true, "strict": true,
"moduleResolution": "node", "moduleResolution": "node",
"module": "ESNext", "module": "esnext",
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,15 @@
declare global { declare global {
function syscall(id: number, name: string, args: any[]): Promise<any>; function syscall(id: number, name: string, args: any[]): Promise<any>;
var reqId: number;
}
// This needs to be global, because this will be shared with all other functions in the same environment (worker-like)
if (typeof self.reqId === "undefined") {
self.reqId = 0;
} }
export async function syscall(name: string, ...args: any[]): Promise<any> { export async function syscall(name: string, ...args: any[]): Promise<any> {
let reqId = Math.floor(Math.random() * 1000000); self.reqId++;
// console.log("Syscall", name, reqId); // console.log("Syscall", name, reqId);
return await self.syscall(reqId, name, args); return await self.syscall(self.reqId, name, args);
// return new Promise((resolve, reject) => {
// self.dispatchEvent(
// new CustomEvent("syscall", {
// detail: {
// id: reqId,
// name: name,
// args: args,
// callback: resolve,
// },
// })
// );
// });
} }

View File

@ -8,6 +8,7 @@ async function navigate(syntaxNode: any) {
console.log("Attempting to navigate based on syntax node", syntaxNode); console.log("Attempting to navigate based on syntax node", syntaxNode);
switch (syntaxNode.name) { switch (syntaxNode.name) {
case "WikiLinkPage": case "WikiLinkPage":
case "AtMention":
await syscall("editor.navigate", syntaxNode.text); await syscall("editor.navigate", syntaxNode.text);
break; break;
case "URL": case "URL":
@ -35,16 +36,31 @@ export async function clickNavigate(event: ClickEvent) {
} }
export async function pageComplete() { export async function pageComplete() {
let prefix = await syscall("editor.matchBefore", "\\[\\[[\\w\\s]*"); let prefix = await syscall(
"editor.matchBefore",
"(\\[\\[[\\w\\s]*|@[\\w\\.]*)"
);
if (!prefix) { if (!prefix) {
return null; return null;
} }
let allPages = await syscall("space.listPages"); let allPages = await syscall("space.listPages");
return { if (prefix.text[0] === "@") {
from: prefix.from + 2, return {
options: allPages.map((pageMeta: any) => ({ from: prefix.from,
label: pageMeta.name, options: allPages
type: "page", .filter((page) => page.name.startsWith(prefix.text))
})), .map((pageMeta: any) => ({
}; label: pageMeta.name,
type: "page",
})),
};
} else {
return {
from: prefix.from + 2,
options: allPages.map((pageMeta: any) => ({
label: pageMeta.name,
type: "page",
})),
};
}
} }

View File

@ -3,9 +3,11 @@ import { pageLinkRegex } from "../../webapp/src/constant";
import { syscall } from "./lib/syscall"; import { syscall } from "./lib/syscall";
const wikilinkRegex = new RegExp(pageLinkRegex, "g"); const wikilinkRegex = new RegExp(pageLinkRegex, "g");
const atMentionRegex = /(@[A-Za-z\.]+)/g;
export async function indexLinks({ name, text }: IndexEvent) { export async function indexLinks({ name, text }: IndexEvent) {
let backLinks: { key: string; value: string }[] = []; let backLinks: { key: string; value: string }[] = [];
// [[Style Links]]
for (let match of text.matchAll(wikilinkRegex)) { for (let match of text.matchAll(wikilinkRegex)) {
let toPage = match[1]; let toPage = match[1];
let pos = match.index!; let pos = match.index!;
@ -14,6 +16,15 @@ export async function indexLinks({ name, text }: IndexEvent) {
value: name, value: name,
}); });
} }
// @links
for (let match of text.matchAll(atMentionRegex)) {
let toPage = match[1];
let pos = match.index!;
backLinks.push({
key: `pl:${toPage}:${pos}`,
value: name,
});
}
console.log("Found", backLinks.length, "wiki link(s)"); console.log("Found", backLinks.length, "wiki link(s)");
// throw Error("Boom"); // throw Error("Boom");
await syscall("indexer.batchSet", name, backLinks); await syscall("indexer.batchSet", name, backLinks);

View File

@ -11,220 +11,219 @@ import { PageMeta } from "./server";
import { ClientPageState, Page } from "./types"; import { ClientPageState, Page } from "./types";
import { safeRun } from "./util"; import { safeRun } from "./util";
export class SocketAPI { export function exposeSocketAPI(rootPath: string, io: Server) {
openPages = new Map<string, Page>(); const openPages = new Map<string, Page>();
connectedSockets: Set<Socket> = new Set(); const connectedSockets: Set<Socket> = new Set();
pageStore: DiskStorage; const pageStore = new DiskStorage(rootPath);
fileWatcher(rootPath);
constructor(rootPath: string, io: Server) { io.on("connection", (socket) => {
this.pageStore = new DiskStorage(rootPath); const socketOpenPages = new Set<string>();
this.fileWatcher(rootPath);
io.on("connection", (socket) => { console.log("Connected", socket.id);
console.log("Connected", socket.id); connectedSockets.add(socket);
this.connectedSockets.add(socket);
const socketOpenPages = new Set<string>();
socket.on("disconnect", () => { socket.on("disconnect", () => {
console.log("Disconnected", socket.id); console.log("Disconnected", socket.id);
socketOpenPages.forEach(disconnectPageSocket); socketOpenPages.forEach(disconnectPageSocket);
this.connectedSockets.delete(socket); connectedSockets.delete(socket);
});
function onCall(eventName: string, cb: (...args: any[]) => Promise<any>) {
socket.on(eventName, (reqId: number, ...args) => {
cb(...args).then((result) => {
socket.emit(`${eventName}Resp${reqId}`, result);
});
});
}
const _this = this;
function disconnectPageSocket(pageName: string) {
let page = _this.openPages.get(pageName);
if (page) {
for (let client of page.clientStates) {
if (client.socket === socket) {
_this.disconnectClient(client, page);
}
}
}
}
onCall("openPage", async (pageName: string) => {
let page = this.openPages.get(pageName);
if (!page) {
try {
let { text, meta } = await this.pageStore.readPage(pageName);
page = new Page(pageName, text, meta);
} catch (e) {
console.log("Creating new page", pageName);
page = new Page(pageName, "", { name: pageName, lastModified: 0 });
}
this.openPages.set(pageName, page);
}
page.clientStates.add(new ClientPageState(socket, page.version));
socketOpenPages.add(pageName);
console.log("Opened page", pageName);
this.broadcastCursors(page);
return page.toJSON();
});
socket.on("closePage", (pageName: string) => {
console.log("Closing page", pageName);
socketOpenPages.delete(pageName);
disconnectPageSocket(pageName);
});
onCall(
"pushUpdates",
async (
pageName: string,
version: number,
updates: any[]
): Promise<boolean> => {
let page = this.openPages.get(pageName);
if (!page) {
console.error(
"Received updates for not open page",
pageName,
this.openPages.keys()
);
return false;
}
if (version !== page.version) {
console.error("Invalid version", version, page.version);
return false;
} else {
console.log("Applying", updates.length, "updates to", pageName);
let transformedUpdates = [];
let textChanged = false;
for (let update of updates) {
let changes = ChangeSet.fromJSON(update.changes);
let transformedUpdate = {
changes,
clientID: update.clientID,
effects: update.cursors?.map((c: Cursor) => {
page.cursors.set(c.userId, c);
return cursorEffect.of(c);
}),
};
page.updates.push(transformedUpdate);
transformedUpdates.push(transformedUpdate);
let oldText = page.text;
page.text = changes.apply(page.text);
if (oldText !== page.text) {
textChanged = true;
}
}
if (textChanged) {
if (page.saveTimer) {
clearTimeout(page.saveTimer);
}
page.saveTimer = setTimeout(() => {
this.flushPageToDisk(pageName, page);
}, 1000);
}
while (page.pending.length) {
page.pending.pop()!(transformedUpdates);
}
return true;
}
}
);
onCall(
"pullUpdates",
async (pageName: string, version: number): Promise<Update[]> => {
let page = this.openPages.get(pageName);
// console.log("Pulling updates for", pageName);
if (!page) {
console.error("Fetching updates for not open page");
return [];
}
// TODO: Optimize this
let oldestVersion = Infinity;
page.clientStates.forEach((client) => {
oldestVersion = Math.min(client.version, oldestVersion);
if (client.socket === socket) {
client.version = version;
}
});
page.flushUpdates(oldestVersion);
if (version < page.version) {
return page.updatesSince(version);
} else {
return new Promise((resolve) => {
page.pending.push(resolve);
});
}
}
);
onCall(
"readPage",
async (pageName: string): Promise<{ text: string; meta: PageMeta }> => {
let page = this.openPages.get(pageName);
if (page) {
console.log("Serving page from memory", pageName);
return {
text: page.text.sliceString(0),
meta: page.meta,
};
} else {
return this.pageStore.readPage(pageName);
}
}
);
onCall("writePage", async (pageName: string, text: string) => {
let page = this.openPages.get(pageName);
if (page) {
for (let client of page.clientStates) {
client.socket.emit("reloadPage", pageName);
}
this.openPages.delete(pageName);
}
return this.pageStore.writePage(pageName, text);
});
onCall("deletePage", async (pageName: string) => {
this.openPages.delete(pageName);
socketOpenPages.delete(pageName);
// Cascading of this to all connected clients will be handled by file watcher
return this.pageStore.deletePage(pageName);
});
onCall("listPages", async (): Promise<PageMeta[]> => {
return this.pageStore.listPages();
});
onCall("getPageMeta", async (pageName: string): Promise<PageMeta> => {
let page = this.openPages.get(pageName);
if (page) {
return page.meta;
}
return this.pageStore.getPageMeta(pageName);
});
}); });
}
private disconnectClient(client: ClientPageState, page: Page) { socket.on("closePage", (pageName: string) => {
console.log("Closing page", pageName);
socketOpenPages.delete(pageName);
disconnectPageSocket(pageName);
});
const onCall = (
eventName: string,
cb: (...args: any[]) => Promise<any>
) => {
socket.on(eventName, (reqId: number, ...args) => {
cb(...args).then((result) => {
socket.emit(`${eventName}Resp${reqId}`, result);
});
});
};
const disconnectPageSocket = (pageName: string) => {
let page = openPages.get(pageName);
if (page) {
for (let client of page.clientStates) {
if (client.socket === socket) {
disconnectClient(client, page);
}
}
}
};
onCall("openPage", async (pageName: string) => {
let page = openPages.get(pageName);
if (!page) {
try {
let { text, meta } = await pageStore.readPage(pageName);
page = new Page(pageName, text, meta);
} catch (e) {
console.log("Creating new page", pageName);
page = new Page(pageName, "", { name: pageName, lastModified: 0 });
}
openPages.set(pageName, page);
}
page.clientStates.add(new ClientPageState(socket, page.version));
socketOpenPages.add(pageName);
console.log("Opened page", pageName);
broadcastCursors(page);
return page.toJSON();
});
onCall(
"pushUpdates",
async (
pageName: string,
version: number,
updates: any[]
): Promise<boolean> => {
let page = openPages.get(pageName);
if (!page) {
console.error(
"Received updates for not open page",
pageName,
openPages.keys()
);
return false;
}
if (version !== page.version) {
console.error("Invalid version", version, page.version);
return false;
} else {
console.log("Applying", updates.length, "updates to", pageName);
let transformedUpdates = [];
let textChanged = false;
for (let update of updates) {
let changes = ChangeSet.fromJSON(update.changes);
let transformedUpdate = {
changes,
clientID: update.clientID,
effects: update.cursors?.map((c: Cursor) => {
page.cursors.set(c.userId, c);
return cursorEffect.of(c);
}),
};
page.updates.push(transformedUpdate);
transformedUpdates.push(transformedUpdate);
let oldText = page.text;
page.text = changes.apply(page.text);
if (oldText !== page.text) {
textChanged = true;
}
}
if (textChanged) {
if (page.saveTimer) {
clearTimeout(page.saveTimer);
}
page.saveTimer = setTimeout(() => {
flushPageToDisk(pageName, page);
}, 1000);
}
while (page.pending.length) {
page.pending.pop()!(transformedUpdates);
}
return true;
}
}
);
onCall(
"pullUpdates",
async (pageName: string, version: number): Promise<Update[]> => {
let page = openPages.get(pageName);
// console.log("Pulling updates for", pageName);
if (!page) {
console.error("Fetching updates for not open page");
return [];
}
// TODO: Optimize this
let oldestVersion = Infinity;
page.clientStates.forEach((client) => {
oldestVersion = Math.min(client.version, oldestVersion);
if (client.socket === socket) {
client.version = version;
}
});
page.flushUpdates(oldestVersion);
if (version < page.version) {
return page.updatesSince(version);
} else {
return new Promise((resolve) => {
page.pending.push(resolve);
});
}
}
);
onCall(
"readPage",
async (pageName: string): Promise<{ text: string; meta: PageMeta }> => {
let page = openPages.get(pageName);
if (page) {
console.log("Serving page from memory", pageName);
return {
text: page.text.sliceString(0),
meta: page.meta,
};
} else {
return pageStore.readPage(pageName);
}
}
);
onCall("writePage", async (pageName: string, text: string) => {
let page = openPages.get(pageName);
if (page) {
for (let client of page.clientStates) {
client.socket.emit("reloadPage", pageName);
}
openPages.delete(pageName);
}
return pageStore.writePage(pageName, text);
});
onCall("deletePage", async (pageName: string) => {
openPages.delete(pageName);
socketOpenPages.delete(pageName);
// Cascading of this to all connected clients will be handled by file watcher
return pageStore.deletePage(pageName);
});
onCall("listPages", async (): Promise<PageMeta[]> => {
return pageStore.listPages();
});
onCall("getPageMeta", async (pageName: string): Promise<PageMeta> => {
let page = openPages.get(pageName);
if (page) {
return page.meta;
}
return pageStore.getPageMeta(pageName);
});
});
function disconnectClient(client: ClientPageState, page: Page) {
page.clientStates.delete(client); page.clientStates.delete(client);
if (page.clientStates.size === 0) { if (page.clientStates.size === 0) {
console.log("No more clients for", page.name, "flushing"); console.log("No more clients for", page.name, "flushing");
this.flushPageToDisk(page.name, page); flushPageToDisk(page.name, page);
this.openPages.delete(page.name); openPages.delete(page.name);
} else { } else {
page.cursors.delete(client.socket.id); page.cursors.delete(client.socket.id);
this.broadcastCursors(page); broadcastCursors(page);
} }
} }
private broadcastCursors(page: Page) { function broadcastCursors(page: Page) {
page.clientStates.forEach((client) => { page.clientStates.forEach((client) => {
client.socket.emit( client.socket.emit(
"cursorSnapshot", "cursorSnapshot",
@ -234,15 +233,15 @@ export class SocketAPI {
}); });
} }
private flushPageToDisk(name: string, page: Page) { function flushPageToDisk(name: string, page: Page) {
safeRun(async () => { safeRun(async () => {
let meta = await this.pageStore.writePage(name, page.text.sliceString(0)); let meta = await pageStore.writePage(name, page.text.sliceString(0));
console.log(`Wrote page ${name} to disk`); console.log(`Wrote page ${name} to disk`);
page.meta = meta; page.meta = meta;
}); });
} }
private fileWatcher(rootPath: string) { function fileWatcher(rootPath: string) {
fs.watch( fs.watch(
rootPath, rootPath,
{ {
@ -264,16 +263,16 @@ export class SocketAPI {
} catch (e) { } catch (e) {
// File was deleted // File was deleted
console.log("Deleted", pageName); console.log("Deleted", pageName);
for (let socket of this.connectedSockets) { for (let socket of connectedSockets) {
socket.emit("pageDeleted", pageName); socket.emit("pageDeleted", pageName);
} }
return; return;
} }
const openPage = this.openPages.get(pageName); const openPage = openPages.get(pageName);
if (openPage) { if (openPage) {
if (openPage.meta.lastModified < modifiedTime) { if (openPage.meta.lastModified < modifiedTime) {
console.log("Page changed on disk outside of editor, reloading"); console.log("Page changed on disk outside of editor, reloading");
this.openPages.delete(pageName); openPages.delete(pageName);
const meta = { const meta = {
name: pageName, name: pageName,
lastModified: modifiedTime, lastModified: modifiedTime,
@ -288,7 +287,7 @@ export class SocketAPI {
console.log( console.log(
"New file created, broadcasting to all connected sockets" "New file created, broadcasting to all connected sockets"
); );
for (let socket of this.connectedSockets) { for (let socket of connectedSockets) {
socket.emit("pageCreated", { socket.emit("pageCreated", {
name: pageName, name: pageName,
lastModified: modifiedTime, lastModified: modifiedTime,

View File

@ -12,9 +12,7 @@ export class DiskStorage {
async listPages(): Promise<PageMeta[]> { async listPages(): Promise<PageMeta[]> {
let fileNames: PageMeta[] = []; let fileNames: PageMeta[] = [];
let _this = this; const walkPath = async (dir: string) => {
async function walkPath(dir: string) {
let files = await readdir(dir); let files = await readdir(dir);
for (let file of files) { for (let file of files) {
const fullPath = path.join(dir, file); const fullPath = path.join(dir, file);
@ -25,7 +23,7 @@ export class DiskStorage {
if (path.extname(file) === ".md") { if (path.extname(file) === ".md") {
fileNames.push({ fileNames.push({
name: fullPath.substring( name: fullPath.substring(
_this.rootPath.length + 1, this.rootPath.length + 1,
fullPath.length - 3 fullPath.length - 3
), ),
lastModified: s.mtime.getTime(), lastModified: s.mtime.getTime(),
@ -33,7 +31,7 @@ export class DiskStorage {
} }
} }
} }
} };
await walkPath(this.rootPath); await walkPath(this.rootPath);
return fileNames; return fileNames;
} }

View File

@ -2,7 +2,7 @@ import express from "express";
import { readFile } from "fs/promises"; import { readFile } from "fs/promises";
import http from "http"; import http from "http";
import { Server } from "socket.io"; import { Server } from "socket.io";
import { SocketAPI } from "./api"; import { exposeSocketAPI } from "./api";
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
@ -24,7 +24,7 @@ export type PageMeta = {
}; };
app.use("/", express.static(distDir)); app.use("/", express.static(distDir));
let filesystem = new SocketAPI(pagesPath, io); exposeSocketAPI(pagesPath, io);
// Fallback, serve index.html // Fallback, serve index.html
let cachedIndex: string | undefined = undefined; let cachedIndex: string | undefined = undefined;

View File

@ -18,7 +18,7 @@
"@parcel/transformer-webmanifest": "2.3.2", "@parcel/transformer-webmanifest": "2.3.2",
"@parcel/validator-typescript": "^2.3.2", "@parcel/validator-typescript": "^2.3.2",
"@types/events": "^3.0.0", "@types/events": "^3.0.0",
"@types/lodash": "^4.14.179", "@types/jest": "^27.4.1",
"@types/node": "^17.0.21", "@types/node": "^17.0.21",
"@types/react": "^17.0.39", "@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11", "@types/react-dom": "^17.0.11",

View File

@ -1,17 +1,12 @@
// TODO:
// Send state to client
// Shape of editor.editorView.state.toJSON({"cursors": cursorField})
// From there import it
// EditorState.fromJSON(js, {extensions: cursorField}, {cursors: cursorField})
import { import {
collab, collab,
getSyncedVersion, getSyncedVersion,
receiveUpdates, receiveUpdates,
sendableUpdates, sendableUpdates,
Update,
} from "@codemirror/collab"; } from "@codemirror/collab";
import { RangeSetBuilder } from "@codemirror/rangeset"; import { RangeSetBuilder } from "@codemirror/rangeset";
import { Text } from "@codemirror/state"; import { Text, Transaction } from "@codemirror/state";
import { import {
Decoration, Decoration,
DecorationSet, DecorationSet,
@ -20,27 +15,13 @@ import {
ViewUpdate, ViewUpdate,
WidgetType, WidgetType,
} from "@codemirror/view"; } from "@codemirror/view";
import { throttle } from "./util";
import { Cursor, cursorEffect } from "./cursorEffect"; import { Cursor, cursorEffect } from "./cursorEffect";
import { RealtimeSpace, SpaceEventHandlers } from "./space"; import { EventEmitter } from "./event";
const throttleInterval = 250; const throttleInterval = 250;
const throttle = (func: () => void, limit: number) => { export class CollabDocument {
let timer: any = null;
return function () {
if (!timer) {
timer = setTimeout(() => {
func();
timer = null;
}, limit);
}
};
};
//@ts-ignore
window.throttle = throttle;
export class Document {
text: Text; text: Text;
version: number; version: number;
cursors: Map<string, Cursor>; cursors: Map<string, Cursor>;
@ -81,24 +62,39 @@ class CursorWidget extends WidgetType {
} }
} }
export type CollabEvents = {
cursorSnapshot: (pageName: string, cursors: Map<string, Cursor>) => void;
};
export function collabExtension( export function collabExtension(
pageName: string, pageName: string,
clientID: string, clientID: string,
doc: Document, doc: CollabDocument,
space: RealtimeSpace, collabEmitter: EventEmitter<CollabEvents>,
reloadCallback: () => void callbacks: {
pushUpdates: (
pageName: string,
version: number,
updates: readonly (Update & { origin: Transaction })[]
) => Promise<boolean>;
pullUpdates: (
pageName: string,
version: number
) => Promise<readonly Update[]>;
reload: () => void;
}
) { ) {
let plugin = ViewPlugin.fromClass( let plugin = ViewPlugin.fromClass(
class { class {
private pushing = false; private pushing = false;
private done = false; private done = false;
private failedPushes = 0; private failedPushes = 0;
decorations: DecorationSet;
private cursorPositions: Map<string, Cursor> = doc.cursors; private cursorPositions: Map<string, Cursor> = doc.cursors;
decorations: DecorationSet;
throttledPush = throttle(() => this.push(), throttleInterval); throttledPush = throttle(() => this.push(), throttleInterval);
eventHandlers: Partial<SpaceEventHandlers> = { eventHandlers: Partial<CollabEvents> = {
cursorSnapshot: (pageName, cursors) => { cursorSnapshot: (pageName, cursors) => {
console.log("Received new cursor snapshot", cursors); console.log("Received new cursor snapshot", cursors);
this.cursorPositions = new Map(Object.entries(cursors)); this.cursorPositions = new Map(Object.entries(cursors));
@ -136,7 +132,7 @@ export function collabExtension(
this.pull(); this.pull();
} }
this.decorations = this.buildDecorations(view); this.decorations = this.buildDecorations(view);
space.on(this.eventHandlers); collabEmitter.on(this.eventHandlers);
} }
update(update: ViewUpdate) { update(update: ViewUpdate) {
@ -184,7 +180,7 @@ export function collabExtension(
console.log("Updates", updates); console.log("Updates", updates);
this.pushing = true; this.pushing = true;
let version = getSyncedVersion(this.view.state); let version = getSyncedVersion(this.view.state);
let success = await space.pushUpdates(pageName, version, updates); let success = await callbacks.pushUpdates(pageName, version, updates);
this.pushing = false; this.pushing = false;
if (!success && !this.done) { if (!success && !this.done) {
@ -192,7 +188,7 @@ export function collabExtension(
if (this.failedPushes > 10) { if (this.failedPushes > 10) {
// Not sure if 10 is a good number, but YOLO // Not sure if 10 is a good number, but YOLO
console.log("10 pushes failed, reloading"); console.log("10 pushes failed, reloading");
reloadCallback(); callbacks.reload();
return this.destroy(); return this.destroy();
} }
console.log( console.log(
@ -213,7 +209,7 @@ export function collabExtension(
async pull() { async pull() {
while (!this.done) { while (!this.done) {
let version = getSyncedVersion(this.view.state); let version = getSyncedVersion(this.view.state);
let updates = await space.pullUpdates(pageName, version); let updates = await callbacks.pullUpdates(pageName, version);
let d = receiveUpdates(this.view.state, updates); let d = receiveUpdates(this.view.state, updates);
// Pull out cursor updates and update local state // Pull out cursor updates and update local state
for (let update of updates) { for (let update of updates) {
@ -235,7 +231,7 @@ export function collabExtension(
destroy() { destroy() {
this.done = true; this.done = true;
space.off(this.eventHandlers); collabEmitter.off(this.eventHandlers);
} }
}, },
{ {

View File

@ -1,6 +1,7 @@
import { PageMeta } from "../types"; import { AppViewState, PageMeta } from "../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFileLines } from "@fortawesome/free-solid-svg-icons"; import { faFileLines } from "@fortawesome/free-solid-svg-icons";
import { Notification } from "../types";
function prettyName(s: string | undefined): string { function prettyName(s: string | undefined): string {
if (!s) { if (!s) {
@ -10,10 +11,14 @@ function prettyName(s: string | undefined): string {
} }
export function TopBar({ export function TopBar({
currentPage, pageName,
status,
notifications,
onClick, onClick,
}: { }: {
currentPage?: string; pageName?: string;
status?: string;
notifications: Notification[];
onClick: () => void; onClick: () => void;
}) { }) {
return ( return (
@ -22,7 +27,12 @@ export function TopBar({
<span className="icon"> <span className="icon">
<FontAwesomeIcon icon={faFileLines} /> <FontAwesomeIcon icon={faFileLines} />
</span> </span>
<span className="current-page">{prettyName(currentPage)}</span> <span className="current-page">{prettyName(pageName)}</span>
<div className="status">
{notifications.map((notification) => (
<div key={notification.id}>{notification.message}</div>
))}
</div>
</div> </div>
</div> </div>
); );

View File

@ -10,7 +10,13 @@ import { indentWithTab, standardKeymap } from "@codemirror/commands";
import { history, historyKeymap } from "@codemirror/history"; import { history, historyKeymap } from "@codemirror/history";
import { bracketMatching } from "@codemirror/matchbrackets"; import { bracketMatching } from "@codemirror/matchbrackets";
import { searchKeymap } from "@codemirror/search"; import { searchKeymap } from "@codemirror/search";
import { EditorState, StateField, Transaction, Text } from "@codemirror/state"; import {
EditorSelection,
EditorState,
StateField,
Text,
Transaction,
} from "@codemirror/state";
import { import {
drawSelection, drawSelection,
dropCursor, dropCursor,
@ -19,30 +25,25 @@ import {
KeyBinding, KeyBinding,
keymap, keymap,
} from "@codemirror/view"; } from "@codemirror/view";
// import { debounce } from "lodash";
import { debounce } from "lodash";
import React, { useEffect, useReducer } from "react"; import React, { useEffect, useReducer } from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import coreManifest from "./generated/core.plug.json"; import { Plug, System } from "../../plugbox/src/runtime";
import { WebworkerSandbox } from "../../plugbox/src/worker_sandbox";
// @ts-ignore
window.coreManifest = coreManifest;
import { AppEvent, AppEventDispatcher, ClickEvent } from "./app_event"; import { AppEvent, AppEventDispatcher, ClickEvent } from "./app_event";
import { collabExtension, CollabDocument } from "./collab";
import * as commands from "./commands"; import * as commands from "./commands";
import { CommandPalette } from "./components/command_palette"; import { CommandPalette } from "./components/command_palette";
import { PageNavigator } from "./components/page_navigator"; import { PageNavigator } from "./components/page_navigator";
import { StatusBar } from "./components/status_bar"; import { StatusBar } from "./components/status_bar";
import { TopBar } from "./components/top_bar"; import { TopBar } from "./components/top_bar";
import { Cursor } from "./cursorEffect";
import coreManifest from "./generated/core.plug.json";
import { Indexer } from "./indexer"; import { Indexer } from "./indexer";
import { lineWrapper } from "./lineWrapper"; import { lineWrapper } from "./lineWrapper";
import { markdown } from "./markdown"; import { markdown } from "./markdown";
import { IPageNavigator, PathPageNavigator } from "./navigator"; import { IPageNavigator, PathPageNavigator } from "./navigator";
import customMarkDown from "./parser"; import customMarkDown from "./parser";
import { System } from "../../plugbox/src/runtime";
import { Plug } from "../../plugbox/src/runtime";
import { slashCommandRegexp } from "./types";
import reducer from "./reducer"; import reducer from "./reducer";
import { smartQuoteKeymap } from "./smart_quotes"; import { smartQuoteKeymap } from "./smart_quotes";
import { RealtimeSpace } from "./space"; import { RealtimeSpace } from "./space";
@ -57,15 +58,9 @@ import {
AppViewState, AppViewState,
initialViewState, initialViewState,
NuggetHook, NuggetHook,
PageMeta, slashCommandRegexp,
} from "./types"; } from "./types";
import { safeRun } from "./util"; import { safeRun, throttle } from "./util";
import { collabExtension } from "./collab";
import { Document } from "./collab";
import { EditorSelection } from "@codemirror/state";
import { Cursor } from "./cursorEffect";
class PageState { class PageState {
scrollTop: number; scrollTop: number;
@ -77,8 +72,6 @@ class PageState {
} }
} }
const watchInterval = 5000;
export class Editor implements AppEventDispatcher { export class Editor implements AppEventDispatcher {
editorView?: EditorView; editorView?: EditorView;
viewState: AppViewState; viewState: AppViewState;
@ -103,18 +96,20 @@ export class Editor implements AppEventDispatcher {
this.editorView = new EditorView({ this.editorView = new EditorView({
state: this.createEditorState( state: this.createEditorState(
"", "",
new Document(Text.of([""]), 0, new Map<string, Cursor>()) new CollabDocument(Text.of([""]), 0, new Map<string, Cursor>())
), ),
parent: document.getElementById("editor")!, parent: document.getElementById("editor")!,
}); });
this.pageNavigator = new PathPageNavigator(); this.pageNavigator = new PathPageNavigator();
this.indexer = new Indexer("page-index", space); this.indexer = new Indexer("page-index", space);
this.indexCurrentPageDebounced = debounce(this.indexCurrentPage, 2000); this.indexCurrentPageDebounced = throttle(
this.indexCurrentPage.bind(this),
2000
);
} }
async init() { async init() {
// await this.loadPageList();
await this.loadPlugs(); await this.loadPlugs();
this.focus(); this.focus();
@ -125,16 +120,6 @@ export class Editor implements AppEventDispatcher {
return; return;
} }
if (this.currentPage) {
let pageState = this.openPages.get(this.currentPage)!;
if (pageState) {
pageState.selection = this.editorView!.state.selection;
pageState.scrollTop = this.editorView!.scrollDOM.scrollTop;
}
this.space.closePage(this.currentPage);
}
await this.loadPage(pageName); await this.loadPage(pageName);
}); });
@ -142,12 +127,14 @@ export class Editor implements AppEventDispatcher {
connect: () => { connect: () => {
if (this.currentPage) { if (this.currentPage) {
console.log("Connected to socket, fetch fresh?"); console.log("Connected to socket, fetch fresh?");
this.flashNotification("Reconnected, reloading page");
this.reloadPage(); this.reloadPage();
} }
}, },
pageChanged: (meta) => { pageChanged: (meta) => {
if (this.currentPage === meta.name) { if (this.currentPage === meta.name) {
console.log("page changed on disk, reloading"); console.log("Page changed on disk, reloading");
this.flashNotification("Page changed on disk, reloading");
this.reloadPage(); this.reloadPage();
} }
}, },
@ -164,6 +151,24 @@ export class Editor implements AppEventDispatcher {
} }
} }
flashNotification(message: string) {
let id = Math.floor(Math.random() * 1000000);
this.viewDispatch({
type: "show-notification",
notification: {
id: id,
message: message,
date: new Date(),
},
});
setTimeout(() => {
this.viewDispatch({
type: "dismiss-notification",
id: id,
});
}, 2000);
}
async loadPlugs() { async loadPlugs() {
const system = new System<NuggetHook>(); const system = new System<NuggetHook>();
system.registerSyscalls( system.registerSyscalls(
@ -174,7 +179,11 @@ export class Editor implements AppEventDispatcher {
); );
console.log("Now loading core plug"); console.log("Now loading core plug");
let mainPlug = await system.load("core", coreManifest); let mainPlug = await system.load(
"core",
coreManifest,
new WebworkerSandbox(system)
);
this.plugs.push(mainPlug); this.plugs.push(mainPlug);
this.editorCommands = new Map<string, AppCommand>(); this.editorCommands = new Map<string, AppCommand>();
for (let plug of this.plugs) { for (let plug of this.plugs) {
@ -217,7 +226,7 @@ export class Editor implements AppEventDispatcher {
return this.viewState.currentPage; return this.viewState.currentPage;
} }
createEditorState(pageName: string, doc: Document): EditorState { createEditorState(pageName: string, doc: CollabDocument): EditorState {
const editor = this; const editor = this;
let commandKeyBindings: KeyBinding[] = []; let commandKeyBindings: KeyBinding[] = [];
for (let def of this.editorCommands.values()) { for (let def of this.editorCommands.values()) {
@ -243,17 +252,14 @@ export class Editor implements AppEventDispatcher {
history(), history(),
drawSelection(), drawSelection(),
dropCursor(), dropCursor(),
// indentOnInput(),
customMarkdownStyle, customMarkdownStyle,
bracketMatching(), bracketMatching(),
closeBrackets(), closeBrackets(),
collabExtension( collabExtension(pageName, this.space.socket.id, doc, this.space, {
pageName, pushUpdates: this.space.pushUpdates.bind(this.space),
this.space.socket.id, pullUpdates: this.space.pullUpdates.bind(this.space),
doc, reload: this.reloadPage.bind(this),
this.space, }),
this.reloadPage.bind(this)
),
autocompletion({ autocompletion({
override: [ override: [
this.plugCompleter.bind(this), this.plugCompleter.bind(this),
@ -428,13 +434,26 @@ export class Editor implements AppEventDispatcher {
} }
async loadPage(pageName: string) { async loadPage(pageName: string) {
let doc = await this.space.openPage(pageName);
let editorState = this.createEditorState(pageName, doc);
let pageState = this.openPages.get(pageName);
const editorView = this.editorView; const editorView = this.editorView;
if (!editorView) { if (!editorView) {
return; return;
} }
// Persist current page state and nicely close page
if (this.currentPage) {
let pageState = this.openPages.get(this.currentPage)!;
if (pageState) {
pageState.selection = this.editorView!.state.selection;
pageState.scrollTop = this.editorView!.scrollDOM.scrollTop;
}
this.space.closePage(this.currentPage);
}
// Fetch next page to open
let doc = await this.space.openPage(pageName);
let editorState = this.createEditorState(pageName, doc);
let pageState = this.openPages.get(pageName);
editorView.setState(editorState); editorView.setState(editorState);
if (!pageState) { if (!pageState) {
pageState = new PageState(0, editorState.selection); pageState = new PageState(0, editorState.selection);
@ -444,7 +463,7 @@ export class Editor implements AppEventDispatcher {
}); });
} else { } else {
// Restore state // Restore state
console.log("Restoring selection state"); console.log("Restoring selection state", pageState.selection);
editorView.dispatch({ editorView.dispatch({
selection: pageState.selection, selection: pageState.selection,
}); });
@ -456,15 +475,8 @@ export class Editor implements AppEventDispatcher {
name: pageName, name: pageName,
}); });
// let indexerPageMeta = await this.indexer.getPageIndexPageMeta(pageName); // TODO: Check if indexing is required?
// if (
// (indexerPageMeta &&
// doc.meta.lastModified.getTime() !==
// indexerPageMeta.lastModified.getTime()) ||
// !indexerPageMeta
// ) {
await this.indexCurrentPage(); await this.indexCurrentPage();
// }
} }
ViewComponent(): React.ReactElement { ViewComponent(): React.ReactElement {
@ -513,13 +525,13 @@ export class Editor implements AppEventDispatcher {
/> />
)} )}
<TopBar <TopBar
currentPage={viewState.currentPage} pageName={viewState.currentPage}
notifications={viewState.notifications}
onClick={() => { onClick={() => {
dispatch({ type: "start-navigate" }); dispatch({ type: "start-navigate" });
}} }}
/> />
<div id="editor"></div> <div id="editor"></div>
<StatusBar editorView={this.editorView} />
</> </>
); );
} }

20
webapp/src/event.ts Normal file
View File

@ -0,0 +1,20 @@
export abstract class EventEmitter<HandlerT> {
private handlers: Partial<HandlerT>[] = [];
on(handlers: Partial<HandlerT>) {
this.handlers.push(handlers);
}
off(handlers: Partial<HandlerT>) {
this.handlers = this.handlers.filter((h) => h !== handlers);
}
emit(eventName: keyof HandlerT, ...args: any[]) {
for (let handler of this.handlers) {
let fn: any = handler[eventName];
if (fn) {
fn(...args);
}
}
}
}

View File

@ -48,6 +48,16 @@ export default function reducer(
...state, ...state,
commands: action.commands, commands: action.commands,
}; };
case "show-notification":
return {
...state,
notifications: [action.notification, ...state.notifications],
};
case "dismiss-notification":
return {
...state,
notifications: state.notifications.filter((n) => n.id !== action.id),
};
} }
return state; return state;
} }

View File

@ -3,8 +3,9 @@ import { Socket } from "socket.io-client";
import { Update } from "@codemirror/collab"; import { Update } from "@codemirror/collab";
import { Transaction, Text, ChangeSet } from "@codemirror/state"; import { Transaction, Text, ChangeSet } from "@codemirror/state";
import { Document } from "./collab"; import { CollabEvents, CollabDocument } from "./collab";
import { Cursor, cursorEffect } from "./cursorEffect"; import { Cursor, cursorEffect } from "./cursorEffect";
import { EventEmitter } from "./event";
export interface Space { export interface Space {
listPages(): Promise<PageMeta[]>; listPages(): Promise<PageMeta[]>;
@ -14,43 +15,15 @@ export interface Space {
getPageMeta(name: string): Promise<PageMeta>; getPageMeta(name: string): Promise<PageMeta>;
} }
export type SpaceEventHandlers = { export type SpaceEvents = {
connect: () => void; connect: () => void;
cursorSnapshot: (
pageName: string,
cursors: { [key: string]: Cursor }
) => void;
pageCreated: (meta: PageMeta) => void; pageCreated: (meta: PageMeta) => void;
pageChanged: (meta: PageMeta) => void; pageChanged: (meta: PageMeta) => void;
pageDeleted: (name: string) => void; pageDeleted: (name: string) => void;
pageListUpdated: (pages: Set<PageMeta>) => void; pageListUpdated: (pages: Set<PageMeta>) => void;
}; } & CollabEvents;
abstract class EventEmitter<HandlerT> { export class RealtimeSpace extends EventEmitter<SpaceEvents> implements Space {
private handlers: Partial<HandlerT>[] = [];
on(handlers: Partial<HandlerT>) {
this.handlers.push(handlers);
}
off(handlers: Partial<HandlerT>) {
this.handlers = this.handlers.filter((h) => h !== handlers);
}
emit(eventName: keyof HandlerT, ...args: any[]) {
for (let handler of this.handlers) {
let fn: any = handler[eventName];
if (fn) {
fn(...args);
}
}
}
}
export class RealtimeSpace
extends EventEmitter<SpaceEventHandlers>
implements Space
{
socket: Socket; socket: Socket;
reqId = 0; reqId = 0;
allPages = new Set<PageMeta>(); allPages = new Set<PageMeta>();
@ -67,7 +40,7 @@ export class RealtimeSpace
"pageDeleted", "pageDeleted",
].forEach((eventName) => { ].forEach((eventName) => {
socket.on(eventName, (...args) => { socket.on(eventName, (...args) => {
this.emit(eventName as keyof SpaceEventHandlers, ...args); this.emit(eventName as keyof SpaceEvents, ...args);
}); });
}); });
this.wsCall("listPages").then((pages) => { this.wsCall("listPages").then((pages) => {
@ -133,18 +106,19 @@ export class RealtimeSpace
return Array.from(this.allPages); return Array.from(this.allPages);
} }
async openPage(name: string): Promise<Document> { async openPage(name: string): Promise<CollabDocument> {
this.reqId++; this.reqId++;
let pageJSON = await this.wsCall("openPage", name); let pageJSON = await this.wsCall("openPage", name);
let cursors = new Map<string, Cursor>();
for (let p in pageJSON.cursors) { return new CollabDocument(
cursors.set(p, pageJSON.cursors[p]); Text.of(pageJSON.text),
} pageJSON.version,
return new Document(Text.of(pageJSON.text), pageJSON.version, cursors); new Map(Object.entries(pageJSON.cursors))
);
} }
async closePage(name: string): Promise<void> { async closePage(name: string): Promise<void> {
this.socket!.emit("closePage", name); this.socket.emit("closePage", name);
} }
async readPage(name: string): Promise<{ text: string; meta: PageMeta }> { async readPage(name: string): Promise<{ text: string; meta: PageMeta }> {

View File

@ -32,34 +32,41 @@ body {
max-width: 800px; max-width: 800px;
font-size: 28px; font-size: 28px;
margin: auto; margin: auto;
}
.current-page { .status {
font-family: var(--ui-font); float: right;
font-weight: bold; border: rgb(41, 41, 41) 1px solid;
} border-radius: 5px;
padding: 3px;
font-size: 14px;
}
.current-page {
font-family: var(--ui-font);
font-weight: bold;
}
.icon { .icon {
padding-left: 5px; padding-left: 5px;
padding-right: 10px; padding-right: 10px;
}
} }
} }
#bottom { // #bottom {
position: fixed; // position: fixed;
bottom: 0; // bottom: 0;
left: 0; // left: 0;
right: 0; // right: 0;
height: 20px; // height: 20px;
background-color: rgb(232, 232, 232); // background-color: rgb(232, 232, 232);
color: rgb(79, 78, 78); // color: rgb(79, 78, 78);
border-top: rgb(186, 186, 186) 1px solid; // border-top: rgb(186, 186, 186) 1px solid;
margin: 0; // margin: 0;
padding: 5px 10px; // padding: 5px 10px;
font-family: var(--ui-font); // font-family: var(--ui-font);
font-size: 0.9em; // font-size: 0.9em;
text-align: right; // text-align: right;
} // }
// body.keyboard #bottom { // body.keyboard #bottom {
// bottom: 250px; // bottom: 250px;
@ -68,7 +75,7 @@ body {
#editor { #editor {
position: absolute; position: absolute;
top: 55px; top: 55px;
bottom: 30px; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
overflow-y: hidden; overflow-y: hidden;

View File

@ -35,12 +35,19 @@ export interface CommandDef {
slashCommand?: string; slashCommand?: string;
} }
export type Notification = {
id: number;
message: string;
date: Date;
};
export type AppViewState = { export type AppViewState = {
currentPage?: string; currentPage?: string;
showPageNavigator: boolean; showPageNavigator: boolean;
showCommandPalette: boolean; showCommandPalette: boolean;
allPages: Set<PageMeta>; allPages: Set<PageMeta>;
commands: Map<string, AppCommand>; commands: Map<string, AppCommand>;
notifications: Notification[];
}; };
export const initialViewState: AppViewState = { export const initialViewState: AppViewState = {
@ -48,6 +55,7 @@ export const initialViewState: AppViewState = {
showCommandPalette: false, showCommandPalette: false,
allPages: new Set(), allPages: new Set(),
commands: new Map(), commands: new Map(),
notifications: [],
}; };
export type Action = export type Action =
@ -57,4 +65,6 @@ export type Action =
| { type: "stop-navigate" } | { type: "stop-navigate" }
| { type: "update-commands"; commands: Map<string, AppCommand> } | { type: "update-commands"; commands: Map<string, AppCommand> }
| { type: "show-palette" } | { type: "show-palette" }
| { type: "hide-palette" }; | { type: "hide-palette" }
| { type: "show-notification"; notification: Notification }
| { type: "dismiss-notification"; id: number };

View File

@ -17,3 +17,15 @@ export function safeRun(fn: () => Promise<void>) {
export function isMacLike() { export function isMacLike() {
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
} }
export function throttle(func: () => void, limit: number) {
let timer: any = null;
return function () {
if (!timer) {
timer = setTimeout(() => {
func();
timer = null;
}, limit);
}
};
}

View File

@ -979,10 +979,13 @@
resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==
"@types/lodash@^4.14.179": "@types/jest@^27.4.1":
version "4.14.179" version "27.4.1"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.179.tgz#490ec3288088c91295780237d2497a3aa9dfb5c5" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.4.1.tgz#185cbe2926eaaf9662d340cc02e548ce9e11ab6d"
integrity sha512-uwc1x90yCKqGcIOAT6DwOSuxnrAbpkdPsUOZtwrXb4D/6wZs+6qG7QnIawDuZWg0sWpxl+ltIKCaLoMlna678w== integrity sha512-23iPJADSmicDVrWk+HT58LMJtzLAnB2AgIzplQuq/bSrGaxCrlvRFjGbXmamnnk/mAmCdLStiGqggu28ocUyiw==
dependencies:
jest-matcher-utils "^27.0.0"
pretty-format "^27.0.0"
"@types/node@^17.0.21": "@types/node@^17.0.21":
version "17.0.21" version "17.0.21"
@ -1025,6 +1028,11 @@ abortcontroller-polyfill@^1.1.9:
resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.3.tgz#1b5b487bd6436b5b764fd52a612509702c3144b5" resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.3.tgz#1b5b487bd6436b5b764fd52a612509702c3144b5"
integrity sha512-zetDJxd89y3X99Kvo4qFx8GKlt6GsvN3UcRZHwU6iFA/0KiOmhkTVhe8oRoTBiTVPZu09x3vCra47+w8Yz1+2Q== integrity sha512-zetDJxd89y3X99Kvo4qFx8GKlt6GsvN3UcRZHwU6iFA/0KiOmhkTVhe8oRoTBiTVPZu09x3vCra47+w8Yz1+2Q==
ansi-regex@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
ansi-styles@^3.2.1: ansi-styles@^3.2.1:
version "3.2.1" version "3.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
@ -1039,6 +1047,11 @@ ansi-styles@^4.1.0:
dependencies: dependencies:
color-convert "^2.0.1" color-convert "^2.0.1"
ansi-styles@^5.0.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b"
integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
anymatch@~3.1.2: anymatch@~3.1.2:
version "3.1.2" version "3.1.2"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
@ -1241,7 +1254,7 @@ chalk@^2.0.0:
escape-string-regexp "^1.0.5" escape-string-regexp "^1.0.5"
supports-color "^5.3.0" supports-color "^5.3.0"
chalk@^4.1.0: chalk@^4.0.0, chalk@^4.1.0:
version "4.1.2" version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@ -1514,6 +1527,11 @@ dexie@^3.2.1:
resolved "https://registry.yarnpkg.com/dexie/-/dexie-3.2.1.tgz#ef21456d725e700c1ab7ac4307896e4fdabaf753" resolved "https://registry.yarnpkg.com/dexie/-/dexie-3.2.1.tgz#ef21456d725e700c1ab7ac4307896e4fdabaf753"
integrity sha512-Y8oz3t2XC9hvjkP35B5I8rUkKKwM36GGRjWQCMjzIYScg7W+GHKDXobSYswkisW7CxL1/tKQtggMDsiWqDUc1g== integrity sha512-Y8oz3t2XC9hvjkP35B5I8rUkKKwM36GGRjWQCMjzIYScg7W+GHKDXobSYswkisW7CxL1/tKQtggMDsiWqDUc1g==
diff-sequences@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327"
integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==
diffie-hellman@^5.0.0: diffie-hellman@^5.0.0:
version "5.0.3" version "5.0.3"
resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"
@ -2003,6 +2021,31 @@ is-weakref@^1.0.1:
dependencies: dependencies:
call-bind "^1.0.2" call-bind "^1.0.2"
jest-diff@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def"
integrity sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==
dependencies:
chalk "^4.0.0"
diff-sequences "^27.5.1"
jest-get-type "^27.5.1"
pretty-format "^27.5.1"
jest-get-type@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1"
integrity sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==
jest-matcher-utils@^27.0.0:
version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz#9c0cdbda8245bc22d2331729d1091308b40cf8ab"
integrity sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==
dependencies:
chalk "^4.0.0"
jest-diff "^27.5.1"
jest-get-type "^27.5.1"
pretty-format "^27.5.1"
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@ -2544,6 +2587,15 @@ posthtml@^0.16.4, posthtml@^0.16.5:
posthtml-parser "^0.10.0" posthtml-parser "^0.10.0"
posthtml-render "^3.0.0" posthtml-render "^3.0.0"
pretty-format@^27.0.0, pretty-format@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e"
integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==
dependencies:
ansi-regex "^5.0.1"
ansi-styles "^5.0.0"
react-is "^17.0.1"
prop-types@^15.8.1: prop-types@^15.8.1:
version "15.8.1" version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
@ -2609,6 +2661,11 @@ react-is@^16.13.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-is@^17.0.1:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
react-refresh@^0.9.0: react-refresh@^0.9.0:
version "0.9.0" version "0.9.0"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.9.0.tgz#71863337adc3e5c2f8a6bfddd12ae3bfe32aafbf" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.9.0.tgz#71863337adc3e5c2f8a6bfddd12ae3bfe32aafbf"