Merge commit '90bc53cc64dc684dc3d741f45ae6a8d548c188f9' into worktree/retarget-pr885-20260729
This commit is contained in:
@@ -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: 1236054d5a05464c43ad1bb0dcbe52b09281e68a
|
||||
README.zh.md: 567881e8ca7d5e8017f82884cd638f08b13fc7e7
|
||||
README.md: 3ff2717af7eeb6ef7f85c24456c7fe23b09d0faa
|
||||
README.zh.md: 56c4e9f1dae3eee1dc7ea64616e2ed4d536928e2
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock), 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, and TerminalBlock. Contract: api-contracts v3 §8.
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
|
||||
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
|
||||
|
||||
## Terminal output
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock),以及 TerminalBlock。契约:api-contracts v3 §8。
|
||||
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器,以及 TerminalBlock。契约:api-contracts v3 §8。
|
||||
|
||||
## Markdown 渲染
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任的 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
## 终端输出
|
||||
|
||||
`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)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-primitives",
|
||||
"description": "Pure React atoms for the dsh web UI: StateDot, ic_ds_* icon set, Button/Pill/Menu/Modal/Input, markdown family (zero cordis)",
|
||||
"description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -23,6 +23,9 @@
|
||||
"@shikijs/langs": "^4.3.1",
|
||||
"anser": "^2.3.5",
|
||||
"clsx": "^2.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
222
packages/client/ui-primitives/src/JsonTree.module.css
Normal file
222
packages/client/ui-primitives/src/JsonTree.module.css
Normal file
@@ -0,0 +1,222 @@
|
||||
.root {
|
||||
--json-tree-property: #881391;
|
||||
--json-tree-string: #c41a16;
|
||||
--json-tree-number: #1c00cf;
|
||||
--json-tree-keyword: #1c00cf;
|
||||
--json-tree-punctuation: #202124;
|
||||
--json-tree-icon: #5f6368;
|
||||
--json-tree-hover: rgb(60 64 67 / 4%);
|
||||
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
font: 12px/16px var(--ds-font-family-code);
|
||||
overscroll-behavior-x: contain;
|
||||
overscroll-behavior-y: auto;
|
||||
}
|
||||
|
||||
:global(body[data-ds-dark-theme]) .root {
|
||||
--json-tree-property: #5db0d7;
|
||||
--json-tree-string: #f28b82;
|
||||
--json-tree-number: #99c8ff;
|
||||
--json-tree-keyword: #99c8ff;
|
||||
--json-tree-punctuation: #e8eaed;
|
||||
--json-tree-icon: #9aa0a6;
|
||||
--json-tree-hover: rgb(232 234 237 / 5%);
|
||||
}
|
||||
|
||||
.container {
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
margin: 0;
|
||||
padding: 6px 8px 8px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.expandedTopLevel {
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
padding: 6px 8px 8px 14px;
|
||||
}
|
||||
|
||||
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row]:hover),
|
||||
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row][data-json-copy-active]) {
|
||||
background: var(--json-tree-hover);
|
||||
}
|
||||
|
||||
.expandedTopLevelContainer {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row.topLevelBracket {
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.children {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
min-width: 100%;
|
||||
min-height: 16px;
|
||||
margin: 0;
|
||||
padding: 0 0 0 10px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row:not(.topLevelBracket):hover:not(:has(.row:hover))::after,
|
||||
.row:not(.topLevelBracket)[data-json-copy-active]::after,
|
||||
.row:has(> .expander:focus-visible)::after {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 16px;
|
||||
background: var(--json-tree-hover);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.row > span:not(.expander) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-right: 3px;
|
||||
color: var(--json-tree-property);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.clickableLabel {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stringValue {
|
||||
color: var(--json-tree-string);
|
||||
}
|
||||
|
||||
.numberValue {
|
||||
color: var(--json-tree-number);
|
||||
}
|
||||
|
||||
.keywordValue {
|
||||
color: var(--json-tree-keyword);
|
||||
}
|
||||
|
||||
.otherValue {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.punctuation {
|
||||
color: var(--json-tree-punctuation);
|
||||
}
|
||||
|
||||
.preview {
|
||||
color: var(--json-tree-punctuation);
|
||||
}
|
||||
|
||||
.previewProperty {
|
||||
color: var(--json-tree-punctuation);
|
||||
}
|
||||
|
||||
.previewEllipsis {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.copyAnchor {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copyButton:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.copyButton:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.copyButton[data-state='failed'] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.expander {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
box-sizing: border-box;
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
color: var(--json-tree-icon);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.expander::before {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 6px solid currentColor;
|
||||
content: '';
|
||||
transform: scale(0.75);
|
||||
transform-origin: 33.333% center;
|
||||
}
|
||||
|
||||
.collapseIcon::before {
|
||||
transform: rotate(90deg) scale(0.75);
|
||||
}
|
||||
|
||||
.expander:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.expander:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.collapsedContent {
|
||||
margin: 0 1px;
|
||||
color: var(--json-tree-punctuation);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collapsedContent::after {
|
||||
content: '…';
|
||||
}
|
||||
602
packages/client/ui-primitives/src/JsonTree.tsx
Normal file
602
packages/client/ui-primitives/src/JsonTree.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import type {
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
UIEvent as ReactUIEvent,
|
||||
} from 'react'
|
||||
import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx'
|
||||
import { Menu } from './Menu.tsx'
|
||||
import type { MenuEntry } from './Menu.tsx'
|
||||
import css from './JsonTree.module.css'
|
||||
|
||||
const OBJECT_PREVIEW_LIMIT = 4
|
||||
const ARRAY_PREVIEW_LIMIT = 5
|
||||
const PREVIEW_DEPTH_LIMIT = 2
|
||||
const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [
|
||||
{ id: 'value', label: 'Copy value' },
|
||||
{ id: 'json', label: 'Copy JSON' },
|
||||
{ id: 'path', label: 'Copy property path' },
|
||||
]
|
||||
const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [
|
||||
{ id: 'prettyJson', label: 'Copy pretty JSON' },
|
||||
{ id: 'json', label: 'Copy compact JSON' },
|
||||
{ id: 'path', label: 'Copy property path' },
|
||||
]
|
||||
|
||||
type JsonPath = readonly (number | string)[]
|
||||
|
||||
interface RowTarget {
|
||||
path: JsonPath
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface CopyTarget extends RowTarget {
|
||||
left: number
|
||||
side: 'bottom' | 'top'
|
||||
top: number
|
||||
}
|
||||
|
||||
function isExpandableValue(value: unknown): value is object | unknown[] {
|
||||
return typeof value === 'object' && value !== null && !(value instanceof Date)
|
||||
}
|
||||
|
||||
function entriesOf(value: object | unknown[]): readonly (readonly [string, unknown])[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item, index) => [String(index), item] as const)
|
||||
}
|
||||
return Object.keys(value).map(key => [
|
||||
key,
|
||||
(value as Record<string, unknown>)[key],
|
||||
] as const)
|
||||
}
|
||||
|
||||
function bracketOf(value: object | unknown[]): readonly [string, string] {
|
||||
return Array.isArray(value) ? ['[', ']'] : ['{', '}']
|
||||
}
|
||||
|
||||
function previewPrimitive(value: unknown): ReactNode {
|
||||
if (value === null) return <span className={css.keywordValue}>null</span>
|
||||
if (typeof value === 'string') {
|
||||
return <span className={css.stringValue}>{JSON.stringify(value)}</span>
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return <span className={css.numberValue}>{String(value)}</span>
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return <span className={css.keywordValue}>{String(value)}</span>
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return <span className={css.otherValue}>{value.toString()}</span>
|
||||
}
|
||||
if (typeof value === 'undefined') {
|
||||
return <span className={css.otherValue}>undefined</span>
|
||||
}
|
||||
if (typeof value === 'symbol') {
|
||||
return <span className={css.otherValue}>{value.description ?? 'Symbol'}</span>
|
||||
}
|
||||
if (typeof value === 'function') {
|
||||
return <span className={css.otherValue}>{value.name || 'Function'}</span>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function previewValue(value: unknown, depth: number): ReactNode {
|
||||
if (!isExpandableValue(value)) return previewPrimitive(value)
|
||||
|
||||
const array = Array.isArray(value)
|
||||
const entries = entriesOf(value)
|
||||
const limit = array ? ARRAY_PREVIEW_LIMIT : OBJECT_PREVIEW_LIMIT
|
||||
const visible = entries.slice(0, limit)
|
||||
const [open, close] = bracketOf(value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={css.punctuation}>{open}</span>
|
||||
{depth >= PREVIEW_DEPTH_LIMIT
|
||||
? <span className={css.previewEllipsis}>…</span>
|
||||
: visible.map(([key, item], index) => (
|
||||
<span key={key}>
|
||||
{index > 0 && <span className={css.punctuation}>, </span>}
|
||||
{!array && (
|
||||
<>
|
||||
<span className={css.previewProperty}>{key}</span>
|
||||
<span className={css.punctuation}>: </span>
|
||||
</>
|
||||
)}
|
||||
{previewValue(item, depth + 1)}
|
||||
</span>
|
||||
))}
|
||||
{depth < PREVIEW_DEPTH_LIMIT && entries.length > limit && (
|
||||
<span className={css.previewEllipsis}>, …</span>
|
||||
)}
|
||||
<span className={css.punctuation}>{close}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function primitiveValue(value: unknown): ReactNode {
|
||||
if (value === null) return <span className={css.keywordValue}>null</span>
|
||||
if (typeof value === 'string') {
|
||||
return <span className={css.stringValue}>{JSON.stringify(value)}</span>
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return <span className={css.keywordValue}>{String(value)}</span>
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return <span className={css.numberValue}>{String(value)}</span>
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return <span className={css.numberValue}>{`${value.toString()}n`}</span>
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return <span className={css.otherValue}>{value.toISOString()}</span>
|
||||
}
|
||||
if (typeof value === 'function') {
|
||||
return <span className={css.otherValue}>function() {'{ }'}</span>
|
||||
}
|
||||
if (typeof value === 'undefined') {
|
||||
return <span className={css.otherValue}>undefined</span>
|
||||
}
|
||||
return <span className={css.otherValue}>{(value as symbol).toString()}</span>
|
||||
}
|
||||
|
||||
function fieldText(field: string): string {
|
||||
return field === '' ? '""' : field
|
||||
}
|
||||
|
||||
function pathId(path: JsonPath): string {
|
||||
return path.map(part => (
|
||||
typeof part === 'number' ? `n${String(part)}` : `s${String(part.length)}:${part}`
|
||||
)).join('/')
|
||||
}
|
||||
|
||||
function claimFocus(button: HTMLElement): void {
|
||||
button.focus()
|
||||
}
|
||||
|
||||
function moveFocus(button: HTMLElement, direction: -1 | 1): void {
|
||||
const tree = button.closest<HTMLElement>('[role="tree"]')
|
||||
/* v8 ignore next -- JsonTree attaches expander handlers only beneath its owning role=tree. */
|
||||
if (tree === null) return
|
||||
const expanders = Array.from(tree.querySelectorAll<HTMLElement>('[data-json-expander]'))
|
||||
const current = expanders.indexOf(button)
|
||||
/* v8 ignore next -- the current expander is a member of the queried non-empty set. */
|
||||
if (current < 0 || expanders.length === 0) return
|
||||
const next = (current + direction + expanders.length) % expanders.length
|
||||
const nextExpander = expanders[next]
|
||||
/* v8 ignore next -- modulo over the non-empty expander set always resolves a member. */
|
||||
if (nextExpander !== undefined) claimFocus(nextExpander)
|
||||
}
|
||||
|
||||
function NodeField({
|
||||
field,
|
||||
expandable,
|
||||
onToggle,
|
||||
}: {
|
||||
field: string | undefined
|
||||
expandable: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
if (field === undefined) return null
|
||||
return (
|
||||
<span
|
||||
className={clsx(css.label, expandable && css.clickableLabel)}
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
>
|
||||
{fieldText(field)}:
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface JsonTreeNodeProps {
|
||||
field?: string
|
||||
initialExpanded: boolean
|
||||
lastElement: boolean
|
||||
onClaimTabStop: (id: string) => void
|
||||
onRowHover: (row: HTMLElement, target: RowTarget) => void
|
||||
path: JsonPath
|
||||
tabStopId: string | null
|
||||
value: unknown
|
||||
}
|
||||
|
||||
function JsonTreeNode({
|
||||
field,
|
||||
initialExpanded,
|
||||
lastElement,
|
||||
onClaimTabStop,
|
||||
onRowHover,
|
||||
path,
|
||||
tabStopId,
|
||||
value,
|
||||
}: JsonTreeNodeProps) {
|
||||
const contentsId = useId()
|
||||
const expanderRef = useRef<HTMLSpanElement>(null)
|
||||
const [expanded, setExpanded] = useState(initialExpanded)
|
||||
const nodeId = pathId(path)
|
||||
const container = isExpandableValue(value)
|
||||
const entries = container ? entriesOf(value) : []
|
||||
const expandable = entries.length > 0
|
||||
|
||||
const toggle = () => {
|
||||
setExpanded(current => !current)
|
||||
claimFocus(expanderRef.current as HTMLSpanElement)
|
||||
}
|
||||
|
||||
const onExpanderKeyDown = (event: ReactKeyboardEvent<HTMLSpanElement>) => {
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
setExpanded(event.key === 'ArrowRight')
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
moveFocus(event.currentTarget, event.key === 'ArrowUp' ? -1 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
const row = (children: ReactNode, ariaExpanded?: boolean) => (
|
||||
<div
|
||||
className={css.row}
|
||||
role="treeitem"
|
||||
aria-expanded={ariaExpanded}
|
||||
onMouseOver={(event) => {
|
||||
event.stopPropagation()
|
||||
onRowHover(event.currentTarget, { path, value })
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!container) {
|
||||
return row((
|
||||
<>
|
||||
<NodeField field={field} expandable={false} onToggle={toggle} />
|
||||
{primitiveValue(value)}
|
||||
{!lastElement && <span className={css.punctuation}>,</span>}
|
||||
</>
|
||||
))
|
||||
}
|
||||
|
||||
const [open, close] = bracketOf(value)
|
||||
if (!expandable) {
|
||||
return row((
|
||||
<>
|
||||
<NodeField field={field} expandable={false} onToggle={toggle} />
|
||||
<span className={css.punctuation}>{open}</span>
|
||||
<span className={css.punctuation}>{close}</span>
|
||||
{!lastElement && <span className={css.punctuation}>,</span>}
|
||||
</>
|
||||
))
|
||||
}
|
||||
|
||||
return row((
|
||||
<>
|
||||
<span
|
||||
ref={expanderRef}
|
||||
className={clsx(css.expander, expanded ? css.collapseIcon : css.expandIcon)}
|
||||
data-json-expander
|
||||
role="button"
|
||||
aria-label={expanded ? 'Collapse JSON node' : 'Expand JSON node'}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={expanded ? contentsId : undefined}
|
||||
tabIndex={tabStopId === nodeId ? 0 : -1}
|
||||
onFocus={() => { onClaimTabStop(nodeId) }}
|
||||
onClick={toggle}
|
||||
onKeyDown={onExpanderKeyDown}
|
||||
/>
|
||||
<NodeField field={field} expandable onToggle={toggle} />
|
||||
<span className={css.preview}>{previewValue(value, 0)}</span>
|
||||
{!lastElement && <span className={css.punctuation}>,</span>}
|
||||
{expanded && (
|
||||
<ul id={contentsId} role="group" className={css.children}>
|
||||
{entries.map(([key, item], index) => (
|
||||
<JsonTreeNode
|
||||
key={key}
|
||||
field={key}
|
||||
value={item}
|
||||
path={[...path, Array.isArray(value) ? index : key]}
|
||||
lastElement={index === entries.length - 1}
|
||||
initialExpanded={false}
|
||||
tabStopId={tabStopId}
|
||||
onClaimTabStop={onClaimTabStop}
|
||||
onRowHover={onRowHover}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
), expanded)
|
||||
}
|
||||
|
||||
function formattedPath(path: JsonPath): string {
|
||||
return path.reduce<string>((result, part) => {
|
||||
if (typeof part === 'number') return `${result}[${String(part)}]`
|
||||
return /^[A-Za-z_$][\w$]*$/.test(part)
|
||||
? `${result}.${part}`
|
||||
: `${result}[${JSON.stringify(part)}]`
|
||||
}, '$')
|
||||
}
|
||||
|
||||
function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
|
||||
if (mode === 'path') return formattedPath(target.path)
|
||||
if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
|
||||
if (mode === 'json') return JSON.stringify(target.value)
|
||||
if (typeof target.value === 'string') return target.value
|
||||
if (typeof target.value === 'undefined') return 'undefined'
|
||||
if (typeof target.value === 'bigint') return target.value.toString()
|
||||
if (typeof target.value === 'symbol') return target.value.description ?? 'Symbol'
|
||||
if (typeof target.value === 'function') return target.value.name || 'Function'
|
||||
return JSON.stringify(target.value)
|
||||
}
|
||||
|
||||
/** Props for the read-only, token-themed JSON tree. */
|
||||
export interface JsonTreeProps {
|
||||
/** Parsed JSON object or array. */
|
||||
data: object | unknown[]
|
||||
/** Accessible label for the tree. */
|
||||
label?: string
|
||||
/** Optional positioning class owned by the caller. */
|
||||
className?: string | undefined
|
||||
/** Whether JSON rows expose copy actions. */
|
||||
copyable?: boolean
|
||||
/** Whether the top-level object or array is always expanded. */
|
||||
expandTopLevel?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Render parsed JSON as a compact, keyboard-accessible inspector tree.
|
||||
* @param props - Parsed data, accessible label, and display options.
|
||||
* @returns A read-only JSON tree with an optionally fixed-open top level.
|
||||
*/
|
||||
export function JsonTree({
|
||||
data,
|
||||
label = 'JSON',
|
||||
className,
|
||||
copyable = true,
|
||||
expandTopLevel = true,
|
||||
}: JsonTreeProps) {
|
||||
const rootEntries = entriesOf(data)
|
||||
const firstExpandableIndex = rootEntries.findIndex(([, value]) => (
|
||||
isExpandableValue(value) && entriesOf(value).length > 0
|
||||
))
|
||||
const firstExpandableEntry = rootEntries[firstExpandableIndex]
|
||||
const initialTabStopId = expandTopLevel
|
||||
? firstExpandableEntry === undefined
|
||||
? null
|
||||
: pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]])
|
||||
: isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const activeRowRef = useRef<HTMLElement>()
|
||||
const copyButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const copyMenuOpenRef = useRef(false)
|
||||
const resetTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||
const [copyTarget, setCopyTarget] = useState<CopyTarget>()
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
|
||||
const [copyMenuOpen, setCopyMenuOpen] = useState(false)
|
||||
const [tabStopId, setTabStopId] = useState<string | null>(initialTabStopId)
|
||||
|
||||
const setActiveRow = (row: HTMLElement | undefined) => {
|
||||
activeRowRef.current?.removeAttribute('data-json-copy-active')
|
||||
activeRowRef.current = row
|
||||
row?.setAttribute('data-json-copy-active', '')
|
||||
}
|
||||
|
||||
const clearCopyTarget = () => {
|
||||
setActiveRow(undefined)
|
||||
setCopyTarget(undefined)
|
||||
setCopyState('idle')
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyMenuOpen(false)
|
||||
}
|
||||
|
||||
const copyPosition = (row: HTMLElement): Pick<CopyTarget, 'left' | 'side' | 'top'> => {
|
||||
const root = rootRef.current
|
||||
/* v8 ignore next -- row events and viewport listeners run only after the root ref mounts. */
|
||||
if (root === null) throw new Error('JsonTree root is not mounted')
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const rowRect = row.getBoundingClientRect()
|
||||
return {
|
||||
left: rootRect.left + root.clientWidth - 26,
|
||||
side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom',
|
||||
top: rowRect.top,
|
||||
}
|
||||
}
|
||||
|
||||
const positionCopyButton = (row: HTMLElement, target: RowTarget) => {
|
||||
const position = copyPosition(row)
|
||||
setCopyTarget({ ...target, ...position })
|
||||
}
|
||||
|
||||
const repositionCopyButton = (row: HTMLElement) => {
|
||||
const position = copyPosition(row)
|
||||
setCopyTarget((current) => {
|
||||
/* v8 ignore next -- an active row and its copy target are installed together. */
|
||||
if (current === undefined) return current
|
||||
return { ...current, ...position }
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
|
||||
activeRowRef.current?.removeAttribute('data-json-copy-active')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
activeRowRef.current?.removeAttribute('data-json-copy-active')
|
||||
activeRowRef.current = undefined
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyTarget(undefined)
|
||||
setCopyState('idle')
|
||||
setCopyMenuOpen(false)
|
||||
setTabStopId(initialTabStopId)
|
||||
}, [data, expandTopLevel, initialTabStopId])
|
||||
|
||||
useEffect(() => {
|
||||
const reposition = () => {
|
||||
const row = activeRowRef.current
|
||||
if (row !== undefined) repositionCopyButton(row)
|
||||
}
|
||||
window.addEventListener('scroll', reposition, true)
|
||||
window.addEventListener('resize', reposition)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', reposition, true)
|
||||
window.removeEventListener('resize', reposition)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRowHover = (row: HTMLElement, target: RowTarget) => {
|
||||
if (!copyable || copyMenuOpenRef.current) return
|
||||
if (activeRowRef.current === row) return
|
||||
setActiveRow(row)
|
||||
setCopyState('idle')
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyMenuOpen(false)
|
||||
positionCopyButton(row, target)
|
||||
}
|
||||
|
||||
const handleRootMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!copyable || copyMenuOpenRef.current) return
|
||||
/* v8 ignore next -- browser mouse events delivered through React target an Element. */
|
||||
if (!(event.target instanceof Element)) return
|
||||
if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget()
|
||||
}
|
||||
|
||||
const handleScroll = (_event: ReactUIEvent<HTMLDivElement>) => {
|
||||
const row = activeRowRef.current
|
||||
if (row !== undefined) repositionCopyButton(row)
|
||||
}
|
||||
|
||||
const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => {
|
||||
/* v8 ignore next -- copy controls only render while their target exists. */
|
||||
if (copyTarget === undefined) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyText(copyTarget, mode))
|
||||
setCopyState('copied')
|
||||
} catch {
|
||||
setCopyState('failed')
|
||||
}
|
||||
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
|
||||
resetTimer.current = setTimeout(() => { setCopyState('idle') }, 1_500)
|
||||
}
|
||||
|
||||
const [rootOpen, rootClose] = bracketOf(data)
|
||||
const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
|
||||
const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
|
||||
const copyTitle = copyState === 'copied'
|
||||
? 'Copied'
|
||||
: copyState === 'failed'
|
||||
? 'Copy failed'
|
||||
: copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={clsx(css.root, className)}
|
||||
onMouseOver={handleRootMouseOver}
|
||||
onMouseLeave={() => {
|
||||
if (!copyMenuOpenRef.current) clearCopyTarget()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{expandTopLevel
|
||||
? (
|
||||
<div className={css.expandedTopLevel}>
|
||||
<div
|
||||
className={clsx(css.row, css.topLevelBracket)}
|
||||
data-json-root-row
|
||||
onMouseOver={(event) => {
|
||||
event.stopPropagation()
|
||||
handleRowHover(event.currentTarget, { path: [], value: data })
|
||||
}}
|
||||
>
|
||||
<span className={css.punctuation}>{rootOpen}</span>
|
||||
</div>
|
||||
<div
|
||||
aria-label={label}
|
||||
className={clsx(css.container, css.expandedTopLevelContainer)}
|
||||
role="tree"
|
||||
>
|
||||
{rootEntries.map(([key, value], index) => (
|
||||
<JsonTreeNode
|
||||
key={key}
|
||||
field={key}
|
||||
value={value}
|
||||
path={[Array.isArray(data) ? index : key]}
|
||||
lastElement={index === rootEntries.length - 1}
|
||||
initialExpanded={false}
|
||||
tabStopId={tabStopId}
|
||||
onClaimTabStop={setTabStopId}
|
||||
onRowHover={handleRowHover}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={clsx(css.row, css.topLevelBracket)}>
|
||||
<span className={css.punctuation}>{rootClose}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div aria-label={label} className={css.container} role="tree">
|
||||
<JsonTreeNode
|
||||
value={data}
|
||||
path={[]}
|
||||
lastElement
|
||||
initialExpanded
|
||||
tabStopId={tabStopId}
|
||||
onClaimTabStop={setTabStopId}
|
||||
onRowHover={handleRowHover}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{copyTarget !== undefined && (
|
||||
<span
|
||||
className={css.copyAnchor}
|
||||
style={{ left: copyTarget.left, top: copyTarget.top }}
|
||||
>
|
||||
<Menu
|
||||
open={copyMenuOpen}
|
||||
compact
|
||||
portal
|
||||
align="end"
|
||||
side={copyTarget.side}
|
||||
anchor={(
|
||||
<button
|
||||
ref={copyButtonRef}
|
||||
type="button"
|
||||
className={css.copyButton}
|
||||
data-json-copy-button
|
||||
data-state={copyState}
|
||||
aria-label={copyTitle}
|
||||
title={`${copyTitle}; right-click for copy options`}
|
||||
onClick={() => void copy(defaultCopyMode)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
copyMenuOpenRef.current = true
|
||||
setCopyMenuOpen(true)
|
||||
}}
|
||||
>
|
||||
{copyState === 'copied'
|
||||
? <IconCheckOutline16 size={12} />
|
||||
: <IconCopyOutline16 size={12} />}
|
||||
</button>
|
||||
)}
|
||||
items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS}
|
||||
onSelect={(id) => {
|
||||
void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyMenuOpen(false)
|
||||
}}
|
||||
onClose={clearCopyTarget}
|
||||
getAnchorRect={() => (
|
||||
copyButtonRef.current as HTMLButtonElement
|
||||
).getBoundingClientRect()}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -115,6 +115,37 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.list.compactList,
|
||||
.submenu.compactList {
|
||||
min-width: 164px;
|
||||
padding: 2px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.compactList .item {
|
||||
min-height: 26px;
|
||||
gap: 6px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.compactList .itemIcon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.compactList .separator {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.compactList .label {
|
||||
padding: 4px 7px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.item:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -71,6 +71,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
||||
* keeps the pure-CSS in-place behavior.
|
||||
* @param props.closeOnPointerLeave - close the list when the pointer leaves
|
||||
* it (default false keeps it open until outside click/Escape/selection).
|
||||
* @param props.compact - use reduced menu typography and spacing.
|
||||
* @param props.getAnchorRect - portal mode only: supply the anchor rect
|
||||
* directly (e.g. from a host-owned trigger button) instead of measuring the
|
||||
* Menu's own wrapper span. Required when the wrapper isn't itself laid out at
|
||||
@@ -81,7 +82,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
||||
* by a hairline; they stay visible while the items above scroll.
|
||||
* @returns anchor wrapper with the conditional list.
|
||||
*/
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, footer, className }: {
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, footer, className }: {
|
||||
open: boolean
|
||||
anchor: ReactNode
|
||||
items: readonly MenuEntry[]
|
||||
@@ -93,6 +94,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
side?: 'bottom' | 'top' | 'right'
|
||||
portal?: boolean
|
||||
closeOnPointerLeave?: boolean
|
||||
compact?: boolean
|
||||
getAnchorRect?: () => DOMRect | null
|
||||
className?: string
|
||||
}) {
|
||||
@@ -219,7 +221,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
||||
</button>
|
||||
{subOpen && entry.submenu !== undefined && (
|
||||
<div className={css.submenu} role="menu">
|
||||
<div className={clsx(css.submenu, compact && css.compactList)} role="menu">
|
||||
{entry.submenu.map(sub => (
|
||||
<button
|
||||
key={sub.id}
|
||||
@@ -246,7 +248,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
const list = open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
className={clsx(css.list, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
|
||||
role="menu"
|
||||
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
|
||||
|
||||
@@ -17,10 +17,14 @@ export { FishLogo } from './FishLogo.tsx'
|
||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
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 { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
export { MessageText } from './markdown/MessageText.tsx'
|
||||
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
|
||||
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
|
||||
export * from './icons/index.tsx'
|
||||
|
||||
124
packages/client/ui-primitives/src/markdown/plain-text.ts
Normal file
124
packages/client/ui-primitives/src/markdown/plain-text.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Markdown-to-plain-text projection for compact summaries and labels.
|
||||
* Parsing shares the renderer's GFM grammar; raw HTML stays literal, links
|
||||
* keep their labels, images keep alt text, and code keeps its source text.
|
||||
*/
|
||||
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
|
||||
/** Amount of parsed Markdown content returned by the extractor. */
|
||||
export type MarkdownPlainTextMode = 'all' | 'first-line' | 'first-paragraph'
|
||||
|
||||
/** Options for {@link extractMarkdownPlainText}. */
|
||||
export interface MarkdownPlainTextOptions {
|
||||
/** Projection boundary; defaults to the complete document. */
|
||||
mode?: MarkdownPlainTextMode
|
||||
}
|
||||
|
||||
interface MarkdownNode {
|
||||
type: string
|
||||
value?: string
|
||||
alt?: string
|
||||
children?: MarkdownNode[]
|
||||
}
|
||||
|
||||
function inlineText(node: MarkdownNode): string {
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
case 'inlineCode':
|
||||
case 'code':
|
||||
return node.value ?? ''
|
||||
case 'image':
|
||||
case 'imageReference':
|
||||
return node.alt ?? ''
|
||||
case 'break':
|
||||
return '\n'
|
||||
case 'html':
|
||||
return node.value ?? ''
|
||||
default:
|
||||
return node.children?.map(inlineText).join('') ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function compactInline(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function blockText(node: MarkdownNode): string {
|
||||
switch (node.type) {
|
||||
case 'root':
|
||||
case 'blockquote':
|
||||
return node.children?.map(blockText).filter(Boolean).join('\n\n') ?? ''
|
||||
case 'paragraph':
|
||||
case 'heading':
|
||||
return compactInline(inlineText(node))
|
||||
case 'code':
|
||||
return node.value?.trim() ?? ''
|
||||
case 'list':
|
||||
return node.children?.map(blockText).filter(Boolean).join('\n') ?? ''
|
||||
case 'listItem':
|
||||
return node.children?.map(blockText).filter(Boolean).join(' ') ?? ''
|
||||
case 'table':
|
||||
return node.children?.map(blockText).filter(Boolean).join('\n') ?? ''
|
||||
case 'tableRow':
|
||||
return node.children?.map(blockText).join('\t') ?? ''
|
||||
case 'tableCell':
|
||||
return compactInline(inlineText(node))
|
||||
case 'html':
|
||||
return node.value ?? ''
|
||||
case 'thematicBreak':
|
||||
case 'definition':
|
||||
return ''
|
||||
default:
|
||||
return compactInline(inlineText(node))
|
||||
}
|
||||
}
|
||||
|
||||
function findFirstParagraph(node: MarkdownNode): string | undefined {
|
||||
if (node.type === 'paragraph') {
|
||||
const text = compactInline(inlineText(node))
|
||||
if (text !== '') return text
|
||||
}
|
||||
for (const child of node.children ?? []) {
|
||||
const text = findFirstParagraph(child)
|
||||
if (text !== undefined) return text
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function fullText(root: MarkdownNode): string {
|
||||
return blockText(root)
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GFM Markdown, remove its presentation markup, and preserve raw HTML literally.
|
||||
* @param markdown - Markdown source.
|
||||
* @param options - Optional extraction boundary.
|
||||
* @returns Plain text for the whole document, first visible line, or first semantic paragraph.
|
||||
*/
|
||||
export function extractMarkdownPlainText(
|
||||
markdown: string,
|
||||
options: MarkdownPlainTextOptions = {},
|
||||
): string {
|
||||
const { mode = 'all' } = options
|
||||
const root = fromMarkdown(markdown, {
|
||||
extensions: [gfm()],
|
||||
mdastExtensions: [gfmFromMarkdown()],
|
||||
}) as MarkdownNode
|
||||
const all = fullText(root)
|
||||
switch (mode) {
|
||||
case 'all':
|
||||
return all
|
||||
case 'first-line':
|
||||
return all.split('\n').find(line => line !== '') ?? ''
|
||||
case 'first-paragraph':
|
||||
return findFirstParagraph(root) ?? all.split('\n').find(line => line !== '') ?? ''
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@ describe('Menu', () => {
|
||||
render(
|
||||
<Menu
|
||||
open
|
||||
compact
|
||||
anchor={<span>trigger</span>}
|
||||
items={[
|
||||
{ id: 'a', label: 'Alpha', icon: <svg data-testid="ic" /> },
|
||||
@@ -186,6 +187,7 @@ describe('Menu', () => {
|
||||
render(
|
||||
<Menu
|
||||
open
|
||||
compact
|
||||
anchor={<span>trigger</span>}
|
||||
items={[
|
||||
{ id: 'plain', label: 'Plain' },
|
||||
|
||||
339
packages/client/ui-primitives/tests/json-tree.spec.tsx
Normal file
339
packages/client/ui-primitives/tests/json-tree.spec.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { JsonTree } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
let writeText: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('JsonTree', () => {
|
||||
it('keeps the top level open and renders expandable value previews', () => {
|
||||
render(
|
||||
<JsonTree
|
||||
label="Payload"
|
||||
data={{
|
||||
nested: { answer: 42 },
|
||||
list: ['alpha', 'beta'],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
const tree = screen.getByRole('tree', { name: 'Payload' })
|
||||
const rows = within(tree).getAllByRole('treeitem')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]?.textContent).toBe('nested:{answer: 42},')
|
||||
expect(rows[1]?.textContent).toBe('list:["alpha", "beta"]')
|
||||
|
||||
const expanders = within(tree).getAllByRole('button', { name: 'Expand JSON node' })
|
||||
expect(expanders[0]?.tabIndex).toBe(0)
|
||||
expect(expanders[1]?.tabIndex).toBe(-1)
|
||||
|
||||
fireEvent.click(expanders[0] as HTMLElement)
|
||||
expect(within(tree).getAllByRole('treeitem')).toHaveLength(3)
|
||||
expect(screen.getByText('answer:')).toBeDefined()
|
||||
expect(within(tree).getByRole('button', { name: 'Collapse JSON node' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('moves the single tab stop between visible expanders with arrow keys', () => {
|
||||
render(
|
||||
<JsonTree
|
||||
expandTopLevel={false}
|
||||
data={{
|
||||
first: { nested: 1 },
|
||||
second: { nested: 2 },
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
const tree = screen.getByRole('tree', { name: 'JSON' })
|
||||
const root = within(tree).getByRole('button', { name: 'Collapse JSON node' })
|
||||
const children = within(tree).getAllByRole('button', { name: 'Expand JSON node' })
|
||||
|
||||
expect(root.tabIndex).toBe(0)
|
||||
fireEvent.keyDown(root, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(children[0])
|
||||
expect(root.tabIndex).toBe(-1)
|
||||
expect(children[0]?.tabIndex).toBe(0)
|
||||
|
||||
fireEvent.keyDown(children[0] as HTMLElement, { key: 'ArrowRight' })
|
||||
expect(children[0]?.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(children[0] as HTMLElement, { key: 'ArrowLeft' })
|
||||
expect(children[0]?.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(children[0] as HTMLElement, { key: 'Enter' })
|
||||
|
||||
fireEvent.keyDown(children[0] as HTMLElement, { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(root)
|
||||
fireEvent.keyDown(root, { key: 'ArrowUp' })
|
||||
expect(document.activeElement).toBe(children[1])
|
||||
})
|
||||
|
||||
it('copies an array element path without recovering data from rendered labels', async () => {
|
||||
render(<JsonTree data={{ list: [{ value: 'x' }, 'tail'] }} />)
|
||||
|
||||
const tree = screen.getByRole('tree')
|
||||
fireEvent.click(within(tree).getByRole('button', { name: 'Expand JSON node' }))
|
||||
const arrayRow = within(tree).getAllByRole('treeitem')
|
||||
.find(row => row.textContent?.startsWith('0:'))
|
||||
expect(arrayRow).toBeDefined()
|
||||
|
||||
fireEvent.mouseOver(arrayRow as HTMLElement)
|
||||
const copyButton = screen.getByRole('button', { name: 'Copy pretty JSON' })
|
||||
fireEvent.contextMenu(copyButton)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Copy property path' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalledWith('$.list[0]')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders empty containers, JSON-adjacent primitives, and bounded deep previews', () => {
|
||||
const anonymous = Object.defineProperty(() => {}, 'name', { value: '' })
|
||||
const date = new Date('2026-07-28T00:00:00.000Z')
|
||||
const data = {
|
||||
'': 'empty key',
|
||||
nil: null,
|
||||
text: 'quoted',
|
||||
flag: true,
|
||||
count: 3,
|
||||
big: 4n,
|
||||
date,
|
||||
named: function named() {},
|
||||
missing: undefined,
|
||||
symbol: Symbol('token'),
|
||||
emptyObject: {},
|
||||
emptyArray: [],
|
||||
primitivePreview: {
|
||||
nil: null,
|
||||
flag: false,
|
||||
big: 9n,
|
||||
missing: undefined,
|
||||
},
|
||||
exoticPreview: {
|
||||
symbol: Symbol(),
|
||||
named: function sample() {},
|
||||
anonymous,
|
||||
date,
|
||||
},
|
||||
wideObject: { a: 1, b: 2, c: 3, d: 4, e: 5 },
|
||||
wideArray: [1, 2, 3, 4, 5, 6],
|
||||
deep: { a: { b: { c: 1 } } },
|
||||
}
|
||||
render(<JsonTree copyable={false} data={data} />)
|
||||
|
||||
const text = screen.getByRole('tree').textContent
|
||||
expect(text).toContain('"":\"empty key\"')
|
||||
expect(text).toContain('nil:null')
|
||||
expect(text).toContain('flag:true')
|
||||
expect(text).toContain('count:3')
|
||||
expect(text).toContain('big:4n')
|
||||
expect(text).toContain('date:2026-07-28T00:00:00.000Z')
|
||||
expect(text).toContain('named:function() { }')
|
||||
expect(text).toContain('missing:undefined')
|
||||
expect(text).toContain('symbol:Symbol(token)')
|
||||
expect(text).toContain('emptyObject:{}')
|
||||
expect(text).toContain('emptyArray:[]')
|
||||
expect(text).toContain('primitivePreview:{nil: null, flag: false, big: 9, missing: undefined}')
|
||||
expect(text).toContain('exoticPreview:{symbol: Symbol, named: sample, anonymous: Function, date: }')
|
||||
expect(text).toContain('wideObject:{a: 1, b: 2, c: 3, d: 4, …}')
|
||||
expect(text).toContain('wideArray:[1, 2, 3, 4, 5, …]')
|
||||
expect(text).toContain('deep:{a: {b: {…}}}')
|
||||
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
|
||||
fireEvent.mouseOver(screen.getByRole('tree').parentElement as HTMLElement)
|
||||
})
|
||||
|
||||
it('renders child commas and lets a clickable property label toggle its node', () => {
|
||||
render(<JsonTree data={{ parent: { emptyObject: {}, emptyArray: [], scalar: 1, last: 2 } }} />)
|
||||
|
||||
fireEvent.click(screen.getByText('parent:'))
|
||||
const tree = screen.getByRole('tree')
|
||||
const rows = within(tree).getAllByRole('treeitem')
|
||||
expect(rows.find(row => row.textContent === 'emptyObject:{},')).toBeDefined()
|
||||
expect(rows.find(row => row.textContent === 'emptyArray:[],')).toBeDefined()
|
||||
expect(rows.find(row => row.textContent === 'scalar:1,')).toBeDefined()
|
||||
expect(rows.find(row => row.textContent === 'last:2')).toBeDefined()
|
||||
|
||||
fireEvent.click(screen.getByText('parent:'))
|
||||
expect(within(tree).getAllByRole('treeitem')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('assigns the initial array tab stop and supports an empty collapsible root', () => {
|
||||
const first = render(<JsonTree data={['plain', { nested: true }]} />)
|
||||
const tree = screen.getByRole('tree')
|
||||
expect(tree.textContent).toContain('0:"plain"')
|
||||
expect(within(tree).getByRole('button', { name: 'Expand JSON node' }).tabIndex).toBe(0)
|
||||
first.unmount()
|
||||
|
||||
render(<JsonTree expandTopLevel={false} data={{}} />)
|
||||
expect(screen.getByRole('tree').textContent).toBe('{}')
|
||||
expect(screen.queryByRole('button', { name: /JSON node/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('copies primitive and object values in every menu mode', async () => {
|
||||
const anonymous = Object.defineProperty(() => {}, 'name', { value: '' })
|
||||
render(
|
||||
<JsonTree
|
||||
data={{
|
||||
plain: 'hello',
|
||||
'odd-key': 3,
|
||||
object: { a: 1 },
|
||||
missing: undefined,
|
||||
big: 7n,
|
||||
symbol: Symbol(),
|
||||
symbolNamed: Symbol('token'),
|
||||
named: function named() {},
|
||||
anonymous,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
const tree = screen.getByRole('tree')
|
||||
const row = (prefix: string) => {
|
||||
const match = within(tree).getAllByRole('treeitem')
|
||||
.find(item => item.textContent?.startsWith(prefix))
|
||||
expect(match).toBeDefined()
|
||||
return match as HTMLElement
|
||||
}
|
||||
const hover = (prefix: string) => {
|
||||
fireEvent.mouseOver(row(prefix))
|
||||
return screen.getByRole('button', { name: /Cop/ })
|
||||
}
|
||||
const select = (name: string) => {
|
||||
const button = screen.getByRole('button', { name: /Cop/ })
|
||||
fireEvent.contextMenu(button)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name }))
|
||||
}
|
||||
|
||||
fireEvent.click(hover('plain:'))
|
||||
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('hello') })
|
||||
|
||||
hover('odd-key:')
|
||||
select('Copy property path')
|
||||
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('$["odd-key"]') })
|
||||
select('Copy JSON')
|
||||
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('3') })
|
||||
fireEvent.click(hover('odd-key:'))
|
||||
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('3') })
|
||||
|
||||
fireEvent.click(hover('object:'))
|
||||
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('{\n "a": 1\n}') })
|
||||
select('Copy compact JSON')
|
||||
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('{"a":1}') })
|
||||
|
||||
for (const [prefix, expected] of [
|
||||
['missing:', 'undefined'],
|
||||
['big:', '7'],
|
||||
['symbol:', 'Symbol'],
|
||||
['symbolNamed:', 'token'],
|
||||
['named:', 'named'],
|
||||
['anonymous:', 'Function'],
|
||||
] as const) {
|
||||
fireEvent.click(hover(prefix))
|
||||
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith(expected) })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports clipboard failure, resets feedback, and clears a prior timer', async () => {
|
||||
vi.useFakeTimers()
|
||||
writeText.mockRejectedValue(new Error('denied'))
|
||||
const view = render(<JsonTree data={{ value: 'x' }} />)
|
||||
const row = screen.getByRole('treeitem')
|
||||
fireEvent.mouseOver(row)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy value' }))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(screen.getByRole('button', { name: 'Copy failed' })).toBeDefined()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy failed' }))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
act(() => { vi.advanceTimersByTime(1_500) })
|
||||
expect(screen.getByRole('button', { name: 'Copy value' })).toBeDefined()
|
||||
view.unmount()
|
||||
})
|
||||
|
||||
it('keeps copy placement synchronized and clears stale targets', () => {
|
||||
const view = render(<JsonTree data={{ first: { a: 1 }, second: 2 }} />)
|
||||
const root = view.container.firstElementChild as HTMLElement
|
||||
const tree = screen.getByRole('tree')
|
||||
const firstRow = within(tree).getAllByRole('treeitem')[0] as HTMLElement
|
||||
const secondRow = within(tree).getAllByRole('treeitem')[1] as HTMLElement
|
||||
|
||||
Object.defineProperty(root, 'clientHeight', { configurable: true, value: 100 })
|
||||
Object.defineProperty(root, 'clientWidth', { configurable: true, value: 300 })
|
||||
vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 100,
|
||||
height: 100,
|
||||
left: 10,
|
||||
right: 310,
|
||||
top: 0,
|
||||
width: 300,
|
||||
x: 10,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
vi.spyOn(firstRow, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 91,
|
||||
height: 16,
|
||||
left: 10,
|
||||
right: 200,
|
||||
top: 75,
|
||||
width: 190,
|
||||
x: 10,
|
||||
y: 75,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
|
||||
fireEvent.mouseOver(firstRow)
|
||||
const copyButton = screen.getByRole('button', { name: 'Copy pretty JSON' })
|
||||
expect((copyButton.closest('span')?.parentElement as HTMLElement).style.left).toBe('284px')
|
||||
fireEvent.mouseOver(copyButton)
|
||||
expect(screen.getByRole('button', { name: 'Copy pretty JSON' })).toBeDefined()
|
||||
fireEvent.mouseOver(firstRow)
|
||||
|
||||
fireEvent.scroll(root)
|
||||
fireEvent.scroll(window)
|
||||
fireEvent.resize(window)
|
||||
|
||||
fireEvent.contextMenu(copyButton)
|
||||
fireEvent.mouseOver(secondRow)
|
||||
fireEvent.mouseOver(root)
|
||||
fireEvent.mouseLeave(root)
|
||||
expect(screen.getByRole('menu')).toBeDefined()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
|
||||
|
||||
fireEvent.mouseOver(secondRow)
|
||||
expect(screen.getByRole('button', { name: 'Copy value' })).toBeDefined()
|
||||
fireEvent.mouseOver(root)
|
||||
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
|
||||
|
||||
fireEvent.scroll(root)
|
||||
view.rerender(<JsonTree data={{ replacement: 3 }} />)
|
||||
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('copies the fixed root and clears it when the pointer leaves', async () => {
|
||||
const view = render(<JsonTree data={{ value: 1 }} />)
|
||||
const root = view.container.firstElementChild as HTMLElement
|
||||
const openingBracket = root.querySelector<HTMLElement>('[data-json-root-row]')
|
||||
expect(openingBracket).not.toBeNull()
|
||||
|
||||
fireEvent.mouseOver(openingBracket as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy pretty JSON' }))
|
||||
await waitFor(() => { expect(writeText).toHaveBeenCalledWith('{\n "value": 1\n}') })
|
||||
|
||||
fireEvent.mouseLeave(root)
|
||||
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
const MARKDOWN = [
|
||||
'# Release notes',
|
||||
'',
|
||||
'First **paragraph** with [a link](https://example.com) and .',
|
||||
'',
|
||||
'- shipped',
|
||||
'- `verified`',
|
||||
'',
|
||||
'```ts',
|
||||
'const ready = true',
|
||||
'```',
|
||||
].join('\n')
|
||||
|
||||
describe('extractMarkdownPlainText', () => {
|
||||
it('projects the complete GFM document without presentation syntax', () => {
|
||||
expect(extractMarkdownPlainText(MARKDOWN)).toBe([
|
||||
'Release notes',
|
||||
'',
|
||||
'First paragraph with a link and diagram.',
|
||||
'',
|
||||
'shipped',
|
||||
'verified',
|
||||
'',
|
||||
'const ready = true',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('selects the first visible line or first semantic paragraph', () => {
|
||||
expect(extractMarkdownPlainText(MARKDOWN, { mode: 'first-line' })).toBe('Release notes')
|
||||
expect(extractMarkdownPlainText(MARKDOWN, { mode: 'first-paragraph' }))
|
||||
.toBe('First paragraph with a link and diagram.')
|
||||
})
|
||||
|
||||
it('preserves raw HTML while removing Markdown presentation markup', () => {
|
||||
const block = [
|
||||
'<background-task-complete id="trajectory-ui-watch">',
|
||||
'Command: pnpm test',
|
||||
'Exit code: 0',
|
||||
'</background-task-complete>',
|
||||
].join('\n')
|
||||
expect(extractMarkdownPlainText(block)).toBe(block)
|
||||
expect(extractMarkdownPlainText('**Status:** <span data-state="ok">ready</span>'))
|
||||
.toBe('Status: <span data-state="ok">ready</span>')
|
||||
expect(extractMarkdownPlainText(block, { mode: 'first-paragraph' }))
|
||||
.toBe('<background-task-complete id="trajectory-ui-watch">')
|
||||
})
|
||||
|
||||
it('projects GFM tables, references, hard breaks, and block structure', () => {
|
||||
const markdown = [
|
||||
'> first\\',
|
||||
'> second with ![diagram][asset] and <span>visible</span>',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'| Name | Value |',
|
||||
'| --- | --- |',
|
||||
'| alpha | `1` |',
|
||||
'',
|
||||
'[asset]: diagram.png',
|
||||
].join('\n')
|
||||
expect(extractMarkdownPlainText(markdown)).toBe([
|
||||
'first second with diagram and <span>visible</span>',
|
||||
'',
|
||||
'Name\tValue',
|
||||
'alpha\t1',
|
||||
].join('\n'))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user