claude-code-best/src/components/GlobalSearchDialog.tsx
2026-05-01 21:39:30 +08:00

284 lines
10 KiB
TypeScript

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<Match[]>([]);
const [truncated, setTruncated] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [query, setQuery] = useState('');
const [focused, setFocused] = useState<Match | undefined>(undefined);
const [preview, setPreview] = useState<{
file: string;
line: number;
content: string;
} | null>(null);
const abortRef = useRef<AbortController | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
<FuzzyPicker
title="Global Search"
placeholder="Type to search…"
items={matches}
getKey={matchKey}
visibleCount={visibleResults}
direction="up"
previewPosition={previewOnRight ? 'right' : 'bottom'}
onQueryChange={handleQueryChange}
onFocus={m => setFocused(m)}
onSelect={handleOpen}
onTab={{ action: 'mention', handler: m => 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) => (
<Text color={isFocused ? 'suggestion' : undefined}>
<Text dimColor>
{truncatePathMiddle(m.file, maxPathWidth)}:{m.line}
</Text>{' '}
{highlightMatch(truncateToWidth(m.text.trimStart(), maxTextWidth), query)}
</Text>
)}
renderPreview={m =>
preview?.file === m.file && preview.line === m.line ? (
<>
<Text dimColor>
{truncatePathMiddle(m.file, previewWidth)}:{m.line}
</Text>
{preview.content.split('\n').map((line, i) => (
<Text key={i}>{highlightMatch(truncateToWidth(line, previewWidth), query)}</Text>
))}
</>
) : (
<LoadingState message="Loading…" dimColor />
)
}
/>
);
}
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 :<digits>: 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 ?? '' };
}