import React, { useState, useEffect, useRef } from 'react'; import { Box, Text, useInput } from 'ink'; import { readdirSync, statSync, existsSync } from 'fs'; import { dirname, join, basename } from 'path'; import { homedir } from 'os'; import { colors, borders } from '../theme/index.js'; export interface PathInputProps { value: string; onChange: (value: string) => void; onSubmit?: () => void; placeholder?: string; width?: number; focused?: boolean; } const CURSOR_CHAR = '▌'; /** * Expand ~ to home directory */ function expandPath(path: string): string { if (path.startsWith('~')) { return join(homedir(), path.slice(1)); } return path; } /** * Get directories in a given path */ function getDirectories(basePath: string): string[] { try { const expanded = expandPath(basePath); if (!existsSync(expanded)) return []; const entries = readdirSync(expanded, { withFileTypes: true }); return entries .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) .map((entry) => entry.name) .sort(); } catch { return []; } } /** * Get autocomplete suggestions for a partial path */ function getSuggestions(partialPath: string): string[] { const expanded = expandPath(partialPath); const dir = dirname(expanded); const partial = basename(expanded); try { if (!existsSync(dir)) return []; const entries = readdirSync(dir, { withFileTypes: true }); return entries .filter((entry) => { if (!entry.isDirectory()) return false; if (entry.name.startsWith('.')) return false; return entry.name.toLowerCase().startsWith(partial.toLowerCase()); }) .map((entry) => join(dir, entry.name)) .sort(); } catch { return []; } } /** * Check if path is a valid directory */ function isValidDirectory(path: string): boolean { try { const expanded = expandPath(path); return existsSync(expanded) && statSync(expanded).isDirectory(); } catch { return false; } } export const PathInput: React.FC = ({ value, onChange, onSubmit, placeholder = '', width = 40, focused = false, }) => { const [suggestions, setSuggestions] = useState([]); const [selectedSuggestion, setSelectedSuggestion] = useState(0); const [showSuggestions, setShowSuggestions] = useState(false); const [directories, setDirectories] = useState([]); // Refs for stable callbacks const valueRef = useRef(value); const onChangeRef = useRef(onChange); const onSubmitRef = useRef(onSubmit); useEffect(() => { valueRef.current = value; }, [value]); useEffect(() => { onChangeRef.current = onChange; }, [onChange]); useEffect(() => { onSubmitRef.current = onSubmit; }, [onSubmit]); // Update suggestions when value changes useEffect(() => { if (!focused) { setShowSuggestions(false); return; } const expanded = expandPath(value); // Check if we're at a directory boundary (ends with /) if (value.endsWith('/') || value.endsWith('\\')) { const dirs = getDirectories(expanded); setDirectories(dirs); setSuggestions([]); setShowSuggestions(dirs.length > 0); setSelectedSuggestion(0); } else { // Get autocomplete suggestions const suggs = getSuggestions(value); setSuggestions(suggs); setDirectories([]); setShowSuggestions(suggs.length > 0 && value.length > 0); setSelectedSuggestion(0); } }, [value, focused]); // Handle keyboard input useInput( (input, key) => { // Tab to autocomplete or cycle suggestions if (key.tab) { if (suggestions.length > 0) { // Autocomplete with current suggestion const suggestion = suggestions[selectedSuggestion]; if (suggestion) { const newValue = suggestion + '/'; valueRef.current = newValue; onChangeRef.current(newValue); } } else if (directories.length > 0) { // Select from directory list const dir = directories[selectedSuggestion]; if (dir) { const basePath = expandPath(value); const newValue = join(basePath, dir) + '/'; valueRef.current = newValue; onChangeRef.current(newValue); } } return; } // Up/Down to cycle through suggestions when showing if (showSuggestions && (key.upArrow || key.downArrow)) { const list = suggestions.length > 0 ? suggestions : directories; if (list.length > 0) { if (key.upArrow) { setSelectedSuggestion((prev) => prev > 0 ? prev - 1 : list.length - 1 ); } else { setSelectedSuggestion((prev) => prev < list.length - 1 ? prev + 1 : 0 ); } } return; } // Enter to submit or select suggestion if (key.return) { if ( showSuggestions && (suggestions.length > 0 || directories.length > 0) ) { const list = suggestions.length > 0 ? suggestions : directories; const selected = list[selectedSuggestion]; if (selected) { const newValue = suggestions.length > 0 ? selected + '/' : join(expandPath(value), selected) + '/'; valueRef.current = newValue; onChangeRef.current(newValue); return; } } if (onSubmitRef.current) { onSubmitRef.current(); } return; } // Handle backspace/delete if (key.backspace || key.delete) { if (valueRef.current.length > 0) { const newValue = valueRef.current.slice(0, -1); valueRef.current = newValue; onChangeRef.current(newValue); } return; } // Ignore other control keys if ( key.ctrl || key.meta || key.escape || key.leftArrow || key.rightArrow ) { return; } // Append printable characters if (input && input.length > 0) { const newValue = valueRef.current + input; valueRef.current = newValue; onChangeRef.current(newValue); } }, { isActive: focused } ); // Calculate dimensions const innerWidth = Math.max(width - 4, 1); const isEmpty = value.length === 0; const displayText = isEmpty ? placeholder : value; // Visible text with scrolling const cursorSpace = focused ? 1 : 0; const maxVisibleLength = innerWidth - cursorSpace; const visibleText = displayText.length > maxVisibleLength ? displayText.slice(displayText.length - maxVisibleLength) : displayText; const textLength = visibleText.length + cursorSpace; const paddingLength = Math.max(0, innerWidth - textLength); const padding = ' '.repeat(paddingLength); const borderColor = focused ? colors.primary : colors.muted; const horizontalLine = borders.horizontal.repeat(width - 2); // Validity indicator const isValid = isValidDirectory(value); // Determine what list to show const listToShow = suggestions.length > 0 ? suggestions : directories; const maxVisible = 5; const visibleList = listToShow.slice(0, maxVisible); return ( {/* Top border */} {borders.rounded.topLeft} {horizontalLine} {borders.rounded.topRight} {/* Input content row */} {borders.vertical} {visibleText} {focused && {CURSOR_CHAR}} {padding} {borders.vertical} {/* Bottom border of input */} {borders.rounded.bottomLeft} {horizontalLine} {borders.rounded.bottomRight} {/* Suggestions dropdown */} {focused && showSuggestions && visibleList.length > 0 && ( {suggestions.length > 0 ? ' Suggestions:' : ' Folders:'} {visibleList.map((item, index) => { const isSelected = index === selectedSuggestion; const displayName = suggestions.length > 0 ? basename(item) : item; const truncated = displayName.length > innerWidth - 4 ? displayName.slice(0, innerWidth - 7) + '...' : displayName; return ( {isSelected ? ' > ' : ' '} {truncated} ); })} {listToShow.length > maxVisible && ( {` ... and ${listToShow.length - maxVisible} more`} )} {' Tab: complete ↑↓: select'} )} ); }; export default PathInput;