363 lines
12 KiB
TypeScript
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>;
|
|
});
|