From 593597454afd5819d42ac9464bc018f1b29ce244 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Sun, 27 Aug 2023 11:02:24 +0200 Subject: [PATCH] Huge event system refactoring --- common/spaces/evented_space_primitives.ts | 142 ++++++++++++++++++--- server/server_system.ts | 2 +- web/client.ts | 54 +++++--- web/client_system.ts | 18 +++ web/space.ts | 145 +++++++++------------- 5 files changed, 239 insertions(+), 122 deletions(-) diff --git a/common/spaces/evented_space_primitives.ts b/common/spaces/evented_space_primitives.ts index 18f2865..d449b8f 100644 --- a/common/spaces/evented_space_primitives.ts +++ b/common/spaces/evented_space_primitives.ts @@ -3,17 +3,96 @@ import { EventHook } from "../../plugos/hooks/event.ts"; import type { SpacePrimitives } from "./space_primitives.ts"; -export class EventedSpacePrimitives implements SpacePrimitives { - constructor(private wrapped: SpacePrimitives, private eventHook: EventHook) {} +/** + * Events exposed: + * - file:changed (FileMeta) + * - file:deleted (string) + * - file:listed (FileMeta[]) + * - page:saved (string, FileMeta) + * - page:deleted (string) + */ - fetchFileList(): Promise { - return this.wrapped.fetchFileList(); +export class EventedSpacePrimitives implements SpacePrimitives { + private fileMetaCache = new Map(); + initialFileListLoad = true; + + constructor( + private wrapped: SpacePrimitives, + private eventHook: EventHook, + private eventsToDispatch = [ + "file:changed", + "file:deleted", + "file:listed", + "page:saved", + "page:deleted", + ], + ) {} + + dispatchEvent(name: string, ...args: any[]): Promise { + if (this.eventsToDispatch.includes(name)) { + return this.eventHook.dispatchEvent(name, ...args); + } else { + return Promise.resolve([]); + } } - readFile( + async fetchFileList(): Promise { + const newFileList = await this.wrapped.fetchFileList(); + const deletedFiles = new Set(this.fileMetaCache.keys()); + for (const meta of newFileList) { + const oldFileMeta = this.fileMetaCache.get(meta.name); + const newFileMeta: FileMeta = { ...meta }; + if ( + ( + // New file scenario + !oldFileMeta && !this.initialFileListLoad + ) || ( + // Changed file scenario + oldFileMeta && + oldFileMeta.lastModified !== newFileMeta.lastModified + ) + ) { + this.dispatchEvent("file:changed", newFileMeta); + } + // Page found, not deleted + deletedFiles.delete(meta.name); + + // Update in cache + this.fileMetaCache.set(meta.name, newFileMeta); + } + + for (const deletedFile of deletedFiles) { + this.fileMetaCache.delete(deletedFile); + this.dispatchEvent("file:deleted", deletedFile); + + if (deletedFile.endsWith(".md")) { + const pageName = deletedFile.substring(0, deletedFile.length - 3); + await this.dispatchEvent("page:deleted", pageName); + } + } + + const fileList = [...new Set(this.fileMetaCache.values())]; + this.dispatchEvent("file:listed", fileList); + this.initialFileListLoad = false; + return fileList; + } + + async readFile( name: string, ): Promise<{ data: Uint8Array; meta: FileMeta }> { - return this.wrapped.readFile(name); + const data = await this.wrapped.readFile(name); + const previousMeta = this.fileMetaCache.get(name); + const newMeta = data.meta; + if (previousMeta) { + if (previousMeta.lastModified !== newMeta.lastModified) { + // Page changed since last cached metadata, trigger event + this.dispatchEvent("file:changed", newMeta); + } + } + return { + data: data.data, + meta: this.metaCacher(name, newMeta), + }; } async writeFile( @@ -28,6 +107,11 @@ export class EventedSpacePrimitives implements SpacePrimitives { selfUpdate, meta, ); + if (!selfUpdate) { + this.dispatchEvent("file:changed", newMeta); + } + this.metaCacher(name, newMeta); + // This can happen async if (name.endsWith(".md")) { const pageName = name.substring(0, name.length - 3); @@ -35,10 +119,9 @@ export class EventedSpacePrimitives implements SpacePrimitives { const decoder = new TextDecoder("utf-8"); text = decoder.decode(data); - this.eventHook - .dispatchEvent("page:saved", pageName, newMeta) + this.dispatchEvent("page:saved", pageName, newMeta) .then(() => { - return this.eventHook.dispatchEvent("page:index_text", { + return this.dispatchEvent("page:index_text", { name: pageName, text, }); @@ -48,20 +131,51 @@ export class EventedSpacePrimitives implements SpacePrimitives { }); } if (name.startsWith("_plug/") && name.endsWith(".plug.js")) { - await this.eventHook.dispatchEvent("plug:changed", name); + await this.dispatchEvent("plug:changed", name); } return newMeta; } - getFileMeta(name: string): Promise { - return this.wrapped.getFileMeta(name); + async getFileMeta(name: string): Promise { + try { + const oldMeta = this.fileMetaCache.get(name); + const newMeta = await this.wrapped.getFileMeta(name); + if (oldMeta) { + if (oldMeta.lastModified !== newMeta.lastModified) { + // Changed on disk, trigger event + this.dispatchEvent("file:changed", newMeta); + } + } + return this.metaCacher(name, newMeta); + } catch (e: any) { + console.log("Checking error", e, name); + if (e.message === "Not found") { + this.dispatchEvent("file:deleted", name); + if (name.endsWith(".md")) { + const pageName = name.substring(0, name.length - 3); + await this.dispatchEvent("page:deleted", pageName); + } + } + throw e; + } } async deleteFile(name: string): Promise { if (name.endsWith(".md")) { const pageName = name.substring(0, name.length - 3); - await this.eventHook.dispatchEvent("page:deleted", pageName); + await this.dispatchEvent("page:deleted", pageName); } - return this.wrapped.deleteFile(name); + // await this.getPageMeta(name); // Check if page exists, if not throws Error + await this.wrapped.deleteFile(name); + this.fileMetaCache.delete(name); + this.dispatchEvent("file:deleted", name); + } + + private metaCacher(name: string, meta: FileMeta): FileMeta { + if (meta.lastModified !== 0) { + // Don't cache metadata for pages with a 0 lastModified timestamp (usualy dynamically generated pages) + this.fileMetaCache.set(name, meta); + } + return meta; } } diff --git a/server/server_system.ts b/server/server_system.ts index 1ac6640..62325f5 100644 --- a/server/server_system.ts +++ b/server/server_system.ts @@ -84,7 +84,7 @@ export class ServerSystem { ), pageIndexCalls, ); - const space = new Space(this.spacePrimitives, this.kvStore); + const space = new Space(this.spacePrimitives, this.kvStore, eventHook); // Add syscalls this.system.registerSyscalls( diff --git a/web/client.ts b/web/client.ts index 0269293..2d114ca 100644 --- a/web/client.ts +++ b/web/client.ts @@ -6,7 +6,7 @@ import { gitIgnoreCompiler, syntaxTree, } from "../common/deps.ts"; -import { Space } from "./space.ts"; +import { fileMetaToPageMeta, Space } from "./space.ts"; import { FilterOption, PageMeta } from "./types.ts"; import { parseYamlSettings } from "../common/util.ts"; import { EventHook } from "../plugos/hooks/event.ts"; @@ -37,6 +37,7 @@ import { DexieMQ } from "../plugos/lib/mq.dexie.ts"; import { cleanPageRef } from "$sb/lib/resolve.ts"; import { expandPropertyNames } from "$sb/lib/json.ts"; import { SpacePrimitives } from "../common/spaces/space_primitives.ts"; +import { FileMeta } from "$sb/types.ts"; const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/; const autoSaveInterval = 1000; @@ -320,6 +321,12 @@ export class Client { } })().catch(console.error); } + + // this.eventHook.addLocalListener("page:deleted", (pageName) => { + // if (pageName === this.currentPage) { + // this.flashNotification("Page does exist, creating as a new page"); + // } + // }); } initSpace(): SpacePrimitives { @@ -365,26 +372,37 @@ export class Client { }, ); } else { - localSpacePrimitives = this.plugSpaceRemotePrimitives; + localSpacePrimitives = new EventedSpacePrimitives( + this.plugSpaceRemotePrimitives, + this.eventHook, + [ + "file:changed", + "file:listed", + "page:deleted", + ], + ); } - this.space = new Space(localSpacePrimitives, this.kvStore); + this.space = new Space(localSpacePrimitives, this.kvStore, this.eventHook); - this.space.on({ - pageChanged: (meta) => { - // Only reload when watching the current page (to avoid reloading when switching pages) - if (this.space.watchInterval && this.currentPage === meta.name) { - console.log("Page changed elsewhere, reloading"); - this.flashNotification("Page changed elsewhere, reloading"); - this.reloadPage(); - } - }, - pageListUpdated: (pages) => { - this.ui.viewDispatch({ - type: "pages-listed", - pages: pages, - }); - }, + this.eventHook.addLocalListener("file:changed", (fileMeta: FileMeta) => { + // Only reload when watching the current page (to avoid reloading when switching pages) + if ( + this.space.watchInterval && `${this.currentPage}.md` === fileMeta.name + ) { + console.log("Page changed elsewhere, reloading"); + this.flashNotification("Page changed elsewhere, reloading"); + this.reloadPage(); + } + }); + + this.eventHook.addLocalListener("file:listed", (fileList: FileMeta[]) => { + this.ui.viewDispatch({ + type: "pages-listed", + pages: fileList.filter((f) => f.name.endsWith(".md")).map( + fileMetaToPageMeta, + ), + }); }); this.space.watch(); diff --git a/web/client_system.ts b/web/client_system.ts index a2d06a4..299232c 100644 --- a/web/client_system.ts +++ b/web/client_system.ts @@ -111,6 +111,24 @@ export class ClientSystem { } this.plugsUpdated = true; }); + + // Debugging + // this.eventHook.addLocalListener("file:listed", (files) => { + // console.log("New file list", files); + // }); + + this.eventHook.addLocalListener("file:changed", (file) => { + console.log("File changed", file); + }); + + this.eventHook.addLocalListener("file:created", (file) => { + console.log("File created", file); + }); + + this.eventHook.addLocalListener("file:deleted", (file) => { + console.log("File deleted", file); + }); + this.registerSyscalls(); } diff --git a/web/space.ts b/web/space.ts index 86d4110..4ec5c3c 100644 --- a/web/space.ts +++ b/web/space.ts @@ -1,24 +1,18 @@ import { SpacePrimitives } from "../common/spaces/space_primitives.ts"; -import { EventEmitter } from "../plugos/event.ts"; import { plugPrefix } from "../common/spaces/constants.ts"; import { safeRun } from "../common/util.ts"; import { AttachmentMeta, PageMeta } from "./types.ts"; import { throttle } from "../common/async_util.ts"; import { KVStore } from "../plugos/lib/kv_store.ts"; import { FileMeta } from "$sb/types.ts"; - -export type SpaceEvents = { - pageCreated: (meta: PageMeta) => void; - pageChanged: (meta: PageMeta) => void; - pageDeleted: (name: string) => void; - pageListUpdated: (pages: PageMeta[]) => void; -}; +import { EventHook } from "../plugos/hooks/event.ts"; const pageWatchInterval = 5000; -export class Space extends EventEmitter { +export class Space { imageHeightCache: Record = {}; - pageMetaCache = new Map(); + // pageMetaCache = new Map(); + cachedPageList: PageMeta[] = []; debouncedCacheFlush = throttle(() => { this.kvStore.set("imageHeightCache", this.imageHeightCache).catch( @@ -40,81 +34,82 @@ export class Space extends EventEmitter { watchedPages = new Set(); watchInterval?: number; - private initialPageListLoad = true; + // private initialPageListLoad = true; private saving = false; constructor( readonly spacePrimitives: SpacePrimitives, private kvStore: KVStore, + private eventHook: EventHook, ) { - super(); + // super(); this.kvStore.get("imageHeightCache").then((cache) => { if (cache) { // console.log("Loaded image height cache from KV store", cache); this.imageHeightCache = cache; } }); + eventHook.addLocalListener("file:listed", (files: FileMeta[]) => { + this.cachedPageList = files.filter(this.isListedPage).map( + fileMetaToPageMeta, + ); + }); + eventHook.addLocalListener("page:deleted", (pageName: string) => { + if (this.watchedPages.has(pageName)) { + // Stop watching deleted pages already + this.watchedPages.delete(pageName); + } + }); } public async updatePageList() { - const newPageList = await this.fetchPageList(); - const deletedPages = new Set(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); + // This will trigger appropriate events automatically + await this.fetchPageList(); + // const deletedPages = new Set(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); - }); + // // Update in cache + // this.pageMetaCache.set(pageName, newPageMeta); + // }); - for (const deletedPage of deletedPages) { - this.pageMetaCache.delete(deletedPage); - this.emit("pageDeleted", deletedPage); - } + // for (const deletedPage of deletedPages) { + // this.pageMetaCache.delete(deletedPage); + // this.emit("pageDeleted", deletedPage); + // } - this.emit("pageListUpdated", this.listPages()); - this.initialPageListLoad = false; + // this.emit("pageListUpdated", this.listPages()); + // this.initialPageListLoad = false; } async deletePage(name: string): Promise { 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 { - const oldMeta = this.pageMetaCache.get(name); - const newMeta = fileMetaToPageMeta( + return 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())]; + return this.cachedPageList; } async listPlugs(): Promise { @@ -129,18 +124,9 @@ export class Space extends EventEmitter { 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, + meta: fileMetaToPageMeta(pageData.meta), }; } @@ -151,37 +137,33 @@ export class Space extends EventEmitter { ): Promise { try { this.saving = true; - const pageMeta = fileMetaToPageMeta( + return 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; } } // We're listing all pages that don't start with a _ - isListedPageName(name: string): boolean { - return name.endsWith(".md") && !name.startsWith("_"); + isListedPage(fileMeta: FileMeta): boolean { + return fileMeta.name.endsWith(".md") && !fileMeta.name.startsWith("_"); } async fetchPageList(): Promise { return (await this.spacePrimitives.fetchFileList()) - .filter((fileMeta) => this.isListedPageName(fileMeta.name)) + .filter(this.isListedPage) .map(fileMetaToPageMeta); } async fetchAttachmentList(): Promise { return (await this.spacePrimitives.fetchFileList()).filter( (fileMeta) => - !this.isListedPageName(fileMeta.name) && + !this.isListedPage(fileMeta) && !fileMeta.name.endsWith(".plug.js"), ); } @@ -225,13 +207,6 @@ export class Space extends EventEmitter { 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); } }); @@ -252,17 +227,9 @@ export class Space extends EventEmitter { 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 { +export function fileMetaToPageMeta(fileMeta: FileMeta): PageMeta { return { ...fileMeta, name: fileMeta.name.substring(0, fileMeta.name.length - 3),