"use client"; import { useEffect, useState, useRef, useCallback } from "react"; import { FileManagerTree, type TreeNode } from "./file-manager-tree"; import { ProfileSwitcher } from "./profile-switcher"; import { CreateWorkspaceDialog } from "./create-workspace-dialog"; import { UnicodeSpinner } from "../unicode-spinner"; /** Shape returned by /api/workspace/suggest-files */ type SuggestItem = { name: string; path: string; type: "folder" | "file" | "document" | "database"; }; type WorkspaceSidebarProps = { tree: TreeNode[]; activePath: string | null; onSelect: (node: TreeNode) => void; onRefresh: () => void; orgName?: string; loading?: boolean; /** Current browse directory (absolute path), or null when in workspace mode. */ browseDir?: string | null; /** Parent directory for ".." navigation. Null at filesystem root or when browsing is unavailable. */ parentDir?: string | null; /** Navigate up one directory. */ onNavigateUp?: () => void; /** Return to workspace mode from browse mode. */ onGoHome?: () => void; /** Called when a file/folder is selected from the search dropdown. */ onFileSearchSelect?: (item: SuggestItem) => void; /** Absolute path of the workspace root folder, used to render it as a special entry in browse mode. */ workspaceRoot?: string | null; /** Navigate to the main chat / home panel. */ onGoToChat?: () => void; /** Called when a tree node is dragged and dropped onto an external target (e.g. chat input). */ onExternalDrop?: (node: TreeNode) => void; /** When true, renders as a mobile overlay drawer instead of a static sidebar. */ mobile?: boolean; /** Close the mobile drawer. */ onClose?: () => void; /** Active workspace profile name (null = default). */ activeProfile?: string | null; /** Fixed width in px when not mobile (overrides default 260). */ width?: number; /** Called after the user switches to a different profile. */ onProfileSwitch?: () => void; /** Whether hidden (dot) files/folders are currently shown. */ showHidden?: boolean; /** Toggle hidden files visibility. */ onToggleHidden?: () => void; /** Called when the user clicks the collapse/hide sidebar button. */ onCollapse?: () => void; }; function HomeIcon() { return ( ); } function FolderOpenIcon() { return ( ); } /* ─── Theme toggle ─── */ function ThemeToggle() { const [isDark, setIsDark] = useState(false); useEffect(() => { setIsDark(document.documentElement.classList.contains("dark")); }, []); const toggle = () => { const next = !isDark; setIsDark(next); if (next) { document.documentElement.classList.add("dark"); localStorage.setItem("theme", "dark"); } else { document.documentElement.classList.remove("dark"); localStorage.setItem("theme", "light"); } }; return ( ); } function SearchIcon() { return ( ); } function SmallFolderIcon() { return ( ); } function SmallFileIcon() { return ( ); } function SmallDocIcon() { return ( ); } function SmallDbIcon() { return ( ); } function SuggestTypeIcon({ type }: { type: string }) { switch (type) { case "folder": return ; case "document": return ; case "database": return ; default: return ; } } /* ─── File search ─── */ function FileSearch({ onSelect }: { onSelect: (item: SuggestItem) => void }) { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); const containerRef = useRef(null); // Debounced fetch from the same suggest-files API that tiptap uses useEffect(() => { if (!query.trim()) { setResults([]); setOpen(false); return; } setLoading(true); const timer = setTimeout(async () => { try { const res = await fetch( `/api/workspace/suggest-files?q=${encodeURIComponent(query.trim())}`, ); const data = await res.json(); setResults(data.items ?? []); setOpen(true); setSelectedIndex(0); } catch { setResults([]); } finally { setLoading(false); } }, 150); return () => clearTimeout(timer); }, [query]); // Click outside to close useEffect(() => { function handleClickOutside(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); } } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((i) => Math.min(i + 1, results.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)); } else if (e.key === "Enter" && results[selectedIndex]) { e.preventDefault(); onSelect(results[selectedIndex]); setQuery(""); setOpen(false); inputRef.current?.blur(); } else if (e.key === "Escape") { setOpen(false); setQuery(""); inputRef.current?.blur(); } }, [results, selectedIndex, onSelect], ); const handleSelect = useCallback( (item: SuggestItem) => { onSelect(item); setQuery(""); setOpen(false); }, [onSelect], ); return (
setQuery(e.target.value)} onKeyDown={handleKeyDown} onFocus={() => { if (results.length > 0) {setOpen(true);} }} placeholder="Search files..." className="w-full pl-8 pr-3 py-1.5 rounded-lg text-xs outline-none transition-colors" style={{ background: "var(--color-bg)", color: "var(--color-text)", border: "1px solid var(--color-border)", }} /> {loading && ( )}
{open && results.length > 0 && (
{results.map((item, i) => ( ))}
)} {open && query.trim() && !loading && results.length === 0 && (

No files found

)}
); } /** Extract the directory name from an absolute path for display. */ function dirDisplayName(dir: string): string { if (dir === "/") {return "/";} return dir.split("/").pop() || dir; } export function WorkspaceSidebar({ tree, activePath, onSelect, onRefresh, orgName, loading, browseDir, parentDir, onNavigateUp, onGoHome, onFileSearchSelect, workspaceRoot, onGoToChat, onExternalDrop, mobile, onClose, activeProfile, onProfileSwitch, showHidden, onToggleHidden, width: widthProp, onCollapse, }: WorkspaceSidebarProps) { const isBrowsing = browseDir != null; const [showCreateWorkspace, setShowCreateWorkspace] = useState(false); const width = mobile ? "280px" : (widthProp ?? 260); const sidebar = ( ); if (!mobile) { return sidebar; } return (
void onClose?.()}> {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
e.stopPropagation()} className="fixed inset-y-0 left-0 z-50"> {sidebar}
); }