diff --git a/bun.lock b/bun.lock index b09109b..7a1e7e7 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "name": "calque", "dependencies": { "@solid-primitives/clipboard": "^1.5.10", + "@solid-primitives/destructure": "^0.2.0", "@solid-primitives/i18n": "^2.1.1", "@solid-primitives/scheduled": "^1.4.4", "@solid-primitives/storage": "^4.2.1", @@ -415,6 +416,8 @@ "@solid-primitives/clipboard": ["@solid-primitives/clipboard@1.5.10", "", { "dependencies": { "@solid-primitives/utils": "^6.2.3" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ohwlrBP4j+Qjksg01CFWaP/USpzX78dBNVA1DPRZkf/vJgytX0T6KMc2YxF6o8fs6ePIYSI8Nt3sKxF0sh1q+Q=="], + "@solid-primitives/destructure": ["@solid-primitives/destructure@0.2.0", "", { "dependencies": { "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nfE6nSkyLle+hIQzvxGwzyt3TvFwgBjhFiQ7y2Cq+amBiwpvVVm+1qncE8tKaKk6JNn/CilgXZgJ/KMb/p3csA=="], + "@solid-primitives/i18n": ["@solid-primitives/i18n@2.1.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-1p9B8hveu+gzFRWfXcRtdzzEdr7gw3c8uLXm+2bU33JHgiI8kYJsWvG128sE6vU1ZtYGPrGq980Jd6hxYupyZQ=="], "@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-BTGdFP7t+s7RSak+s1u0eTix4lHP23MrbGkgQTFlt1E+4fmnD/bEx3ZfNW7Grylz3GXgKyXrgDKA7jQ/wuWKgA=="], @@ -1601,6 +1604,8 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@solid-primitives/destructure/@solid-primitives/utils": ["@solid-primitives/utils@6.3.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-e7hTlJ1Ywh2+g/Qug+n4L1mpfxsikoIS4/sHE2EK9WatQt8UJqop/vE6bsLnXlU1xuhb/jo94Ah5Y27rd4wP7A=="], + "@surma/rollup-plugin-off-main-thread/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], diff --git a/package.json b/package.json index 44f9b11..7174ad2 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "calque", "dependencies": { "@solid-primitives/clipboard": "^1.5.10", + "@solid-primitives/destructure": "^0.2.0", "@solid-primitives/i18n": "^2.1.1", "@solid-primitives/scheduled": "^1.4.4", "@solid-primitives/storage": "^4.2.1", diff --git a/src/components/table/table.tsx b/src/components/table/table.tsx index bde47a7..115a987 100644 --- a/src/components/table/table.tsx +++ b/src/components/table/table.tsx @@ -232,7 +232,6 @@ function Row>(props: { ({ id }) => { const content = table.cellRenderers()[id]?.({ row: props.key as number, column: id, value: props.value[id] }) ?? props.value[id]; - // return <>{content}; return {content}; } } diff --git a/src/features/file/context.tsx b/src/features/file/context.tsx index f70d979..f73cc41 100644 --- a/src/features/file/context.tsx +++ b/src/features/file/context.tsx @@ -1,8 +1,7 @@ import Dexie, { EntityTable } from "dexie"; -import { Accessor, createContext, createMemo, onMount, ParentComponent, useContext } from "solid-js"; +import { Accessor, createContext, createMemo, createResource, InitializedResource, onCleanup, onMount, ParentComponent, useContext } from "solid-js"; import { createStore } from "solid-js/store"; import { isServer } from "solid-js/web"; -import { json } from "./parser"; const ROOT = '__root__'; @@ -163,12 +162,4 @@ export const FilesProvider: ParentComponent = (props) => { return {props.children}; } -export const useFiles = () => useContext(FilesContext)!; - -export const load = (file: File): Promise | undefined> => { - switch (file.type) { - case 'application/json': return json.load(file.stream()) - - default: return Promise.resolve(undefined); - } -}; \ No newline at end of file +export const useFiles = () => useContext(FilesContext)!; \ No newline at end of file diff --git a/src/features/file/grid.tsx b/src/features/file/grid.tsx index d906a9c..93e118d 100644 --- a/src/features/file/grid.tsx +++ b/src/features/file/grid.tsx @@ -13,6 +13,7 @@ export interface GridApi { readonly selection: Accessor[]>; remove(indices: number[]): void; addKey(key: string): void; + addLocale(locale: string): void; selectAll(): void; clearSelection(): void; }; @@ -33,8 +34,9 @@ const groupBy = (rows: DataSetRowNode[]) => { export function Grid(props: { class?: string, rows: Entry[], locales: string[], api?: (api: GridApi) => any, children?: (key: string) => JSX.Element }) { const { t } = useI18n(); + const [addedLocales, setAddedLocales] = createSignal([]); const rows = createMemo(() => createDataSet(props.rows, { group: { by: 'key', with: groupBy } })); - const locales = createMemo(() => props.locales); + const locales = createMemo(() => [...props.locales, ...addedLocales()]); const columns = createMemo[]>(() => [ { id: 'key', @@ -56,9 +58,9 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[], createEffect(() => { const r = rows(); - const l = locales(); + const l = addedLocales(); - r.mutateEach(({ key, ...rest }) => ({ key, ...Object.fromEntries(l.map(locale => [locale, rest[locale] ?? ''])) })); + r.mutateEach(({ key, ...rest }) => ({ key, ...rest, ...Object.fromEntries(l.map(locale => [locale, rest[locale] ?? ''])) })); }); createEffect(() => { @@ -71,6 +73,9 @@ export function Grid(props: { class?: string, rows: Entry[], locales: string[], addKey(key) { r.insert({ key, ...Object.fromEntries(locales().map(l => [l, ''])) }); }, + addLocale(locale) { + setAddedLocales(locales => new Set([...locales, locale]).values().toArray()) + }, selectAll() { api()?.selectAll(); }, diff --git a/src/features/file/helpers.ts b/src/features/file/helpers.ts new file mode 100644 index 0000000..94c815d --- /dev/null +++ b/src/features/file/helpers.ts @@ -0,0 +1,51 @@ +import { Accessor, createResource, InitializedResource, onCleanup } from "solid-js"; +import { json } from "./parser"; +import { filter } from "~/utilities"; + +interface Files extends Record { } + +export const load = (file: File): Promise | undefined> => { + switch (file.type) { + case 'application/json': return json.load(file.stream()) + + default: return Promise.resolve(undefined); + } +}; + +export const readFiles = (directory: Accessor): InitializedResource => { + const [value, { refetch }] = createResource(async (_, { value: prev }) => { + prev ??= {}; + + const next: Files = Object.fromEntries(await Array.fromAsync( + filter(directory().values(), (handle): handle is FileSystemFileHandle => handle.kind === 'file' && handle.name.endsWith('.json')), + async handle => [await handle.getUniqueId(), { file: await handle.getFile(), handle }] + )); + + const keysPrev = Object.keys(prev); + const keysNext = Object.keys(next); + + if (keysPrev.length !== keysNext.length) { + return next; + } + + if (keysPrev.some(prev => keysNext.includes(prev) === false)) { + return next; + } + + if (Object.entries(prev).every(([id, { file }]) => next[id].file.lastModified === file.lastModified) === false) { + return next; + } + + return prev; + }, { initialValue: {} }) + + const interval = setInterval(() => { + refetch(); + }, 1000); + + onCleanup(() => { + clearInterval(interval); + }); + + return value; +}; \ No newline at end of file diff --git a/src/features/file/index.tsx b/src/features/file/index.tsx index c3159f1..6eba3ce 100644 --- a/src/features/file/index.tsx +++ b/src/features/file/index.tsx @@ -1,4 +1,7 @@ -export { useFiles, FilesProvider, load } from './context'; +export { load, readFiles } from './helpers'; +export { useFiles, FilesProvider } from './context'; export { Grid } from './grid'; +export { TreeProvider, Tree, useTree } from './tree'; + export type { Entry } from './grid'; \ No newline at end of file diff --git a/src/components/filetree.module.css b/src/features/file/tree.module.css similarity index 100% rename from src/components/filetree.module.css rename to src/features/file/tree.module.css diff --git a/src/components/filetree.tsx b/src/features/file/tree.tsx similarity index 52% rename from src/components/filetree.tsx rename to src/features/file/tree.tsx index 2cdbae5..a9022da 100644 --- a/src/components/filetree.tsx +++ b/src/features/file/tree.tsx @@ -1,8 +1,8 @@ -import { Accessor, Component, createContext, createSignal, For, JSX, Show, useContext } from "solid-js"; +import { Accessor, children, Component, createContext, createEffect, createMemo, createResource, createSignal, For, InitializedResource, JSX, onCleanup, ParentComponent, Setter, Show, useContext } from "solid-js"; import { AiFillFile, AiFillFolder, AiFillFolderOpen } from "solid-icons/ai"; import { SelectionProvider, selectable } from "~/features/selectable"; import { debounce } from "@solid-primitives/scheduled"; -import css from "./filetree.module.css"; +import css from "./tree.module.css"; selectable; @@ -16,7 +16,7 @@ export interface FileEntry { } export interface FolderEntry { - name: string; + name: string; handle id: string; kind: 'folder'; handle: FileSystemDirectoryHandle; @@ -49,27 +49,59 @@ export async function* walk(directory: FileSystemDirectoryHandle, filters: RegEx } interface TreeContextType { - open(file: File): void; + readonly tree: Accessor; + readonly name: Accessor; + readonly open: Accessor; + readonly setOpen: Setter; + + onOpen(file: File): void; } const TreeContext = createContext(); -export const Tree: Component<{ entries: Entry[], children: readonly [(folder: Accessor) => JSX.Element, (file: Accessor) => JSX.Element], open?: TreeContextType['open'] }> = (props) => { - const [, setSelection] = createSignal([]); +export const TreeProvider: ParentComponent<{ directory: FileSystemDirectoryHandle, onOpen?: (file: File) => void }> = (props) => { + const [open, setOpen] = createSignal(false); + const tree = readTree(() => props.directory); const context = { - open: props.open ?? (() => { }), + tree, + name: createMemo(() => props.directory.name), + open, + setOpen, + + onOpen(file: File) { + props.onOpen?.(file); + }, }; + return + {props.children} + ; +} + +export const useTree = () => { + const context = useContext(TreeContext); + + if (!context) { + throw new Error('`useTree` is called outside of a '); + } + + return context; +} + +export const Tree: Component<{ + children: readonly [(folder: Accessor) => JSX.Element, (file: Accessor) => JSX.Element] +}> = (props) => { + const [, setSelection] = createSignal([]); + const context = useTree(); + return - -
<_Tree entries={props.entries} children={props.children} />
-
+
<_Tree entries={context.tree().entries} children={props.children} />
; } const _Tree: Component<{ entries: Entry[], children: readonly [(folder: Accessor) => JSX.Element, (file: Accessor) => JSX.Element] }> = (props) => { - const context = useContext(TreeContext); + const context = useTree(); return { entry => <> @@ -78,7 +110,7 @@ const _Tree: Component<{ entries: Entry[], children: readonly [(folder: Accessor } { - file => context?.open(file().meta)}> {props.children[1](file)} + file => context.onOpen(file().meta)}> {props.children[1](file)} } } @@ -98,4 +130,44 @@ const sort_by = (key: string) => (objA: Record, objB: Record): Accessor => { + const [entries, { refetch }] = createResource(async (_, { value: prev }) => { + const dir = directory(); + + prev ??= []; + const next: Entry[] = await Array.fromAsync(walk(dir)); + + const prevEntries = flatten(prev).map(e => e.id).toSorted(); + const nextEntries = flatten(next).map(e => e.id).toSorted(); + + if (prevEntries.length !== nextEntries.length) { + return next; + } + + if (prevEntries.some((entry, i) => entry !== nextEntries[i])) { + return next; + } + + return prev; + }, { initialValue: [] }) + + const interval = setInterval(() => { + refetch(); + }, 1000); + + onCleanup(() => { + clearInterval(interval); + }); + + createEffect(() => { + console.log(entries.latest); + }); + + return createMemo(() => ({ name: directory().name, id: '', kind: 'folder', handle: directory(), entries: entries.latest })); +}; + +const flatten = (entries: Entry[]): Entry[] => { + return entries.flatMap(entry => entry.kind === 'folder' ? [entry, ...flatten(entry.entries)] : entry) +} \ No newline at end of file diff --git a/src/routes/(editor)/edit.tsx b/src/routes/(editor)/edit.tsx index baab32c..591f5bb 100644 --- a/src/routes/(editor)/edit.tsx +++ b/src/routes/(editor)/edit.tsx @@ -1,9 +1,8 @@ -import { Component, createEffect, createMemo, createSignal, For, onMount, ParentProps, Setter, Show } from "solid-js"; +import { Component, createEffect, createMemo, createResource, 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 { Grid, load, readFiles, TreeProvider, Tree, 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"; @@ -13,6 +12,7 @@ import EditBlankImage from '~/assets/edit-blank.svg' import { useI18n } from "~/features/i18n"; import { makePersisted } from "@solid-primitives/storage"; import { writeClipboard } from "@solid-primitives/clipboard"; +import { destructure } from "@solid-primitives/destructure"; import css from "./edit.module.css"; const isInstalledPWA = !isServer && window.matchMedia('(display-mode: standalone)').matches; @@ -93,7 +93,6 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { })); 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(); @@ -232,7 +231,6 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { (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)) }); })(); }); @@ -350,24 +348,26 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { - - {[ - folder => { - return { - filesContext?.set(folder().name, folder().handle); - }}>{folder().name}; - }, - file => { - const mutated = createMemo(() => mutatedFiles().values().find(({ id }) => id === file().id) !== undefined); + + + {[ + 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} - + return { + const folder = file().directory; + filesContext?.set(folder.name, folder); + setActive(folder.name); + }}>{file().name}; + }, + ] as const} + +
{ @@ -379,12 +379,39 @@ const Editor: Component<{ root: FileSystemDirectoryHandle }> = (props) => { ; }; -const Content: Component<{ directory: FileSystemDirectoryHandle, api?: Setter<(GridApi & { addLocale(locale: string): void }) | undefined>, entries?: Setter }> = (props) => { - const [entries, setEntries] = createSignal(new Map()); +const Content: Component<{ directory: FileSystemDirectoryHandle, api?: Setter, entries?: Setter }> = (props) => { const [locales, setLocales] = createSignal([]); - const [rows, setRows] = createSignal([]); const [api, setApi] = createSignal(); + const files = readFiles(() => props.directory); + const [contents] = createResource(() => files.latest, (files) => Promise.all(Object.entries(files).map(async ([id, { file, handle }]) => ({ id, handle, lang: file.name.split('.').at(0)!, entries: (await load(file))! }))), { initialValue: [] }); + + const [entries, rows] = destructure(() => { + const template = contents.latest.map(({ lang, handle }) => [lang, { handle, value: '' }]); + const merged = contents.latest.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>()); + + const rows = merged.entries().map(([key, langs]) => ({ key, ...Object.fromEntries(Object.entries(langs).map(([lang, { value }]) => [lang, value])) } as Entry)).toArray(); + + return [ + new Map(merged.entries().map(([key, langs], i) => [i.toString(), { key, ...langs }])) as Entries, + rows + ] as const; + }); + + createEffect(() => { + setLocales(contents.latest.map(({ lang }) => lang)); + }); + createEffect(() => { props.entries?.(entries()); }); @@ -396,52 +423,7 @@ const Content: Component<{ directory: FileSystemDirectoryHandle, api?: Setter<(G 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()); - })(); + props.api?.(a); }); const copyKey = createCommand('page.edit.command.copyKey', (key: string) => writeClipboard(key));