fix(web-search-card): finish search bot-review round (import, docs)

Complete the stalled review pass: drop the unused useMemo import (rows flatten
inline), add SearchBlock to the ui-primitives README (both languages) with a
Search results section, and re-record the doc pairings. Search card behavior
and tests unchanged (313 pass).
This commit is contained in:
Chinesezjc
2026-07-30 20:42:25 +08:00
parent 3dbf83e918
commit b832f46eff
16 changed files with 117 additions and 59 deletions

View File

@@ -137,7 +137,7 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
}
/**
* Structured grep result for the search sample (turn 67): matches grouped by
* Structured grep result for the search sample (turn 66): matches grouped by
* file, authored inline because the client-side fixture cannot import the tool
* that produces the canonical value. `truncated` with a larger `total` than the
* retained match count exercises the search card's capped indicator; the file
@@ -162,33 +162,43 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin
]
/**
* The model-facing grep render text for the sample, grouped under file headers
* with `Line N:` rows and a spill footer — what a UI without a search card
* shows, attached as the view's `content`.
* The model-facing grep render text for the sample — what a UI without a search
* card shows, attached as the view's `content`. Mirrors the real grep
* presenter's shape (see formatGrepOutput in dsh-tool-fs-search): a
* `Found X of Y matches` header, the matches grouped under file headers with
* `Line N:` rows, then a spill-recovery footer.
*/
const SEARCH_MATCHES_TEXT = [
...SEARCH_MATCHES_FIXTURE.flatMap(file => [
file.path,
...file.matches.map(m => ` Line ${m.lineNumber}: ${m.line}`),
]),
'Found 5 of 42 matches',
'',
'(已显示 5 处匹配中的前 5 处,共 42 处;其余见溢出文件)',
...SEARCH_MATCHES_FIXTURE.map(file =>
[file.path, ...file.matches.map(m => `Line ${m.lineNumber}: ${m.line}`)].join('\n')),
'',
'(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)',
].join('\n')
/**
* Structured glob result for the search sample (turn 68): a flat path list,
* Structured glob result for the search sample (turn 67): a flat path list,
* truncated with a larger `total` so the path card shows its capped indicator.
*/
const SEARCH_PATHS_FIXTURE = [
'packages/client/ui-primitives/src/SearchBlock.tsx',
'packages/client/ui-primitives/src/SearchBlock.module.css',
'packages/client/ui-conversation/src/client/contract/search-card-model.ts',
'packages/client/ui-conversation/src/client/toolviews/search-sample.tsx',
'packages/client/ui-conversation/src/client/toolviews/search-sample.module.css',
'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
'packages/client/ui-conversation/src/client/toolviews/search-row.module.css',
]
/** The model-facing glob render text: the newline-joined path list plus a spill footer. */
const SEARCH_PATHS_TEXT = [...SEARCH_PATHS_FIXTURE, '', '(共 23 个路径,已显示前 5 个)'].join('\n')
/**
* The model-facing glob render text — the newline-joined path list plus a
* spill-recovery footer, mirroring the real glob presenter's shape (see
* formatGlobOutput in dsh-tool-fs-search).
*/
const SEARCH_PATHS_TEXT = [
...SEARCH_PATHS_FIXTURE,
'',
'(Showing 5 of 23 paths. Full sorted result stored at: fixture://spill/glob-67. Read it to see every path.)',
].join('\n')
const DEEPSEEK_REASONING = {
efforts: [

View File

@@ -20,7 +20,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { StatsLine } from './chat/StatsLine.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { searchToolview } from './toolviews/search-sample.tsx'
import { searchToolview } from './toolviews/search-row.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
import { askQuestionToolview } from './toolviews/ask-question-row.tsx'

View File

@@ -65,8 +65,10 @@ export interface SearchCardModel {
* a still-running call (no result view) is null, as is a settled call whose
* result view is not a search card — including a `card` value this UI version
* does not know, which arrives over the wire and cannot be trusted to be one of
* the compiled variants, and a generic result a `grep`/`glob` failure or nested
* `run_code` dispatch produces (its text keeps the generic path).
* the compiled variants, a `card: 'search'` view whose `kind` is neither
* `matches` nor `paths` (equally untrusted wire data), and a generic result a
* `grep`/`glob` failure or nested `run_code` dispatch produces (its text keeps
* the generic path).
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @returns the search-card props, or null for the generic path.
*/
@@ -76,10 +78,15 @@ export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
const result = block.resultView?.card === 'search' ? block.resultView : null
if (result === null) return null
const common = { truncated: result.truncated, total: result.total }
return {
title: result.title,
card: result.kind === 'matches'
? { kind: 'matches', files: result.files, ...common }
: { kind: 'paths', paths: result.paths, ...common },
if (result.kind === 'matches') {
return { title: result.title, card: { kind: 'matches', files: result.files, ...common } }
}
// `kind` rides the same untrusted wire frame as `card`, so a version mismatch
// or a loose protocol producer could deliver a `card: 'search'` subtype this
// client does not compile. Guard the paths shape explicitly: an unknown kind
// falls to the generic path rather than being rendered as a paths card, which
// would leave SearchBlock calling `.length`/`.map` on an absent `paths`.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- kind is wire data; the compiled union cannot prove this exhaustive.
if (result.kind !== 'paths') return null
return { title: result.title, card: { kind: 'paths', paths: result.paths, ...common } }
}

View File

@@ -101,8 +101,9 @@
font: var(--dsw-font-xs-13);
}
/* The terminal card sits directly under its section label, so it drops the
primitive's standalone vertical margin; the section owns the spacing. */
/* A render-intent card (terminal or search) sits directly under its section
label, so it drops the primitive's standalone vertical margin; the section
owns the spacing. */
.terminal {
margin: 0;
}

View File

@@ -17,7 +17,7 @@ import { IconSearchOutline16, SearchBlock, StateDot } from '@deepseek-ai/dsh-cli
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../contract/search-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './search-sample.module.css'
import css from './search-row.module.css'
/** Leading-slot glyph substitution: the search icon yields to the terminal
* state semantic (error = red, interrupted = amber). Running keeps the icon

View File

@@ -18,7 +18,7 @@ import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/contract/s
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { SearchRow, searchToolview } from '../src/client/toolviews/search-sample.tsx'
import { SearchRow, searchToolview } from '../src/client/toolviews/search-row.tsx'
afterEach(cleanup)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 0ef3c20f848b3d331c007911d0837f11cd72c024
README.zh.md: af94551bfb9e12dbadcef6a96a54f9bf7ea71299
README.md: c237e63074cc2e62e41d59e93fab3c020151c720
README.zh.md: 432b5599bda06b0a9799870ae689a3a6834a3f88

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), and TerminalBlock. Contract: api-contracts v3 §8.
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, and SearchBlock. Contract: api-contracts v3 §8.
## Markdown rendering
@@ -12,6 +12,10 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
## Search results
`SearchBlock` renders a completed search, one component for both kinds (discriminated by `kind`). A `matches` (grep) shows each file as a bold path header with its `lineNumber: line` rows, the per-file group collapsible; a `paths` (glob) shows a flat path list. Both flatten to one row list the height cap slices head/tail over (default 16, the TerminalBlock split arithmetic), and neither soft-wraps — a long match line or path scrolls horizontally instead of folding. The banner summary folds the pre-cap total in when the tool capped the result (`显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob), so the card never presents a capped result as complete; a copy control writes the whole structured result regardless of the cap or which groups are collapsed. Geometry mirrors CodeBlock/TerminalBlock. Rationale: [the web search card note](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md).
## Model Experience
None, as the package renders pure React atoms in the browser; nothing here reaches a model request.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族MessageText/MarkdownText/JsonBlock、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook把底部锚定的浮层高度收敛到锚点上方的视口空间并在 resize、scroll 与调用方提供的依赖变化时重新测量),以及 TerminalBlock。契约api-contracts v3 §8。
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族MessageText/MarkdownText/JsonBlock、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook把底部锚定的浮层高度收敛到锚点上方的视口空间并在 resize、scroll 与调用方提供的依赖变化时重新测量)TerminalBlock,以及 SearchBlock。契约api-contracts v3 §8。
## Markdown 渲染
@@ -11,6 +11,10 @@
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot``aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`因此重复空格、制表符与缩进续行都原样呈现同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循光标按终端列推进8 列制表位emoji 与 CJK 占两列组合标记不占列SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
## 搜索结果
`SearchBlock` 渲染一次已完成的搜索,一个组件绘制两种 kind(由 `kind` 判别)。`matches`(grep)把每个文件渲染为粗体路径头加其 `lineNumber: line` 行,每个文件组可折叠;`paths`(glob)渲染扁平路径列表。两者都摊平成一个行列表,由高度上限做头/尾切片(默认 16,与 TerminalBlock 相同的切分算法),且都不软换行——长匹配行或路径横向滚动而非折行。当工具截断结果时,banner 摘要把截断前总数折入(grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径`),使卡片绝不把截断结果呈现为完整;复制控件写入完整结构化结果,无论是否触及上限或哪些组被折叠。几何镜像 CodeBlock/TerminalBlock。原理:[Web 搜索卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)。
## 模型体验
无。该包package在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。

View File

@@ -15,8 +15,8 @@
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. */
/* The banner: result summary on the left, the copy control holding its
intrinsic width on the right. */
.header {
display: flex;
align-items: center;

View File

@@ -1,13 +1,14 @@
// 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
// banner (result summary that folds the pre-cap total in when the tool capped
// the result, plus 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 { useCallback, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { writeClipboard } from './clipboard.ts'
import css from './SearchBlock.module.css'
@@ -39,8 +40,9 @@ export interface SearchFileGroup {
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.
* retained results, not every result the search found. The banner summary
* folds the pre-cap `total` in (`显示 X / 共 N …`) 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`). */
@@ -77,7 +79,7 @@ export type SearchBlockProps = SearchMatchesBlockProps | SearchPathsBlockProps
*/
type SearchRow =
| { type: 'file'; path: string; count: number; index: number; collapsed: boolean }
| { type: 'match'; lineNumber: number; line: string; key: string }
| { type: 'match'; lineNumber: number; line: string; key: string; fileIndex: number }
| { type: 'path'; path: string }
/**
@@ -97,7 +99,7 @@ function copyText(props: SearchBlockProps): string {
/**
* 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`.
* the banner summary reports against `total` when the result was capped.
* @param props - the card's props.
* @returns the retained result count.
*/
@@ -141,7 +143,7 @@ function toRows(props: SearchBlockProps, collapsed: ReadonlySet<number>): Search
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}` })
rows.push({ type: 'match', lineNumber: match.lineNumber, line: match.line, key: `${index}:${match.lineNumber}`, fileIndex: index })
}
})
return rows
@@ -173,7 +175,9 @@ export function SearchBlock(props: SearchBlockProps) {
const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
const [copied, setCopied] = useState(false)
const rows = useMemo(() => toRows(props, collapsed), [props, collapsed])
// `props` is a fresh object each render, so memoizing on it never hits; the
// flatten is cheap, so it runs inline keyed on the collapse set instead.
const rows = toRows(props, collapsed)
const shown = shownCount(props)
const empty = rows.length === 0
const text = copyText(props)
@@ -204,6 +208,18 @@ export function SearchBlock(props: SearchBlockProps) {
// 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 head = capped ? rows.slice(0, headLines) : rows
const tail = capped ? rows.slice(rows.length - tailLines) : []
// When the tail slice begins inside a file's matches, its own header sits
// above the cut and is not shown, so those rows could not be attributed to a
// file. Restore the owning header at the top of the tail — unless the head
// slice already carries it (a single large file), where it would duplicate.
const tailLead = tail[0]
const tailHeader = tailLead?.type === 'match'
&& !head.some(row => row.type === 'file' && row.index === tailLead.fileIndex)
? rows.find((row): row is Extract<SearchRow, { type: 'file' }> =>
row.type === 'file' && row.index === tailLead.fileIndex)
: undefined
const renderRow = (row: SearchRow): ReactNode => {
if (row.type === 'path') return <div className={css.line}>{row.path}</div>
@@ -242,7 +258,7 @@ export function SearchBlock(props: SearchBlockProps) {
? <div className={css.empty}></div>
: (
<div className={css.body}>
{(capped ? rows.slice(0, headLines) : rows).map(row => (
{head.map(row => (
<div key={rowKey(row)}>{renderRow(row)}</div>
))}
{hidden > 0 && (
@@ -256,7 +272,10 @@ export function SearchBlock(props: SearchBlockProps) {
{expanded ? '收起' : `… 其余 ${hidden}`}
</button>
)}
{capped && rows.slice(rows.length - tailLines).map(row => (
{tailHeader !== undefined && (
<div key={`tailHeader:${rowKey(tailHeader)}`}>{renderRow(tailHeader)}</div>
)}
{tail.map(row => (
<div key={rowKey(row)}>{renderRow(row)}</div>
))}
</div>

View File

@@ -1,7 +1,8 @@
// @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
// folded truncation summary, the empty arm, per-file collapse/expand, the
// head/tail height cap and its expand control, the tail slice restoring its
// owning file header, 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'
@@ -41,9 +42,9 @@ describe('SearchBlock matches kind', () => {
]} />)
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.
// The summary counts matches and files, with no folded pre-cap total below the cap.
expect(view.getByText('3 处匹配 · 2 个文件')).toBeTruthy()
expect(view.queryByText(//u)).toBeNull()
expect(view.queryByText(/|/u)).toBeNull()
})
it('collapses and re-expands a single file group without touching the others', () => {
@@ -64,7 +65,6 @@ describe('SearchBlock matches kind', () => {
it('folds the pre-cap total into the summary when truncated', () => {
const view = render(<SearchBlock kind="matches" truncated total={99} files={[group('a.ts', 2)]} />)
expect(view.getByText('显示 2 / 共 99 处匹配 · 1 个文件')).toBeTruthy()
expect(view.queryByText(//u)).toBeNull()
})
})
@@ -80,7 +80,6 @@ describe('SearchBlock paths kind', () => {
it('folds the pre-cap total into the paths summary when truncated', () => {
const view = render(<SearchBlock kind="paths" truncated total={50} paths={['a', 'b']} />)
expect(view.getByText('显示 2 / 共 50 个路径')).toBeTruthy()
expect(view.queryByText(//u)).toBeNull()
})
})
@@ -139,6 +138,20 @@ describe('SearchBlock height cap', () => {
expect(view.getByRole('button', { name: '展开其余 4 行结果' })).toBeTruthy()
})
it('restores the owning file header above a tail slice that begins mid-file', () => {
// Two files of 10 matches each → 22 rows. Cap 8: head 4 (a.ts header + 3
// matches), tail 4 (last 4 of b.ts, whose header sits above the cut).
const view = render(<SearchBlock kind="matches" truncated={false} total={20} maxLines={8} files={[
group('a.ts', 10), group('b.ts', 10, 11),
]} />)
// The tail's own header is restored so its rows can be attributed to b.ts.
expect(fileHeaders(view.container)).toEqual(['a.ts10', 'b.ts10'])
expect(lines(view.container)).toEqual([
'1: hit 1', '2: hit 2', '3: hit 3',
'17: hit 17', '18: hit 18', '19: hit 19', '20: hit 20',
])
})
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} />)