import { useState, useEffect } from "react"; import { Box, Text, useInput } from "ink"; import { existsSync, statSync, readdirSync } from "fs"; import { homedir } from "os"; import { join, dirname, basename } from "path"; import { useAddDirectoryFlow } from "../../hooks/useAddDirectoryFlow.ts"; import type { ExtendedKey } from "../../types/ink-keys.ts"; export interface DirectoriesStepProps { directories: Array<{ path: string; maxDepth: number; label?: string }>; onComplete: (directories: Array<{ path: string; maxDepth: number; label?: string }>) => void; onBack: () => void; onCancel: () => void; } interface DirectoryItem { path: string; maxDepth: number; label?: string; valid: boolean; error?: string; } interface SimplifiedBrowserState { // Current working directory currentPath: string; // Contents of currentPath entries: Array<{ name: string; fullPath: string }>; // What user is typing inputBuffer: string; // Completions for current input completions: string[]; // Index for Tab cycling completionIndex: number; // Scroll offset for folder list scrollOffset: number; // Selected folder index in the filtered list selectedFolderIndex: number; error: string | null; } export function validateDirectoryPath(path: string): { valid: boolean; error?: string } { if (!path) { return { valid: false, error: "Path is required" }; } const expanded = path.replace(/^~/, homedir()); if (!existsSync(expanded)) { return { valid: false, error: "Path does not exist" }; } try { const stat = statSync(expanded); if (!stat.isDirectory()) { return { valid: false, error: "Not a directory" }; } } catch { return { valid: false, error: "Cannot access path" }; } return { valid: true }; } /** * Read directory contents, filtering to directories only */ export function readDirectory(path: string): Array<{ name: string; fullPath: string }> { try { const entries = readdirSync(path, { withFileTypes: true }); return entries .filter((e) => { if (e.name.startsWith(".")) return false; // Regular directory check if (e.isDirectory()) return true; // Check if symlink points to a directory if (e.isSymbolicLink()) { try { const fullPath = join(path, e.name); return statSync(fullPath).isDirectory(); } catch { return false; } } return false; }) .map((e) => ({ name: e.name, fullPath: join(path, e.name), })) .sort((a, b) => a.name.localeCompare(b.name)); } catch { return []; } } /** * Get path completions based on input */ export function getCompletions(input: string, currentPath: string): string[] { // Strip trailing slash for proper dirname/basename handling const normalizedInput = input.endsWith("/") ? input.slice(0, -1) : input; const expanded = normalizedInput.replace(/^~/, homedir()); // If input starts with /, it's an absolute path if (input.startsWith("/")) { const dir = dirname(expanded || "/"); const prefix = basename(expanded); const entries = readDirectory(dir); return entries .filter((e) => e.name.toLowerCase().startsWith(prefix.toLowerCase())) .map((e) => join(dir, e.name)); } // Relative or home-based path const basePath = expanded.startsWith("/") ? "/" : currentPath; const dir = join(basePath, dirname(expanded || ".")); const prefix = basename(expanded); try { const entries = readDirectory(dir); return entries .filter((e) => e.name.toLowerCase().startsWith(prefix.toLowerCase())) .map((e) => join(dir, e.name)); } catch { return []; } } /** * Format path for display (replace home with ~) */ export function formatDisplayPath(path: string): string { const home = homedir(); if (path.startsWith(home)) { return "~" + path.slice(home.length); } return path; } /** * Hybrid directory browser with typing + navigation */ interface HybridDirectoryBrowserProps { startingPath: string; onSelect: (path: string) => void; onCancel: () => void; } export function HybridDirectoryBrowser({ startingPath, onSelect, onCancel }: HybridDirectoryBrowserProps) { const MAX_DISPLAY_FOLDERS = 10; const [browser, setBrowser] = useState({ currentPath: startingPath, entries: readDirectory(startingPath), inputBuffer: "~", completions: [], completionIndex: 0, scrollOffset: 0, selectedFolderIndex: 0, error: null, }); // Update completions when input changes useEffect(() => { if (browser.inputBuffer.length > 0) { const matches = getCompletions(browser.inputBuffer, browser.currentPath); setBrowser((prev) => ({ ...prev, completions: matches, completionIndex: 0, })); } else { setBrowser((prev) => ({ ...prev, completions: [], completionIndex: 0, })); } }, [browser.inputBuffer, browser.currentPath]); // Update currentPath when input is a valid directory useEffect(() => { if (browser.inputBuffer.length > 0) { // Expand ~ for validation const expanded = browser.inputBuffer.replace(/^~/, homedir()); // Check if it's a valid directory if (existsSync(expanded) && statSync(expanded).isDirectory()) { setBrowser((prev) => ({ ...prev, currentPath: expanded, entries: readDirectory(expanded), })); } } else { // When input is empty, default to home directory setBrowser((prev) => ({ ...prev, currentPath: startingPath, entries: readDirectory(startingPath), })); } }, [browser.inputBuffer, startingPath]); // Reset scroll offset and selection when currentPath changes useEffect(() => { setBrowser((prev) => ({ ...prev, scrollOffset: 0, selectedFolderIndex: 0, })); }, [browser.currentPath]); useInput((input, key) => { // Esc: go back to parent directory or cancel if (key.escape) { // If input ends with / (navigated into a directory), go back to parent if (browser.inputBuffer.endsWith("/")) { const parentPath = dirname(browser.currentPath); setBrowser((prev) => ({ ...prev, inputBuffer: "", currentPath: parentPath, entries: readDirectory(parentPath), completions: [], scrollOffset: 0, selectedFolderIndex: 0, error: null, })); return; } // If there's other input beyond ~, clear it (back to ~) if (browser.inputBuffer.length > 1) { setBrowser((prev) => ({ ...prev, inputBuffer: "~", completions: [], selectedFolderIndex: 0, scrollOffset: 0, error: null, })); return; } // Otherwise cancel onCancel(); return; } // Tab: cycle completions and update current path if (key.tab && browser.completions.length > 0) { const nextIndex = (browser.completionIndex + 1) % browser.completions.length; const completedPath = browser.completions[nextIndex]!; // Check if it's a directory and add trailing slash const expanded = completedPath.replace(/^~/, homedir()); const isDirectory = existsSync(expanded) && statSync(expanded).isDirectory(); // Use ~ format for home directory paths in input (for backspace-ability) const home = homedir(); const inputPath = completedPath.startsWith(home) ? "~" + completedPath.slice(home.length) : completedPath; const inputWithSlash = isDirectory ? inputPath + "/" : inputPath; setBrowser((prev) => ({ ...prev, completionIndex: nextIndex, inputBuffer: inputWithSlash, currentPath: completedPath, entries: readDirectory(completedPath), scrollOffset: 0, selectedFolderIndex: 0, error: null, })); return; } // Compute filtered entries for navigation (only filter if not a path) const isPathInput = browser.inputBuffer.includes("/"); const filteredEntries = (browser.inputBuffer.length > 0 && !isPathInput) ? browser.entries.filter(e => e.name.toLowerCase().includes(browser.inputBuffer.toLowerCase()) ) : browser.entries; // j/k or arrow keys for navigating filtered folder list if (input === "j" || key.downArrow) { const maxIndex = Math.max(filteredEntries.length - 1, 0); setBrowser((prev) => ({ ...prev, selectedFolderIndex: Math.min(prev.selectedFolderIndex + 1, maxIndex), // Auto-scroll if selection goes below visible area scrollOffset: Math.max(prev.scrollOffset, Math.min(prev.selectedFolderIndex + 1 - MAX_DISPLAY_FOLDERS + 1, Math.max(filteredEntries.length - MAX_DISPLAY_FOLDERS, 0))), })); return; } if (input === "k" || key.upArrow) { setBrowser((prev) => ({ ...prev, selectedFolderIndex: Math.max(prev.selectedFolderIndex - 1, 0), // Auto-scroll if selection goes above visible area scrollOffset: Math.min(prev.scrollOffset, prev.selectedFolderIndex - 1), })); return; } // Space: select folder from filtered list and navigate if (input === " ") { // If there's a selected folder in filtered list, navigate into it if (filteredEntries.length > 0 && browser.selectedFolderIndex < filteredEntries.length) { const selectedEntry = filteredEntries[browser.selectedFolderIndex]!; // Use ~ format for home directory paths in input const home = homedir(); const inputPath = selectedEntry.fullPath.startsWith(home) ? "~" + selectedEntry.fullPath.slice(home.length) : selectedEntry.fullPath; setBrowser((prev) => ({ ...prev, inputBuffer: inputPath + "/", currentPath: selectedEntry.fullPath, entries: readDirectory(selectedEntry.fullPath), completions: [], scrollOffset: 0, selectedFolderIndex: 0, error: null, })); return; } // Space on current path with no selection onSelect(browser.currentPath); return; } // Enter: accept current path and continue if (key.return) { const targetPath = browser.inputBuffer.replace(/\/$/, "") || browser.currentPath; const validation = validateDirectoryPath(targetPath); if (validation.valid) { onSelect(targetPath); } else { setBrowser((prev) => ({ ...prev, error: validation.error || "Invalid path", })); } return; } // Ctrl+U: clear input back to ~ if (key.ctrl && input === 'u') { setBrowser((prev) => ({ ...prev, inputBuffer: "~", completions: [], selectedFolderIndex: 0, scrollOffset: 0, error: null, })); return; } // Backspace/delete if ((key as unknown as ExtendedKey).backspace || (key as unknown as ExtendedKey).delete) { if (browser.inputBuffer.length > 0) { setBrowser((prev) => ({ ...prev, inputBuffer: prev.inputBuffer.slice(0, -1), selectedFolderIndex: 0, scrollOffset: 0, error: null, })); } return; } // Regular character input if (input.length === 1 && /[a-zA-Z0-9_\/\.~-]/.test(input)) { setBrowser((prev) => ({ ...prev, inputBuffer: prev.inputBuffer + input, selectedFolderIndex: 0, scrollOffset: 0, error: null, })); return; } }); return ( {/* Text input */} {"> "} {browser.inputBuffer} _ {/* Error message */} {browser.error && ( {browser.error} )} {/* Directory list - filtered and scrollable */} Folders in {formatDisplayPath(browser.currentPath)}: {(() => { // Only filter by name if input doesn't contain slashes (not a path) const isPathInput = browser.inputBuffer.includes("/"); const filteredEntries = (browser.inputBuffer.length > 0 && !isPathInput) ? browser.entries.filter(e => e.name.toLowerCase().includes(browser.inputBuffer.toLowerCase()) ) : browser.entries; if (filteredEntries.length === 0) { return No matching folders; } const visibleEntries = filteredEntries.slice( browser.scrollOffset, browser.scrollOffset + MAX_DISPLAY_FOLDERS ); return ( <> {browser.scrollOffset > 0 && ( ▲ {browser.scrollOffset} more above )} {visibleEntries.map((item, idx) => { const globalIndex = browser.scrollOffset + idx; const isSelected = globalIndex === browser.selectedFolderIndex; return ( {isSelected ? : } {" "}{item.name}/ ); })} {browser.scrollOffset + MAX_DISPLAY_FOLDERS < filteredEntries.length && ( ▼ {filteredEntries.length - browser.scrollOffset - MAX_DISPLAY_FOLDERS} more below )} ); })()} {/* Help text */} Tab: autocomplete j/k: navigate Space: navigate folder Enter: select & continue Esc: go back ); } interface FlowSeed { initialPath?: string; initialMaxDepth?: string; initialLabel?: string; } /** * Sub-component that owns one mount of useAddDirectoryFlow. Wrapping the * hook in a child lets us remount (and re-seed) it cleanly when the user * picks "a" (add new) vs "e" (edit existing). */ function FlowMode({ seed, onComplete, onCancel, }: { seed: FlowSeed; onComplete: (path: string, maxDepth: number, label: string) => void; onCancel: () => void; }) { const flow = useAddDirectoryFlow({ initialPath: seed.initialPath, initialMaxDepth: seed.initialMaxDepth, initialLabel: seed.initialLabel, onComplete, onCancel, }); useInput((inputStr, key) => { if (flow.step === "browse") return; const extKey = key as unknown as ExtendedKey; if (key.escape) { flow.back(); return; } if (key.return) { flow.advance(); return; } if (flow.step === "maxDepth") { if (extKey.backspace || extKey.delete) { flow.setMaxDepthInput(flow.maxDepthInput.slice(0, -1)); } else if (/^[0-9]$/.test(inputStr)) { flow.setMaxDepthInput(flow.maxDepthInput + inputStr); } } else if (flow.step === "label") { if (extKey.backspace || extKey.delete) { flow.setLabelInput(flow.labelInput.slice(0, -1)); } else if (inputStr.length === 1) { flow.setLabelInput(flow.labelInput + inputStr); } } }); return ( <> {flow.step === "browse" && ( )} {flow.step === "maxDepth" && ( Max scan depth (0-10, default 2): {"> "} {flow.maxDepthInput} _ Path: {formatDisplayPath(flow.selectedPath || "")} {flow.error && {flow.error}} Press Enter to confirm, Esc to go back )} {flow.step === "label" && ( Label (optional, press Enter to skip): {"> "} {flow.labelInput} _ Path: {formatDisplayPath(flow.selectedPath || "")} Depth: {flow.maxDepthInput} {flow.error && {flow.error}} Press Enter to confirm, Esc to go back )} ); } export function DirectoriesStep({ directories, onComplete, onBack, onCancel, }: DirectoriesStepProps) { const [items, setItems] = useState( directories.map((d) => ({ ...d, valid: true, })) ); const [selectedIndex, setSelectedIndex] = useState(0); // Outer mode: in "list" we render the items table; in "flow" the // useAddDirectoryFlow sub-component owns the screen. const [outerMode, setOuterMode] = useState<"list" | "flow">("list"); const [flowSeed, setFlowSeed] = useState({}); const [error, setError] = useState(null); const handleFlowComplete = (path: string, maxDepth: number, label: string) => { const newItem: DirectoryItem = { path, maxDepth, label: label || undefined, valid: true, }; setItems((prev) => { const existingIndex = prev.findIndex((i) => i.path === path); if (existingIndex >= 0) { const updated = [...prev]; updated[existingIndex] = newItem; return updated; } return [...prev, newItem]; }); setOuterMode("list"); setError(null); }; const handleFlowCancel = () => { setOuterMode("list"); setError(null); }; const handleSkip = () => { const defaultDir: DirectoryItem = { path: `${homedir()}/projects`, maxDepth: 2, label: "Projects", valid: true, }; onComplete([{ path: defaultDir.path, maxDepth: defaultDir.maxDepth, label: defaultDir.label }]); }; const handleComplete = () => { const validItems = items.filter((i) => i.valid); if (validItems.length === 0) { setError("Add at least one directory"); return; } onComplete( validItems.map((i) => ({ path: i.path, maxDepth: i.maxDepth, label: i.label, })) ); }; useInput((inputStr, key) => { if (outerMode === "flow") return; // FlowMode owns all keys if (key.escape || inputStr === "q" || inputStr === "Q") { onCancel(); return; } if ((key as unknown as ExtendedKey).backspace || (key as unknown as ExtendedKey).delete) { onBack(); return; } if (key.return) { if (items.length > 0) { handleComplete(); } return; } if (inputStr === "j" || key.downArrow) { setSelectedIndex((i) => Math.min(i + 1, Math.max(items.length - 1, 0))); return; } if (inputStr === "k" || key.upArrow) { setSelectedIndex((i) => Math.max(i - 1, 0)); return; } if (inputStr === "a" || inputStr === "A") { setFlowSeed({}); setOuterMode("flow"); setError(null); return; } if ((inputStr === "d" || inputStr === "D") && items.length > 0) { setItems((prev) => prev.filter((_, i) => i !== selectedIndex)); if (selectedIndex >= items.length - 1) { setSelectedIndex(Math.max(items.length - 2, 0)); } return; } if (inputStr === "s" || inputStr === "S") { handleSkip(); return; } if ((inputStr === "e" || inputStr === "E") && items.length > 0) { const item = items[selectedIndex]; if (item) { setFlowSeed({ initialPath: item.path, initialMaxDepth: String(item.maxDepth), initialLabel: item.label || "", }); setOuterMode("flow"); setError(null); } return; } }); return ( {/* Title */} Configure Project Directories {/* Instructions */} {outerMode === "list" && ( Add the directories containing your git projects. )} {/* Add/edit flow */} {outerMode === "flow" && ( )} {/* List mode */} {outerMode === "list" && ( <> {/* Current directories */} {items.length > 0 && ( Current directories: {items.map((item, index) => ( {index === selectedIndex ? "●" : "○"} {item.label || item.path} (depth: {item.maxDepth}) ))} )} {/* Empty state */} {items.length === 0 && ( No directories added yet. Press 'a' to browse your filesystem. )} {/* Error message */} {error && ( {error} )} {/* Actions */} Shortcuts: a Add {items.length > 0 && ( <> d Delete e Edit )} s Skip (defaults) j/k Navigate Enter Continue Backspace Back q Quit )} ); }