From 5e7f7729990f86a746b97da51a5e5413dbc432dc Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Wed, 5 Mar 2025 15:48:59 +0100 Subject: [PATCH] implement file system observer --- src/features/file/helpers.ts | 177 +++++++++++++++++- src/routes/(editor)/edit.tsx | 4 +- src/routes/(editor)/experimental.tsx | 1 + .../experimental/file-system-observer.tsx | 32 ++++ 4 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 src/routes/(editor)/experimental/file-system-observer.tsx diff --git a/src/features/file/helpers.ts b/src/features/file/helpers.ts index 544bbd2..a4b68bd 100644 --- a/src/features/file/helpers.ts +++ b/src/features/file/helpers.ts @@ -1,6 +1,11 @@ import { Accessor, createEffect, from, createSignal } from "solid-js"; import { json } from "./parser"; import { filter } from "~/utilities"; +import { isServer } from "solid-js/web"; +import { installIntoGlobal } from 'iterator-helpers-polyfill'; +import { debounce } from "@solid-primitives/scheduled"; + +installIntoGlobal(); interface Files extends Record { } interface Contents extends Map> { } @@ -13,8 +18,16 @@ export const read = (file: File): Promise | undefined> => { } }; -export const readFiles = (directory: Accessor): Accessor => { - return createPolled(directory, async (directory, prev) => { +export const readFiles = (directory: Accessor): Accessor => { + return (!isServer && 'FileSystemObserver' in window) ? readFiles__observer(directory) : readFiles__polled(directory) +}; + +const readFiles__polled = (directory: Accessor): Accessor => { + return createPolled(directory, async (directory, prev) => { + if (!directory) { + return prev; + } + const next: Files = Object.fromEntries(await Array.fromAsync( filter(directory.values(), (handle): handle is FileSystemFileHandle => handle.kind === 'file' && handle.name.endsWith('.json')), async handle => [await handle.getUniqueId(), { file: await handle.getFile(), handle }] @@ -39,9 +52,130 @@ export const readFiles = (directory: Accessor): Acces }, { interval: 1000, initialValue: {} }); }; +const readFiles__observer = (directory: Accessor): Accessor => { + const [files, setFiles] = createSignal({}); + + const observer = new FileSystemObserver(debounce(async records => { + for (const record of records) { + switch (record.type) { + case 'modified': { + if (record.changedHandle.kind === 'file') { + const handle = record.changedHandle as FileSystemFileHandle; + const id = await handle.getUniqueId(); + const file = await handle.getFile(); + + setFiles(prev => ({ ...prev, [id]: { file, handle } })); + } + + break; + } + + default: { + console.log(record); + + break; + } + } + } + }, 10)); + + createEffect((last = undefined) => { + if (last) { + observer.unobserve(last); + } + + const dir = directory(); + + if (!dir) { + return; + } + + observer.observe(dir); + + (async () => { + setFiles(Object.fromEntries( + await dir.values() + .filter((handle): handle is FileSystemFileHandle => handle.kind === 'file' && handle.name.endsWith('.json')) + .map(async handle => [await handle.getUniqueId(), { file: await handle.getFile(), handle }] as const) + .toArray() + )); + })(); + + return dir; + }); + + return files; +}; + +const HANDLE = Symbol('handle'); const LAST_MODIFIED = Symbol('lastModified'); -export const contentsOf = (directory: Accessor): Accessor => { - return createPolled(directory, async (directory, prev) => { +export const contentsOf = (directory: Accessor): Accessor => { + return (!isServer && 'FileSystemObserver' in window) ? contentsOf__observer(directory) : contentsOf__polled(directory) +}; + +const contentsOf__observer = (directory: Accessor): Accessor => { + const [contents, setContents] = createSignal(new Map); + + const observer = new FileSystemObserver(debounce(async records => { + for (const record of records) { + switch (record.type) { + case 'modified': { + if (record.changedHandle.kind === 'file') { + const handle = record.changedHandle as FileSystemFileHandle; + const id = await handle.getUniqueId(); + const file = await handle.getFile(); + const entries = (await read(file))!; + entries[LAST_MODIFIED] = file.lastModified; + + setContents(prev => new Map([...prev, [id, entries]])); + } + + break; + } + + default: { + console.log(record); + + break; + } + } + } + }, 10)); + + createEffect((last = undefined) => { + if (last) { + observer.unobserve(last); + } + + const dir = directory(); + + if (!dir) { + return; + } + + observer.observe(dir); + + (async () => { + setContents(new Map(await walk(dir).map(async ({ id, file }) => { + const entries = (await read(file))!; + entries[LAST_MODIFIED] = file.lastModified; + + return [id, entries] as const; + }).toArray())); + })(); + + return dir; + }); + + return contents; +}; + +const contentsOf__polled = (directory: Accessor): Accessor => { + return createPolled(directory, async (directory, prev) => { + if (!directory) { + return prev; + } + const files = await Array.fromAsync(walk(directory)); const next = async () => new Map(await Promise.all(files.map(async ({ id, file }) => { @@ -116,6 +250,41 @@ async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []): declare global { interface Map { + [HANDLE]: FileSystemFileHandle; [LAST_MODIFIED]: number; } + + type FileSystemObserverCallback = ( + records: FileSystemChangeRecord[], + observer: FileSystemObserver + ) => void; + + interface FileSystemObserverObserveOptions { + recursive?: boolean; + } + + type FileSystemChangeType = 'appeared' | 'disappeared' | 'modified' | 'moved' | 'unknown' | 'errored'; + + interface FileSystemChangeRecord { + readonly changedHandle: FileSystemHandle; + readonly relativePathComponents: ReadonlyArray; + readonly type: FileSystemChangeType; + readonly relativePathMovedFrom?: ReadonlyArray; + } + + interface FileSystemObserver { + observe( + handle: FileSystemHandle, + options?: FileSystemObserverObserveOptions + ): Promise; + unobserve(handle: FileSystemHandle): void; + disconnect(): void; + } + + interface FileSystemObserverConstructor { + new(callback: FileSystemObserverCallback): FileSystemObserver; + readonly prototype: FileSystemObserver; + } + + var FileSystemObserver: FileSystemObserverConstructor; } \ No newline at end of file diff --git a/src/routes/(editor)/edit.tsx b/src/routes/(editor)/edit.tsx index 95604ae..0eed71e 100644 --- a/src/routes/(editor)/edit.tsx +++ b/src/routes/(editor)/edit.tsx @@ -13,9 +13,8 @@ import { useI18n } from "~/features/i18n"; import { makePersisted } from "@solid-primitives/storage"; import { writeClipboard } from "@solid-primitives/clipboard"; import { destructure } from "@solid-primitives/destructure"; -import css from "./edit.module.css"; import { contentsOf } from "~/features/file/helpers"; -import { createHtmlParser, createMarkdownParser, createSource } from "~/features/source"; +import css from "./edit.module.css"; const isInstalledPWA = !isServer && window.matchMedia('(display-mode: standalone)').matches; @@ -346,6 +345,7 @@ const Content: Component<{ directory: FileSystemDirectoryHandle, api?: Setter(); const files = readFiles(() => props.directory); + // const __contents = contentsOf(() => props.directory); const [contents] = createResource(files, (files) => Promise.all(Object.entries(files).map(async ([id, { file, handle }]) => ({ id, handle, lang: file.name.split('.').at(0)!, entries: (await read(file))! }))), { initialValue: [] }); const [entries, rows] = destructure(() => { diff --git a/src/routes/(editor)/experimental.tsx b/src/routes/(editor)/experimental.tsx index 5ddac59..6282f50 100644 --- a/src/routes/(editor)/experimental.tsx +++ b/src/routes/(editor)/experimental.tsx @@ -18,6 +18,7 @@ export default function Experimental(props: ParentProps) { + }> diff --git a/src/routes/(editor)/experimental/file-system-observer.tsx b/src/routes/(editor)/experimental/file-system-observer.tsx new file mode 100644 index 0000000..44f4eb9 --- /dev/null +++ b/src/routes/(editor)/experimental/file-system-observer.tsx @@ -0,0 +1,32 @@ +import { createEffect, createSignal, on } from "solid-js"; +import { readFiles } from "~/features/file"; +import { contentsOf } from "~/features/file/helpers"; + +export default function FileObserver(props: {}) { + const [dir, setDir] = createSignal(); + + const files = readFiles(dir); + const contents = contentsOf(dir); + + const open = async () => { + const handle = await window.showDirectoryPicker(); + + setDir(handle) + }; + + createEffect(() => { + console.log('dir', dir()); + }); + + createEffect(() => { + console.log('files', files()); + }); + + createEffect(() => { + console.log('contents', contents()); + }); + + return
+ +
; +} \ No newline at end of file