mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Improve file content search result layout (#298)
* wip * fix: use shared tooltip wrapper for search results
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user