import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js" import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft } from "lucide-solid" import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types" import { serverApi } from "../lib/api-client" import { getLogger } from "../lib/logger" const log = getLogger("actions") const MAX_RESULTS = 200 function normalizeEntryPath(path: string | undefined): string { if (!path || path === "." || path === "./") { return "." } let cleaned = path.replace(/\\/g, "/") if (cleaned.startsWith("./")) { cleaned = cleaned.replace(/^\.\/+/, "") } if (cleaned.startsWith("/")) { cleaned = cleaned.replace(/^\/+/, "") } cleaned = cleaned.replace(/\/+/g, "/") return cleaned === "" ? "." : cleaned } function resolveAbsolutePath(root: string, relativePath: string): string { if (!root) { return relativePath } if (!relativePath || relativePath === "." || relativePath === "./") { return root } const separator = root.includes("\\") ? "\\" : "/" const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}` const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "") return `${trimmedRoot}${normalized}` } interface FileSystemBrowserDialogProps { open: boolean mode: "directories" | "files" title: string description?: string onSelect: (absolutePath: string) => void onClose: () => void } type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry } const FileSystemBrowserDialog: Component = (props) => { const [rootPath, setRootPath] = createSignal("") const [entries, setEntries] = createSignal([]) const [currentMetadata, setCurrentMetadata] = createSignal(null) const [loadingPath, setLoadingPath] = createSignal(null) const [error, setError] = createSignal(null) const [searchQuery, setSearchQuery] = createSignal("") const [selectedIndex, setSelectedIndex] = createSignal(0) let searchInputRef: HTMLInputElement | undefined const directoryCache = new Map() const metadataCache = new Map() const inFlightLoads = new Map>() function resetDialogState() { directoryCache.clear() metadataCache.clear() inFlightLoads.clear() setEntries([]) setCurrentMetadata(null) setLoadingPath(null) } async function fetchDirectory(path: string, makeCurrent = false): Promise { const normalized = normalizeEntryPath(path) if (directoryCache.has(normalized) && metadataCache.has(normalized)) { if (makeCurrent) { setCurrentMetadata(metadataCache.get(normalized) ?? null) setEntries(directoryCache.get(normalized) ?? []) } return metadataCache.get(normalized) as FileSystemListingMetadata } if (inFlightLoads.has(normalized)) { const metadata = await inFlightLoads.get(normalized)! if (makeCurrent) { setCurrentMetadata(metadata) setEntries(directoryCache.get(normalized) ?? []) } return metadata } const loadPromise = (async () => { setLoadingPath(normalized) const response = await serverApi.listFileSystem(normalized === "." ? "." : normalized, { includeFiles: props.mode === "files", }) directoryCache.set(normalized, response.entries) metadataCache.set(normalized, response.metadata) if (!rootPath()) { setRootPath(response.metadata.rootPath) } if (loadingPath() === normalized) { setLoadingPath(null) } return response.metadata })().catch((err) => { if (loadingPath() === normalized) { setLoadingPath(null) } throw err }) inFlightLoads.set(normalized, loadPromise) try { const metadata = await loadPromise if (makeCurrent) { const key = normalizeEntryPath(metadata.currentPath) setCurrentMetadata(metadata) setEntries(directoryCache.get(key) ?? directoryCache.get(normalized) ?? []) } return metadata } finally { inFlightLoads.delete(normalized) } } async function refreshEntries() { setError(null) resetDialogState() try { const metadata = await fetchDirectory(".", true) setRootPath(metadata.rootPath) setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? []) } catch (err) { const message = err instanceof Error ? err.message : "Unable to load filesystem" setError(message) } } function describeLoadingPath() { const path = loadingPath() if (!path) { return "filesystem" } if (path === ".") { return rootPath() || "workspace root" } return resolveAbsolutePath(rootPath(), path) } function currentAbsolutePath(): string { const metadata = currentMetadata() if (!metadata) { return rootPath() } if (metadata.pathKind === "relative") { return resolveAbsolutePath(rootPath(), metadata.currentPath) } return metadata.displayPath } function handleOverlayClick(event: MouseEvent) { if (event.target === event.currentTarget) { props.onClose() } } function handleEntrySelect(entry: FileSystemEntry) { const absolute = resolveAbsolutePath(rootPath(), entry.path) props.onSelect(absolute) } function handleNavigateTo(path: string) { void fetchDirectory(path, true).catch((err) => { log.error("Failed to open directory", err) setError(err instanceof Error ? err.message : "Unable to open directory") }) } function handleNavigateUp() { const parent = currentMetadata()?.parentPath if (!parent) { return } handleNavigateTo(parent) } const filteredEntries = createMemo(() => { const query = searchQuery().trim().toLowerCase() const subset = entries().filter((entry) => (props.mode === "directories" ? entry.type === "directory" : true)) if (!query) { return subset } return subset.filter((entry) => { const absolute = resolveAbsolutePath(rootPath(), entry.path) return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query) }) }) const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS)) const folderRows = createMemo(() => { const rows: FolderRow[] = [] const metadata = currentMetadata() if (metadata?.parentPath) { rows.push({ type: "up", path: metadata.parentPath }) } for (const entry of visibleEntries()) { rows.push({ type: "entry", entry }) } return rows }) createEffect(() => { const list = visibleEntries() if (list.length === 0) { setSelectedIndex(0) return } if (selectedIndex() >= list.length) { setSelectedIndex(list.length - 1) } }) createEffect(() => { if (!props.open) { return } setSearchQuery("") setSelectedIndex(0) void refreshEntries() setTimeout(() => searchInputRef?.focus(), 50) const handleKeyDown = (event: KeyboardEvent) => { if (!props.open) return const results = visibleEntries() if (event.key === "Escape") { event.preventDefault() props.onClose() return } if (results.length === 0) { return } if (event.key === "ArrowDown") { event.preventDefault() setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1)) } else if (event.key === "ArrowUp") { event.preventDefault() setSelectedIndex((prev) => Math.max(prev - 1, 0)) } else if (event.key === "Enter") { event.preventDefault() const entry = results[selectedIndex()] if (entry) { handleEntrySelect(entry) } } } window.addEventListener("keydown", handleKeyDown) onCleanup(() => { window.removeEventListener("keydown", handleKeyDown) resetDialogState() setRootPath("") setError(null) }) }) return (
) } export default FileSystemBrowserDialog