"use client"; import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import { IconBox, IconCheck, IconChevronRight, IconFolder, IconFolderPlus, IconX, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@/lib/utils"; import { listDirectory, type DirectoryListing } from "@/lib/api/domains/fs-api"; type FolderPickerProps = { /** Currently chosen absolute path; empty when nothing is picked. */ value: string; /** Called with the absolute path when the user chooses a folder. */ onChange: (path: string) => void; placeholder?: string; }; /** * Folder picker for repo-less tasks. Drives GET /api/v1/fs/list-dir on the * local kandev backend (browsers can't enumerate the host filesystem). The * trigger lives in the chip row and the popover handles browse + commit. */ export function FolderPicker({ value, onChange, placeholder }: FolderPickerProps) { const [open, setOpen] = useState(false); const { listing, loading, error, load } = useDirectoryListing(open, value); const leaf = leafName(value); const triggerLabel = leaf || placeholder || "scratch workspace"; const hasValue = !!value; const triggerClass = cn( "h-7 inline-flex items-center gap-1.5 rounded-md px-2.5 text-xs cursor-pointer", "border border-border/60 transition-colors", hasValue ? "bg-primary/10 text-foreground hover:bg-primary/15" : "bg-muted/30 text-muted-foreground hover:bg-muted/60", ); const trigger = ( {hasValue ? ( ) : ( )} {triggerLabel} ); return ( {hasValue ? ( {trigger} {value} ) : ( trigger )} void load(p)} /> ); } function leafName(path: string): string { if (!path) return ""; if (path === "/") return "/"; const trimmed = path.replace(/[\\/]+$/, ""); if (/^[A-Za-z]:$/.test(trimmed)) return `${trimmed}\\`; const idx = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); return idx === -1 ? trimmed : trimmed.slice(idx + 1) || "/"; } export function useDirectoryListing(open: boolean, value: string) { const [listing, setListing] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const requestGeneration = useRef(0); const load = useCallback(async (path: string) => { const generation = ++requestGeneration.current; setLoading(true); setError(null); setListing(null); try { const nextListing = await listDirectory(path); if (generation !== requestGeneration.current) return; setListing(nextListing); } catch (err) { if (generation !== requestGeneration.current) return; setError(err instanceof Error ? err.message : "Failed to load directory"); } finally { if (generation === requestGeneration.current) setLoading(false); } }, []); useEffect(() => { // Reset cached listing when the popover closes so the next open reloads // from `value`. Without this, a user who browsed deep into the tree, // closed without committing, and reopened would still see the stale // last-browsed directory instead of their picked folder (or the root). if (!open) { requestGeneration.current++; setListing(null); setLoading(false); setError(null); return; } void load(value || ""); return () => { requestGeneration.current++; }; }, [open, load, value]); return { listing, loading, error, load }; } /** Splits an absolute path into clickable breadcrumb segments. */ function pathSegments(path: string): Array<{ name: string; path: string }> { if (!path) return []; const segs: Array<{ name: string; path: string }> = [{ name: "/", path: "/" }]; const driveMatch = /^([A-Za-z]:)[\\/]*(.*)$/.exec(path); if (driveMatch) { const driveRoot = `${driveMatch[1]}\\`; segs.push({ name: driveRoot, path: driveRoot }); appendPathSegments(segs, driveRoot, driveMatch[2], "\\"); return segs; } const uncMatch = /^\\\\([^\\/]+)[\\/]([^\\/]+)[\\/]*(.*)$/.exec(path); if (uncMatch) { const shareRoot = `\\\\${uncMatch[1]}\\${uncMatch[2]}\\`; segs.push({ name: shareRoot, path: shareRoot }); appendPathSegments(segs, shareRoot, uncMatch[3], "\\"); return segs; } appendPathSegments(segs, "", path, "/"); return segs; } function appendPathSegments( segs: Array<{ name: string; path: string }>, root: string, path: string, separator: "/" | "\\", ) { const parts = path.split(/[\\/]+/).filter(Boolean); let acc = root.replace(/[\\/]+$/, ""); for (const part of parts) { acc += `${separator}${part}`; segs.push({ name: part, path: acc }); } } function Breadcrumb({ path, onNavigate, touchRows = false, }: { path: string; onNavigate: (p: string) => void; touchRows?: boolean; }) { const segs = pathSegments(path); return ( {segs.length === 0 && ( Loading… )} {segs.map((seg, i) => { const last = i === segs.length - 1; return ( {i > 0 && ( )} !last && onNavigate(seg.path)} disabled={last} className={cn( "rounded px-1.5 py-0.5 text-[11px] font-mono whitespace-nowrap", touchRows && "min-h-12", last ? "text-foreground cursor-default" : "text-muted-foreground hover:bg-accent hover:text-foreground cursor-pointer", )} > {seg.name} ); })} ); } export function DirectoryBrowserBody({ listing, loading, error, onNavigate, onCreateDirectory, touchRows = false, fillAvailableHeight = false, }: { listing: DirectoryListing | null; loading: boolean; error: string | null; onNavigate: (path: string) => void; onCreateDirectory?: (name: string) => Promise; touchRows?: boolean; fillAvailableHeight?: boolean; }) { return ( {onCreateDirectory ? ( ) : null} ); } function DirectoryBrowserToolbar({ disabled, onCreateDirectory, touchRows, }: { disabled: boolean; onCreateDirectory: (name: string) => Promise; touchRows: boolean; }) { const [editing, setEditing] = useState(false); const [name, setName] = useState(""); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); const createFolder = async () => { const trimmedName = name.trim(); if (!trimmedName || creating) return; setCreating(true); setError(null); try { await onCreateDirectory(trimmedName); setEditing(false); } catch (err) { setError(err instanceof Error ? err.message : "Failed to create folder"); } finally { setCreating(false); } }; return ( {editing ? ( { setName(event.target.value); setError(null); }} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void createFolder(); } else if (event.key === "Escape") { setEditing(false); } }} placeholder="Folder name" autoFocus className={cn("min-w-0 flex-1", touchRows && "h-10")} /> void createFolder()} disabled={!name.trim() || creating} aria-label="Create folder" title="Create folder" > setEditing(false)} aria-label="Cancel new folder" title="Cancel" > {error ? ( {error} ) : null} ) : ( Folders setEditing(true)} disabled={disabled} > New folder )} ); } function Entries({ listing, loading, error, onDescend, touchRows, fillAvailableHeight, }: { listing: DirectoryListing | null; loading: boolean; error: string | null; onDescend: (path: string) => void; touchRows?: boolean; fillAvailableHeight?: boolean; }) { if (loading) return ; if (error) return ; if (!listing || listing.entries.length === 0) { return ; } return ( e.stopPropagation()} > {listing.entries.map((entry) => ( onDescend(entry.path)} className={cn( "group flex w-full items-center gap-2 px-3 text-left text-xs hover:bg-accent cursor-pointer", touchRows ? "min-h-12 py-2" : "py-1.5", )} data-testid="folder-picker-entry" > {entry.name} ))} ); } function EmptyRow({ text, variant, testId }: { text: string; variant?: "error"; testId?: string }) { return ( {text} ); } function Footer({ choosable, onUseScratch, onChoose, }: { choosable: boolean; onUseScratch: () => void; onChoose: () => void; }) { return ( Use scratch instead Use this folder ); }
{error}