import { FileMeta } from "$sb/types.ts"; import { EventHook } from "../../plugos/hooks/event.ts"; import type { SpacePrimitives } from "./space_primitives.ts"; /** * Events exposed: * - file:changed (string, localUpdate: boolean) * - file:deleted (string) * - file:listed (FileMeta[]) * - page:saved (string, FileMeta) * - page:deleted (string) */ export class EventedSpacePrimitives implements SpacePrimitives { alreadyFetching = false; initialFileListLoad = true; spaceSnapshot: Record = {}; constructor( private wrapped: SpacePrimitives, private eventHook: EventHook, ) {} dispatchEvent(name: string, ...args: any[]): Promise { return this.eventHook.dispatchEvent(name, ...args); } async fetchFileList(): Promise { const newFileList = await this.wrapped.fetchFileList(); if (this.alreadyFetching) { // Avoid race conditions return newFileList; } this.alreadyFetching = true; const deletedFiles = new Set(Object.keys(this.spaceSnapshot)); for (const meta of newFileList) { const oldHash = this.spaceSnapshot[meta.name]; const newHash = meta.lastModified; if ( ( // New file scenario !oldHash && !this.initialFileListLoad ) || ( // Changed file scenario oldHash && oldHash !== newHash ) ) { await this.dispatchEvent("file:changed", meta.name); } // Page found, not deleted deletedFiles.delete(meta.name); // Update in snapshot this.spaceSnapshot[meta.name] = newHash; } for (const deletedFile of deletedFiles) { delete this.spaceSnapshot[deletedFile]; await this.dispatchEvent("file:deleted", deletedFile); if (deletedFile.endsWith(".md")) { const pageName = deletedFile.substring(0, deletedFile.length - 3); await this.dispatchEvent("page:deleted", pageName); } } await this.dispatchEvent("file:listed", newFileList); this.alreadyFetching = false; this.initialFileListLoad = false; return newFileList; } async readFile( name: string, ): Promise<{ data: Uint8Array; meta: FileMeta }> { const data = await this.wrapped.readFile(name); this.triggerEventsAndCache(name, data.meta.lastModified); return data; } async writeFile( name: string, data: Uint8Array, selfUpdate?: boolean, meta?: FileMeta, ): Promise { const newMeta = await this.wrapped.writeFile( name, data, selfUpdate, meta, ); if (!selfUpdate) { await this.dispatchEvent("file:changed", name, true); } this.spaceSnapshot[name] = newMeta.lastModified; // This can happen async if (name.endsWith(".md")) { const pageName = name.substring(0, name.length - 3); let text = ""; const decoder = new TextDecoder("utf-8"); text = decoder.decode(data); await this.dispatchEvent("page:saved", pageName, newMeta); await this.dispatchEvent("page:index_text", { name: pageName, text, }); } return newMeta; } triggerEventsAndCache(name: string, newHash: number) { const oldHash = this.spaceSnapshot[name]; if (oldHash && oldHash !== newHash) { // Page changed since last cached metadata, trigger event this.dispatchEvent("file:changed", name); } this.spaceSnapshot[name] = newHash; return; } async getFileMeta(name: string): Promise { try { const newMeta = await this.wrapped.getFileMeta(name); this.triggerEventsAndCache(name, newMeta.lastModified); return newMeta; } catch (e: any) { // console.log("Checking error", e, name); if (e.message === "Not found") { await 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.dispatchEvent("page:deleted", pageName); } // await this.getPageMeta(name); // Check if page exists, if not throws Error await this.wrapped.deleteFile(name); delete this.spaceSnapshot[name]; await this.dispatchEvent("file:deleted", name); } }