/** * Folder navigation primitives for the tool-organization tree — shared by the tools * screen and the flows page so both explore, breadcrumb, and re-file against * one identical set of controls. * * The tree is passed FLAT (a {@link Folder} list keyed by `id`, each naming its * `parentId`); these helpers derive the current directory, the breadcrumb path, and * the indented picker options from it. Every derivation is cycle-SAFE: a malformed * parent chain (a loop, a dangling parent) is bounded by the folder count rather than * looping forever — bad data degrades to a truncated path, never a hang. * * `FolderPicker` carries inline new-folder creation, so a folder can be created from * BOTH consuming screens through this one component — never a flows-page-only * affordance. */ import { type ReactNode, useState } from 'react'; import { ChevronRightIcon, FolderIcon } from './icons'; import { TextInput } from './inputs'; import { Button } from './primitives'; import { Select } from './select'; /** A folder in the tree, in the SDK's camelCase UI shape (`parentId`, `null` = root). */ export interface Folder { readonly id: string; readonly name: string; readonly parentId: string | null; } /** The subfolders directly under `parentId` (`null` = the root directory), name-sorted. */ export function childFolders(folders: readonly Folder[], parentId: string | null): Folder[] { return folders .filter((folder) => folder.parentId === parentId) .sort((a, b) => a.name.localeCompare(b.name)); } /** * The ordered ancestor path from the root down to (and including) `folderId`, for a * breadcrumb. `null` is the root (an empty path). The walk is bounded by the folder * count, so a cyclic or dangling chain truncates instead of looping. */ export function folderPathTo(folders: readonly Folder[], folderId: string | null): Folder[] { if (folderId === null) return []; const byId = new Map(folders.map((folder) => [folder.id, folder])); const path: Folder[] = []; const seen = new Set(); let current: string | null = folderId; while (current !== null && !seen.has(current)) { seen.add(current); const folder = byId.get(current); if (folder === undefined) break; path.unshift(folder); current = folder.parentId; } return path; } export interface FolderBreadcrumbProps { readonly folders: readonly Folder[]; readonly currentFolderId: string | null; readonly onNavigate: (folderId: string | null) => void; /** The root crumb's label (e.g. "All tools"). */ readonly rootLabel: string; } /** The root → … → current path; each crumb navigates to that level. */ export function FolderBreadcrumb({ folders, currentFolderId, onNavigate, rootLabel, }: FolderBreadcrumbProps): ReactNode { const path = folderPathTo(folders, currentFolderId); return ( ); } export interface FolderRowProps { readonly folder: Folder; readonly onOpen: (folderId: string) => void; } /** One subfolder in the current-directory explorer; opening it navigates inward. */ export function FolderRow({ folder, onOpen }: FolderRowProps): ReactNode { return ( ); } /** The sentinel option value for "no folder" — folder ids are UUIDs, so it never collides. */ const UNFILED_VALUE = '__unfiled__'; /** Flatten the tree into indented `Select` options, root first, depth-first. */ function folderOptions(folders: readonly Folder[]): { value: string; label: string }[] { const options: { value: string; label: string }[] = []; const walk = (parentId: string | null, depth: number): void => { for (const folder of childFolders(folders, parentId)) { options.push({ value: folder.id, label: `${'  '.repeat(depth)}${folder.name}` }); walk(folder.id, depth + 1); } }; walk(null, 0); return options; } export interface FolderPickerProps { /** The chosen folder id, or `null` for unfiled. */ readonly value: string | null; readonly onChange: (folderId: string | null) => void; readonly folders: readonly Folder[]; /** * Create a folder under `parentId` (the current selection) and resolve with its * id; the picker selects it. Rejection is the caller's to surface — the picker * leaves the draft in place so the operator can retry. */ readonly onCreateFolder: (name: string, parentId: string | null) => Promise; readonly disabled?: boolean; /** The unfiled option's label (e.g. "No folder"). */ readonly unfiledLabel: string; } /** A folder chooser with inline new-folder creation, for the edit / move dialogs. */ export function FolderPicker({ value, onChange, folders, onCreateFolder, disabled, unfiledLabel, }: FolderPickerProps): ReactNode { const [creating, setCreating] = useState(false); const [draft, setDraft] = useState(''); const [busy, setBusy] = useState(false); const options = [{ value: UNFILED_VALUE, label: unfiledLabel }, ...folderOptions(folders)]; const create = async (): Promise => { const name = draft.trim(); if (name === '' || busy) return; setBusy(true); try { const id = await onCreateFolder(name, value); onChange(id); setDraft(''); setCreating(false); } catch { // The caller owns surfacing the failure (its `onCreateFolder` is the loud // surface); the picker's part is to keep the draft OPEN with its text so the // operator can retry — never to clear it or select nothing. } finally { setBusy(false); } }; return (