agent-ecosystem/src/renderer/components/team/editor/SearchInFilesPanel.tsx
infiniti 09db3abfcd fix: harden Windows frontend path handling
Harden Windows path handling and packaged app smoke checks.
2026-05-16 17:38:34 +03:00

363 lines
12 KiB
TypeScript

/**
* Search in files panel (Cmd+Shift+F).
*
* Debounced literal string search with cancellation.
* Results are clickable to open the file at the matched line.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import {
getBasename,
getRelativePathWithinPrefix,
lastSeparatorIndex,
} from '@shared/utils/platformPath';
import { Loader2, Search, X } from 'lucide-react';
import { FileIcon } from './FileIcon';
import type { SearchFileResult, SearchInFilesResult } from '@shared/types/editor';
// =============================================================================
// Types
// =============================================================================
interface SearchInFilesPanelProps {
projectPath: string;
onClose: () => void;
onSelectMatch: (filePath: string, line: number) => void;
}
// =============================================================================
// Constants
// =============================================================================
const DEBOUNCE_MS = 300;
// =============================================================================
// Component
// =============================================================================
export const SearchInFilesPanel = ({
projectPath,
onClose,
onSelectMatch,
}: SearchInFilesPanelProps): React.ReactElement => {
const [query, setQuery] = useState('');
const [caseSensitive, setCaseSensitive] = useState(false);
const [results, setResults] = useState<SearchInFilesResult | null>(null);
const [searching, setSearching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Monotonic request ID — prevents stale results from overwriting fresh ones
const requestIdRef = useRef(0);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
// Escape closes panel (capture phase to prevent overlay close)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [onClose]);
const doSearch = useCallback(async (searchQuery: string, isCaseSensitive: boolean) => {
if (!searchQuery.trim()) {
setResults(null);
setSearching(false);
setError(null);
return;
}
// Bump request ID — any in-flight request with a lower ID is stale
const myRequestId = ++requestIdRef.current;
setSearching(true);
setError(null);
try {
const result = await api.editor.searchInFiles({
query: searchQuery,
caseSensitive: isCaseSensitive,
});
// Discard result if a newer request was fired while we were waiting
if (requestIdRef.current !== myRequestId) return;
setResults(result);
// Auto-expand first few files
const firstFiles = new Set(result.results.slice(0, 5).map((r) => r.filePath));
setExpandedFiles(firstFiles);
} catch (err) {
if (requestIdRef.current !== myRequestId) return;
const message = err instanceof Error ? err.message : String(err);
setError(message);
} finally {
if (requestIdRef.current === myRequestId) {
setSearching(false);
}
}
}, []);
const handleQueryChange = useCallback(
(value: string) => {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void doSearch(value, caseSensitive);
}, DEBOUNCE_MS);
},
[caseSensitive, doSearch]
);
const handleCaseSensitiveToggle = useCallback(() => {
const newValue = !caseSensitive;
setCaseSensitive(newValue);
if (query.trim()) {
if (debounceRef.current) clearTimeout(debounceRef.current);
void doSearch(query, newValue);
}
}, [caseSensitive, query, doSearch]);
const toggleFileExpanded = useCallback((filePath: string) => {
setExpandedFiles((prev) => {
const next = new Set(prev);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
}, []);
const getRelativePath = useCallback(
(filePath: string) => {
return getRelativePathWithinPrefix(projectPath, filePath) ?? filePath;
},
[projectPath]
);
return (
<div className="flex h-full flex-col border-r border-border bg-surface-sidebar">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-xs font-medium text-text-secondary">Search in Files</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-6 text-text-muted"
onClick={onClose}
aria-label="Close search"
>
<X className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Close search (Esc)</TooltipContent>
</Tooltip>
</div>
{/* Search input */}
<div className="border-b border-border p-2">
<div className="flex items-center gap-1 rounded border border-border bg-surface px-2 py-1">
<Search className="size-3.5 shrink-0 text-text-muted" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder="Search..."
className="flex-1 bg-transparent text-xs text-text outline-none placeholder:text-text-muted"
/>
{searching && <Loader2 className="size-3 shrink-0 animate-spin text-text-muted" />}
</div>
<div className="mt-1.5 flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleCaseSensitiveToggle}
className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
caseSensitive
? 'bg-blue-500/20 text-blue-400'
: 'text-text-muted hover:bg-surface-raised'
}`}
aria-label="Match Case"
aria-pressed={caseSensitive}
>
Aa
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Match Case</TooltipContent>
</Tooltip>
</div>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto">
{error && <div className="p-3 text-xs text-red-400">{error}</div>}
{results?.totalMatches === 0 && query.trim() && (
<div className="p-4 text-center text-xs text-text-muted">No results found</div>
)}
{results && results.totalMatches > 0 && (
<>
<div className="border-b border-border px-3 py-1.5 text-[10px] text-text-muted">
{results.totalMatches} match{results.totalMatches !== 1 ? 'es' : ''} in{' '}
{results.results.length} file{results.results.length !== 1 ? 's' : ''}
{results.truncated && ' (truncated)'}
</div>
{results.results.map((fileResult) => (
<SearchFileGroup
key={fileResult.filePath}
fileResult={fileResult}
relativePath={getRelativePath(fileResult.filePath)}
expanded={expandedFiles.has(fileResult.filePath)}
onToggle={() => toggleFileExpanded(fileResult.filePath)}
onSelectMatch={(line) => onSelectMatch(fileResult.filePath, line)}
query={query}
caseSensitive={caseSensitive}
/>
))}
</>
)}
</div>
</div>
);
};
// =============================================================================
// File group
// =============================================================================
interface SearchFileGroupProps {
fileResult: SearchFileResult;
relativePath: string;
expanded: boolean;
onToggle: () => void;
onSelectMatch: (line: number) => void;
query: string;
caseSensitive: boolean;
}
const SearchFileGroup = ({
fileResult,
relativePath,
expanded,
onToggle,
onSelectMatch,
query,
caseSensitive,
}: SearchFileGroupProps): React.ReactElement => {
const fileName = getBasename(relativePath) || relativePath;
const sepIdx = lastSeparatorIndex(relativePath);
const dirPath = sepIdx >= 0 ? relativePath.slice(0, sepIdx) : '';
return (
<div className="border-border/50 border-b">
<button
onClick={onToggle}
className="flex w-full items-center gap-1.5 px-3 py-1 text-left transition-colors hover:bg-surface-raised"
>
<span className="text-[10px] text-text-muted">{expanded ? '▼' : '▶'}</span>
<FileIcon fileName={fileName} className="size-3.5" />
<span className="truncate text-xs font-medium text-text">{fileName}</span>
{dirPath && <span className="ml-1 truncate text-[10px] text-text-muted">{dirPath}</span>}
<span className="ml-auto shrink-0 text-[10px] text-text-muted">
{fileResult.matches.length}
</span>
</button>
{expanded && (
<div className="pb-1">
{fileResult.matches.map((match, idx) => (
<button
key={`${match.line}-${idx}`}
onClick={() => onSelectMatch(match.line)}
className="flex w-full items-center gap-2 px-6 py-0.5 text-left transition-colors hover:bg-surface-raised"
>
<span className="w-8 shrink-0 text-right text-[10px] text-text-muted">
{match.line}
</span>
<HighlightedLine
text={match.lineContent}
query={query}
caseSensitive={caseSensitive}
/>
</button>
))}
</div>
)}
</div>
);
};
// =============================================================================
// Highlighted line
// =============================================================================
interface HighlightedLineProps {
text: string;
query: string;
caseSensitive: boolean;
}
const HighlightedLine = React.memo(function HighlightedLine({
text,
query,
caseSensitive,
}: HighlightedLineProps): React.ReactElement {
if (!query) {
return <span className="truncate text-[11px] text-text-secondary">{text}</span>;
}
const searchText = caseSensitive ? text : text.toLowerCase();
const searchQuery = caseSensitive ? query : query.toLowerCase();
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let idx = searchText.indexOf(searchQuery);
while (idx !== -1) {
if (idx > lastIndex) {
parts.push(
<span key={`t-${lastIndex}`} className="text-text-secondary">
{text.slice(lastIndex, idx)}
</span>
);
}
parts.push(
<span key={`h-${idx}`} className="rounded bg-yellow-500/30 text-yellow-200">
{text.slice(idx, idx + query.length)}
</span>
);
lastIndex = idx + query.length;
idx = searchText.indexOf(searchQuery, lastIndex);
}
if (lastIndex < text.length) {
parts.push(
<span key={`t-${lastIndex}`} className="text-text-secondary">
{text.slice(lastIndex)}
</span>
);
}
return <span className="truncate text-[11px]">{parts}</span>;
});