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:
@@ -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: [
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 —
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量),以及 TerminalBlock。契约:api-contracts v3 §8。
|
||||
纯 React 原子组件(零 cordis):StateDot、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 原子组件;这里没有任何内容进入模型请求。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
Reference in New Issue
Block a user