import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js" import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid" import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types" import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types" import { serverApi } from "../lib/api-client" function normalizePathKey(input?: string | null) { if (!input || input === "." || input === "./") { return "." } if (input === WINDOWS_DRIVES_ROOT) { return WINDOWS_DRIVES_ROOT } let normalized = input.replace(/\\/g, "/") if (/^[a-zA-Z]:/.test(normalized)) { const [drive, rest = ""] = normalized.split(":") const suffix = rest.startsWith("/") ? rest : rest ? `/${rest}` : "/" return `${drive.toUpperCase()}:${suffix.replace(/\/+/g, "/")}` } if (normalized.startsWith("//")) { return `//${normalized.slice(2).replace(/\/+/g, "/")}` } if (normalized.startsWith("/")) { return `/${normalized.slice(1).replace(/\/+/g, "/")}` } normalized = normalized.replace(/^\.\/+/, "").replace(/\/+/g, "/") return normalized === "" ? "." : normalized } function isAbsolutePathLike(input: string) { return input.startsWith("/") || /^[a-zA-Z]:/.test(input) || input.startsWith("\\\\") } interface DirectoryBrowserDialogProps { open: boolean title: string description?: string onSelect: (absolutePath: string) => void onClose: () => void } function resolveAbsolutePath(root: string, relativePath: string) { if (!root) { return relativePath } if (!relativePath || relativePath === "." || relativePath === "./") { return root } if (isAbsolutePathLike(relativePath)) { return relativePath } const separator = root.includes("\\") ? "\\" : "/" const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}` const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "") return `${trimmedRoot}${normalized}` } type FolderRow = | { type: "up"; path: string } | { type: "folder"; entry: FileSystemEntry } const DirectoryBrowserDialog: Component = (props) => { const [rootPath, setRootPath] = createSignal("") const [loading, setLoading] = createSignal(false) const [error, setError] = createSignal(null) const [directoryChildren, setDirectoryChildren] = createSignal>(new Map()) const [loadingPaths, setLoadingPaths] = createSignal>(new Set()) const [currentPathKey, setCurrentPathKey] = createSignal(null) const [currentMetadata, setCurrentMetadata] = createSignal(null) const metadataCache = new Map() const inFlightRequests = new Map>() function resetState() { setDirectoryChildren(new Map()) setLoadingPaths(new Set()) setCurrentPathKey(null) setCurrentMetadata(null) metadataCache.clear() inFlightRequests.clear() setError(null) } createEffect(() => { if (!props.open) { return } resetState() void initialize() const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault() props.onClose() } } window.addEventListener("keydown", handleKeyDown) onCleanup(() => { window.removeEventListener("keydown", handleKeyDown) }) }) async function initialize() { setLoading(true) try { const metadata = await loadDirectory() applyMetadata(metadata) } catch (err) { const message = err instanceof Error ? err.message : "Unable to load filesystem" setError(message) } finally { setLoading(false) } } function applyMetadata(metadata: FileSystemListingMetadata) { const key = normalizePathKey(metadata.currentPath) setCurrentPathKey(key) setCurrentMetadata(metadata) setRootPath(metadata.rootPath) } async function loadDirectory(targetPath?: string): Promise { const key = targetPath ? normalizePathKey(targetPath) : undefined if (key) { const cached = metadataCache.get(key) if (cached) { return cached } const pending = inFlightRequests.get(key) if (pending) { return pending } } const request = (async () => { if (key) { setLoadingPaths((prev) => { const next = new Set(prev) next.add(key) return next }) } const response = await serverApi.listFileSystem(targetPath, { includeFiles: false }) const canonicalKey = normalizePathKey(response.metadata.currentPath) const directories = response.entries .filter((entry) => entry.type === "directory") .sort((a, b) => a.name.localeCompare(b.name)) setDirectoryChildren((prev) => { const next = new Map(prev) next.set(canonicalKey, directories) return next }) metadataCache.set(canonicalKey, response.metadata) setLoadingPaths((prev) => { const next = new Set(prev) if (key) { next.delete(key) } next.delete(canonicalKey) return next }) return response.metadata })() .catch((err) => { if (key) { setLoadingPaths((prev) => { const next = new Set(prev) next.delete(key) return next }) } throw err }) .finally(() => { if (key) { inFlightRequests.delete(key) } }) if (key) { inFlightRequests.set(key, request) } return request } async function navigateTo(path?: string) { setError(null) try { const metadata = await loadDirectory(path) applyMetadata(metadata) } catch (err) { const message = err instanceof Error ? err.message : "Unable to load filesystem" setError(message) } } const folderRows = createMemo(() => { const rows: FolderRow[] = [] const metadata = currentMetadata() if (metadata?.parentPath) { rows.push({ type: "up", path: metadata.parentPath }) } const key = currentPathKey() if (!key) { return rows } const children = directoryChildren().get(key) ?? [] for (const entry of children) { rows.push({ type: "folder", entry }) } return rows }) function handleNavigateTo(path: string) { void navigateTo(path) } function handleNavigateUp() { const parent = currentMetadata()?.parentPath if (parent) { void navigateTo(parent) } } const currentAbsolutePath = createMemo(() => { const metadata = currentMetadata() if (!metadata) { return "" } if (metadata.pathKind === "drives") { return "" } if (metadata.pathKind === "relative") { return resolveAbsolutePath(metadata.rootPath, metadata.currentPath) } return metadata.displayPath }) const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath())) function handleEntrySelect(entry: FileSystemEntry) { const absolutePath = entry.absolutePath ? entry.absolutePath : isAbsolutePathLike(entry.path) ? entry.path : resolveAbsolutePath(rootPath(), entry.path) props.onSelect(absolutePath) } function isPathLoading(path: string) { return loadingPaths().has(normalizePathKey(path)) } function handleOverlayClick(event: MouseEvent) { if (event.target === event.currentTarget) { props.onClose() } } return (
) } export default DirectoryBrowserDialog