import { DbClient, DbProvider, eq, useDbClient, useLiveQuery } from "@tanstack/react-db" import { useCallback, useEffect, useRef, useState } from "preact/hooks" import { QueryClient } from "@tanstack/query-core" import { twJoin } from "tailwind-merge" import { createFileActions, filesCollection } from "../../lib/actions" import { usePreference } from "../../hooks/usePreference" import { selectedEntries, sortOptions, fileQuery, viewOptions } from "../../lib/files" import { type FileEntry } from "../../lib/api" import { FileList } from "./FileList" import { Toolbar } from "./Toolbar" import { NameDialog } from "./NameDialog" import { FileInspector } from "./FileInspector" import { PopoverMenu, makeOpt } from "./PopoverMenu" import type Anchor from "../../models/Anchor" import useClickOutside from "../../hooks/useClickOutside" type NameAction = { kind: "file" | "directory" } | { kind: "rename"; file: FileEntry } const contentUrl = (id: string) => `/files/${encodeURIComponent(id)}` const dbClient = new DbClient({ queryClient: new QueryClient() }) export function Explorer({ did, spaceUri }: { did: string; spaceUri: string }) { return ( ) } function useFilesCollection() { return useDbClient().collection(filesCollection) } export function ExplorerBody({ did, spaceUri }: { did: string; spaceUri: string }) { const [trail, setTrail] = useState([]) const [selected, setSelected] = useState([]) const [search, setSearch] = useState("") const [descending, setDescending] = useState(false) const [sort, setSort] = usePreference("crate.sort", sortOptions, "Name") const [view, setView] = usePreference("crate.view", viewOptions, "list") const [dialog, setDialog] = useState(null) const [inspecting, setInspecting] = useState(false) const [context, setContext] = useState(null) const filesRef = useRef(null) const parentId = trail.at(-1)?.id const [error, setError] = useState(null) const collection = useFilesCollection() const actions = createFileActions(collection) const { data: visible, isLoading: loading, isError, } = useLiveQuery({ queryKey: [did, spaceUri, parentId, search, sort, descending], query: (q) => fileQuery(q, collection, search, sort, descending, parentId), }) const { data: unsynced } = useLiveQuery({ query: (q) => q.from({ file: collection }).where(({ file }) => eq(file.$synced, false)), }) const pending = unsynced.length > 0 const busy = loading || isError || pending const loadError = isError ? collection.utils.lastError : undefined const showError = (cause: unknown) => setError(cause instanceof Error ? cause : new Error(String(cause))) const refresh = () => collection.utils.refetch({ throwOnError: true }).catch(showError) const rename = async (file: FileEntry, name: string) => { actions.rename(file, name).isPersisted.promise.catch(showError) return true } const selection = selectedEntries(visible, selected) useEffect(() => { setSelected([]) setSearch("") setContext(null) }, [parentId]) const onOpen = (file: FileEntry) => { if (file.kind === "directory") setTrail([...trail, file]) else window.open(contentUrl(file.id), "_blank", "noopener") } const closeContext = useCallback(() => setContext(null), []) const deleteSelection = (files: FileEntry[]) => { if (!window.confirm(`Delete ${files.length} selected item(s)?`)) return for (const file of files) actions.trash(file).isPersisted.promise.catch(showError) setSelected([]) } const menuActions = (entries: FileEntry[]) => ({ onOpen: () => entries.forEach(onOpen), onDownload: () => entries .filter((file) => file.kind === "file") .forEach((file) => { const link = document.createElement("a") link.href = contentUrl(file.id) link.download = file.name link.click() }), onDelete: () => void deleteSelection(entries), onRenameRequest: entries.length === 1 ? () => setDialog({ kind: "rename", file: entries[0]! }) : undefined, onDuplicate: () => { for (const file of entries.filter((file) => file.kind === "file")) actions.duplicate(file, `Copy of ${file.name}`).isPersisted.promise.catch(showError) }, onInspect: () => setInspecting(true), onCopyCID: () => void navigator.clipboard .writeText(entries.map((file) => file.revision).join(",")) .catch(showError), }) useClickOutside({ ref: filesRef, handler: (event) => { if (event.ctrlKey || event.metaKey) return if ( event.target instanceof Element && event.target.closest("#file-toolbar, #file-inspector, .popover-menu, .popover") ) return setSelected([]) }, }) return (
{ if (event.target instanceof Element && event.target.closest("input, #file-toolbar")) return event.preventDefault() setContext({ top: event.clientY, left: event.clientX }) }} onClick={(event) => { if (event.target === event.currentTarget) setSelected([]) }} >

{trail.map((folder, index) => ( ))}

setDialog({ kind })} onUpload={(files) => { for (const file of files) actions.upload(file, parentId).isPersisted.promise.catch(showError) }} /> {error && (
{error.message}
)} {loadError && (

{loadError instanceof Error ? loadError.message : String(loadError)}

)} {(loading || !loadError) && ( file.id)} view={view} pending={busy} onSelect={(id, checked, additive) => setSelected( additive ? checked ? [...selected.filter((value) => value !== id), id] : selected.filter((value) => value !== id) : [id], ) } menuActions={menuActions} onDeselect={(ids) => setSelected((current) => current.filter((id) => !ids.includes(id))) } onRename={rename} onOpen={onOpen} /> )}
{context && ( setDialog({ kind: "file" }), false, busy), makeOpt( "New Folder", closeContext, () => setDialog({ kind: "directory" }), false, busy, ), "divider", makeOpt("Select all", closeContext, () => setSelected(visible.map((file) => file.id))), makeOpt("Clear selection", closeContext, () => setSelected([])), makeOpt(descending ? "Sort ascending" : "Sort descending", closeContext, () => setDescending(!descending), ), ]} /> )} {dialog && ( setDialog(null)} onSave={async (name) => { const transaction = dialog.kind === "rename" ? actions.rename(dialog.file, name) : actions.create(name, dialog.kind, parentId) transaction.isPersisted.promise.catch(showError) return true }} /> )}
) }