diff --git a/src/renderer/src/components/right-sidebar/Search.tsx b/src/renderer/src/components/right-sidebar/Search.tsx index 0f74d0219e3..dcc5acd3ee3 100644 --- a/src/renderer/src/components/right-sidebar/Search.tsx +++ b/src/renderer/src/components/right-sidebar/Search.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { Search as SearchIcon, CaseSensitive, WholeWord, Regex, X, Loader2 } from 'lucide-react' import { useAppStore } from '@/store' @@ -38,7 +38,6 @@ export default function Search(): React.JSX.Element { const clearFileSearch = useAppStore((s) => s.clearFileSearch) const inputRef = useRef(null) - const [showFilters, setShowFilters] = useState(false) const searchTimerRef = useRef | null>(null) const latestSearchIdRef = useRef(0) const resultsScrollRef = useRef(null) @@ -135,25 +134,25 @@ export default function Search(): React.JSX.Element { estimateSize: (index) => { const row = searchRows[index] if (!row) { - return 24 - } - if (row.type === 'summary') { - return 24 + return 20 } + // Why: file rows include pt-1.5 (6 px) for inter-group spacing, so + // their estimate is taller than match rows. if (row.type === 'file') { - return 26 + return 28 } - return 22 + return 20 }, + // Why: paddingEnd adds visible breathing room after the last result row. + // paddingStart is unnecessary because each file row already includes + // pt-1.5 for inter-group spacing (which also covers the first row). + paddingEnd: 8, overscan: SEARCH_VIRTUAL_OVERSCAN, getItemKey: (index) => { const row = searchRows[index] if (!row) { return `missing:${index}` } - if (row.type === 'summary') { - return 'summary' - } if (row.type === 'file') { return `file:${row.fileResult.filePath}` } @@ -286,7 +285,7 @@ export default function Search(): React.JSX.Element { setShowFilters(!showFilters)} onIncludeChange={(value) => { updateActiveSearchState({ includePattern: value }) rerunSearch() @@ -355,6 +352,18 @@ export default function Search(): React.JSX.Element { /> + {/* Why: the summary is rendered outside the virtualizer so it stays + pinned at the top while the user scrolls through results. */} + {deferredSearchResults && searchRows.length > 0 && ( +
+ {deferredSearchResults.totalMatches} result + {deferredSearchResults.totalMatches !== 1 ? 's' : ''} in{' '} + {deferredSearchResults.files.length} file + {deferredSearchResults.files.length !== 1 ? 's' : ''} + {deferredSearchResults.truncated && ' (results truncated)'} +
+ )} +
{searchRows.length > 0 && (
- {row.type === 'summary' && ( -
- {row.totalMatches} result{row.totalMatches !== 1 ? 's' : ''} in{' '} - {row.fileCount} file{row.fileCount !== 1 ? 's' : ''} - {row.truncated && ' (results truncated)'} -
- )} {row.type === 'file' && ( void onIncludeChange: (value: string) => void onExcludeChange: (value: string) => void } export function SearchFilters({ - showFilters, includePattern, excludePattern, - onToggleFilters, onIncludeChange, onExcludeChange }: SearchFiltersProps): React.JSX.Element { return ( - <> - - - {showFilters && ( -
- onIncludeChange(e.target.value)} - spellCheck={false} - /> - onExcludeChange(e.target.value)} - spellCheck={false} - /> -
- )} - +
+ onIncludeChange(e.target.value)} + spellCheck={false} + /> + onExcludeChange(e.target.value)} + spellCheck={false} + /> +
) } diff --git a/src/renderer/src/components/right-sidebar/SearchResultItems.tsx b/src/renderer/src/components/right-sidebar/SearchResultItems.tsx index 1ee7478982e..97dcc4ed223 100644 --- a/src/renderer/src/components/right-sidebar/SearchResultItems.tsx +++ b/src/renderer/src/components/right-sidebar/SearchResultItems.tsx @@ -1,8 +1,9 @@ import React, { useMemo } from 'react' -import { ChevronRight, File, Copy } from 'lucide-react' +import { ChevronRight, Copy } from 'lucide-react' import { basename, dirname } from '@/lib/path' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' +import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' import { ContextMenu, ContextMenuTrigger, @@ -57,43 +58,56 @@ export function FileResultRow({ const dirPath = parentDir === '.' ? '' : parentDir return ( -
+
{/* File header with context menu */} - - - - - - window.api.ui.writeClipboardText(fileResult.relativePath)} - > - - Copy Path - - - + + + + + + + + + + window.api.ui.writeClipboardText(fileResult.relativePath)} + > + + Copy Path + + + + {/* Why: the row label intentionally truncates long parent paths to + keep the result list compact, so the tooltip preserves the full + relative path for copy/verification without widening the row. */} + + {fileResult.relativePath} + + +
) } @@ -143,7 +157,7 @@ export function MatchResultRow({ }} onClick={onClick} > - + {match.line} diff --git a/src/renderer/src/components/right-sidebar/search-rows.test.ts b/src/renderer/src/components/right-sidebar/search-rows.test.ts index ef3dc928f8e..3d56221247a 100644 --- a/src/renderer/src/components/right-sidebar/search-rows.test.ts +++ b/src/renderer/src/components/right-sidebar/search-rows.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { buildSearchRows } from './search-rows' describe('buildSearchRows', () => { - it('includes summary, file headers, and expanded matches in row order', () => { + it('includes file headers and expanded matches in row order (summary is rendered separately)', () => { const rows = buildSearchRows( { totalMatches: 3, @@ -26,14 +26,7 @@ describe('buildSearchRows', () => { new Set() ) - expect(rows.map((row) => row.type)).toEqual([ - 'summary', - 'file', - 'match', - 'match', - 'file', - 'match' - ]) + expect(rows.map((row) => row.type)).toEqual(['file', 'match', 'match', 'file', 'match']) }) it('omits match rows for collapsed files', () => { @@ -57,6 +50,6 @@ describe('buildSearchRows', () => { new Set(['/repo/a.ts']) ) - expect(rows.map((row) => row.type)).toEqual(['summary', 'file', 'file', 'match']) + expect(rows.map((row) => row.type)).toEqual(['file', 'file', 'match']) }) }) diff --git a/src/renderer/src/components/right-sidebar/search-rows.ts b/src/renderer/src/components/right-sidebar/search-rows.ts index a3a02966785..e9697febea3 100644 --- a/src/renderer/src/components/right-sidebar/search-rows.ts +++ b/src/renderer/src/components/right-sidebar/search-rows.ts @@ -1,12 +1,6 @@ import type { SearchFileResult, SearchMatch, SearchResult } from '../../../../shared/types' export type SearchRow = - | { - type: 'summary' - totalMatches: number - fileCount: number - truncated: boolean - } | { type: 'file' fileResult: SearchFileResult @@ -27,14 +21,10 @@ export function buildSearchRows( return [] } - const rows: SearchRow[] = [ - { - type: 'summary', - totalMatches: results.totalMatches, - fileCount: results.files.length, - truncated: results.truncated - } - ] + // Why: the summary row is rendered as a fixed header in Search.tsx so it + // stays visible while the user scrolls through results and doesn't + // participate in virtualisation. + const rows: SearchRow[] = [] for (const fileResult of results.files) { const collapsed = collapsedFiles.has(fileResult.filePath)