import { Component, createEffect, createMemo, createSignal, For, onMount, ParentProps, Setter, Show } from "solid-js"; import { Created, filter, MutarionKind, Mutation, splitAt } from "~/utilities"; import { Sidebar } from "~/components/sidebar"; import { emptyFolder, FolderEntry, walk as fileTreeWalk, Tree } from "~/components/filetree"; import { Menu } from "~/features/menu"; import { Grid, load, useFiles } from "~/features/file"; import { Command, CommandType, Context, createCommand, Modifier } from "~/features/command"; import { Entry, GridApi } from "~/features/file/grid"; import { Tab, Tabs } from "~/components/tabs"; import { isServer } from "solid-js/web"; import { Prompt, PromptApi } from "~/components/prompt"; import EditBlankImage from '~/assets/edit-blank.svg' import { useI18n } from "~/features/i18n"; import { makePersisted } from "@solid-primitives/storage"; import css from "./edit.module.css"; const isInstalledPWA = !isServer && window.matchMedia('(display-mode: standalone)').matches; 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]); continue; } if (!handle.name.endsWith('.json')) { 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 { id, handle, path, lang, entries }; } } }; // interface Entries extends Map> { }; interface Entries extends Map> { }; export default function Edit(props: ParentProps) { const filesContext = useFiles(); onMount(() => { if (!('showDirectoryPicker' in window)) { throw new Error('Unable to manage files', { cause: { description:

The browser you are using does not support the File Access API.
This API is crutial for this app.

} }); } }); const open = createCommand('page.edit.command.open', async () => { const directory = await window.showDirectoryPicker({ mode: 'readwrite' }); filesContext.open(directory); }, { key: 'o', modifier: Modifier.Control }); return }>{ root => } ; } const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { const filesContext = useFiles(); const { t } = useI18n(); const tabs = createMemo(() => filesContext.files().map(({ key, handle }) => { const [api, setApi] = createSignal<(GridApi & { addLocale(locale: string): void })>(); const [entries, setEntries] = createSignal(new Map()); const [files, setFiles] = createSignal>(new Map()); (async () => { const files = await Array.fromAsync( filter(handle.values(), entry => entry.kind === 'file'), async file => [file.name.split('.').at(0)!, { handle: file, id: await file.getUniqueId() }] as const ); setFiles(new Map(files)); })(); return ({ key, handle, api, setApi, entries, setEntries, files }); })); const [active, setActive] = makePersisted(createSignal(), { name: 'edit__aciveTab' }); const [contents, setContents] = createSignal>>(new Map()); const [tree, setFiles] = createSignal(emptyFolder); const [newKeyPrompt, setNewKeyPrompt] = createSignal(); const [newLanguagePrompt, setNewLanguagePrompt] = createSignal(); const tab = createMemo(() => { const name = active(); return tabs().find(t => t.handle.name === name); }); const api = createMemo(() => tab()?.api()); const mutations = createMemo<(Mutation & { lang: string, 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.flatMap((m): any => { const [index, lang] = splitAt(m.key, m.key.indexOf('.')); switch (m.kind) { case MutarionKind.Update: { const entry = entries.get(index as any)!; return { kind: MutarionKind.Update, key: entry.key, lang, file: files.get(lang)!, value: m.value }; } case MutarionKind.Create: { if (typeof m.value === 'object') { const { key, ...locales } = m.value; return Object.entries(locales).map(([lang, value]) => ({ kind: MutarionKind.Create, key, lang, file: files.get(lang)!, value })); } const entry = entries.get(index as any)!; return { kind: MutarionKind.Create, key: entry.key, lang, file: undefined, value: m.value }; } case MutarionKind.Delete: { const entry = entries.get(index as any)!; return files.values().map(file => ({ kind: MutarionKind.Delete, key: entry.key, file })).toArray(); } default: throw new Error('unreachable code'); } }); })); const mutatedFiles = createMemo(() => new Set((mutations()).map(({ file }) => file).filter(file => file !== undefined)) ); const mutatedData = createMemo(() => { const muts = mutations(); const files = contents(); const entries = mutatedFiles().values(); if (muts.length === 0) { return []; } const groupedByFileId = Object.groupBy(muts, m => m.file?.id ?? 'undefined'); const newFiles = Object.entries(Object.groupBy((groupedByFileId['undefined'] ?? []) as (Created & { lang: string, file: undefined })[], m => m.lang)).map(([lang, mutations]) => { const data = mutations!.reduce((aggregate, { key, value }) => { let obj = aggregate; const i = key.lastIndexOf('.'); if (i !== -1) { const [k, lastPart] = splitAt(key, i); for (const part of k.split('.')) { if (!Object.hasOwn(obj, part)) { obj[part] = {}; } obj = obj[part]; } obj[lastPart] = value; } else { obj[key] = value; } return aggregate; }, {} as Record); return [{ existing: false, name: lang }, data] as const; }) const existingFiles = entries.map(({ id, handle }) => { const existing = new Map(files.get(id)!); const mutations = groupedByFileId[id]!; for (const mutation of mutations) { switch (mutation.kind) { case MutarionKind.Delete: { existing.delete(mutation.key); break; } case MutarionKind.Update: case MutarionKind.Create: { existing.set(mutation.key, mutation.value); break; } } } return [ { existing: true, handle }, existing.entries().reduce((aggregate, [key, value]) => { let obj = aggregate; const i = key.lastIndexOf('.'); if (i !== -1) { const [k, lastPart] = splitAt(key, i); for (const part of k.split('.')) { if (!Object.hasOwn(obj, part)) { obj[part] = {}; } obj = obj[part]; } obj[lastPart] = value; } else { obj[key] = value; } return aggregate; }, {} as Record) ] as const; }).toArray() as (readonly [({ existing: true, handle: FileSystemFileHandle } | { existing: false, name: string }), Record])[]; return existingFiles.concat(newFiles); }); createEffect(() => { const directory = props.root; (async () => { setContents(new Map(await Array.fromAsync(walk(directory), ({ id, entries }) => [id, entries] as const))) setFiles({ name: directory.name, id: '', kind: 'folder', handle: directory, entries: await Array.fromAsync(fileTreeWalk(directory)) }); })(); }); const commands = { open: createCommand('page.edit.command.open', async () => { const directory = await window.showDirectoryPicker({ mode: 'readwrite' }); await filesContext.open(directory); }, { key: 'o', modifier: Modifier.Control }), close: createCommand('page.edit.command.close', async () => { await filesContext.close(); }), closeTab: createCommand('page.edit.command.closeTab', async (id: string) => { filesContext.remove(id); }, { key: 'w', modifier: Modifier.Control | (isInstalledPWA ? Modifier.None : Modifier.Alt) }), save: createCommand('page.edit.command.save', async () => { await Promise.allSettled(mutatedData().map(async ([file, data]) => { // TODO :: add the newly created file to the known files list to that the save file picker is not shown again on subsequent saves const handle = file.existing ? file.handle : await window.showSaveFilePicker({ suggestedName: file.name, excludeAcceptAllOption: true, types: [{ description: 'JSON file', accept: { 'application/json': ['.json'] } }] }); const stream = await handle.createWritable({ keepExistingData: false }); stream.write(JSON.stringify(data, null, 4)); stream.write('\n'); stream.close(); })); }, { key: 's', modifier: Modifier.Control }), saveAs: createCommand('page.edit.command.saveAs', (handle?: FileSystemFileHandle) => { console.log('save as ...', handle); window.showSaveFilePicker({ startIn: props.root, excludeAcceptAllOption: true, types: [ { accept: { 'application/json': ['.json'] }, description: 'JSON' }, { accept: { 'application/yaml': ['.yml', '.yaml'] }, description: 'YAML' }, { accept: { 'application/csv': ['.csv'] }, description: 'CSV' }, ] }); }, { key: 's', modifier: Modifier.Control | Modifier.Shift }), selectAll: createCommand('page.edit.command.selectAll', () => { api()?.selectAll(); }, { key: 'a', modifier: Modifier.Control }), clearSelection: createCommand('page.edit.command.clearSelection', () => { api()?.clearSelection(); }), delete: createCommand('page.edit.command.delete', () => { const { selection, remove } = api() ?? {}; if (!selection || !remove) { return; } remove(selection().map(s => s.key)); }, { key: 'delete', modifier: Modifier.None }), insertKey: createCommand('page.edit.command.insertKey', async () => { const formData = await newKeyPrompt()?.showModal(); const key = formData?.get('key')?.toString(); if (!key) { return; } api()?.addKey(key); }), insertLanguage: createCommand('page.edit.command.insertLanguage', async () => { const formData = await newLanguagePrompt()?.showModal(); const locale = formData?.get('locale')?.toString(); if (!locale) { return; } api()?.addLocale(locale); }), } as const; return
{ command => } {[ folder => { return { filesContext?.set(folder().name, folder().handle); }}>{folder().name}; }, file => { const mutated = createMemo(() => mutatedFiles().values().find(({ id }) => id === file().id) !== undefined); return { const folder = file().directory; filesContext?.set(folder.name, folder); setActive(folder.name); }}>{file().name}; }, ] as const} { ({ key, handle, setApi, setEntries }) => }
; }; const Content: Component<{ directory: FileSystemDirectoryHandle, api?: Setter<(GridApi & { addLocale(locale: string): void }) | undefined>, entries?: Setter }> = (props) => { const [entries, setEntries] = createSignal(new Map()); const [locales, setLocales] = createSignal([]); const [rows, setRows] = createSignal([]); const [api, setApi] = createSignal(); createEffect(() => { props.entries?.(entries()); }); createEffect(() => { const a = api(); if (!a) { return; } props.api?.({ ...a, addLocale(locale) { setLocales(current => new Set([...current, locale]).values().toArray()); }, }); }); createEffect(() => { const directory = props.directory; if (!directory) { return; } (async () => { const contents = await Array.fromAsync( filter(directory.values(), (handle): handle is FileSystemFileHandle => handle.kind === 'file' && handle.name.endsWith('.json')), async handle => { const id = await handle.getUniqueId(); const file = await handle.getFile(); const lang = file.name.split('.').at(0)!; const entries = (await load(file))!; return { id, handle, lang, entries }; } ); const template = contents.map(({ lang, handle }) => [lang, { handle, value: '' }]); setLocales(contents.map(({ lang }) => lang)); const merged = contents.reduce((aggregate, { id, handle, lang, entries }) => { for (const [key, value] of entries.entries()) { if (!aggregate.has(key)) { aggregate.set(key, Object.fromEntries(template)); } aggregate.get(key)![lang] = { value, handle, id }; } return aggregate; }, new Map>()); setEntries(new Map(merged.entries().map(([key, langs], i) => [i.toString(), { key, ...langs }])) as Entries); setRows(merged.entries().map(([key, langs]) => ({ key, ...Object.fromEntries(Object.entries(langs).map(([lang, { value }]) => [lang, value])) } as Entry)).toArray()); })(); }); return ; }; const Blank: Component<{ open: CommandType }> = (props) => { return
};