feature: poll files to auto-update on external file changes

This commit is contained in:
Chris Kruining 2025-01-27 15:09:43 +01:00
parent c9bc7d061b
commit 8b852b4ca4
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
10 changed files with 209 additions and 100 deletions

View file

@ -1,8 +1,7 @@
import Dexie, { EntityTable } from "dexie";
import { Accessor, createContext, createMemo, onMount, ParentComponent, useContext } from "solid-js";
import { Accessor, createContext, createMemo, createResource, InitializedResource, onCleanup, onMount, ParentComponent, useContext } from "solid-js";
import { createStore } from "solid-js/store";
import { isServer } from "solid-js/web";
import { json } from "./parser";
const ROOT = '__root__';
@ -163,12 +162,4 @@ export const FilesProvider: ParentComponent = (props) => {
return <FilesContext.Provider value={context}>{props.children}</FilesContext.Provider>;
}
export const useFiles = () => useContext(FilesContext)!;
export const load = (file: File): Promise<Map<string, string> | undefined> => {
switch (file.type) {
case 'application/json': return json.load(file.stream())
default: return Promise.resolve(undefined);
}
};
export const useFiles = () => useContext(FilesContext)!;

View file

@ -13,6 +13,7 @@ export interface GridApi {
readonly selection: Accessor<SelectionItem<number, Entry>[]>;
remove(indices: number[]): void;
addKey(key: string): void;
addLocale(locale: string): void;
selectAll(): void;
clearSelection(): void;
};
@ -33,8 +34,9 @@ const groupBy = (rows: DataSetRowNode<number, Entry>[]) => {
export function Grid(props: { class?: string, rows: Entry[], locales: string[], api?: (api: GridApi) => any, children?: (key: string) => JSX.Element }) {
const { t } = useI18n();
const [addedLocales, setAddedLocales] = createSignal<string[]>([]);
const rows = createMemo(() => createDataSet<Entry>(props.rows, { group: { by: 'key', with: groupBy } }));
const locales = createMemo(() => props.locales);
const locales = createMemo(() => [...props.locales, ...addedLocales()]);
const columns = createMemo<Column<Entry>[]>(() => [
{
id: 'key',
@ -56,9 +58,9 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[],
createEffect(() => {
const r = rows();
const l = locales();
const l = addedLocales();
r.mutateEach(({ key, ...rest }) => ({ key, ...Object.fromEntries(l.map(locale => [locale, rest[locale] ?? ''])) }));
r.mutateEach(({ key, ...rest }) => ({ key, ...rest, ...Object.fromEntries(l.map(locale => [locale, rest[locale] ?? ''])) }));
});
createEffect(() => {
@ -71,6 +73,9 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[],
addKey(key) {
r.insert({ key, ...Object.fromEntries(locales().map(l => [l, ''])) });
},
addLocale(locale) {
setAddedLocales(locales => new Set([...locales, locale]).values().toArray())
},
selectAll() {
api()?.selectAll();
},

View file

@ -0,0 +1,51 @@
import { Accessor, createResource, InitializedResource, onCleanup } from "solid-js";
import { json } from "./parser";
import { filter } from "~/utilities";
interface Files extends Record<string, { handle: FileSystemFileHandle, file: File }> { }
export const load = (file: File): Promise<Map<string, string> | undefined> => {
switch (file.type) {
case 'application/json': return json.load(file.stream())
default: return Promise.resolve(undefined);
}
};
export const readFiles = (directory: Accessor<FileSystemDirectoryHandle>): InitializedResource<Files> => {
const [value, { refetch }] = createResource<Files>(async (_, { value: prev }) => {
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 }]
));
const keysPrev = Object.keys(prev);
const keysNext = Object.keys(next);
if (keysPrev.length !== keysNext.length) {
return next;
}
if (keysPrev.some(prev => keysNext.includes(prev) === false)) {
return next;
}
if (Object.entries(prev).every(([id, { file }]) => next[id].file.lastModified === file.lastModified) === false) {
return next;
}
return prev;
}, { initialValue: {} })
const interval = setInterval(() => {
refetch();
}, 1000);
onCleanup(() => {
clearInterval(interval);
});
return value;
};

View file

@ -1,4 +1,7 @@
export { useFiles, FilesProvider, load } from './context';
export { load, readFiles } from './helpers';
export { useFiles, FilesProvider } from './context';
export { Grid } from './grid';
export { TreeProvider, Tree, useTree } from './tree';
export type { Entry } from './grid';

View file

@ -0,0 +1,30 @@
.root {
display: flex;
flex-direction: column;
list-style: none;
& details {
& > summary {
padding: var(--padding-s);
&::marker {
content: none;
color: var(--text-1) !important;
}
}
&::details-content {
display: flex;
flex-direction: column;
list-style: none;
padding-inline-start: 1.25em;
}
}
& span {
cursor: pointer;
white-space: nowrap;
padding: var(--padding-s);
}
}

173
src/features/file/tree.tsx Normal file
View file

@ -0,0 +1,173 @@
import { Accessor, children, Component, createContext, createEffect, createMemo, createResource, createSignal, For, InitializedResource, JSX, onCleanup, ParentComponent, Setter, Show, useContext } from "solid-js";
import { AiFillFile, AiFillFolder, AiFillFolderOpen } from "solid-icons/ai";
import { SelectionProvider, selectable } from "~/features/selectable";
import { debounce } from "@solid-primitives/scheduled";
import css from "./tree.module.css";
selectable;
export interface FileEntry {
name: string;
id: string;
kind: 'file';
handle: FileSystemFileHandle;
directory: FileSystemDirectoryHandle;
meta: File;
}
export interface FolderEntry {
name: string; handle
id: string;
kind: 'folder';
handle: FileSystemDirectoryHandle;
entries: Entry[];
}
export type Entry = FileEntry | FolderEntry;
export const emptyFolder: FolderEntry = { name: '', id: '', kind: 'folder', entries: [], handle: undefined as unknown as FileSystemDirectoryHandle } as const;
export async function* walk(directory: FileSystemDirectoryHandle, filters: RegExp[] = [], depth = 0): AsyncGenerator<Entry, void, never> {
if (depth === 10) {
return;
}
for await (const handle of directory.values()) {
if (filters.some(f => f.test(handle.name))) {
continue;
}
const id = await handle.getUniqueId();
if (handle.kind === 'file') {
yield { name: handle.name, id, handle, kind: 'file', meta: await handle.getFile(), directory };
}
else {
yield { name: handle.name, id, handle, kind: 'folder', entries: await Array.fromAsync(walk(handle, filters, depth + 1)) };
}
}
}
interface TreeContextType {
readonly tree: Accessor<FolderEntry>;
readonly name: Accessor<string>;
readonly open: Accessor<boolean>;
readonly setOpen: Setter<boolean>;
onOpen(file: File): void;
}
const TreeContext = createContext<TreeContextType>();
export const TreeProvider: ParentComponent<{ directory: FileSystemDirectoryHandle, onOpen?: (file: File) => void }> = (props) => {
const [open, setOpen] = createSignal(false);
const tree = readTree(() => props.directory);
const context = {
tree,
name: createMemo(() => props.directory.name),
open,
setOpen,
onOpen(file: File) {
props.onOpen?.(file);
},
};
return <TreeContext.Provider value={context}>
{props.children}
</TreeContext.Provider>;
}
export const useTree = () => {
const context = useContext(TreeContext);
if (!context) {
throw new Error('`useTree` is called outside of a <TreeProvider />');
}
return context;
}
export const Tree: Component<{
children: readonly [(folder: Accessor<FolderEntry>) => JSX.Element, (file: Accessor<FileEntry>) => JSX.Element]
}> = (props) => {
const [, setSelection] = createSignal<object[]>([]);
const context = useTree();
return <SelectionProvider selection={setSelection}>
<div class={css.root}><_Tree entries={context.tree().entries} children={props.children} /></div>
</SelectionProvider>;
}
const _Tree: Component<{ entries: Entry[], children: readonly [(folder: Accessor<FolderEntry>) => JSX.Element, (file: Accessor<FileEntry>) => JSX.Element] }> = (props) => {
const context = useTree();
return <For each={props.entries.toSorted(sort_by('kind'))}>{
entry => <>
<Show when={entry.kind === 'folder' ? entry : undefined}>{
folder => <Folder folder={folder()} children={props.children} />
}</Show>
<Show when={entry.kind === 'file' ? entry : undefined}>{
file => <span use:selectable={{ key: file().id, value: file() }} ondblclick={() => context.onOpen(file().meta)}><AiFillFile /> {props.children[1](file)}</span>
}</Show>
</>
}</For>
}
const Folder: Component<{ folder: FolderEntry, children: readonly [(folder: Accessor<FolderEntry>) => JSX.Element, (file: Accessor<FileEntry>) => JSX.Element] }> = (props) => {
const [open, setOpen] = createSignal(true);
return <details open={open()} ontoggle={() => debounce(() => setOpen(o => !o), 1)}>
<summary><Show when={open()} fallback={<AiFillFolder />}><AiFillFolderOpen /></Show> {props.children[0](() => props.folder)}</summary>
<_Tree entries={props.folder.entries} children={props.children} />
</details>;
};
const sort_by = (key: string) => (objA: Record<string, any>, objB: Record<string, any>) => {
const a = objA[key];
const b = objB[key];
return Number(a < b) - Number(b < a);
};
const readTree = (directory: Accessor<FileSystemDirectoryHandle>): Accessor<FolderEntry> => {
const [entries, { refetch }] = createResource<Entry[]>(async (_, { value: prev }) => {
const dir = directory();
prev ??= [];
const next: Entry[] = await Array.fromAsync(walk(dir));
const prevEntries = flatten(prev).map(e => e.id).toSorted();
const nextEntries = flatten(next).map(e => e.id).toSorted();
if (prevEntries.length !== nextEntries.length) {
return next;
}
if (prevEntries.some((entry, i) => entry !== nextEntries[i])) {
return next;
}
return prev;
}, { initialValue: [] })
const interval = setInterval(() => {
refetch();
}, 1000);
onCleanup(() => {
clearInterval(interval);
});
createEffect(() => {
console.log(entries.latest);
});
return createMemo<FolderEntry>(() => ({ name: directory().name, id: '', kind: 'folder', handle: directory(), entries: entries.latest }));
};
const flatten = (entries: Entry[]): Entry[] => {
return entries.flatMap(entry => entry.kind === 'folder' ? [entry, ...flatten(entry.entries)] : entry)
}