From 992bb77d2f085bca27f23ff9784b8f500b49e2a4 Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Mon, 4 Nov 2024 17:03:41 +0100 Subject: [PATCH] working on fixing/reimplementing save command now that the mutations logic is more complete --- app.config.ts | 5 +++ public/manifest.json | 10 +++++ src/app.css | 8 ++++ src/components/filetree.tsx | 2 +- src/components/prompt.module.css | 34 +++++++++++++++ src/components/prompt.tsx | 75 ++++++++++++++++++++++++++++++++ src/entry-server.tsx | 2 +- src/features/file/grid.tsx | 41 ++++++++--------- src/features/file/index.tsx | 15 ++++--- src/routes/(editor)/edit.tsx | 68 +++++++++++++++++++++++------ src/routes/editor.module.css | 2 +- src/utilities.ts | 35 +++++++++------ 12 files changed, 239 insertions(+), 58 deletions(-) create mode 100644 src/components/prompt.module.css create mode 100644 src/components/prompt.tsx diff --git a/app.config.ts b/app.config.ts index e216b11..f13cb63 100644 --- a/app.config.ts +++ b/app.config.ts @@ -30,4 +30,9 @@ export default defineConfig({ compact: true, }, }, + server: { + prerender: { + crawlLinks: true, + }, + }, }); diff --git a/public/manifest.json b/public/manifest.json index 3ef91f7..c9372f3 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -26,5 +26,15 @@ "sizes": "2092x1295", "form_factor": "wide" } + ], + "file_handlers": [ + { + "action": "/edit", + "accept": { + "text/*": [ + ".json" + ] + } + } ] } \ No newline at end of file diff --git a/src/app.css b/src/app.css index df7ff87..82265d3 100644 --- a/src/app.css +++ b/src/app.css @@ -78,4 +78,12 @@ h1 { p { line-height: 1.35; + margin: 0; +} + +code { + padding-inline: var(--padding-s); + background-color: var(--surface-3); + border: 1px solid var(--surface-5); + border-radius: var(--radii-m); } \ No newline at end of file diff --git a/src/components/filetree.tsx b/src/components/filetree.tsx index b32583c..34ec976 100644 --- a/src/components/filetree.tsx +++ b/src/components/filetree.tsx @@ -55,7 +55,7 @@ interface TreeContextType { const TreeContext = createContext(); export const Tree: Component<{ entries: Entry[], children: readonly [(folder: Accessor) => JSX.Element, (file: Accessor) => JSX.Element], open?: TreeContextType['open'] }> = (props) => { - const [selection, setSelection] = createSignal([]); + const [, setSelection] = createSignal([]); const context = { open: props.open ?? (() => { }), diff --git a/src/components/prompt.module.css b/src/components/prompt.module.css new file mode 100644 index 0000000..dba74f2 --- /dev/null +++ b/src/components/prompt.module.css @@ -0,0 +1,34 @@ +.prompt { + display: grid; + gap: var(--padding-m); + padding: var(--padding-m); + background-color: var(--surface-1); + color: var(--text-2); + border: 1px solid var(--surface-5); + border-radius: var(--radii-m); + + &:not(&[open]) { + display: none; + } + + &[open]::backdrop { + background-color: color(from var(--surface-1) xyz x y z / .3); + backdrop-filter: blur(.25em); + } + + & > form { + display: contents; + + & > header > .title { + font-size: var(--text-l); + color: var(--text-1); + } + + & > footer { + display: flex; + flex-flow: row; + justify-content: end; + gap: var(--padding-m); + } + } +} \ No newline at end of file diff --git a/src/components/prompt.tsx b/src/components/prompt.tsx new file mode 100644 index 0000000..943c738 --- /dev/null +++ b/src/components/prompt.tsx @@ -0,0 +1,75 @@ +import { createEffect, createSignal, createUniqueId, JSX, onMount, ParentComponent, Show } from "solid-js"; +import css from './prompt.module.css'; + +export interface PromptApi { + showModal(): Promise; +}; + +class PromptCanceledError extends Error { } + +export const Prompt: ParentComponent<{ api: (api: PromptApi) => any, title?: string, description?: string | JSX.Element }> = (props) => { + const [dialog, setDialog] = createSignal(); + const [form, setForm] = createSignal(); + const [resolvers, setResolvers] = createSignal<[(...args: any[]) => any, (...args: any[]) => any]>(); + const submitId = createUniqueId(); + const cancelId = createUniqueId(); + + const api = { + async showModal(): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + + setResolvers([resolve, reject]); + + dialog()!.showModal(); + + try { + await promise; + + return new FormData(form()); + } + catch (e) { + if (!(e instanceof PromptCanceledError)) { + throw e; + } + + dialog()!.close(); + setResolvers(undefined); + } + }, + }; + + const onSubmit = (e: SubmitEvent) => { + resolvers()?.[0](); + }; + + const onCancel = (e: Event) => { + resolvers()?.[1](new PromptCanceledError()); + }; + + createEffect(() => { + props.api(api); + }); + + return +
+ +
+ { + title => {title()} + } + + { + description =>

{description()}

+ }
+
+
+ +
{props.children}
+ +
+ + +
+
+
; +}; \ No newline at end of file diff --git a/src/entry-server.tsx b/src/entry-server.tsx index 551cb37..6919774 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -10,7 +10,7 @@ export default createHandler(() => ( - + {assets} diff --git a/src/features/file/grid.tsx b/src/features/file/grid.tsx index e11d368..7baf4f0 100644 --- a/src/features/file/grid.tsx +++ b/src/features/file/grid.tsx @@ -1,9 +1,8 @@ -import { Accessor, Component, createContext, createEffect, createMemo, createRenderEffect, createSignal, createUniqueId, For, onMount, ParentComponent, Show, useContext } from "solid-js"; -import { createStore, produce, reconcile, unwrap } from "solid-js/store"; +import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, ParentComponent, Show, useContext } from "solid-js"; +import { createStore, produce, unwrap } from "solid-js/store"; import { SelectionProvider, useSelection, selectable } from "../selectable"; import { debounce, deepCopy, deepDiff, Mutation } from "~/utilities"; import css from './grid.module.css'; -import diff from "microdiff"; selectable // prevents removal of import @@ -37,7 +36,7 @@ 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: Rows }> = (props) => { +export const Grid: Component<{ class?: string, columns: string[], rows: Rows, api?: (api: GridApi) => any }> = (props) => { const [selection, setSelection] = createSignal([]); const [state, setState] = createStore<{ rows: Record>, snapshot: Rows, numberOfRows: number }>({ rows: {}, @@ -45,7 +44,12 @@ const GridProvider: ParentComponent<{ rows: Rows }> = (props) => { numberOfRows: 0, }); - const mutations = createMemo(() => deepDiff(state.snapshot, state.rows).toArray()); + const mutations = createMemo(() => { + // enumerate all values to make sure the memo is recalculated on any change + Object.values(state.rows).map(entry => Object.values(entry)); + + return deepDiff(state.snapshot, state.rows).toArray(); + }); const rows = createMemo(() => Object.fromEntries(Object.entries(state.rows).map(([key, row]) => [key, unwrap(row)] as const))); createEffect(() => { @@ -57,10 +61,6 @@ const GridProvider: ParentComponent<{ rows: Rows }> = (props) => { setState('numberOfRows', Object.keys(state.rows).length); }); - createEffect(() => { - console.log(mutations()); - }); - const ctx: GridContextType = { rows, mutations, @@ -82,7 +82,7 @@ const GridProvider: ParentComponent<{ rows: Rows }> = (props) => { insert(prop: string) { setState('rows', produce(rows => { - rows[prop] = { en: '' }; + rows[prop] = Object.fromEntries(props.columns.slice(1).map(lang => [lang, ''])); return rows })) @@ -91,15 +91,16 @@ const GridProvider: ParentComponent<{ rows: Rows }> = (props) => { return - {props.children} + + + <_Grid class={props.class} columns={props.columns} rows={rows()} /> ; }; -export const Grid: Component<{ class?: string, columns: string[], rows: Rows, api?: (api: GridApi) => any }> = (props) => { +const _Grid: Component<{ class?: string, columns: string[], rows: Record> }> = (props) => { const columnCount = createMemo(() => props.columns.length - 1); - const root = createMemo(() => props.rows - ?.entries() + const root = createMemo(() => Object.entries(props.rows) .reduce((aggregate, [key, value]) => { let obj: any = aggregate; const parts = key.split('.'); @@ -121,15 +122,11 @@ export const Grid: Component<{ class?: string, columns: string[], rows: Rows, ap }, {})); return
- - + - - -
- -
-
+
+ +
}; diff --git a/src/features/file/index.tsx b/src/features/file/index.tsx index fb72c7f..6e9737f 100644 --- a/src/features/file/index.tsx +++ b/src/features/file/index.tsx @@ -19,7 +19,7 @@ interface InternalFilesContextType { onChange(hook: (key: string, handle: FileSystemDirectoryHandle) => any): void; set(key: string, handle: FileSystemDirectoryHandle): Promise; get(key: string): Promise; - remove(key: string): Promise; + remove(...keys: string[]): Promise; keys(): Promise; entries(): Promise; list(): Promise; @@ -65,8 +65,8 @@ const clientContext = (): InternalFilesContextType => { async get(key: string) { return (await db.files.get(key))?.handle; }, - async remove(key: string) { - return (await db.files.delete(key)); + async remove(...keys: string[]) { + await Promise.all(keys.map(key => db.files.delete(key))); }, async keys() { return (await db.files.where('key').notEqual(ROOT).toArray()).map(f => f.key); @@ -92,7 +92,7 @@ const serverContext = (): InternalFilesContextType => ({ get(key: string) { return Promise.resolve(undefined); }, - remove(key: string) { + remove(...keys: string[]) { return Promise.resolve(undefined); }, keys() { @@ -131,9 +131,12 @@ export const FilesProvider: ParentComponent = (props) => { files: createMemo(() => state.openedFiles), root: createMemo(() => state.root), - open(directory: FileSystemDirectoryHandle) { + async open(directory: FileSystemDirectoryHandle) { + await internal.remove(...(await internal.keys())); + setState('root', directory); - internal.set(ROOT, directory); + + await internal.set(ROOT, directory); }, get(key: string): Accessor { diff --git a/src/routes/(editor)/edit.tsx b/src/routes/(editor)/edit.tsx index d3f7381..57542ba 100644 --- a/src/routes/(editor)/edit.tsx +++ b/src/routes/(editor)/edit.tsx @@ -1,4 +1,4 @@ -import { Component, createEffect, createMemo, createSignal, For, ParentProps, Setter, Show } from "solid-js"; +import { Component, createEffect, createMemo, createSignal, For, onMount, ParentProps, Setter, Show } from "solid-js"; import { filter, MutarionKind, Mutation, splitAt } from "~/utilities"; import { Sidebar } from "~/components/sidebar"; import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree } from "~/components/filetree"; @@ -9,6 +9,7 @@ import { GridApi } from "~/features/file/grid"; import { Tab, Tabs } from "~/components/tabs"; import css from "./edit.module.css"; import { isServer } from "solid-js/web"; +import { Prompt, PromptApi } from "~/components/prompt"; const isInstalledPWA = !isServer && window.matchMedia('(display-mode: standalone)').matches; @@ -60,8 +61,18 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { const tabs = createMemo(() => filesContext.files().map(({ key, handle }) => { const [api, setApi] = createSignal(); const [entries, setEntries] = createSignal(new Map()); + const [files, setFiles] = createSignal>(new Map()); - return ({ key, handle, api, setApi, entries, setEntries }); + (async () => { + const files = await Array.fromAsync( + filter(handle.values(), entry => entry.kind === 'file'), + async file => [file.name.split('.').at(0)!, { handle: file, key: await file.getUniqueId() }] as const + ); + + setFiles(new Map(files)); + })(); + + return ({ key, handle, api, setApi, entries, setEntries, files }); })); const [active, setActive] = createSignal(); const [contents, setContents] = createSignal>>(new Map()); @@ -74,14 +85,27 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { const api = createMemo(() => tab()?.api()); const mutations = createMemo<(Mutation & { file?: { value: string, handle: FileSystemFileHandle, id: string } })[]>(() => tabs().flatMap(tab => { const entries = tab.entries(); + const files = tab.files(); const mutations = tab.api()?.mutations() ?? []; - return mutations.map(m => { - const [key, lang] = splitAt(m.key, m.key.lastIndexOf('.')); + return mutations.flatMap(m => { + switch (m.kind) { + case MutarionKind.Update: { + const [key, lang] = splitAt(m.key, m.key.lastIndexOf('.')); - console.log(m.key, key, lang, entries); + return { kind: MutarionKind.Update, key, file: entries.get(key)?.[lang] }; + } - return { ...m, key, file: entries.get(key)?.[lang] }; + case MutarionKind.Create: { + return Object.entries(m.value).map(([lang, value]) => ({ kind: MutarionKind.Create, key: m.key, file: files.get(lang)!, value })); + } + + case MutarionKind.Delete: { + return files.values().map(file => ({ kind: MutarionKind.Delete, key: m.key, file })).toArray(); + } + + default: throw new Error('unreachable code'); + } }); })); const mutatedFiles = createMemo(() => @@ -149,10 +173,21 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { }); createEffect(() => { - console.log(mutations()); + console.log(mutatedFiles()); }); + createEffect(() => { + console.log(mutatedData()); + }); + + const [prompt, setPrompt] = createSignal(); + const commands = { + open: createCommand('open folder', async () => { + const directory = await window.showDirectoryPicker({ mode: 'readwrite' }); + + await filesContext.open(directory); + }, { key: 'o', modifier: Modifier.Control }), close: createCommand('close folder', async () => { filesContext.remove('__root__'); }), @@ -197,8 +232,15 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { remove(Object.keys(selection())); }, { key: 'delete', modifier: Modifier.None }), - inserNewKey: createCommand('insert new key', () => { - api()?.insert('this.is.some.key'); + inserNewKey: createCommand('insert new key', async () => { + const formData = await prompt()?.showModal(); + const key = formData?.get('key')?.toString(); + + if (!key) { + return; + } + + api()?.insert(key); }), inserNewLanguage: noop.withLabel('insert new language'), } as const; @@ -214,10 +256,6 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { - - - - @@ -240,6 +278,10 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { + hint: use . to denote nested keys,
i.e. this.is.some.key would be a key that is four levels deep}> + +
+ {[ folder => { diff --git a/src/routes/editor.module.css b/src/routes/editor.module.css index 8291471..7e16001 100644 --- a/src/routes/editor.module.css +++ b/src/routes/editor.module.css @@ -11,7 +11,7 @@ grid-template-columns: auto minmax(0, 1fr) auto; grid-auto-flow: column; justify-content: start; - justify-items: start; + place-items: center start; position: relative; z-index: 10; diff --git a/src/utilities.ts b/src/utilities.ts index 0f5651c..b5e000b 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -2,10 +2,10 @@ export const splitAt = (subject: string, index: number): readonly [string, strin return [subject.slice(0, index), subject.slice(index + 1)] as const; }; -export const debounce = void>(callback: T, delay: number): T => { +export const debounce = void>(callback: T, delay: number): ((...args: Parameters) => void) => { let handle: ReturnType | undefined; - return (...args: any[]) => { + return (...args: Parameters) => { if (handle) { clearTimeout(handle); } @@ -58,10 +58,7 @@ export function* deepDiff(a: T1, b: T2, pa return; } - for (const [[keyA, valueA], [keyB, valueB]] of zip(entriesOf(a), entriesOf(b)).take(10)) { - // console.log('deepdiff', keyA, valueA, keyB, valueB); - // continue; - + 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'); } @@ -125,9 +122,6 @@ const zip = function* (a: Iterable, b: Iterable // if we have a match on the keys of a and b we can simply consume and yield if (iterA.current.key === iterB.current.key) { yield [iterA.consume(), iterB.consume()]; - - iterA.advance(); - iterB.advance(); } // key of a aligns with last key in buffer b @@ -140,8 +134,6 @@ const zip = function* (a: Iterable, b: Iterable } yield [a, iterB.consume()]; - - iterB.advance(); } // the reverse case, key of b is aligns with the last key in buffer a @@ -154,8 +146,14 @@ const zip = function* (a: Iterable, b: Iterable } yield [iterA.consume(), b]; + } - iterA.advance(); + else if (iterA.done && !iterB.done) { + yield [EMPTY, iterB.consume()]; + } + + else if (!iterA.done && iterB.done) { + yield [iterA.consume(), EMPTY]; } // Neiter of the above cases are hit. @@ -196,8 +194,11 @@ const bufferredIterator = (subject: I consume() { cursor = 0; + const value = buffer.shift()!; - return buffer.shift()!; + this.advance(); + + return value; }, flush(): T[] { @@ -213,7 +214,7 @@ const bufferredIterator = (subject: I }, get done() { - return done && Math.max(0, buffer.length - 1) === cursor; + return done && buffer.length === 0; }, get top() { @@ -247,3 +248,9 @@ export const filter = async function*(subject: AsyncIterableIter } }; +export const map = async function*(subject: AsyncIterableIterator, predicate: (value: TIn) => TResult): AsyncGenerator { + for await (const value of subject) { + yield predicate(value); + } +}; +