switch over to deep diffing for mutations

This commit is contained in:
Chris Kruining 2024-10-21 14:19:39 +02:00
parent 2e3a3e90de
commit 6064fd3b45
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
7 changed files with 228 additions and 88 deletions

View file

@ -2,22 +2,27 @@ import { Accessor, Component, createContext, createSignal, For, JSX, Show, useCo
import { AiFillFile, AiFillFolder, AiFillFolderOpen } from "solid-icons/ai"; import { AiFillFile, AiFillFolder, AiFillFolderOpen } from "solid-icons/ai";
import { SelectionProvider, selectable } from "~/features/selectable"; import { SelectionProvider, selectable } from "~/features/selectable";
import css from "./filetree.module.css"; import css from "./filetree.module.css";
import { debounce } from "~/utilities";
selectable;
export interface FileEntry { export interface FileEntry {
name: string; name: string;
id: string;
kind: 'file'; kind: 'file';
meta: File; meta: File;
} }
export interface FolderEntry { export interface FolderEntry {
name: string; name: string;
id: string;
kind: 'folder'; kind: 'folder';
entries: Entry[]; entries: Entry[];
} }
export type Entry = FileEntry | FolderEntry; export type Entry = FileEntry | FolderEntry;
export const emptyFolder: FolderEntry = { name: '', kind: 'folder', entries: [] } as const; export const emptyFolder: FolderEntry = { name: '', id: '', kind: 'folder', entries: [] } as const;
export async function* walk(directory: FileSystemDirectoryHandle, filters: RegExp[] = [], depth = 0): AsyncGenerator<Entry, void, never> { export async function* walk(directory: FileSystemDirectoryHandle, filters: RegExp[] = [], depth = 0): AsyncGenerator<Entry, void, never> {
if (depth === 10) { if (depth === 10) {
@ -25,16 +30,17 @@ export async function* walk(directory: FileSystemDirectoryHandle, filters: RegEx
} }
for await (const handle of directory.values()) { for await (const handle of directory.values()) {
if (filters.some(f => f.test(handle.name))) { if (filters.some(f => f.test(handle.name))) {
continue; continue;
} }
const id = await handle.getUniqueId();
if (handle.kind === 'file') { if (handle.kind === 'file') {
yield { name: handle.name, kind: 'file', meta: await handle.getFile() }; yield { name: handle.name, id, kind: 'file', meta: await handle.getFile() };
} }
else { else {
yield { name: handle.name, kind: 'folder', entries: await Array.fromAsync(walk(handle, filters, depth + 1)) }; yield { name: handle.name, id, kind: 'folder', entries: await Array.fromAsync(walk(handle, filters, depth + 1)) };
} }
} }
} }
@ -48,15 +54,8 @@ const TreeContext = createContext<TreeContextType>();
export const Tree: Component<{ entries: Entry[], children: (file: Accessor<FileEntry>) => JSX.Element, open?: TreeContextType['open'] }> = (props) => { export const Tree: Component<{ entries: Entry[], children: (file: Accessor<FileEntry>) => JSX.Element, open?: TreeContextType['open'] }> = (props) => {
const [selection, setSelection] = createSignal<object[]>([]); const [selection, setSelection] = createSignal<object[]>([]);
// createEffect(() => {
// console.log(selection());
// });
const context = { const context = {
open: props.open ?? (() => { }), open: props.open ?? (() => { }),
// open(file: File) {
// console.log(`open ${file.name}`)
// },
}; };
return <SelectionProvider selection={setSelection}> return <SelectionProvider selection={setSelection}>
@ -76,16 +75,16 @@ const _Tree: Component<{ entries: Entry[], children: (file: Accessor<FileEntry>)
}</Show> }</Show>
<Show when={entry.kind === 'file' ? entry : undefined}>{ <Show when={entry.kind === 'file' ? entry : undefined}>{
file => <span use:selectable={file()} ondblclick={() => context?.open(file().meta)}><AiFillFile /> {props.children(file)}</span> file => <span use:selectable={{ value: file() }} ondblclick={() => context?.open(file().meta)}><AiFillFile /> {props.children(file)}</span>
}</Show> }</Show>
</> </>
}</For> }</For>
} }
const Folder: Component<{ folder: FolderEntry, children: (file: Accessor<FileEntry>) => JSX.Element }> = (props) => { const Folder: Component<{ folder: FolderEntry, children: (file: Accessor<FileEntry>) => JSX.Element }> = (props) => {
const [open, setOpen] = createSignal(false); const [open, setOpen] = createSignal(true);
return <details open={open()} ontoggle={() => setOpen(o => !o)}> return <details open={open()} ontoggle={() => debounce(() => setOpen(o => !o), 1)}>
<summary><Show when={open()} fallback={<AiFillFolder />}><AiFillFolderOpen /></Show> {props.folder.name}</summary> <summary><Show when={open()} fallback={<AiFillFolder />}><AiFillFolderOpen /></Show> {props.folder.name}</summary>
<_Tree entries={props.folder.entries} children={props.children} /> <_Tree entries={props.folder.entries} children={props.children} />
</details>; </details>;

View file

@ -1,35 +1,24 @@
import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, onMount, ParentComponent, Show, useContext } from "solid-js"; import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, onMount, ParentComponent, Show, useContext } from "solid-js";
import { createStore, produce } from "solid-js/store"; import { createStore } from "solid-js/store";
import { SelectionProvider, useSelection, selectable } from "../selectable"; import { SelectionProvider, useSelection, selectable } from "../selectable";
import { debounce, deepCopy, deepDiff, Mutation } from "~/utilities";
import css from './grid.module.css'; import css from './grid.module.css';
selectable // prevents removal of import selectable // prevents removal of import
const debounce = <T extends (...args: any[]) => void>(callback: T, delay: number): T => {
let handle: ReturnType<typeof setTimeout> | undefined;
return (...args: any[]) => {
if (handle) {
clearTimeout(handle);
}
handle = setTimeout(() => callback(...args), delay);
}
};
interface Leaf extends Record<string, string> { } interface Leaf extends Record<string, string> { }
export interface Entry extends Record<string, Entry | Leaf> { } export interface Entry extends Record<string, Entry | Leaf> { }
type Rows = Record<string, { [lang: string]: { original: string, value: string } }>; type Rows = Map<string, Record<string, string>>;
export interface GridContextType { export interface GridContextType {
readonly rows: Accessor<Rows>; readonly mutations: Accessor<Mutation[]>;
readonly selection: Accessor<object[]>; readonly selection: Accessor<object[]>;
mutate(prop: string, lang: string, value: string): void; mutate(prop: string, lang: string, value: string): void;
} }
export interface GridApi { export interface GridApi {
readonly rows: Accessor<Rows>; readonly mutations: Accessor<Mutation[]>;
selectAll(): void; selectAll(): void;
clear(): void; clear(): void;
} }
@ -39,19 +28,19 @@ const GridContext = createContext<GridContextType>();
const isLeaf = (entry: Entry | Leaf): entry is Leaf => Object.values(entry).some(v => typeof v === 'string'); const isLeaf = (entry: Entry | Leaf): entry is Leaf => Object.values(entry).some(v => typeof v === 'string');
const useGrid = () => useContext(GridContext)!; const useGrid = () => useContext(GridContext)!;
const GridProvider: ParentComponent<{ rows: Map<string, { [lang: string]: { value: string, handle: FileSystemFileHandle } }> }> = (props) => { const GridProvider: ParentComponent<{ rows: Rows }> = (props) => {
const [selection, setSelection] = createSignal<object[]>([]); const [selection, setSelection] = createSignal<object[]>([]);
const [state, setState] = createStore<{ rows: Rows, numberOfRows: number }>({ const [state, setState] = createStore<{ rows: Record<string, Record<string, string>>, snapshot: Rows, numberOfRows: number }>({
rows: {}, rows: {},
snapshot: new Map,
numberOfRows: 0, numberOfRows: 0,
}); });
createEffect(() => { const mutations = createMemo(() => deepDiff(state.snapshot, state.rows).toArray());
const rows = props.rows
.entries()
.map(([prop, entry]) => [prop, Object.fromEntries(Object.entries(entry).map(([lang, { value }]) => [lang, { original: value, value }]))]);
setState('rows', Object.fromEntries(rows)); createEffect(() => {
setState('rows', Object.fromEntries(deepCopy(props.rows).entries()));
setState('snapshot', props.rows);
}); });
createEffect(() => { createEffect(() => {
@ -59,13 +48,11 @@ const GridProvider: ParentComponent<{ rows: Map<string, { [lang: string]: { valu
}); });
const ctx: GridContextType = { const ctx: GridContextType = {
rows: createMemo(() => state.rows), mutations,
selection, selection,
mutate(prop: string, lang: string, value: string) { mutate(prop: string, lang: string, value: string) {
setState('rows', produce(rows => { setState('rows', prop, lang, value);
rows[prop][lang].value = value;
}));
}, },
}; };
@ -76,13 +63,11 @@ const GridProvider: ParentComponent<{ rows: Map<string, { [lang: string]: { valu
</GridContext.Provider>; </GridContext.Provider>;
}; };
export const Grid: Component<{ class?: string, columns: string[], rows: Map<string, { [lang: string]: { value: string, handle: FileSystemFileHandle } }>, api?: (api: GridApi) => any }> = (props) => { export const Grid: Component<{ class?: string, columns: string[], rows: Rows, api?: (api: GridApi) => any }> = (props) => {
const columnCount = createMemo(() => props.columns.length - 1); const columnCount = createMemo(() => props.columns.length - 1);
const root = createMemo<Entry>(() => { const root = createMemo<Entry>(() => props.rows
return props.rows
?.entries() ?.entries()
.map(([key, value]) => [key, Object.fromEntries(Object.entries(value).map(([lang, { value }]) => [lang, value]))] as const) .reduce((aggregate, [key, value]) => {
.reduce((aggregate, [key, entry]) => {
let obj: any = aggregate; let obj: any = aggregate;
const parts = key.split('.'); const parts = key.split('.');
@ -92,7 +77,7 @@ export const Grid: Component<{ class?: string, columns: string[], rows: Map<stri
} }
if (i === (parts.length - 1)) { if (i === (parts.length - 1)) {
obj[part] = entry; obj[part] = value;
} }
else { else {
obj = obj[part]; obj = obj[part];
@ -100,8 +85,7 @@ export const Grid: Component<{ class?: string, columns: string[], rows: Map<stri
} }
return aggregate; return aggregate;
}, {}); }, {}));
});
return <section class={`${css.table} ${props.class}`} style={{ '--columns': columnCount() }}> return <section class={`${css.table} ${props.class}`} style={{ '--columns': columnCount() }}>
<GridProvider rows={props.rows}> <GridProvider rows={props.rows}>
@ -121,7 +105,7 @@ const Api: Component<{ api: undefined | ((api: GridApi) => any) }> = (props) =>
const selectionContext = useSelection(); const selectionContext = useSelection();
const api: GridApi = { const api: GridApi = {
rows: gridContext.rows, mutations: gridContext.mutations,
selectAll() { selectAll() {
selectionContext.selectAll(); selectionContext.selectAll();
}, },
@ -171,7 +155,7 @@ const Row: Component<{ entry: Entry, path?: string[] }> = (props) => {
return <Show when={isLeaf(value)} fallback={<Group key={key} entry={value as Entry} path={path} />}> return <Show when={isLeaf(value)} fallback={<Group key={key} entry={value as Entry} path={path} />}>
<div class={css.row} use:selectable={{ value, key: k }}> <div class={css.row} use:selectable={{ value, key: k }}>
<div class={css.cell}> <div class={css.cell}>
<input type="checkbox" checked={isSelected()} oninput={() => context.select([k], { append: true })} /> <input type="checkbox" checked={isSelected()} oninput={() => context.select([k])} />
</div> </div>
<div class={css.cell}> <div class={css.cell}>
@ -229,5 +213,6 @@ const TextArea: Component<{ key: string, value: string, lang: string, oninput?:
spellcheck spellcheck
wrap="soft" wrap="soft"
onkeyup={onKeyUp} onkeyup={onKeyUp}
on:pointerdown={(e: PointerEvent) => e.stopPropagation()}
/> />
}; };

View file

@ -65,7 +65,7 @@ export const SelectionProvider: ParentComponent<{ selection?: SelectionHandler,
length, length,
select(selection, { mode = SelectionMode.Normal } = {}) { select(selection, { mode = SelectionMode.Normal } = {}) {
if (props.multiSelect === true && mode === SelectionMode.Normal) { if (props.multiSelect === true && mode === SelectionMode.Normal) {
mode = SelectionMode.Append; mode = SelectionMode.Toggle;
} }
setState('selection', existing => { setState('selection', existing => {
@ -238,8 +238,7 @@ export const selectable = (element: HTMLElement, options: Accessor<{ value: obje
const append = Boolean(modifier() & Modifier.Control); const append = Boolean(modifier() & Modifier.Control);
const mode = (() => { const mode = (() => {
if (append) return SelectionMode.Append; if (append) return SelectionMode.Toggle;
if (!withRange && isSelected()) return SelectionMode.Toggle;
if (withRange) return SelectionMode.Replace; if (withRange) return SelectionMode.Replace;
return SelectionMode.Normal; return SelectionMode.Normal;
})(); })();

3
src/global.d.ts vendored
View file

@ -1,2 +1,5 @@
interface FileSystemHandle {
getUniqueId(): Promise<string>;
}

View file

@ -16,4 +16,12 @@
margin: 0; margin: 0;
} }
} }
.mutated {
color: var(--warn);
&::after {
content: ' •';
}
}
} }

View file

@ -1,13 +1,14 @@
import { Menu } from "~/features/menu"; import { Menu } from "~/features/menu";
import { Sidebar } from "~/components/sidebar"; import { Sidebar } from "~/components/sidebar";
import { Component, createEffect, createMemo, createResource, createSignal, For, onMount, ParentProps, Show } from "solid-js"; import { Component, createEffect, createMemo, createResource, createSignal, onMount, ParentComponent, ParentProps } from "solid-js";
import { Grid, load, useFiles } from "~/features/file"; import { Grid, load, useFiles } from "~/features/file";
import { Command, Context, createCommand, Modifier, noop } from "~/features/command"; import { Command, Context, createCommand, Modifier, noop } from "~/features/command";
import { GridApi, GridContextType } from "~/features/file/grid"; import { GridApi } from "~/features/file/grid";
import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree, FileEntry } from "~/components/filetree";
import css from "./edit.module.css"; import css from "./edit.module.css";
import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree } from "~/components/filetree"; import { splitAt } from "~/utilities";
async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []): AsyncGenerator<{ handle: FileSystemFileHandle, path: string[], lang: string, entries: Map<string, string> }, void, never> { async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []): AsyncGenerator<{ id: string, handle: FileSystemFileHandle, path: string[], lang: string, entries: Map<string, string> }, void, never> {
for await (const handle of directory.values()) { for await (const handle of directory.values()) {
if (handle.kind === 'directory') { if (handle.kind === 'directory') {
yield* walk(handle, [...path, handle.name]); yield* walk(handle, [...path, handle.name]);
@ -19,12 +20,13 @@ async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []):
continue; continue;
} }
const id = await handle.getUniqueId();
const file = await handle.getFile(); const file = await handle.getFile();
const lang = file.name.split('.').at(0)!; const lang = file.name.split('.').at(0)!;
const entries = await load(file); const entries = await load(file);
if (entries !== undefined) { if (entries !== undefined) {
yield { handle, path, lang, entries }; yield { id, handle, path, lang, entries };
} }
} }
}; };
@ -33,10 +35,25 @@ export default function Edit(props: ParentProps) {
const filesContext = useFiles(); const filesContext = useFiles();
const [root, { mutate, refetch }] = createResource(() => filesContext?.get('root')); const [root, { mutate, refetch }] = createResource(() => filesContext?.get('root'));
const [tree, setFiles] = createSignal<FolderEntry>(emptyFolder); const [tree, setFiles] = createSignal<FolderEntry>(emptyFolder);
const [columns, setColumns] = createSignal([]); const [columns, setColumns] = createSignal<string[]>([]);
const [rows, setRows] = createSignal<Map<string, { [lang: string]: { value: string, handle: FileSystemFileHandle } }>>(new Map); const [rows, setRows] = createSignal<Map<string, Record<string, string>>>(new Map);
const [entries, setEntries] = createSignal<Map<string, Record<string, { id: String, value: string, handle: FileSystemFileHandle }>>>(new Map);
const [api, setApi] = createSignal<GridApi>(); const [api, setApi] = createSignal<GridApi>();
const mutatedFiles = createMemo(() => {
const mutations = api()?.mutations() ?? [];
const files = entries();
return new Set(mutations
.map(mutation => {
const [key, lang] = splitAt(mutation.key, mutation.key.lastIndexOf('.'));
return files.get(key)?.[lang]?.id;
})
.filter(Boolean)
);
});
// Since the files are stored in indexedDb we need to refetch on the client in order to populate on page load // Since the files are stored in indexedDb we need to refetch on the client in order to populate on page load
onMount(() => { onMount(() => {
refetch(); refetch();
@ -50,7 +67,7 @@ export default function Edit(props: ParentProps) {
const languages = new Set(contents.map(c => c.lang)); const languages = new Set(contents.map(c => c.lang));
const template = contents.map(({ lang, handle }) => [lang, { handle, value: '' }]); const template = contents.map(({ lang, handle }) => [lang, { handle, value: '' }]);
const merged = contents.reduce((aggregate, { handle, path, lang, entries }) => { const merged = contents.reduce((aggregate, { id, handle, path, lang, entries }) => {
for (const [key, value] of entries.entries()) { for (const [key, value] of entries.entries()) {
const k = [...path, key].join('.'); const k = [...path, key].join('.');
@ -58,18 +75,23 @@ export default function Edit(props: ParentProps) {
aggregate.set(k, Object.fromEntries(template)); aggregate.set(k, Object.fromEntries(template));
} }
aggregate.get(k)![lang] = { handle, value }; aggregate.get(k)![lang] = { value, handle, id };
} }
return aggregate; return aggregate;
}, new Map<string, { [lang: string]: { value: string, handle: FileSystemFileHandle } }>()); }, new Map<string, Record<string, { id: string, value: string, handle: FileSystemFileHandle }>>());
setFiles({ name: '', kind: 'folder', entries: await Array.fromAsync(fileTreeWalk(directory)) }); setFiles({ name: '', id: '', kind: 'folder', entries: await Array.fromAsync(fileTreeWalk(directory)) });
setColumns(['key', ...languages]); setColumns(['key', ...languages]);
setRows(merged); setEntries(merged);
setRows(new Map(merged.entries().map(([key, langs]) => [key, Object.fromEntries(Object.entries(langs).map(([lang, { value }]) => [lang, value]))] as const)));
} }
}); });
createEffect(() => {
mutatedFiles()
});
const commands = { const commands = {
open: createCommand('open', async () => { open: createCommand('open', async () => {
const [fileHandle] = await window.showOpenFilePicker({ const [fileHandle] = await window.showOpenFilePicker({
@ -96,7 +118,7 @@ export default function Edit(props: ParentProps) {
mutate(directory); mutate(directory);
}), }),
save: createCommand('save', () => { save: createCommand('save', () => {
console.log('save', rows()); console.log('save');
}, { key: 's', modifier: Modifier.Control }), }, { key: 's', modifier: Modifier.Control }),
saveAs: createCommand('save as', (handle?: FileSystemFileHandle) => { saveAs: createCommand('save as', (handle?: FileSystemFileHandle) => {
console.log('save as ...', handle); console.log('save as ...', handle);
@ -120,12 +142,6 @@ export default function Edit(props: ParentProps) {
}), }),
} as const; } as const;
const mutated = createMemo(() => Object.values(api()?.rows() ?? {}).filter(row => Object.values(row).some(lang => lang.original !== lang.value)));
createEffect(() => {
console.log('KAAS', mutated());
});
return <div class={css.root}> return <div class={css.root}>
<Context.Root commands={[commands.saveAs]}> <Context.Root commands={[commands.saveAs]}>
<Context.Menu>{ <Context.Menu>{
@ -154,7 +170,11 @@ export default function Edit(props: ParentProps) {
<Sidebar as="aside" class={css.sidebar}> <Sidebar as="aside" class={css.sidebar}>
<Tree entries={tree().entries}>{ <Tree entries={tree().entries}>{
file => <Context.Handle>{file().name}</Context.Handle> file => {
const mutated = createMemo(() => mutatedFiles().has(file().id));
return <Context.Handle><span classList={{ [css.mutated]: mutated() }}>{file().name}</span></Context.Handle>;
}
}</Tree> }</Tree>
</Sidebar> </Sidebar>

126
src/utilities.ts Normal file
View file

@ -0,0 +1,126 @@
export const splitAt = (subject: string, index: number): readonly [string, string] => {
return [subject.slice(0, index), subject.slice(index + 1)] as const;
};
export const debounce = <T extends (...args: any[]) => void>(callback: T, delay: number): T => {
let handle: ReturnType<typeof setTimeout> | undefined;
return (...args: any[]) => {
if (handle) {
clearTimeout(handle);
}
handle = setTimeout(() => callback(...args), delay);
};
};
export const deepCopy = <T>(original: T): T => {
if (typeof original !== 'object' || original === null || original === undefined) {
return original;
}
if (original instanceof Date) {
return new Date(original.getTime()) as T;
}
if (original instanceof Array) {
return original.map(item => deepCopy(item)) as T;
}
if (original instanceof Set) {
return new Set(original.values().map(item => deepCopy(item))) as T;
}
if (original instanceof Map) {
return new Map(original.entries().map(([key, value]) => [key, deepCopy(value)])) as T;
}
return Object.assign(
Object.create(Object.getPrototypeOf(original)),
Object.fromEntries(Object.entries(original).map(([key, value]) => [key, deepCopy(value)]))
) as T;
}
type Added = { kind: 'added', value: any };
type Updated = { kind: 'updated', value: any, original: any };
type Removed = { kind: 'removed' };
export type Mutation = { key: string } & (Added | Updated | Removed);
export function* deepDiff<T1 extends object, T2 extends object>(a: T1, b: T2, path: string[] = []): Generator<Mutation, void, unknown> {
if (!isIterable(a) || !isIterable(b)) {
console.log('Edge cases', a, b);
return;
}
for (const [[keyA, valueA], [keyB, valueB]] of zip(entriesOf(a), entriesOf(b))) {
if (!keyA && !keyB) {
throw new Error('this code should not be reachable, there is a bug with an unhandled/unknown edge case');
}
if (!keyA && keyB) {
yield { key: path.concat(keyB.toString()).join('.'), kind: 'added', value: valueB };
continue;
}
if (keyA && !keyB) {
// value was added
yield { key: path.concat(keyA.toString()).join('.'), kind: 'removed' };
continue;
}
if (typeof valueA == 'object' && typeof valueB == 'object') {
yield* deepDiff(valueA, valueB, path.concat(keyA!.toString()));
continue;
}
if (valueA === valueB) {
continue;
}
const key = path.concat(keyA!.toString()).join('.');
yield ((): Mutation => {
if (valueA === null || valueA === undefined) return { key, kind: 'added', value: valueB };
if (valueB === null || valueB === undefined) return { key, kind: 'removed' };
return { key, kind: 'updated', value: valueB, original: valueA };
})();
}
};
const isIterable = (subject: object): subject is Iterable<any> => ['boolean', 'undefined', 'null', 'number'].includes(typeof subject) === false;
const entriesOf = (subject: object): Iterable<readonly [string | number, any]> => {
if (subject instanceof Array) {
return subject.entries();
}
if (subject instanceof Map) {
return subject.entries();
}
if (subject instanceof Set) {
return subject.entries();
}
return Object.entries(subject);
};
const zip = function* (a: Iterable<readonly [string | number, any]>, b: Iterable<readonly [string | number, any]>): Generator<readonly [[string | number | undefined, any], [string | number | undefined, any]], void, unknown> {
const iterA = Iterator.from(a);
const iterB = Iterator.from(b);
while (true) {
const { done: doneA, value: entryA = [] } = iterA.next() ?? {};
const { done: doneB, value: entryB = [] } = iterB.next() ?? {};
if (doneA && doneB) {
break;
}
yield [entryA, entryB] as const;
}
}