import { useEffect, useRef, useState } from "react"; import { searchFiles } from "../helpers/fileSearch"; import { useAutocompleteNavigation } from "../hooks/useAutocompleteNavigation"; import { AutocompleteBox, AutocompleteItem } from "./Autocomplete"; import { colors } from "./colors"; import { Text } from "./Text"; import type { AutocompleteProps, FileMatch } from "./types/autocomplete"; // Extract the text after the "@" symbol where the cursor is positioned function extractSearchQuery( input: string, cursor: number, ): { query: string; hasSpaceAfter: boolean; atIndex: number } | null { // Find all @ positions const atPositions: number[] = []; for (let i = 0; i < input.length; i++) { if (input[i] === "@") { // Only count @ at start or after space if (i === 0 || input[i - 1] === " ") { atPositions.push(i); } } } if (atPositions.length === 0) return null; // Find which @ the cursor is in let atIndex = -1; for (const pos of atPositions) { // Find the end of this @reference (next space or end of string) const afterAt = input.slice(pos + 1); const spaceIndex = afterAt.indexOf(" "); const endPos = spaceIndex === -1 ? input.length : pos + 1 + spaceIndex; // Check if cursor is within this @reference if (cursor >= pos && cursor <= endPos) { atIndex = pos; break; } } // If cursor is not in any @reference, don't show autocomplete if (atIndex === -1) return null; // Get text after "@" until next space or end const afterAt = input.slice(atIndex + 1); const spaceIndex = afterAt.indexOf(" "); const query = spaceIndex === -1 ? afterAt : afterAt.slice(0, spaceIndex); const hasSpaceAfter = spaceIndex !== -1; return { query, hasSpaceAfter, atIndex }; } export function FileAutocomplete({ currentInput, cursorPosition = currentInput.length, onSelect, onActiveChange, }: AutocompleteProps) { const [matches, setMatches] = useState([]); const [isLoading, setIsLoading] = useState(false); const [lastValidQuery, setLastValidQuery] = useState(""); const debounceTimeout = useRef(null); // Use shared navigation hook (with manual active state management due to async loading) const { selectedIndex } = useAutocompleteNavigation({ matches, maxVisible: 10, onSelect: onSelect ? (item) => onSelect(item.path) : undefined, manageActiveState: false, // We manage active state manually due to async loading }); useEffect(() => { // Clear any existing debounce timeout if (debounceTimeout.current) { clearTimeout(debounceTimeout.current); } const result = extractSearchQuery(currentInput, cursorPosition); if (!result) { setMatches([]); setIsLoading(false); onActiveChange?.(false); return; } const { query, hasSpaceAfter } = result; // If there's text after the space, user has moved on - hide autocomplete // But keep it open if there's just a trailing space (allows editing the path) if (hasSpaceAfter && query.length > 0) { const atIndex = currentInput.lastIndexOf("@"); const afterSpace = currentInput.slice(atIndex + 1 + query.length + 1); // Always hide if there's more non-whitespace content after, or another @ if (afterSpace.trim().length > 0 || afterSpace.includes("@")) { setMatches([]); setIsLoading(false); onActiveChange?.(false); return; } // Just a trailing space - check if this query had valid matches when selected // Use lastValidQuery to remember what was successfully selected if (query === lastValidQuery && lastValidQuery.length > 0) { // Show the selected file (non-interactive) if (matches[0]?.path !== query) { setMatches([{ path: query, type: "file" }]); } setIsLoading(false); onActiveChange?.(false); // Don't block Enter key return; } // No valid selection was made, hide setMatches([]); setIsLoading(false); onActiveChange?.(false); return; } // If query is empty (just typed "@"), show current directory contents (no debounce) if (query.length === 0) { setIsLoading(true); onActiveChange?.(true); searchFiles("", false) // Don't do deep search for empty query .then((results) => { setMatches(results); setIsLoading(false); onActiveChange?.(results.length > 0); }) .catch(() => { setMatches([]); setIsLoading(false); onActiveChange?.(false); }); return; } // Check if it's a URL pattern (no debounce) if (query.startsWith("http://") || query.startsWith("https://")) { setMatches([{ path: query, type: "url" }]); setIsLoading(false); onActiveChange?.(true); return; } // Debounce the file search (300ms delay) // Keep existing matches visible while debouncing setIsLoading(true); onActiveChange?.(true); debounceTimeout.current = setTimeout(() => { // Search for matching files (deep search through subdirectories) searchFiles(query, true) // Enable deep search .then((results) => { setMatches(results); setIsLoading(false); onActiveChange?.(results.length > 0); // Remember this query had valid matches if (results.length > 0) { setLastValidQuery(query); } }) .catch(() => { setMatches([]); setIsLoading(false); onActiveChange?.(false); }); }, 300); // Cleanup function to clear timeout on unmount return () => { if (debounceTimeout.current) { clearTimeout(debounceTimeout.current); } }; }, [ currentInput, cursorPosition, onActiveChange, lastValidQuery, matches[0]?.path, ]); // Don't show if no "@" in input if (!currentInput.includes("@")) { return null; } // Don't show if no matches and not loading if (matches.length === 0 && !isLoading) { return null; } const header = ( <> File/URL autocomplete (↑↓ navigate, Tab/Enter select): {isLoading && " Searching..."} ); return ( {matches.length > 0 ? ( <> {matches.slice(0, 10).map((item, idx) => ( {item.type === "dir" ? "📁" : item.type === "url" ? "🔗" : "📄"} {" "} {item.path} ))} {matches.length > 10 && ( ... and {matches.length - 10} more )} ) : ( isLoading && Searching... )} ); }