working on fixing/reimplementing save command now that the mutations logic is more complete

This commit is contained in:
Chris Kruining 2024-11-04 17:03:41 +01:00
parent 6ed9c74862
commit 992bb77d2f
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
12 changed files with 239 additions and 58 deletions

View file

@ -30,4 +30,9 @@ export default defineConfig({
compact: true, compact: true,
}, },
}, },
server: {
prerender: {
crawlLinks: true,
},
},
}); });

View file

@ -26,5 +26,15 @@
"sizes": "2092x1295", "sizes": "2092x1295",
"form_factor": "wide" "form_factor": "wide"
} }
],
"file_handlers": [
{
"action": "/edit",
"accept": {
"text/*": [
".json"
]
}
}
] ]
} }

View file

@ -78,4 +78,12 @@ h1 {
p { p {
line-height: 1.35; 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);
} }

View file

@ -55,7 +55,7 @@ interface TreeContextType {
const TreeContext = createContext<TreeContextType>(); const TreeContext = createContext<TreeContextType>();
export const Tree: Component<{ entries: Entry[], children: readonly [(folder: Accessor<FolderEntry>) => JSX.Element, (file: Accessor<FileEntry>) => JSX.Element], open?: TreeContextType['open'] }> = (props) => { export const Tree: Component<{ entries: Entry[], children: readonly [(folder: Accessor<FolderEntry>) => JSX.Element, (file: Accessor<FileEntry>) => JSX.Element], open?: TreeContextType['open'] }> = (props) => {
const [selection, setSelection] = createSignal<object[]>([]); const [, setSelection] = createSignal<object[]>([]);
const context = { const context = {
open: props.open ?? (() => { }), open: props.open ?? (() => { }),

View file

@ -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);
}
}
}

75
src/components/prompt.tsx Normal file
View file

@ -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<FormData | undefined>;
};
class PromptCanceledError extends Error { }
export const Prompt: ParentComponent<{ api: (api: PromptApi) => any, title?: string, description?: string | JSX.Element }> = (props) => {
const [dialog, setDialog] = createSignal<HTMLDialogElement>();
const [form, setForm] = createSignal<HTMLFormElement>();
const [resolvers, setResolvers] = createSignal<[(...args: any[]) => any, (...args: any[]) => any]>();
const submitId = createUniqueId();
const cancelId = createUniqueId();
const api = {
async showModal(): Promise<FormData | undefined> {
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 <dialog class={css.prompt} ref={setDialog} onsubmit={onSubmit} onCancel={onCancel} onReset={onCancel}>
<form method="dialog" ref={setForm}>
<Show when={props.title || props.description}>
<header>
<Show when={props.title}>{
title => <b class={css.title}>{title()}</b>
}</Show>
<Show when={props.description}>{
description => <p>{description()}</p>
}</Show>
</header>
</Show>
<main>{props.children}</main>
<footer>
<button id={submitId} type="submit">Ok</button>
<button id={cancelId} type="reset">Cancel</button>
</footer>
</form>
</dialog>;
};

View file

@ -10,7 +10,7 @@ export default createHandler(() => (
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
{assets} {assets}
</head> </head>
<body> <body>

View file

@ -1,9 +1,8 @@
import { Accessor, Component, createContext, createEffect, createMemo, createRenderEffect, createSignal, createUniqueId, For, onMount, ParentComponent, Show, useContext } from "solid-js"; import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, ParentComponent, Show, useContext } from "solid-js";
import { createStore, produce, reconcile, unwrap } from "solid-js/store"; import { createStore, produce, unwrap } from "solid-js/store";
import { SelectionProvider, useSelection, selectable } from "../selectable"; import { SelectionProvider, useSelection, selectable } from "../selectable";
import { debounce, deepCopy, deepDiff, Mutation } from "~/utilities"; import { debounce, deepCopy, deepDiff, Mutation } from "~/utilities";
import css from './grid.module.css'; import css from './grid.module.css';
import diff from "microdiff";
selectable // prevents removal of import selectable // prevents removal of import
@ -37,7 +36,7 @@ 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: Rows }> = (props) => { export const Grid: Component<{ class?: string, columns: string[], rows: Rows, api?: (api: GridApi) => any }> = (props) => {
const [selection, setSelection] = createSignal<SelectionItem[]>([]); const [selection, setSelection] = createSignal<SelectionItem[]>([]);
const [state, setState] = createStore<{ rows: Record<string, Record<string, string>>, snapshot: Rows, numberOfRows: number }>({ const [state, setState] = createStore<{ rows: Record<string, Record<string, string>>, snapshot: Rows, numberOfRows: number }>({
rows: {}, rows: {},
@ -45,7 +44,12 @@ const GridProvider: ParentComponent<{ rows: Rows }> = (props) => {
numberOfRows: 0, 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))); const rows = createMemo(() => Object.fromEntries(Object.entries(state.rows).map(([key, row]) => [key, unwrap(row)] as const)));
createEffect(() => { createEffect(() => {
@ -57,10 +61,6 @@ const GridProvider: ParentComponent<{ rows: Rows }> = (props) => {
setState('numberOfRows', Object.keys(state.rows).length); setState('numberOfRows', Object.keys(state.rows).length);
}); });
createEffect(() => {
console.log(mutations());
});
const ctx: GridContextType = { const ctx: GridContextType = {
rows, rows,
mutations, mutations,
@ -82,7 +82,7 @@ const GridProvider: ParentComponent<{ rows: Rows }> = (props) => {
insert(prop: string) { insert(prop: string) {
setState('rows', produce(rows => { setState('rows', produce(rows => {
rows[prop] = { en: '' }; rows[prop] = Object.fromEntries(props.columns.slice(1).map(lang => [lang, '']));
return rows return rows
})) }))
@ -91,15 +91,16 @@ const GridProvider: ParentComponent<{ rows: Rows }> = (props) => {
return <GridContext.Provider value={ctx}> return <GridContext.Provider value={ctx}>
<SelectionProvider selection={setSelection} multiSelect> <SelectionProvider selection={setSelection} multiSelect>
{props.children} <Api api={props.api} />
<_Grid class={props.class} columns={props.columns} rows={rows()} />
</SelectionProvider> </SelectionProvider>
</GridContext.Provider>; </GridContext.Provider>;
}; };
export const Grid: Component<{ class?: string, columns: string[], rows: Rows, api?: (api: GridApi) => any }> = (props) => { const _Grid: Component<{ class?: string, columns: string[], rows: Record<string, Record<string, string>> }> = (props) => {
const columnCount = createMemo(() => props.columns.length - 1); const columnCount = createMemo(() => props.columns.length - 1);
const root = createMemo<Entry>(() => props.rows const root = createMemo<Entry>(() => Object.entries(props.rows)
?.entries()
.reduce((aggregate, [key, value]) => { .reduce((aggregate, [key, value]) => {
let obj: any = aggregate; let obj: any = aggregate;
const parts = key.split('.'); const parts = key.split('.');
@ -121,15 +122,11 @@ export const Grid: Component<{ class?: string, columns: string[], rows: Rows, ap
}, {})); }, {}));
return <section class={`${css.table} ${props.class}`} style={{ '--columns': columnCount() }}> return <section class={`${css.table} ${props.class}`} style={{ '--columns': columnCount() }}>
<GridProvider rows={props.rows}>
<Api api={props.api} />
<Head headers={props.columns} /> <Head headers={props.columns} />
<main class={css.main}> <main class={css.main}>
<Row entry={root()} /> <Row entry={root()} />
</main> </main>
</GridProvider>
</section> </section>
}; };

View file

@ -19,7 +19,7 @@ interface InternalFilesContextType {
onChange(hook: (key: string, handle: FileSystemDirectoryHandle) => any): void; onChange(hook: (key: string, handle: FileSystemDirectoryHandle) => any): void;
set(key: string, handle: FileSystemDirectoryHandle): Promise<void>; set(key: string, handle: FileSystemDirectoryHandle): Promise<void>;
get(key: string): Promise<FileSystemDirectoryHandle | undefined>; get(key: string): Promise<FileSystemDirectoryHandle | undefined>;
remove(key: string): Promise<void>; remove(...keys: string[]): Promise<void>;
keys(): Promise<string[]>; keys(): Promise<string[]>;
entries(): Promise<FileEntity[]>; entries(): Promise<FileEntity[]>;
list(): Promise<FileSystemDirectoryHandle[]>; list(): Promise<FileSystemDirectoryHandle[]>;
@ -65,8 +65,8 @@ const clientContext = (): InternalFilesContextType => {
async get(key: string) { async get(key: string) {
return (await db.files.get(key))?.handle; return (await db.files.get(key))?.handle;
}, },
async remove(key: string) { async remove(...keys: string[]) {
return (await db.files.delete(key)); await Promise.all(keys.map(key => db.files.delete(key)));
}, },
async keys() { async keys() {
return (await db.files.where('key').notEqual(ROOT).toArray()).map(f => f.key); return (await db.files.where('key').notEqual(ROOT).toArray()).map(f => f.key);
@ -92,7 +92,7 @@ const serverContext = (): InternalFilesContextType => ({
get(key: string) { get(key: string) {
return Promise.resolve(undefined); return Promise.resolve(undefined);
}, },
remove(key: string) { remove(...keys: string[]) {
return Promise.resolve(undefined); return Promise.resolve(undefined);
}, },
keys() { keys() {
@ -131,9 +131,12 @@ export const FilesProvider: ParentComponent = (props) => {
files: createMemo(() => state.openedFiles), files: createMemo(() => state.openedFiles),
root: createMemo(() => state.root), root: createMemo(() => state.root),
open(directory: FileSystemDirectoryHandle) { async open(directory: FileSystemDirectoryHandle) {
await internal.remove(...(await internal.keys()));
setState('root', directory); setState('root', directory);
internal.set(ROOT, directory);
await internal.set(ROOT, directory);
}, },
get(key: string): Accessor<FileSystemDirectoryHandle | undefined> { get(key: string): Accessor<FileSystemDirectoryHandle | undefined> {

View file

@ -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 { filter, MutarionKind, Mutation, splitAt } from "~/utilities";
import { Sidebar } from "~/components/sidebar"; import { Sidebar } from "~/components/sidebar";
import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree } from "~/components/filetree"; 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 { Tab, Tabs } from "~/components/tabs";
import css from "./edit.module.css"; import css from "./edit.module.css";
import { isServer } from "solid-js/web"; import { isServer } from "solid-js/web";
import { Prompt, PromptApi } from "~/components/prompt";
const isInstalledPWA = !isServer && window.matchMedia('(display-mode: standalone)').matches; 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 tabs = createMemo(() => filesContext.files().map(({ key, handle }) => {
const [api, setApi] = createSignal<GridApi>(); const [api, setApi] = createSignal<GridApi>();
const [entries, setEntries] = createSignal<Entries>(new Map()); const [entries, setEntries] = createSignal<Entries>(new Map());
const [files, setFiles] = createSignal<Map<string, { key: string, handle: FileSystemFileHandle }>>(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<string>(); const [active, setActive] = createSignal<string>();
const [contents, setContents] = createSignal<Map<string, Map<string, string>>>(new Map()); const [contents, setContents] = createSignal<Map<string, Map<string, string>>>(new Map());
@ -74,14 +85,27 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
const api = createMemo(() => tab()?.api()); const api = createMemo(() => tab()?.api());
const mutations = createMemo<(Mutation & { file?: { value: string, handle: FileSystemFileHandle, id: string } })[]>(() => tabs().flatMap(tab => { const mutations = createMemo<(Mutation & { file?: { value: string, handle: FileSystemFileHandle, id: string } })[]>(() => tabs().flatMap(tab => {
const entries = tab.entries(); const entries = tab.entries();
const files = tab.files();
const mutations = tab.api()?.mutations() ?? []; const mutations = tab.api()?.mutations() ?? [];
return mutations.map(m => { return mutations.flatMap(m => {
switch (m.kind) {
case MutarionKind.Update: {
const [key, lang] = splitAt(m.key, m.key.lastIndexOf('.')); 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(() => const mutatedFiles = createMemo(() =>
@ -149,10 +173,21 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
}); });
createEffect(() => { createEffect(() => {
console.log(mutations()); console.log(mutatedFiles());
}); });
createEffect(() => {
console.log(mutatedData());
});
const [prompt, setPrompt] = createSignal<PromptApi>();
const commands = { 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 () => { close: createCommand('close folder', async () => {
filesContext.remove('__root__'); filesContext.remove('__root__');
}), }),
@ -197,8 +232,15 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
remove(Object.keys(selection())); remove(Object.keys(selection()));
}, { key: 'delete', modifier: Modifier.None }), }, { key: 'delete', modifier: Modifier.None }),
inserNewKey: createCommand('insert new key', () => { inserNewKey: createCommand('insert new key', async () => {
api()?.insert('this.is.some.key'); const formData = await prompt()?.showModal();
const key = formData?.get('key')?.toString();
if (!key) {
return;
}
api()?.insert(key);
}), }),
inserNewLanguage: noop.withLabel('insert new language'), inserNewLanguage: noop.withLabel('insert new language'),
} as const; } as const;
@ -214,10 +256,6 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
<Menu.Item label="file"> <Menu.Item label="file">
<Menu.Item command={commands.open} /> <Menu.Item command={commands.open} />
<Menu.Item command={commands.close} />
<Menu.Separator />
<Menu.Item command={commands.save} /> <Menu.Item command={commands.save} />
</Menu.Item> </Menu.Item>
@ -240,6 +278,10 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => {
<Menu.Item command={noop.withLabel('view')} /> <Menu.Item command={noop.withLabel('view')} />
</Menu.Root> </Menu.Root>
<Prompt api={setPrompt} title="Which key do you want to create?" description={<>hint: use <code>.</code> to denote nested keys,<br /> i.e. <code>this.is.some.key</code> would be a key that is four levels deep</>}>
<input name="key" value="this.is.an.awesome.key" placeholder="name of new key ()" />
</Prompt>
<Sidebar as="aside" label={tree().name} class={css.sidebar}> <Sidebar as="aside" label={tree().name} class={css.sidebar}>
<Tree entries={tree().entries}>{[ <Tree entries={tree().entries}>{[
folder => { folder => {

View file

@ -11,7 +11,7 @@
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;
grid-auto-flow: column; grid-auto-flow: column;
justify-content: start; justify-content: start;
justify-items: start; place-items: center start;
position: relative; position: relative;
z-index: 10; z-index: 10;

View file

@ -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; return [subject.slice(0, index), subject.slice(index + 1)] as const;
}; };
export const debounce = <T extends (...args: any[]) => void>(callback: T, delay: number): T => { export const debounce = <T extends (...args: any[]) => void>(callback: T, delay: number): ((...args: Parameters<T>) => void) => {
let handle: ReturnType<typeof setTimeout> | undefined; let handle: ReturnType<typeof setTimeout> | undefined;
return (...args: any[]) => { return (...args: Parameters<T>) => {
if (handle) { if (handle) {
clearTimeout(handle); clearTimeout(handle);
} }
@ -58,10 +58,7 @@ export function* deepDiff<T1 extends object, T2 extends object>(a: T1, b: T2, pa
return; return;
} }
for (const [[keyA, valueA], [keyB, valueB]] of zip(entriesOf(a), entriesOf(b)).take(10)) { for (const [[keyA, valueA], [keyB, valueB]] of zip(entriesOf(a), entriesOf(b))) {
// console.log('deepdiff', keyA, valueA, keyB, valueB);
// continue;
if (!keyA && !keyB) { if (!keyA && !keyB) {
throw new Error('this code should not be reachable, there is a bug with an unhandled/unknown edge case'); 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<readonly [string | number, any]>, b: Iterable
// if we have a match on the keys of a and b we can simply consume and yield // 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) { if (iterA.current.key === iterB.current.key) {
yield [iterA.consume(), iterB.consume()]; yield [iterA.consume(), iterB.consume()];
iterA.advance();
iterB.advance();
} }
// key of a aligns with last key in buffer b // key of a aligns with last key in buffer b
@ -140,8 +134,6 @@ const zip = function* (a: Iterable<readonly [string | number, any]>, b: Iterable
} }
yield [a, iterB.consume()]; yield [a, iterB.consume()];
iterB.advance();
} }
// the reverse case, key of b is aligns with the last key in buffer a // the reverse case, key of b is aligns with the last key in buffer a
@ -154,8 +146,14 @@ const zip = function* (a: Iterable<readonly [string | number, any]>, b: Iterable
} }
yield [iterA.consume(), b]; 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. // Neiter of the above cases are hit.
@ -196,8 +194,11 @@ const bufferredIterator = <T extends readonly [string | number, any]>(subject: I
consume() { consume() {
cursor = 0; cursor = 0;
const value = buffer.shift()!;
return buffer.shift()!; this.advance();
return value;
}, },
flush(): T[] { flush(): T[] {
@ -213,7 +214,7 @@ const bufferredIterator = <T extends readonly [string | number, any]>(subject: I
}, },
get done() { get done() {
return done && Math.max(0, buffer.length - 1) === cursor; return done && buffer.length === 0;
}, },
get top() { get top() {
@ -247,3 +248,9 @@ export const filter = async function*<T, S extends T>(subject: AsyncIterableIter
} }
}; };
export const map = async function*<TIn, TResult>(subject: AsyncIterableIterator<TIn>, predicate: (value: TIn) => TResult): AsyncGenerator<TResult, void, unknown> {
for await (const value of subject) {
yield predicate(value);
}
};