feat(web): render grep/glob search output as a search card
Consume the card:'search' result view (matches grouped by file for grep, a path list for glob) the search backend PR added. SearchBlock (ui-primitives) draws both kinds via the kind discriminant with a per-file collapse, a truncation pill, a height cap matching TerminalBlock, and a copy control; search-card-model is the single resultView derivation; a keyed SearchRow registers under grep and glob with the card resident under its summary. The generic fallback and the details panel are search-aware. Fixture gains grep and glob turns for the built-boot snapshot.
This commit is contained in:
125
packages/client/ui-primitives/src/SearchBlock.module.css
Normal file
125
packages/client/ui-primitives/src/SearchBlock.module.css
Normal file
@@ -0,0 +1,125 @@
|
||||
/* Geometry mirrors CodeBlock and TerminalBlock (12px radius, code-block
|
||||
surface + banner row, markdown code-block font) so a search card reads as one
|
||||
family with them. The deliberate divergence they share: the result rows keep
|
||||
`white-space: pre` and scroll horizontally, because folding a long match line
|
||||
or path destroys the alignment a reader scans by. */
|
||||
|
||||
.block {
|
||||
--dsl-search-radius: 12px;
|
||||
--dsl-search-line-height: 22px;
|
||||
|
||||
position: relative;
|
||||
margin: 16px 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
border-radius: var(--dsl-search-radius);
|
||||
}
|
||||
|
||||
/* The banner: result summary on the left, the truncation pill and copy control
|
||||
holding their intrinsic width on the right. */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 14px;
|
||||
background: var(--dsw-alias-markdown-code-block-banner);
|
||||
border-top-left-radius: var(--dsl-search-radius);
|
||||
border-top-right-radius: var(--dsl-search-radius);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.truncated {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
flex: none;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 8px 14px 12px 0;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* No wrapping: a match line or a path keeps its content on one row and scrolls
|
||||
sideways instead of folding. */
|
||||
.line {
|
||||
min-height: var(--dsl-search-line-height);
|
||||
padding-left: 14px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* The 1-based line number ahead of a grep match line, dimmed so the match text
|
||||
stays the salient content. */
|
||||
.lineNumber {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* A file group's header: a bold path label plus its match count, the whole row
|
||||
the collapse control. */
|
||||
.fileHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: var(--dsl-search-line-height);
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filePath {
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.fileCount {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.expand {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 12px 14px;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
263
packages/client/ui-primitives/src/SearchBlock.tsx
Normal file
263
packages/client/ui-primitives/src/SearchBlock.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
// SearchBlock: the search surface for a completed content or path search — a
|
||||
// banner (result count + a truncation pill when the tool capped the result +
|
||||
// a copy control), then either grep matches grouped by file (each file a bold
|
||||
// path header with its `lineNumber: line` rows, the group collapsible) or a
|
||||
// flat glob path list. Both shapes flatten to one list of rows the height cap
|
||||
// slices head/tail over, and neither soft-wraps: a long match line or path
|
||||
// scrolls horizontally instead of folding. Geometry mirrors CodeBlock and
|
||||
// TerminalBlock so a search card reads as one family with them.
|
||||
|
||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { writeClipboard } from './clipboard.ts'
|
||||
import { Pill } from './Pill.tsx'
|
||||
import css from './SearchBlock.module.css'
|
||||
|
||||
/**
|
||||
* Result rows shown before the height cap collapses the middle. Matches
|
||||
* {@link DEFAULT_TERMINAL_MAX_LINES} so a search card and a terminal card cut a
|
||||
* long result at the same place.
|
||||
*/
|
||||
export const DEFAULT_SEARCH_MAX_LINES = 16
|
||||
|
||||
/** One matched line inside a {@link SearchFileGroup}: its 1-based line number and text. */
|
||||
export interface SearchBlockLineMatch {
|
||||
/** 1-based line number of the match within its file. */
|
||||
lineNumber: number
|
||||
/** The matched line text, as the tool surfaced it. */
|
||||
line: string
|
||||
}
|
||||
|
||||
/** One file's grouped matches, in first-seen file order. */
|
||||
export interface SearchFileGroup {
|
||||
/** The file the matches belong to (the display path). */
|
||||
path: string
|
||||
/** The file's matched lines, in output order. */
|
||||
matches: SearchBlockLineMatch[]
|
||||
}
|
||||
|
||||
/** Fields both search shapes carry (the render site positions; this component draws). */
|
||||
interface SearchBlockCommon {
|
||||
/**
|
||||
* Whether the tool capped the inline result: the shape carries only the
|
||||
* retained results, not every result the search found. A truncation pill is
|
||||
* shown so the card never presents a capped result as complete.
|
||||
*/
|
||||
truncated: boolean
|
||||
/** Total results the search found before capping (equals the retained count when not `truncated`). */
|
||||
total: number
|
||||
/** Height cap in rows before the middle collapses (default {@link DEFAULT_SEARCH_MAX_LINES}). */
|
||||
maxLines?: number | undefined
|
||||
/** Extra class merged onto the wrapper. */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/** Props for the grouped-matches (`grep`) shape. */
|
||||
export interface SearchMatchesBlockProps extends SearchBlockCommon {
|
||||
kind: 'matches'
|
||||
/** Matched lines grouped by file, in first-seen file order. */
|
||||
files: SearchFileGroup[]
|
||||
}
|
||||
|
||||
/** Props for the flat-path (`glob`) shape. */
|
||||
export interface SearchPathsBlockProps extends SearchBlockCommon {
|
||||
kind: 'paths'
|
||||
/** The discovered paths, in the tool's result order (the retained page when `truncated`). */
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
/** {@link SearchBlock} props: one card, two `kind`-discriminated shapes. */
|
||||
export type SearchBlockProps = SearchMatchesBlockProps | SearchPathsBlockProps
|
||||
|
||||
/**
|
||||
* One flattened render row. A matches card produces a `file` header row per
|
||||
* group followed by a `match` row per retained line while the group is
|
||||
* expanded; a paths card produces one `path` row per path. The height cap
|
||||
* counts these rows uniformly, so a file header costs one row exactly as a
|
||||
* match line or a path does.
|
||||
*/
|
||||
type SearchRow =
|
||||
| { type: 'file'; path: string; count: number; index: number; collapsed: boolean }
|
||||
| { type: 'match'; lineNumber: number; line: string; key: string }
|
||||
| { type: 'path'; path: string }
|
||||
|
||||
/**
|
||||
* The plain-text form the copy control writes: the whole structured result
|
||||
* regardless of the height cap or which groups are collapsed, so the clipboard
|
||||
* carries the result rather than what the card happens to be showing.
|
||||
* @param props - the card's props.
|
||||
* @returns the copyable text, or the empty string for an empty result.
|
||||
*/
|
||||
function copyText(props: SearchBlockProps): string {
|
||||
if (props.kind === 'paths') return props.paths.join('\n')
|
||||
return props.files
|
||||
.map(file => [file.path, ...file.matches.map(m => `${m.lineNumber}: ${m.line}`)].join('\n'))
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of retained results the card holds: the matched-line count across all
|
||||
* files for a matches card, the path count for a paths card. This is the count
|
||||
* the truncation pill reports against `total`.
|
||||
* @param props - the card's props.
|
||||
* @returns the retained result count.
|
||||
*/
|
||||
function shownCount(props: SearchBlockProps): number {
|
||||
return props.kind === 'paths'
|
||||
? props.paths.length
|
||||
: props.files.reduce((sum, file) => sum + file.matches.length, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The banner summary: the structural count of the retained result. The
|
||||
* truncation pill beside it carries the capped-vs-complete signal, so this
|
||||
* stays a plain count of what the card holds.
|
||||
* @param props - the card's props.
|
||||
* @param shown - the retained result count from {@link shownCount}.
|
||||
* @returns the summary text.
|
||||
*/
|
||||
function summaryText(props: SearchBlockProps, shown: number): string {
|
||||
return props.kind === 'paths'
|
||||
? `${shown} 个路径`
|
||||
: `${shown} 处匹配 · ${props.files.length} 个文件`
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a card's shape into its render rows, dropping a collapsed file
|
||||
* group's match rows.
|
||||
* @param props - the card's props.
|
||||
* @param collapsed - the set of collapsed file-group indices (matches only).
|
||||
* @returns the flattened rows in output order.
|
||||
*/
|
||||
function toRows(props: SearchBlockProps, collapsed: ReadonlySet<number>): SearchRow[] {
|
||||
if (props.kind === 'paths') return props.paths.map((path): SearchRow => ({ type: 'path', path }))
|
||||
const rows: SearchRow[] = []
|
||||
props.files.forEach((file, index) => {
|
||||
const isCollapsed = collapsed.has(index)
|
||||
rows.push({ type: 'file', path: file.path, count: file.matches.length, index, collapsed: isCollapsed })
|
||||
if (isCollapsed) return
|
||||
for (const match of file.matches) {
|
||||
rows.push({ type: 'match', lineNumber: match.lineNumber, line: match.line, key: `${index}:${match.lineNumber}` })
|
||||
}
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable React key for a flattened render row: the group-scoped match key, a
|
||||
* file-index-scoped header key, or the path itself. Rows of different types
|
||||
* never collide, since each key carries its type prefix or the group index.
|
||||
* @param row - the flattened row.
|
||||
* @returns the key.
|
||||
*/
|
||||
function rowKey(row: SearchRow): string {
|
||||
switch (row.type) {
|
||||
case 'match': return `match:${row.key}`
|
||||
case 'file': return `file:${row.index}`
|
||||
case 'path': return `path:${row.path}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a completed search as a grouped-matches or flat-path card.
|
||||
* @param props - see {@link SearchBlockProps}.
|
||||
* @returns the search block element.
|
||||
*/
|
||||
export function SearchBlock(props: SearchBlockProps) {
|
||||
const { truncated, total, maxLines = DEFAULT_SEARCH_MAX_LINES, className } = props
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const rows = useMemo(() => toRows(props, collapsed), [props, collapsed])
|
||||
const shown = shownCount(props)
|
||||
const empty = rows.length === 0
|
||||
const text = copyText(props)
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
void writeClipboard(text).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopied(true)
|
||||
window.setTimeout(() => { setCopied(false) }, 1000)
|
||||
})
|
||||
}, [copied, text])
|
||||
|
||||
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
|
||||
const toggleFile = useCallback((index: number) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(index)) next.delete(index)
|
||||
else next.add(index)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const hidden = rows.length - maxLines
|
||||
const capped = hidden > 0 && !expanded
|
||||
// Same split arithmetic as TerminalBlock (and the TUI transcript's collapsed
|
||||
// tool card), so a long result's head and tail slices agree across surfaces.
|
||||
const headLines = Math.ceil(maxLines / 2)
|
||||
const tailLines = maxLines - headLines
|
||||
|
||||
const renderRow = (row: SearchRow): ReactNode => {
|
||||
if (row.type === 'path') return <div className={css.line}>{row.path}</div>
|
||||
if (row.type === 'match') {
|
||||
return (
|
||||
<div className={css.line}>
|
||||
<span className={css.lineNumber}>{row.lineNumber}: </span>
|
||||
{row.line}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileHeader}
|
||||
aria-expanded={!row.collapsed}
|
||||
onClick={() => { toggleFile(row.index) }}
|
||||
>
|
||||
<span className={css.filePath}>{row.path}</span>
|
||||
<span className={css.fileCount}>{row.count}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(css.block, className)} data-search={props.kind}>
|
||||
<div className={css.header}>
|
||||
<span className={css.summary}>{summaryText(props, shown)}</span>
|
||||
{truncated && <Pill className={css.truncated}>{`已截断 · 共 ${total}`}</Pill>}
|
||||
{!empty && (
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{empty
|
||||
? <div className={css.empty}>无结果</div>
|
||||
: (
|
||||
<div className={css.body}>
|
||||
{(capped ? rows.slice(0, headLines) : rows).map(row => (
|
||||
<div key={rowKey(row)}>{renderRow(row)}</div>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.expand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? '收起结果' : `展开其余 ${hidden} 行结果`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? '收起' : `… 其余 ${hidden} 行`}
|
||||
</button>
|
||||
)}
|
||||
{capped && rows.slice(rows.length - tailLines).map(row => (
|
||||
<div key={rowKey(row)}>{renderRow(row)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,10 @@ export { JsonTree } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps } from './JsonTree.tsx'
|
||||
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
|
||||
export type { TerminalBlockProps } from './TerminalBlock.tsx'
|
||||
export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
|
||||
export type {
|
||||
SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch,
|
||||
} from './SearchBlock.tsx'
|
||||
export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
|
||||
196
packages/client/ui-primitives/tests/search-block.spec.tsx
Normal file
196
packages/client/ui-primitives/tests/search-block.spec.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
// @vitest-environment jsdom
|
||||
// SearchBlock: both kinds (grouped grep matches and a flat glob path list), the
|
||||
// truncation pill, the empty arm, per-file collapse/expand, the head/tail height
|
||||
// cap and its expand control, and the copy control writing the whole structured
|
||||
// result on both the accepted and refused clipboard paths.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { DEFAULT_SEARCH_MAX_LINES, SearchBlock } from '../src/index.ts'
|
||||
import type { SearchFileGroup } from '../src/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** The rendered result rows, one string per visible row (CSS-module class prefix). */
|
||||
function lines(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
|
||||
}
|
||||
|
||||
/** The file-group header rows, one string per header (path + count concatenated). */
|
||||
function fileHeaders(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('[class^="_fileHeader_"]')].map(row => row.textContent ?? '')
|
||||
}
|
||||
|
||||
/** `count` numbered match lines under one file, without a terminating newline. */
|
||||
function group(path: string, count: number, from = 1): SearchFileGroup {
|
||||
return {
|
||||
path,
|
||||
matches: Array.from({ length: count }, (_v, i) => ({ lineNumber: from + i, line: `hit ${from + i}` })),
|
||||
}
|
||||
}
|
||||
|
||||
describe('SearchBlock matches kind', () => {
|
||||
it('renders each file as a header group with its matched lines', () => {
|
||||
const view = render(<SearchBlock kind="matches" truncated={false} total={3} files={[
|
||||
{ path: 'a.ts', matches: [{ lineNumber: 12, line: 'const a = 1' }, { lineNumber: 40, line: 'return a' }] },
|
||||
{ path: 'b.ts', matches: [{ lineNumber: 7, line: 'const b = 2' }] },
|
||||
]} />)
|
||||
expect(fileHeaders(view.container)).toEqual(['a.ts2', 'b.ts1'])
|
||||
expect(lines(view.container)).toEqual(['12: const a = 1', '40: return a', '7: const b = 2'])
|
||||
// The summary counts matches and files, no truncation pill under the cap.
|
||||
expect(view.getByText('3 处匹配 · 2 个文件')).toBeTruthy()
|
||||
expect(view.queryByText(/已截断/u)).toBeNull()
|
||||
})
|
||||
|
||||
it('collapses and re-expands a single file group without touching the others', () => {
|
||||
const view = render(<SearchBlock kind="matches" truncated={false} total={3} files={[
|
||||
{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }] },
|
||||
{ path: 'b.ts', matches: [{ lineNumber: 2, line: 'y' }] },
|
||||
]} />)
|
||||
const [headerA] = view.container.querySelectorAll('[class^="_fileHeader_"]')
|
||||
expect(headerA!.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(headerA!)
|
||||
// a.ts collapsed: its match row is gone, b.ts's stays.
|
||||
expect(headerA!.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(lines(view.container)).toEqual(['2: y'])
|
||||
fireEvent.click(headerA!)
|
||||
expect(lines(view.container)).toEqual(['1: x', '2: y'])
|
||||
})
|
||||
|
||||
it('shows the truncation pill with the pre-cap total', () => {
|
||||
const view = render(<SearchBlock kind="matches" truncated total={99} files={[group('a.ts', 2)]} />)
|
||||
expect(view.getByText('已截断 · 共 99')).toBeTruthy()
|
||||
expect(view.getByText('2 处匹配 · 1 个文件')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SearchBlock paths kind', () => {
|
||||
it('renders a flat path list with a path-count summary', () => {
|
||||
const view = render(<SearchBlock kind="paths" truncated={false} total={2} paths={['src/a.ts', 'src/b.ts']} />)
|
||||
expect(lines(view.container)).toEqual(['src/a.ts', 'src/b.ts'])
|
||||
expect(view.getByText('2 个路径')).toBeTruthy()
|
||||
// No file-group headers in the paths shape.
|
||||
expect(fileHeaders(view.container)).toEqual([])
|
||||
})
|
||||
|
||||
it('shows the truncation pill with the pre-cap total', () => {
|
||||
const view = render(<SearchBlock kind="paths" truncated total={50} paths={['a', 'b']} />)
|
||||
expect(view.getByText('已截断 · 共 50')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SearchBlock empty arm', () => {
|
||||
it('shows the placeholder and no copy control for an empty matches result', () => {
|
||||
const view = render(<SearchBlock kind="matches" truncated={false} total={0} files={[]} />)
|
||||
expect(view.getByText('无结果')).toBeTruthy()
|
||||
expect(view.queryByText('复制')).toBeNull()
|
||||
expect(view.getByText('0 处匹配 · 0 个文件')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the placeholder for an empty paths result', () => {
|
||||
const view = render(<SearchBlock kind="paths" truncated={false} total={0} paths={[]} />)
|
||||
expect(view.getByText('无结果')).toBeTruthy()
|
||||
expect(view.queryByText('复制')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SearchBlock height cap', () => {
|
||||
it('renders every row and no expand control under the cap', () => {
|
||||
const view = render(<SearchBlock kind="paths" truncated={false} total={4}
|
||||
paths={['a', 'b', 'c', 'd']} maxLines={4} />)
|
||||
expect(lines(view.container)).toHaveLength(4)
|
||||
expect(view.container.querySelector('[aria-label^="展开"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('slices head and tail over the cap and expands on click', () => {
|
||||
const paths = Array.from({ length: 10 }, (_v, i) => `p${i + 1}`)
|
||||
const view = render(<SearchBlock kind="paths" truncated={false} total={10} paths={paths} maxLines={4} />)
|
||||
// maxLines 4: head = ceil(4/2) = 2, tail = 2, 6 hidden.
|
||||
expect(lines(view.container)).toEqual(['p1', 'p2', 'p9', 'p10'])
|
||||
const toggle = view.getByRole('button', { name: '展开其余 6 行结果' })
|
||||
expect(toggle.textContent).toBe('… 其余 6 行')
|
||||
fireEvent.click(toggle)
|
||||
expect(lines(view.container)).toHaveLength(10)
|
||||
const collapse = view.getByRole('button', { name: '收起结果' })
|
||||
expect(collapse.textContent).toBe('收起')
|
||||
fireEvent.click(collapse)
|
||||
expect(lines(view.container)).toEqual(['p1', 'p2', 'p9', 'p10'])
|
||||
})
|
||||
|
||||
it('counts a file header as one capped row alongside its matches', () => {
|
||||
// One file with 10 matches → 11 rows (header + 10). Cap 4: head 2, tail 2.
|
||||
const view = render(<SearchBlock kind="matches" truncated={false} total={10}
|
||||
files={[group('a.ts', 10)]} maxLines={4} />)
|
||||
// Head takes the header then the first match; tail takes the last two matches.
|
||||
expect(lines(view.container)).toEqual(['1: hit 1', '9: hit 9', '10: hit 10'])
|
||||
expect(fileHeaders(view.container)).toEqual(['a.ts10'])
|
||||
expect(view.getByRole('button', { name: '展开其余 7 行结果' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the head slice alone when the cap leaves no tail', () => {
|
||||
const view = render(<SearchBlock kind="paths" truncated={false} total={5}
|
||||
paths={['a', 'b', 'c', 'd', 'e']} maxLines={1} />)
|
||||
expect(lines(view.container)).toEqual(['a'])
|
||||
expect(view.getByRole('button', { name: '展开其余 4 行结果' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('caps at the documented default when maxLines is absent', () => {
|
||||
const paths = Array.from({ length: DEFAULT_SEARCH_MAX_LINES + 1 }, (_v, i) => `p${i}`)
|
||||
const view = render(<SearchBlock kind="paths" truncated={false} total={paths.length} paths={paths} />)
|
||||
expect(lines(view.container)).toHaveLength(DEFAULT_SEARCH_MAX_LINES)
|
||||
expect(view.getByRole('button', { name: '展开其余 1 行结果' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SearchBlock copy', () => {
|
||||
it('copies the whole structured matches result, not the collapsed or capped view', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
const view = render(<SearchBlock kind="matches" truncated total={9} maxLines={2} files={[
|
||||
{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }, { lineNumber: 2, line: 'y' }] },
|
||||
{ path: 'b.ts', matches: [{ lineNumber: 3, line: 'z' }] },
|
||||
]} />)
|
||||
// Collapse a group and leave the cap in place: the clipboard still gets it all.
|
||||
fireEvent.click(view.container.querySelector('[class^="_fileHeader_"]')!)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('a.ts\n1: x\n2: y\n\nb.ts\n3: z')
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
// A second click while the ok label shows is a no-op.
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('copies the newline-joined path list for the paths shape', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
render(<SearchBlock kind="paths" truncated={false} total={2} paths={['src/a.ts', 'src/b.ts']} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('src/a.ts\nsrc/b.ts')
|
||||
expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not claim success when the host refuses the write', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true, value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||
})
|
||||
render(<SearchBlock kind="paths" truncated={false} total={1} paths={['a']} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
|
||||
})
|
||||
|
||||
it('merges className onto the wrapper and tags the wrapper with the kind', () => {
|
||||
const view = render(<SearchBlock kind="paths" truncated={false} total={0} paths={[]} className="x" />)
|
||||
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
|
||||
expect(view.container.firstElementChild?.getAttribute('data-search')).toBe('paths')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user