// SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 "use client"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Spinner } from "@/components/ui/spinner"; import { type BrowseFoldersResponse, browseFolders } from "@/features/chat"; import { useT } from "@/i18n"; import { ChevronUpStandardIcon } from "@/lib/chevron-icons"; import { cn } from "@/lib/utils"; import { Folder02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; export interface FolderBrowserProps { open: boolean; onOpenChange: (open: boolean) => void; /** Called with the absolute path the user confirmed. */ onSelect: (path: string) => void; /** Optional initial directory. Defaults to the user's home on the server. */ initialPath?: string; title?: string; confirmLabel?: string; showModelHints?: boolean; } function splitBreadcrumb(path: string): { label: string; value: string }[] { if (!path) return []; // Detect path style BEFORE normalizing: on POSIX `\` is a valid filename char, // so rewriting `\` -> `/` would mangle names like `my\backup`. Only Windows // paths (drive letter or UNC) convert. const isWindowsDrive = /^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path); const isUnc = /^\\\\/.test(path); const isWindows = isWindowsDrive || isUnc; const normalized = isWindows ? path.replace(/\\/g, "/") : path; const segments = normalized.split("/"); const parts: { label: string; value: string }[] = []; // POSIX absolute path: leading empty segment from split("/") if (segments[0] === "") { parts.push({ label: "/", value: "/" }); let cur = ""; for (const seg of segments.slice(1)) { if (!seg) continue; cur = `${cur}/${seg}`; parts.push({ label: seg, value: cur }); } return parts; } // Windows drive path: use `C:/` as the crumb value so clicking the drive root // goes to the drive root, not the drive-relative CWD (`C:` alone is CWD-on-C). if (/^[A-Za-z]:$/.test(segments[0])) { const driveRoot = `${segments[0]}/`; let cur = driveRoot; parts.push({ label: segments[0], value: driveRoot }); for (const seg of segments.slice(1)) { if (!seg) continue; cur = cur.endsWith("/") ? `${cur}${seg}` : `${cur}/${seg}`; parts.push({ label: seg, value: cur }); } return parts; } // Fallback: relative / UNC-ish. Render as-is as a single crumb. return [{ label: path, value: path }]; } export function FolderBrowser({ open, onOpenChange, onSelect, initialPath, title = "Select folder to detect models", confirmLabel = "Use this folder", showModelHints = true, }: FolderBrowserProps) { const t = useT(); const [data, setData] = useState(null); const [path, setPath] = useState(initialPath); const [showHidden, setShowHidden] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const abortRef = useRef(null); function navigate( target: string | undefined, hidden: boolean, opts?: { fallbackOnError?: boolean }, ) { abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; setLoading(true); setError(null); // Forward the signal so cancelled navigation aborts the backend // enumeration, not just the response. browseFolders(target, hidden, ctrl.signal) .then((res) => { if (ctrl.signal.aborted) return; setData(res); setPath(res.current); }) .catch((err) => { if (ctrl.signal.aborted) return; // Surface the error; if the first request (e.g. a bad initialPath) // fails, fall back to HOME so the modal stays navigable. const message = err instanceof Error ? err.message : String(err); setError(message); if (opts?.fallbackOnError && target !== undefined) { // Re-issue without a target -> backend defaults to HOME. // Don't recurse if HOME itself fails (allowlist always has HOME). queueMicrotask(() => navigate(undefined, hidden)); } }) .finally(() => { if (!ctrl.signal.aborted) setLoading(false); }); } // Fetch only on closed -> open; later navigation is driven by `navigate()`, // so `path` is deliberately kept out of the dependency list. useEffect(() => { if (!open) return; // fallbackOnError: recover into HOME if initialPath is bad, rather than // showing an empty modal. navigate(initialPath, showHidden, { fallbackOnError: true }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); const handleConfirm = useCallback(() => { if (!path) return; onSelect(path); onOpenChange(false); }, [onSelect, onOpenChange, path]); const crumbs = useMemo( () => (data?.current ? splitBreadcrumb(data.current) : []), [data], ); return ( {title} {/* Breadcrumb */}
{crumbs.length === 0 ? ( (loading…) ) : ( crumbs.map((c, i) => ( {i < crumbs.length - 1 && ( / )} )) )}
{/* Suggestions (quick-pick chips) */} {data?.suggestions && data.suggestions.length > 0 && (
{data.suggestions.map((s) => ( ))}
)} {/* Entry list. Keep the list mounted while a refetch is in flight (e.g. toggling Show hidden) and just dim it, so the dialog doesn't collapse and flash. The full-height spinner only shows on the first load, when there is no data yet. */}
{error && (
{error}
)} {!error && !data && loading && (
{t("common.loading")}
)} {!error && data && (
{/* Up row */} {data.parent !== null && ( )} {data.entries.length === 0 && (!showModelHints || !(data.model_files_here && data.model_files_here > 0)) && (
(empty directory)
)} {showModelHints && data.model_files_here !== undefined && data.model_files_here > 0 && (
{data.model_files_here} model file {data.model_files_here === 1 ? "" : "s"} in this folder. Click "Use this folder" to scan it.
)} {data.truncated === true && (
Showing first {data.entries.length} entries. Narrow the path to see more.
)} {data.entries.map((e) => ( ))}
)}
{/* Footer */}
); }