import * as path from 'path' import * as React from 'react' import { useEffect, useRef, useState } from 'react' import { useRegisterOverlay } from '../context/overlayContext.js' import { generateFileSuggestions } from '../hooks/fileSuggestions.js' import { useTerminalSize } from '../hooks/useTerminalSize.js' import { Text } from '@anthropic/ink' import { logEvent } from '../services/analytics/index.js' import { getCwd } from '../utils/cwd.js' import { openFileInExternalEditor } from '../utils/editor.js' import { truncatePathMiddle, truncateToWidth } from '../utils/format.js' import { highlightMatch } from '../utils/highlightMatch.js' import { readFileInRange } from '../utils/readFileInRange.js' import { FuzzyPicker, LoadingState } from '@anthropic/ink' type Props = { onDone: () => void onInsert: (text: string) => void } const VISIBLE_RESULTS = 8 const PREVIEW_LINES = 20 /** * Quick Open dialog (ctrl+shift+p / cmd+shift+p). * Fuzzy file finder with a syntax-highlighted preview of the focused file. */ export function QuickOpenDialog({ onDone, onInsert }: Props): React.ReactNode { useRegisterOverlay('quick-open') const { columns, rows } = useTerminalSize() // Chrome (title + search + hints + pane border + gaps) eats ~14 rows. // Shrink the list on short terminals so the dialog doesn't clip. const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14)) const [results, setResults] = useState([]) const [query, setQuery] = useState('') const [focusedPath, setFocusedPath] = useState(undefined) const [preview, setPreview] = useState<{ path: string content: string } | null>(null) const queryGenRef = useRef(0) useEffect(() => () => void queryGenRef.current++, []) const previewOnRight = columns >= 120 // Side preview sits in a fixed-height row alongside the list (visibleCount // rows), so overflowing that height garbles the layout — cap to fit, minus // one for the path header line. const effectivePreviewLines = previewOnRight ? VISIBLE_RESULTS - 1 : PREVIEW_LINES // A generation counter invalidates stale results if the user types faster // than the index can respond. const handleQueryChange = (q: string) => { setQuery(q) const gen = ++queryGenRef.current if (!q.trim()) { // generateFileSuggestions('') returns raw readdir() of cwd (designed for // @-mentions). For Quick Open that's just noise — show the empty state. setResults([]) return } void generateFileSuggestions(q, true).then(items => { if (gen !== queryGenRef.current) return // Filter out directory entries — they come back with a trailing path.sep // from getTopLevelPaths() and would cause readFileInRange to throw EISDIR, // leaving the preview pane stuck on "Loading preview…". // Normalize separators to '/' so truncatePathMiddle (which uses // lastIndexOf('/')) can find the filename on Windows too. const paths = items .filter(i => i.id.startsWith('file-')) .map(i => i.displayText) .filter(p => !p.endsWith(path.sep)) .map(p => p.split(path.sep).join('/')) setResults(paths) }) } // Load a short preview of the focused file. Each navigation aborts the // previous read so holding ↓ doesn't pile up whole-file reads and so a // slow early read can't overwrite a faster later one. The stale preview // stays visible until the new one arrives — renderPreview overlays a dim // loading indicator rather than blanking the pane. useEffect(() => { if (!focusedPath) { // No results — clear so the empty-state renders instead of a stale // preview from a previous query. setPreview(null) return } const controller = new AbortController() const absolute = path.resolve(getCwd(), focusedPath) void readFileInRange( absolute, 0, effectivePreviewLines, undefined, controller.signal, ) .then(r => { if (controller.signal.aborted) return setPreview({ path: focusedPath, content: r.content }) }) .catch(() => { if (controller.signal.aborted) return setPreview({ path: focusedPath, content: '(preview unavailable)' }) }) return () => controller.abort() }, [focusedPath, effectivePreviewLines]) const maxPathWidth = previewOnRight ? Math.max(20, Math.floor((columns - 10) * 0.4)) : Math.max(20, columns - 8) const previewWidth = previewOnRight ? Math.max(40, columns - maxPathWidth - 14) : columns - 6 const handleOpen = (p: string) => { const opened = openFileInExternalEditor(path.resolve(getCwd(), p)) logEvent('tengu_quick_open_select', { result_count: results.length, opened_editor: opened, }) onDone() } const handleInsert = (p: string, mention: boolean) => { onInsert(mention ? `@${p} ` : `${p} `) logEvent('tengu_quick_open_insert', { result_count: results.length, mention, }) onDone() } return ( p} visibleCount={visibleResults} direction="up" previewPosition={previewOnRight ? 'right' : 'bottom'} onQueryChange={handleQueryChange} onFocus={p => setFocusedPath(p)} onSelect={handleOpen} onTab={{ action: 'mention', handler: p => handleInsert(p, true) }} onShiftTab={{ action: 'insert path', handler: p => handleInsert(p, false), }} onCancel={onDone} emptyMessage={q => (q ? 'No matching files' : 'Start typing to search…')} selectAction="open in editor" renderItem={(p, isFocused) => ( {truncatePathMiddle(p, maxPathWidth)} )} renderPreview={p => preview ? ( <> {truncatePathMiddle(p, previewWidth)} {preview.path !== p ? ' · loading…' : ''} {preview.content.split('\n').map((line, i) => ( {highlightMatch(truncateToWidth(line, previewWidth), query)} ))} ) : ( ) } /> ) }