Improve file content search result layout (#298)

* wip

* fix: use shared tooltip wrapper for search results
This commit is contained in:
Jinjing
2026-04-04 15:01:25 -07:00
committed by GitHub
parent c40cfb2cc2
commit ea5a989edf
5 changed files with 100 additions and 121 deletions
@@ -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<HTMLInputElement>(null)
const [showFilters, setShowFilters] = useState(false)
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const latestSearchIdRef = useRef(0)
const resultsScrollRef = useRef<HTMLDivElement>(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 {
<input
ref={inputRef}
type="text"
className="flex-1 bg-transparent text-xs py-1.5 outline-none text-foreground placeholder:text-muted-foreground min-w-0"
className="flex-1 bg-transparent text-xs py-1.5 outline-none text-foreground placeholder:text-muted-foreground/50 min-w-0"
placeholder="Search"
value={fileSearchQuery}
onChange={handleQueryChange}
@@ -340,10 +339,8 @@ export default function Search(): React.JSX.Element {
</div>
<SearchFilters
showFilters={showFilters}
includePattern={fileSearchIncludePattern}
excludePattern={fileSearchExcludePattern}
onToggleFilters={() => setShowFilters(!showFilters)}
onIncludeChange={(value) => {
updateActiveSearchState({ includePattern: value })
rerunSearch()
@@ -355,6 +352,18 @@ export default function Search(): React.JSX.Element {
/>
</div>
{/* 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 && (
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b border-border">
{deferredSearchResults.totalMatches} result
{deferredSearchResults.totalMatches !== 1 ? 's' : ''} in{' '}
{deferredSearchResults.files.length} file
{deferredSearchResults.files.length !== 1 ? 's' : ''}
{deferredSearchResults.truncated && ' (results truncated)'}
</div>
)}
<div ref={resultsScrollRef} className="flex-1 min-h-0 overflow-y-auto scrollbar-sleek">
{searchRows.length > 0 && (
<div
@@ -377,13 +386,6 @@ export default function Search(): React.JSX.Element {
transform: `translateY(${virtualRow.start}px)`
}}
>
{row.type === 'summary' && (
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b border-border">
{row.totalMatches} result{row.totalMatches !== 1 ? 's' : ''} in{' '}
{row.fileCount} file{row.fileCount !== 1 ? 's' : ''}
{row.truncated && ' (results truncated)'}
</div>
)}
{row.type === 'file' && (
<FileResultRow
fileResult={row.fileResult}
@@ -1,56 +1,36 @@
import React from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
type SearchFiltersProps = {
showFilters: boolean
includePattern: string
excludePattern: string
onToggleFilters: () => void
onIncludeChange: (value: string) => void
onExcludeChange: (value: string) => void
}
export function SearchFilters({
showFilters,
includePattern,
excludePattern,
onToggleFilters,
onIncludeChange,
onExcludeChange
}: SearchFiltersProps): React.JSX.Element {
return (
<>
<Button
type="button"
variant="ghost"
className="h-auto justify-start gap-1 self-start px-0 text-[10px] text-muted-foreground hover:text-foreground"
onClick={onToggleFilters}
>
{showFilters ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
<span>files to include/exclude</span>
</Button>
{showFilters && (
<div className="flex flex-col gap-1">
<input
type="text"
className="bg-input/50 border border-border rounded-sm px-2 py-1 text-xs outline-none focus:border-ring text-foreground placeholder:text-muted-foreground"
placeholder="files to include (e.g. *.ts, src/**)"
value={includePattern}
onChange={(e) => onIncludeChange(e.target.value)}
spellCheck={false}
/>
<input
type="text"
className="bg-input/50 border border-border rounded-sm px-2 py-1 text-xs outline-none focus:border-ring text-foreground placeholder:text-muted-foreground"
placeholder="files to exclude (e.g. *.min.js, dist/**)"
value={excludePattern}
onChange={(e) => onExcludeChange(e.target.value)}
spellCheck={false}
/>
</div>
)}
</>
<div className="flex flex-col gap-1">
<input
type="text"
className="bg-input/50 border border-border rounded-sm px-2 py-1 text-xs outline-none focus:border-ring text-foreground placeholder:text-muted-foreground/50"
placeholder="files to include (e.g. *.ts, src/**)"
value={includePattern}
onChange={(e) => onIncludeChange(e.target.value)}
spellCheck={false}
/>
<input
type="text"
className="bg-input/50 border border-border rounded-sm px-2 py-1 text-xs outline-none focus:border-ring text-foreground placeholder:text-muted-foreground/50"
placeholder="files to exclude (e.g. *.min.js, dist/**)"
value={excludePattern}
onChange={(e) => onExcludeChange(e.target.value)}
spellCheck={false}
/>
</div>
)
}
@@ -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 (
<div>
<div className="pt-1.5">
{/* File header with context menu */}
<ContextMenu>
<ContextMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-1 rounded-none px-2 py-0.5 text-left group"
onClick={onToggleCollapse}
>
<ChevronRight
className={cn(
'size-3 flex-shrink-0 text-muted-foreground transition-transform',
!collapsed && 'rotate-90'
)}
/>
<File size={12} className="flex-shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-left text-xs">
<span className="text-foreground">{fileName}</span>
{dirPath && (
<span className="ml-1.5 text-[10px] text-muted-foreground">{dirPath}</span>
)}
</span>
<span className="ml-auto text-[10px] text-muted-foreground flex-shrink-0 bg-muted/80 rounded-full px-1.5">
{fileResult.matches.length}
</span>
</Button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => window.api.ui.writeClipboardText(fileResult.relativePath)}
>
<Copy className="size-3.5" />
Copy Path
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<TooltipProvider delayDuration={400}>
<Tooltip>
<ContextMenu>
<ContextMenuTrigger asChild>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-1 rounded-none px-2 py-0.5 text-left group"
onClick={onToggleCollapse}
>
<ChevronRight
className={cn(
'size-3 flex-shrink-0 text-muted-foreground transition-transform',
!collapsed && 'rotate-90'
)}
/>
<div className="min-w-0 flex-1 text-xs">
<span className="min-w-0 block truncate">
<span className="text-foreground">{fileName}</span>
{dirPath && (
<span className="ml-1.5 text-[11px] text-muted-foreground">{dirPath}</span>
)}
</span>
</div>
<span className="text-[10px] text-muted-foreground flex-shrink-0 bg-muted/80 rounded-full px-1.5">
{fileResult.matches.length}
</span>
</Button>
</TooltipTrigger>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => window.api.ui.writeClipboardText(fileResult.relativePath)}
>
<Copy className="size-3.5" />
Copy Path
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{/* 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. */}
<TooltipContent side="top" sideOffset={6}>
{fileResult.relativePath}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)
}
@@ -143,7 +157,7 @@ export function MatchResultRow({
}}
onClick={onClick}
>
<span className="text-[10px] text-muted-foreground flex-shrink-0 w-8 text-right tabular-nums mt-px">
<span className="text-[10px] text-muted-foreground flex-shrink-0 tabular-nums mt-px">
{match.line}
</span>
<span className="text-xs truncate">
@@ -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<string>()
)
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<string>(['/repo/a.ts'])
)
expect(rows.map((row) => row.type)).toEqual(['summary', 'file', 'file', 'match'])
expect(rows.map((row) => row.type)).toEqual(['file', 'file', 'match'])
})
})
@@ -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)