"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"; import { isTauriWebview, nativeFolderPicker, NativeFolderPickerUnavailableError, } from "@/lib/desktop/folder-picker"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; 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; }; type NativeFolderPickerTriggerProps = { triggerClass: string; hasValue: boolean; triggerLabel: string; loading: boolean; error: string | null; onChoose: () => void; }; function NativeFolderPickerTrigger({ triggerClass, hasValue, triggerLabel, loading, error, onChoose, }: NativeFolderPickerTriggerProps) { const { t } = useTranslation(); return (
{error && (

{error}

)}
); } function nativePickerErrorMessage(error: unknown, t: TFunction): string { if (error instanceof NativeFolderPickerUnavailableError) { return t("common:nativeFolderPickerUnavailable"); } if (error instanceof Error && error.message) return error.message; return t("common:nativeFolderPickerFailed"); } /** * 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 { t } = useTranslation(); const [open, setOpen] = useState(false); const [nativeLoading, setNativeLoading] = useState(false); const [nativeError, setNativeError] = useState(null); const tauriWebview = isTauriWebview(); const { listing, loading, error, load } = useDirectoryListing(open && !tauriWebview, value); const leaf = leafName(value); const triggerLabel = leaf || placeholder || t("common:scratchWorkspace"); const hasValue = !!value; const triggerClass = cn( "h-7 inline-flex items-center gap-1.5 rounded-md px-2.5 text-xs cursor-pointer [@media(pointer:coarse)]:h-11", "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 = ( ); const chooseNativeFolder = async () => { setNativeError(null); if (!nativeFolderPicker.isAvailable()) { setNativeError(t("common:nativeFolderPickerUnavailable")); return; } setNativeLoading(true); try { const outcome = await nativeFolderPicker.pickDirectory(); if (outcome.status === "selected") onChange(outcome.path); if (outcome.status === "failed") { setNativeError(outcome.message || t("common:nativeFolderPickerFailed")); } } catch (error) { setNativeError(nativePickerErrorMessage(error, t)); } finally { setNativeLoading(false); } }; if (tauriWebview) { return ( void chooseNativeFolder()} /> ); } return ( {hasValue ? ( {trigger} {value} ) : ( trigger )} void load(p)} />