diff --git a/src/components/filetree.tsx b/src/components/filetree.tsx index 999ffaa..e74803f 100644 --- a/src/components/filetree.tsx +++ b/src/components/filetree.tsx @@ -2,22 +2,27 @@ import { Accessor, Component, createContext, createSignal, For, JSX, Show, useCo import { AiFillFile, AiFillFolder, AiFillFolderOpen } from "solid-icons/ai"; import { SelectionProvider, selectable } from "~/features/selectable"; import css from "./filetree.module.css"; +import { debounce } from "~/utilities"; + +selectable; export interface FileEntry { name: string; + id: string; kind: 'file'; meta: File; } export interface FolderEntry { name: string; + id: string; kind: 'folder'; entries: Entry[]; } 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 { if (depth === 10) { @@ -25,16 +30,17 @@ export async function* walk(directory: FileSystemDirectoryHandle, filters: RegEx } 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, kind: 'file', meta: await handle.getFile() }; + yield { name: handle.name, id, kind: 'file', meta: await handle.getFile() }; } 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(); export const Tree: Component<{ entries: Entry[], children: (file: Accessor) => JSX.Element, open?: TreeContextType['open'] }> = (props) => { const [selection, setSelection] = createSignal([]); - // createEffect(() => { - // console.log(selection()); - // }); - const context = { open: props.open ?? (() => { }), - // open(file: File) { - // console.log(`open ${file.name}`) - // }, }; return @@ -76,16 +75,16 @@ const _Tree: Component<{ entries: Entry[], children: (file: Accessor) } { - file => context?.open(file().meta)}> {props.children(file)} + file => context?.open(file().meta)}> {props.children(file)} } } } const Folder: Component<{ folder: FolderEntry, children: (file: Accessor) => JSX.Element }> = (props) => { - const [open, setOpen] = createSignal(false); + const [open, setOpen] = createSignal(true); - return
setOpen(o => !o)}> + return
debounce(() => setOpen(o => !o), 1)}> }> {props.folder.name} <_Tree entries={props.folder.entries} children={props.children} />
; diff --git a/src/features/file/grid.tsx b/src/features/file/grid.tsx index 348fe63..2ae62da 100644 --- a/src/features/file/grid.tsx +++ b/src/features/file/grid.tsx @@ -1,35 +1,24 @@ 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 { debounce, deepCopy, deepDiff, Mutation } from "~/utilities"; import css from './grid.module.css'; selectable // prevents removal of import -const debounce = void>(callback: T, delay: number): T => { - let handle: ReturnType | undefined; - - return (...args: any[]) => { - if (handle) { - clearTimeout(handle); - } - - handle = setTimeout(() => callback(...args), delay); - } -}; - interface Leaf extends Record { } export interface Entry extends Record { } -type Rows = Record; +type Rows = Map>; export interface GridContextType { - readonly rows: Accessor; + readonly mutations: Accessor; readonly selection: Accessor; mutate(prop: string, lang: string, value: string): void; } export interface GridApi { - readonly rows: Accessor; + readonly mutations: Accessor; selectAll(): void; clear(): void; } @@ -39,19 +28,19 @@ const GridContext = createContext(); const isLeaf = (entry: Entry | Leaf): entry is Leaf => Object.values(entry).some(v => typeof v === 'string'); const useGrid = () => useContext(GridContext)!; -const GridProvider: ParentComponent<{ rows: Map }> = (props) => { +const GridProvider: ParentComponent<{ rows: Rows }> = (props) => { const [selection, setSelection] = createSignal([]); - const [state, setState] = createStore<{ rows: Rows, numberOfRows: number }>({ + const [state, setState] = createStore<{ rows: Record>, snapshot: Rows, numberOfRows: number }>({ rows: {}, + snapshot: new Map, numberOfRows: 0, }); - createEffect(() => { - const rows = props.rows - .entries() - .map(([prop, entry]) => [prop, Object.fromEntries(Object.entries(entry).map(([lang, { value }]) => [lang, { original: value, value }]))]); + const mutations = createMemo(() => deepDiff(state.snapshot, state.rows).toArray()); - setState('rows', Object.fromEntries(rows)); + createEffect(() => { + setState('rows', Object.fromEntries(deepCopy(props.rows).entries())); + setState('snapshot', props.rows); }); createEffect(() => { @@ -59,13 +48,11 @@ const GridProvider: ParentComponent<{ rows: Map state.rows), + mutations, selection, mutate(prop: string, lang: string, value: string) { - setState('rows', produce(rows => { - rows[prop][lang].value = value; - })); + setState('rows', prop, lang, value); }, }; @@ -76,32 +63,29 @@ const GridProvider: ParentComponent<{ rows: Map; }; -export const Grid: Component<{ class?: string, columns: string[], rows: Map, 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 root = createMemo(() => { - return props.rows - ?.entries() - .map(([key, value]) => [key, Object.fromEntries(Object.entries(value).map(([lang, { value }]) => [lang, value]))] as const) - .reduce((aggregate, [key, entry]) => { - let obj: any = aggregate; - const parts = key.split('.'); + const root = createMemo(() => props.rows + ?.entries() + .reduce((aggregate, [key, value]) => { + let obj: any = aggregate; + const parts = key.split('.'); - for (const [i, part] of parts.entries()) { - if (Object.hasOwn(obj, part) === false) { - obj[part] = {}; - } - - if (i === (parts.length - 1)) { - obj[part] = entry; - } - else { - obj = obj[part]; - } + for (const [i, part] of parts.entries()) { + if (Object.hasOwn(obj, part) === false) { + obj[part] = {}; } - return aggregate; - }, {}); - }); + if (i === (parts.length - 1)) { + obj[part] = value; + } + else { + obj = obj[part]; + } + } + + return aggregate; + }, {})); return
@@ -121,7 +105,7 @@ const Api: Component<{ api: undefined | ((api: GridApi) => any) }> = (props) => const selectionContext = useSelection(); const api: GridApi = { - rows: gridContext.rows, + mutations: gridContext.mutations, selectAll() { selectionContext.selectAll(); }, @@ -171,7 +155,7 @@ const Row: Component<{ entry: Entry, path?: string[] }> = (props) => { return }>
- context.select([k], { append: true })} /> + context.select([k])} />
@@ -229,5 +213,6 @@ const TextArea: Component<{ key: string, value: string, lang: string, oninput?: spellcheck wrap="soft" onkeyup={onKeyUp} + on:pointerdown={(e: PointerEvent) => e.stopPropagation()} /> }; \ No newline at end of file diff --git a/src/features/selectable/index.tsx b/src/features/selectable/index.tsx index fb046eb..78b3754 100644 --- a/src/features/selectable/index.tsx +++ b/src/features/selectable/index.tsx @@ -65,7 +65,7 @@ export const SelectionProvider: ParentComponent<{ selection?: SelectionHandler, length, select(selection, { mode = SelectionMode.Normal } = {}) { if (props.multiSelect === true && mode === SelectionMode.Normal) { - mode = SelectionMode.Append; + mode = SelectionMode.Toggle; } setState('selection', existing => { @@ -238,8 +238,7 @@ export const selectable = (element: HTMLElement, options: Accessor<{ value: obje const append = Boolean(modifier() & Modifier.Control); const mode = (() => { - if (append) return SelectionMode.Append; - if (!withRange && isSelected()) return SelectionMode.Toggle; + if (append) return SelectionMode.Toggle; if (withRange) return SelectionMode.Replace; return SelectionMode.Normal; })(); diff --git a/src/global.d.ts b/src/global.d.ts index 139597f..754ff79 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,2 +1,5 @@ +interface FileSystemHandle { + getUniqueId(): Promise; +} \ No newline at end of file diff --git a/src/routes/(editor)/edit.module.css b/src/routes/(editor)/edit.module.css index c9edb51..00a0deb 100644 --- a/src/routes/(editor)/edit.module.css +++ b/src/routes/(editor)/edit.module.css @@ -16,4 +16,12 @@ margin: 0; } } + + .mutated { + color: var(--warn); + + &::after { + content: ' •'; + } + } } \ No newline at end of file diff --git a/src/routes/(editor)/edit.tsx b/src/routes/(editor)/edit.tsx index 17cb88d..e024112 100644 --- a/src/routes/(editor)/edit.tsx +++ b/src/routes/(editor)/edit.tsx @@ -1,13 +1,14 @@ import { Menu } from "~/features/menu"; 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 { 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 { 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 }, void, never> { +async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []): AsyncGenerator<{ id: string, handle: FileSystemFileHandle, path: string[], lang: string, entries: Map }, void, never> { for await (const handle of directory.values()) { if (handle.kind === 'directory') { yield* walk(handle, [...path, handle.name]); @@ -19,12 +20,13 @@ async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []): continue; } + const id = await handle.getUniqueId(); const file = await handle.getFile(); const lang = file.name.split('.').at(0)!; const entries = await load(file); 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 [root, { mutate, refetch }] = createResource(() => filesContext?.get('root')); const [tree, setFiles] = createSignal(emptyFolder); - const [columns, setColumns] = createSignal([]); - const [rows, setRows] = createSignal>(new Map); + const [columns, setColumns] = createSignal([]); + const [rows, setRows] = createSignal>>(new Map); + const [entries, setEntries] = createSignal>>(new Map); const [api, setApi] = createSignal(); + 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 onMount(() => { refetch(); @@ -50,7 +67,7 @@ export default function Edit(props: ParentProps) { const languages = new Set(contents.map(c => c.lang)); 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()) { const k = [...path, key].join('.'); @@ -58,18 +75,23 @@ export default function Edit(props: ParentProps) { aggregate.set(k, Object.fromEntries(template)); } - aggregate.get(k)![lang] = { handle, value }; + aggregate.get(k)![lang] = { value, handle, id }; } return aggregate; - }, new Map()); + }, new Map>()); - setFiles({ name: '', kind: 'folder', entries: await Array.fromAsync(fileTreeWalk(directory)) }); + setFiles({ name: '', id: '', kind: 'folder', entries: await Array.fromAsync(fileTreeWalk(directory)) }); 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 = { open: createCommand('open', async () => { const [fileHandle] = await window.showOpenFilePicker({ @@ -96,7 +118,7 @@ export default function Edit(props: ParentProps) { mutate(directory); }), save: createCommand('save', () => { - console.log('save', rows()); + console.log('save'); }, { key: 's', modifier: Modifier.Control }), saveAs: createCommand('save as', (handle?: FileSystemFileHandle) => { console.log('save as ...', handle); @@ -120,12 +142,6 @@ export default function Edit(props: ParentProps) { }), } 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
{ @@ -154,7 +170,11 @@ export default function Edit(props: ParentProps) { { - file => {file().name} + file => { + const mutated = createMemo(() => mutatedFiles().has(file().id)); + + return {file().name}; + } } diff --git a/src/utilities.ts b/src/utilities.ts new file mode 100644 index 0000000..697fbc0 --- /dev/null +++ b/src/utilities.ts @@ -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 = void>(callback: T, delay: number): T => { + let handle: ReturnType | undefined; + + return (...args: any[]) => { + if (handle) { + clearTimeout(handle); + } + + handle = setTimeout(() => callback(...args), delay); + }; +}; + +export const deepCopy = (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(a: T1, b: T2, path: string[] = []): Generator { + 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 => ['boolean', 'undefined', 'null', 'number'].includes(typeof subject) === false; +const entriesOf = (subject: object): Iterable => { + 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, b: Iterable): Generator { + 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; + } +} +