import { resolve as resolvePath } from 'path' import * as React from 'react' import { useEffect, useRef, useState } from 'react' import { useRegisterOverlay } from '../context/overlayContext.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 { relativePath } from '../utils/permissions/filesystem.js' import { readFileInRange } from '../utils/readFileInRange.js' import { ripGrepStream } from '../utils/ripgrep.js' import { FuzzyPicker, LoadingState } from '@anthropic/ink' type Props = { onDone: () => void onInsert: (text: string) => void } type Match = { file: string line: number text: string } const VISIBLE_RESULTS = 12 const DEBOUNCE_MS = 100 const PREVIEW_CONTEXT_LINES = 4 // rg -m is per-file; we also cap the parsed array to keep memory bounded. const MAX_MATCHES_PER_FILE = 10 const MAX_TOTAL_MATCHES = 500 /** * Global Search dialog (ctrl+shift+f / cmd+shift+f). * Debounced ripgrep search across the workspace. */ export function GlobalSearchDialog({ onDone, onInsert, }: Props): React.ReactNode { useRegisterOverlay('global-search') const { columns, rows } = useTerminalSize() const previewOnRight = columns >= 140 // Chrome (title + search + matchLabel + 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 [matches, setMatches] = useState([]) const [truncated, setTruncated] = useState(false) const [isSearching, setIsSearching] = useState(false) const [query, setQuery] = useState('') const [focused, setFocused] = useState(undefined) const [preview, setPreview] = useState<{ file: string line: number content: string } | null>(null) const abortRef = useRef(null) const timeoutRef = useRef | null>(null) useEffect(() => { return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current) abortRef.current?.abort() } }, []) // Load context lines around the focused match. AbortController prevents // holding ↓ from piling up reads. useEffect(() => { if (!focused) { setPreview(null) return } const controller = new AbortController() const absolute = resolvePath(getCwd(), focused.file) const start = Math.max(0, focused.line - PREVIEW_CONTEXT_LINES - 1) void readFileInRange( absolute, start, PREVIEW_CONTEXT_LINES * 2 + 1, undefined, controller.signal, ) .then(r => { if (controller.signal.aborted) return setPreview({ file: focused.file, line: focused.line, content: r.content, }) }) .catch(() => { if (controller.signal.aborted) return setPreview({ file: focused.file, line: focused.line, content: '(preview unavailable)', }) }) return () => controller.abort() }, [focused]) const handleQueryChange = (q: string) => { setQuery(q) if (timeoutRef.current) clearTimeout(timeoutRef.current) abortRef.current?.abort() if (!q.trim()) { setMatches(m => (m.length ? [] : m)) setIsSearching(false) setTruncated(false) return } const controller = new AbortController() abortRef.current = controller setIsSearching(true) setTruncated(false) // Client-filter existing results while rg walks — keeps something on // screen instead of flashing blank. rg results are merged in (deduped by // file:line) rather than replaced, so the count is monotonic within a // query: it only grows as rg streams, never dips to the first chunk's // size. Narrowing (new query extends old): filter is exact — any line // that matched the old -F -i literal contains the new one iff its text // includes the new query lowered. Non-narrowing (broadening/different): // filter is best-effort — may briefly show a subset until rg fills in // the rest. const queryLower = q.toLowerCase() setMatches(m => { const filtered = m.filter(match => match.text.toLowerCase().includes(queryLower), ) return filtered.length === m.length ? m : filtered }) timeoutRef.current = setTimeout( (query, controller, setMatches, setTruncated, setIsSearching) => { // ripgrep outputs absolute paths when given an absolute target, so // relativize against cwd to preserve directory context in the truncated // display (otherwise the cwd prefix eats the width budget). // relativePath() returns POSIX-normalized output so truncatePathMiddle // (which uses lastIndexOf('/')) works on Windows too. const cwd = getCwd() let collected = 0 void ripGrepStream( // -e disambiguates pattern from options when the query starts with '-' // (e.g. searching for "--verbose" or "-rf"). See GrepTool.ts for the // same precaution. [ '-n', '--no-heading', '-i', '-m', String(MAX_MATCHES_PER_FILE), '-F', '-e', query, ], cwd, controller.signal, lines => { if (controller.signal.aborted) return const parsed: Match[] = [] for (const line of lines) { const m = parseRipgrepLine(line) if (!m) continue const rel = relativePath(cwd, m.file) parsed.push({ ...m, file: rel.startsWith('..') ? m.file : rel }) } if (!parsed.length) return collected += parsed.length setMatches(prev => { // Append+dedupe instead of replace: prev may hold client- // filtered results that are valid matches for this query. // Replacing would drop the count to this chunk's size then // grow it back — visible as a flicker. const seen = new Set(prev.map(matchKey)) const fresh = parsed.filter(p => !seen.has(matchKey(p))) if (!fresh.length) return prev const next = prev.concat(fresh) return next.length > MAX_TOTAL_MATCHES ? next.slice(0, MAX_TOTAL_MATCHES) : next }) if (collected >= MAX_TOTAL_MATCHES) { controller.abort() setTruncated(true) setIsSearching(false) } }, ) .catch(() => {}) // Stream closed with zero chunks — clear stale results so // "No matches" renders instead of the previous query's list. .finally(() => { if (controller.signal.aborted) return if (collected === 0) setMatches(m => (m.length ? [] : m)) setIsSearching(false) }) }, DEBOUNCE_MS, q, controller, setMatches, setTruncated, setIsSearching, ) } const listWidth = previewOnRight ? Math.floor((columns - 10) * 0.5) : columns - 8 const maxPathWidth = Math.max(20, Math.floor(listWidth * 0.4)) const maxTextWidth = Math.max(20, listWidth - maxPathWidth - 4) const previewWidth = previewOnRight ? Math.max(40, columns - listWidth - 14) : columns - 6 const handleOpen = (m: Match) => { const opened = openFileInExternalEditor( resolvePath(getCwd(), m.file), m.line, ) logEvent('tengu_global_search_select', { result_count: matches.length, opened_editor: opened, }) onDone() } const handleInsert = (m: Match, mention: boolean) => { onInsert(mention ? `@${m.file}#L${m.line} ` : `${m.file}:${m.line} `) logEvent('tengu_global_search_insert', { result_count: matches.length, mention, }) onDone() } // Always pass a non-empty string so the line is reserved — prevents the // searchBox from bouncing when the count appears/disappears. const matchLabel = matches.length > 0 ? `${matches.length}${truncated ? '+' : ''} matches${isSearching ? '…' : ''}` : ' ' return ( handleInsert(m, true) }} onShiftTab={{ action: 'insert path', handler: m => handleInsert(m, false), }} onCancel={onDone} emptyMessage={q => isSearching ? 'Searching…' : q ? 'No matches' : 'Type to search…' } matchLabel={matchLabel} selectAction="open in editor" renderItem={(m, isFocused) => ( {truncatePathMiddle(m.file, maxPathWidth)}:{m.line} {' '} {highlightMatch( truncateToWidth(m.text.trimStart(), maxTextWidth), query, )} )} renderPreview={m => preview?.file === m.file && preview.line === m.line ? ( <> {truncatePathMiddle(m.file, previewWidth)}:{m.line} {preview.content.split('\n').map((line, i) => ( {highlightMatch(truncateToWidth(line, previewWidth), query)} ))} ) : ( ) } /> ) } function matchKey(m: Match): string { return `${m.file}:${m.line}` } /** * Parse a ripgrep -n --no-heading output line: "path:line:text". * Windows paths may contain a drive letter ("C:\..."), so a simple split on * the first colon would mangle the path — use a regex that captures up to * the first :: instead. * @internal exported for testing */ export function parseRipgrepLine(line: string): Match | null { const m = /^(.*?):(\d+):(.*)$/.exec(line) if (!m) return null const [, file, lineStr, text] = m const lineNum = Number(lineStr) if (!file || !Number.isFinite(lineNum)) return null return { file, line: lineNum, text: text ?? '' } }