mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
refactor(client): replace patched JSON tree dependency
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.md
|
||||
2026-07-28-local-json-tree-renderer.md: 5e8e6d6968319ada53e65a48e9e03b297dc0141c
|
||||
2026-07-28-local-json-tree-renderer.zh.md: 221880391da05551d05ad9e3dadb909e2e74fa68
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: Local JSON tree renderer
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-local-json-tree-renderer.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The read-only JSON inspector used by the [trajectory ledger](../feature/2026-07-27-trajectory-inspection-ledger.md) needs compact object and array previews, explicit array paths for copy actions, fixed-open and collapsible root modes, and keyboard navigation. `react-json-view-lite` exposes neither custom node rendering nor row identity, so satisfying those requirements through that dependency requires a package-manager patch against compiled distribution files and DOM traversal that reconstructs data paths from visible labels. The patch behaves as an untyped fork while its source maps and upstream source remain unchanged.
|
||||
|
||||
## Decision
|
||||
|
||||
`JsonTree` owns its recursive presentation in `dsh-client-ui-primitives`.
|
||||
|
||||
- Each rendered row receives its value and property path directly. Object keys and array indexes extend that path during recursion, so copy actions never recover application data from rendered DOM text.
|
||||
- Expandable rows render the compact preview locally and mount child rows only while expanded. `expandTopLevel` selects between a fixed-open bracket frame and a collapsible root node without changing the public component contract.
|
||||
- The tree keeps one tabbable expander among visible nodes. Pointer activation claims that tab stop; Up and Down move it cyclically, while Left and Right collapse or expand the focused node.
|
||||
- `react-json-view-lite` is not a package dependency and has no pnpm patch. Focused component tests pin previews, expansion, keyboard focus, and array copy paths.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the distribution patch.** Rejected because the application-specific renderer and array identity contract would remain hidden in generated third-party files, and every dependency update would require reviewing a fork without matching source maps.
|
||||
|
||||
**Use the upstream renderer without previews.** Rejected because `{…}` and `[…]` discard the compact payload context that the trajectory inspector uses for scanning.
|
||||
|
||||
**Inject previews and row metadata after render.** Rejected because effects or mutation observers would depend on the same private DOM structure while splitting one row between React ownership and imperative mutation.
|
||||
|
||||
**Adopt a larger JSON viewer.** Rejected because editing, search, and theme systems are outside the current read-only contract; the added dependency surface would not remove the inspector-specific copy and layout code.
|
||||
|
||||
## Consequences
|
||||
|
||||
The JSON inspector has one source-level owner, explicit data flow, accurate array paths, and no patched dependency. The package now owns recursive rendering, expansion state, ARIA tree structure, and roving focus behavior, so changes to those semantics require focused component coverage. The implementation remains intentionally read-only and limited to the preview, navigation, and copy behavior used by current consumers.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note:本地 JSON 树渲染器
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-local-json-tree-renderer.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[轨迹检查记录表](../feature/2026-07-27-trajectory-inspection-ledger.md)使用的只读 JSON 检查器需要提供紧凑的对象和数组预览、供复制操作使用的明确数组路径、固定展开与可折叠两种根节点模式,以及键盘导航。`react-json-view-lite` 既不提供自定义节点渲染,也不提供行标识;要通过该依赖满足这些要求,就必须使用包管理器为编译后的发布文件打补丁,并遍历 DOM,从可见标签中还原数据路径。该补丁实际上相当于一个不受类型约束的 fork,但其源码映射与上游源码均未同步修改。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-client-ui-primitives` 中的 `JsonTree` 自行负责递归呈现。
|
||||
|
||||
- 每个渲染行都直接接收自身的值和属性路径。递归时,对象键和数组索引会附加到路径末尾,因此复制操作无需再从 DOM 渲染文本中反向还原应用数据。
|
||||
- 可展开行在本地渲染紧凑预览,仅在展开时挂载子行。`expandTopLevel` 可选择固定展开的括号框架或可折叠根节点,而不改变组件的公开契约。
|
||||
- 树中所有可见节点仅保留一个可通过 Tab 键聚焦的展开控件。使用指针激活控件后,该控件会成为 Tab 键焦点位置;上、下方向键循环移动焦点,左、右方向键折叠或展开当前焦点所在节点。
|
||||
- `react-json-view-lite` 不在 `dsh-client-ui-primitives` 的依赖项中,也没有 pnpm 补丁。针对性组件测试锁定预览、展开、键盘焦点和数组复制路径。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**保留发布文件补丁。** 不予采纳:应用专用的渲染逻辑和数组标识契约仍会隐藏在生成的第三方文件中;每次更新依赖,都必须评审一个没有配套源码映射的 fork。
|
||||
|
||||
**直接使用不提供预览的上游渲染器。** 不予采纳:`{…}` 和 `[…]` 会丢失轨迹检查器扫读时所需的紧凑载荷上下文。
|
||||
|
||||
**渲染后注入预览和行元数据。** 不予采纳:Effect 或 MutationObserver 仍会依赖同一套私有 DOM 结构,同时使 React 和命令式变更机制分别负责同一行的不同部分。
|
||||
|
||||
**采用更大型的 JSON 查看器。** 不予采纳:编辑、搜索与主题系统不在当前只读契约范围内;扩大依赖范围也无法去除检查器专用的复制和布局代码。
|
||||
|
||||
## 后果
|
||||
|
||||
JSON 检查器在源码层由单一实现负责,具有显式数据流和准确的数组路径,且没有经过补丁修改的依赖。`dsh-client-ui-primitives` 负责递归渲染、展开状态、ARIA 树结构和焦点循环移动行为,因此修改这些语义时必须提供针对性组件测试。实现有意保持只读,仅包含当前消费方使用的预览、导航与复制行为。
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
README.md: 58e450451ab64f69762817dfb277b8a888e2177f
|
||||
README.zh.md: 6824f3efe4981adf9549941afa7e2f5db2ac005d
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: 29d7b5e20dbc3a53fe4b85e219b0c957c05ed873
|
||||
README.zh.md: 92d256f7f1d413a9792aa54f50317c2a15dd09a0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock). 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), and the read-only JsonTree inspector. Contract: api-contracts v3 §8.
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input,以及 markdown 家族(MessageText/MarkdownText/JsonBlock)。契约:api-contracts v3 §8。
|
||||
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock),以及只读 JsonTree 检查器。契约:api-contracts v3 §8。
|
||||
|
||||
## Markdown 渲染
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -27,7 +27,6 @@
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-json-view-lite": "^2.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.3.1"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import clsx from 'clsx'
|
||||
import { collapseAllNested, JsonView } from 'react-json-view-lite'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { MouseEvent as ReactMouseEvent, ReactNode, UIEvent as ReactUIEvent } from 'react'
|
||||
import type { Props as LiteJsonViewProps } from 'react-json-view-lite'
|
||||
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'
|
||||
@@ -22,34 +25,35 @@ const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [
|
||||
{ id: 'path', label: 'Copy property path' },
|
||||
]
|
||||
|
||||
const TREE_STYLES: NonNullable<LiteJsonViewProps['style']> = {
|
||||
container: clsx(css.container),
|
||||
childFieldsContainer: clsx(css.children),
|
||||
basicChildStyle: clsx(css.row),
|
||||
label: clsx(css.label),
|
||||
clickableLabel: clsx(css.label, css.clickableLabel),
|
||||
nullValue: clsx(css.keywordValue),
|
||||
undefinedValue: clsx(css.keywordValue),
|
||||
numberValue: clsx(css.numberValue),
|
||||
stringValue: clsx(css.stringValue),
|
||||
booleanValue: clsx(css.keywordValue),
|
||||
otherValue: clsx(css.otherValue),
|
||||
punctuation: clsx(css.punctuation),
|
||||
expandIcon: clsx(css.expander, css.expandIcon),
|
||||
collapseIcon: clsx(css.expander, css.collapseIcon),
|
||||
collapsedContent: clsx(css.collapsedContent),
|
||||
noQuotesForStringValues: false,
|
||||
quotesForFieldNames: false,
|
||||
stringifyStringValues: true,
|
||||
ariaLables: {
|
||||
collapseJson: 'Collapse JSON node',
|
||||
expandJson: 'Expand JSON node',
|
||||
},
|
||||
type JsonPath = readonly (number | string)[]
|
||||
|
||||
interface RowTarget {
|
||||
path: JsonPath
|
||||
value: unknown
|
||||
}
|
||||
|
||||
const EXPANDED_TOP_LEVEL_TREE_STYLES: NonNullable<LiteJsonViewProps['style']> = {
|
||||
...TREE_STYLES,
|
||||
container: clsx(css.container, css.expandedTopLevelContainer),
|
||||
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 {
|
||||
@@ -79,16 +83,13 @@ function previewPrimitive(value: unknown): ReactNode {
|
||||
}
|
||||
|
||||
function previewValue(value: unknown, depth: number): ReactNode {
|
||||
if (typeof value !== 'object' || value === null) return previewPrimitive(value)
|
||||
if (!isExpandableValue(value)) return previewPrimitive(value)
|
||||
|
||||
const array = Array.isArray(value)
|
||||
const entries = array
|
||||
? value.map((item, index) => [String(index), item] as const)
|
||||
: Object.entries(value)
|
||||
const entries = entriesOf(value)
|
||||
const limit = array ? ARRAY_PREVIEW_LIMIT : OBJECT_PREVIEW_LIMIT
|
||||
const visible = entries.slice(0, limit)
|
||||
const open = array ? '[' : '{'
|
||||
const close = array ? ']' : '}'
|
||||
const [open, close] = bracketOf(value)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -108,71 +109,208 @@ function previewValue(value: unknown, depth: number): ReactNode {
|
||||
</span>
|
||||
))}
|
||||
{depth < PREVIEW_DEPTH_LIMIT && entries.length > limit && (
|
||||
<span className={css.previewEllipsis}>{visible.length > 0 ? ', …' : '…'}</span>
|
||||
<span className={css.previewEllipsis}>, …</span>
|
||||
)}
|
||||
<span className={css.punctuation}>{close}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderExpandableValue(value: object): ReactNode {
|
||||
return <span className={css.preview}>{previewValue(value, 0)}</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>
|
||||
}
|
||||
|
||||
interface CopyTarget {
|
||||
left: number
|
||||
path: readonly (number | string)[]
|
||||
side: 'bottom' | 'top'
|
||||
top: number
|
||||
value: unknown
|
||||
function fieldText(field: string): string {
|
||||
return field === '' ? '""' : field
|
||||
}
|
||||
|
||||
function fieldOf(row: HTMLElement): string | undefined {
|
||||
const label = Array.from(row.children).find(
|
||||
child => child instanceof HTMLElement && child.classList.contains(clsx(css.label)),
|
||||
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>
|
||||
)
|
||||
const text = label?.textContent
|
||||
return text === undefined ? undefined : text.slice(0, -1)
|
||||
}
|
||||
|
||||
function resolveRow(data: object | unknown[], row: HTMLElement, expandTopLevel: boolean): {
|
||||
path: readonly (number | string)[]
|
||||
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
|
||||
} | undefined {
|
||||
if (row.hasAttribute('data-json-root-row')) return { path: [], value: data }
|
||||
}
|
||||
|
||||
const lineage: HTMLElement[] = []
|
||||
let cursor: HTMLElement | null = row
|
||||
while (cursor !== null) {
|
||||
lineage.unshift(cursor)
|
||||
const group: HTMLElement | null = cursor.parentElement
|
||||
const parentRow: Element | null = group?.getAttribute('role') === 'group'
|
||||
? group.parentElement?.closest('[role="treeitem"]') ?? null
|
||||
: null
|
||||
cursor = parentRow instanceof HTMLElement ? parentRow : null
|
||||
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)
|
||||
}
|
||||
|
||||
let value: unknown = data
|
||||
const path: (number | string)[] = []
|
||||
for (const item of expandTopLevel ? lineage : lineage.slice(1)) {
|
||||
const field = fieldOf(item)
|
||||
if (field === undefined) return undefined
|
||||
if (Array.isArray(value)) {
|
||||
const index = Number(field)
|
||||
if (!Number.isInteger(index)) return undefined
|
||||
path.push(index)
|
||||
value = value[index]
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
path.push(field)
|
||||
value = (value as Record<string, unknown>)[field]
|
||||
} else {
|
||||
return undefined
|
||||
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)
|
||||
}
|
||||
}
|
||||
return { path, value }
|
||||
|
||||
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>
|
||||
{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: readonly (number | string)[]): string {
|
||||
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)
|
||||
@@ -186,9 +324,6 @@ function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'va
|
||||
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 === 'object' && target.value !== null) {
|
||||
return JSON.stringify(target.value, null, 2)
|
||||
}
|
||||
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'
|
||||
@@ -222,6 +357,16 @@ export function JsonTree({
|
||||
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)
|
||||
@@ -230,11 +375,7 @@ export function JsonTree({
|
||||
const [copyTarget, setCopyTarget] = useState<CopyTarget>()
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
|
||||
const [copyMenuOpen, setCopyMenuOpen] = useState(false)
|
||||
|
||||
useEffect(() => () => {
|
||||
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
|
||||
activeRowRef.current?.removeAttribute('data-json-copy-active')
|
||||
}, [])
|
||||
const [tabStopId, setTabStopId] = useState<string | null>(initialTabStopId)
|
||||
|
||||
const setActiveRow = (row: HTMLElement | undefined) => {
|
||||
activeRowRef.current?.removeAttribute('data-json-copy-active')
|
||||
@@ -242,38 +383,6 @@ export function JsonTree({
|
||||
row?.setAttribute('data-json-copy-active', '')
|
||||
}
|
||||
|
||||
const positionCopyButton = (row: HTMLElement, target: {
|
||||
path: readonly (number | string)[]
|
||||
value: unknown
|
||||
}) => {
|
||||
const root = rootRef.current
|
||||
if (root === null) return
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const rowRect = row.getBoundingClientRect()
|
||||
setCopyTarget({
|
||||
left: rootRect.left + root.clientWidth - 26,
|
||||
path: target.path,
|
||||
side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom',
|
||||
top: rowRect.top,
|
||||
value: target.value,
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const reposition = () => {
|
||||
const row = activeRowRef.current
|
||||
if (row === undefined) return
|
||||
const resolved = resolveRow(data, row, expandTopLevel)
|
||||
if (resolved !== undefined) positionCopyButton(row, resolved)
|
||||
}
|
||||
window.addEventListener('scroll', reposition, true)
|
||||
window.addEventListener('resize', reposition)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', reposition, true)
|
||||
window.removeEventListener('resize', reposition)
|
||||
}
|
||||
}, [data, expandTopLevel])
|
||||
|
||||
const clearCopyTarget = () => {
|
||||
setActiveRow(undefined)
|
||||
setCopyTarget(undefined)
|
||||
@@ -282,35 +391,85 @@ export function JsonTree({
|
||||
setCopyMenuOpen(false)
|
||||
}
|
||||
|
||||
const handleMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!copyable || !(event.target instanceof Element)) return
|
||||
if (copyMenuOpenRef.current) return
|
||||
if (!event.currentTarget.contains(event.target)) return
|
||||
if (event.target.closest('[data-json-copy-button]') !== null) return
|
||||
const row = event.target.closest<HTMLElement>('[data-json-root-row], [role="treeitem"]')
|
||||
if (row === null) {
|
||||
clearCopyTarget()
|
||||
return
|
||||
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
|
||||
const resolved = resolveRow(data, row, expandTopLevel)
|
||||
if (resolved === undefined) return
|
||||
setActiveRow(row)
|
||||
setCopyState('idle')
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyMenuOpen(false)
|
||||
positionCopyButton(row, resolved)
|
||||
positionCopyButton(row, target)
|
||||
}
|
||||
|
||||
const handleScroll = (event: ReactUIEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
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) return
|
||||
const resolved = resolveRow(data, row, expandTopLevel)
|
||||
if (resolved !== undefined) positionCopyButton(row, resolved)
|
||||
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))
|
||||
@@ -322,6 +481,7 @@ export function JsonTree({
|
||||
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'
|
||||
@@ -334,7 +494,7 @@ export function JsonTree({
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={clsx(css.root, className)}
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOver={handleRootMouseOver}
|
||||
onMouseLeave={() => {
|
||||
if (!copyMenuOpenRef.current) clearCopyTarget()
|
||||
}}
|
||||
@@ -343,32 +503,52 @@ export function JsonTree({
|
||||
{expandTopLevel
|
||||
? (
|
||||
<div className={css.expandedTopLevel}>
|
||||
<div className={clsx(css.row, css.topLevelBracket)} data-json-root-row>
|
||||
<span className={css.punctuation}>{Array.isArray(data) ? '[' : '{'}</span>
|
||||
<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>
|
||||
<JsonView
|
||||
<div
|
||||
aria-label={label}
|
||||
compactTopLevel
|
||||
data={data}
|
||||
style={EXPANDED_TOP_LEVEL_TREE_STYLES}
|
||||
shouldExpandNode={collapseAllNested}
|
||||
clickToExpandNode
|
||||
renderExpandableValue={renderExpandableValue}
|
||||
/>
|
||||
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
|
||||
initialExpanded={false}
|
||||
tabStopId={tabStopId}
|
||||
onClaimTabStop={setTabStopId}
|
||||
onRowHover={handleRowHover}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={clsx(css.row, css.topLevelBracket)}>
|
||||
<span className={css.punctuation}>{Array.isArray(data) ? ']' : '}'}</span>
|
||||
<span className={css.punctuation}>{rootClose}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<JsonView
|
||||
aria-label={label}
|
||||
data={data}
|
||||
style={TREE_STYLES}
|
||||
shouldExpandNode={collapseAllNested}
|
||||
clickToExpandNode
|
||||
renderExpandableValue={renderExpandableValue}
|
||||
/>
|
||||
<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
|
||||
@@ -405,14 +585,14 @@ export function JsonTree({
|
||||
)}
|
||||
items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS}
|
||||
onSelect={(id) => {
|
||||
if (id === 'value' || id === 'json' || id === 'prettyJson' || id === 'path') {
|
||||
void copy(id)
|
||||
}
|
||||
void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
|
||||
copyMenuOpenRef.current = false
|
||||
setCopyMenuOpen(false)
|
||||
}}
|
||||
onClose={clearCopyTarget}
|
||||
getAnchorRect={() => copyButtonRef.current?.getBoundingClientRect() ?? null}
|
||||
getAnchorRect={() => (
|
||||
copyButtonRef.current as HTMLButtonElement
|
||||
).getBoundingClientRect()}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,317 +0,0 @@
|
||||
diff --git a/dist/DataRenderer.d.ts b/dist/DataRenderer.d.ts
|
||||
index 6838d1c3aa8cea1e77534801bf02b9c11ddad1de..3dc8c6568cc4f174126dad32c33a64b0dc25f198 100644
|
||||
--- a/dist/DataRenderer.d.ts
|
||||
+++ b/dist/DataRenderer.d.ts
|
||||
@@ -30,6 +30,8 @@ interface CommonRenderProps {
|
||||
clickToExpandNode: boolean;
|
||||
outerRef: React.RefObject<HTMLDivElement>;
|
||||
beforeExpandChange?: (event: NodeExpandingEvent) => boolean;
|
||||
+ renderExpandableValue?: (value: Object | Array<any>, expanded: boolean) => React.ReactNode;
|
||||
+ renderStringValue?: (value: string) => React.ReactNode;
|
||||
}
|
||||
export interface JsonRenderProps<T> extends CommonRenderProps {
|
||||
field?: string;
|
||||
diff --git a/dist/index.d.ts b/dist/index.d.ts
|
||||
index 23e9bca4586b7ea71e7d016230e33eb444cd1304..c49677664f0f0a10e67be688a0157ef9f0d93d6d 100644
|
||||
--- a/dist/index.d.ts
|
||||
+++ b/dist/index.d.ts
|
||||
@@ -16,10 +16,12 @@ export interface Props extends React.AriaAttributes {
|
||||
shouldExpandNode?: (level: number, value: any, field?: string) => boolean;
|
||||
clickToExpandNode?: boolean;
|
||||
beforeExpandChange?: (event: NodeExpandingEvent) => boolean;
|
||||
+ renderExpandableValue?: (value: Object | Array<any>, expanded: boolean) => React.ReactNode;
|
||||
+ renderStringValue?: (value: string) => React.ReactNode;
|
||||
compactTopLevel?: boolean;
|
||||
}
|
||||
export declare const defaultStyles: StyleProps;
|
||||
export declare const darkStyles: StyleProps;
|
||||
export declare const allExpanded: () => boolean;
|
||||
export declare const collapseAllNested: (level: number) => boolean;
|
||||
-export declare const JsonView: ({ data, style, shouldExpandNode, clickToExpandNode, beforeExpandChange, compactTopLevel, ...ariaAttrs }: Props) => React.JSX.Element;
|
||||
+export declare const JsonView: ({ data, style, shouldExpandNode, clickToExpandNode, beforeExpandChange, renderExpandableValue, renderStringValue, compactTopLevel, ...ariaAttrs }: Props) => React.JSX.Element;
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index ab8b98c992bd34e00e7c3a0257d6778d7928dde8..669304fbba450c789bf4a4264f0627a8eec9d178 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -50,7 +50,9 @@ function ExpandableObject(_ref) {
|
||||
shouldExpandNode,
|
||||
clickToExpandNode,
|
||||
outerRef,
|
||||
- beforeExpandChange
|
||||
+ beforeExpandChange,
|
||||
+ renderExpandableValue,
|
||||
+ renderStringValue
|
||||
} = _ref;
|
||||
const shouldExpandNodeCalledRef = React.useRef(false);
|
||||
const [expanded, setExpanded] = React.useState(() => shouldExpandNode(level, value, field));
|
||||
@@ -146,7 +148,7 @@ function ExpandableObject(_ref) {
|
||||
onKeyDown: onKeyDown
|
||||
}, quoteString(field, style.quotesForFieldNames), ":")) : (/*#__PURE__*/React.createElement("span", {
|
||||
className: style.label
|
||||
- }, quoteString(field, style.quotesForFieldNames), ":"))), /*#__PURE__*/React.createElement("span", {
|
||||
+ }, quoteString(field, style.quotesForFieldNames), ":"))), renderExpandableValue ? renderExpandableValue(value, expanded) : /*#__PURE__*/React.createElement("span", {
|
||||
className: style.punctuation
|
||||
}, openBracket), expanded ? (/*#__PURE__*/React.createElement("ul", {
|
||||
id: contentsId,
|
||||
@@ -162,16 +164,19 @@ function ExpandableObject(_ref) {
|
||||
shouldExpandNode: shouldExpandNode,
|
||||
clickToExpandNode: clickToExpandNode,
|
||||
beforeExpandChange: beforeExpandChange,
|
||||
- outerRef: outerRef
|
||||
+ outerRef: outerRef,
|
||||
+ renderExpandableValue: renderExpandableValue,
|
||||
+ renderStringValue: renderStringValue
|
||||
}))))) : (
|
||||
+ renderExpandableValue ? null :
|
||||
/*#__PURE__*/
|
||||
React.createElement("span", {
|
||||
className: style.collapsedContent,
|
||||
onClick: onClick,
|
||||
onKeyDown: onKeyDown
|
||||
- })), /*#__PURE__*/React.createElement("span", {
|
||||
+ })), renderExpandableValue ? null : /*#__PURE__*/React.createElement("span", {
|
||||
className: style.punctuation
|
||||
- }, closeBracket), !lastElement && /*#__PURE__*/React.createElement("span", {
|
||||
+ }, closeBracket), !renderExpandableValue && !lastElement && /*#__PURE__*/React.createElement("span", {
|
||||
className: style.punctuation
|
||||
}, ","));
|
||||
}
|
||||
@@ -207,7 +212,9 @@ function JsonObject(_ref3) {
|
||||
clickToExpandNode,
|
||||
level,
|
||||
outerRef,
|
||||
- beforeExpandChange
|
||||
+ beforeExpandChange,
|
||||
+ renderExpandableValue,
|
||||
+ renderStringValue
|
||||
} = _ref3;
|
||||
return ExpandableObject({
|
||||
field,
|
||||
@@ -221,7 +228,9 @@ function JsonObject(_ref3) {
|
||||
clickToExpandNode,
|
||||
data: Object.keys(value).map(key => [key, value[key]]),
|
||||
outerRef,
|
||||
- beforeExpandChange
|
||||
+ beforeExpandChange,
|
||||
+ renderExpandableValue,
|
||||
+ renderStringValue
|
||||
});
|
||||
}
|
||||
function JsonArray(_ref4) {
|
||||
@@ -234,7 +243,9 @@ function JsonArray(_ref4) {
|
||||
shouldExpandNode,
|
||||
clickToExpandNode,
|
||||
outerRef,
|
||||
- beforeExpandChange
|
||||
+ beforeExpandChange,
|
||||
+ renderExpandableValue,
|
||||
+ renderStringValue
|
||||
} = _ref4;
|
||||
return ExpandableObject({
|
||||
field,
|
||||
@@ -246,9 +257,11 @@ function JsonArray(_ref4) {
|
||||
style,
|
||||
shouldExpandNode,
|
||||
clickToExpandNode,
|
||||
- data: value.map(element => [undefined, element]),
|
||||
+ data: value.map((element, index) => [String(index), element]),
|
||||
outerRef,
|
||||
- beforeExpandChange
|
||||
+ beforeExpandChange,
|
||||
+ renderExpandableValue,
|
||||
+ renderStringValue
|
||||
});
|
||||
}
|
||||
function JsonPrimitiveValue(_ref5) {
|
||||
@@ -256,7 +269,8 @@ function JsonPrimitiveValue(_ref5) {
|
||||
field,
|
||||
value,
|
||||
style,
|
||||
- lastElement
|
||||
+ lastElement,
|
||||
+ renderStringValue
|
||||
} = _ref5;
|
||||
let stringValue;
|
||||
let valueStyle = style.otherValue;
|
||||
@@ -291,7 +305,7 @@ function JsonPrimitiveValue(_ref5) {
|
||||
"aria-selected": undefined
|
||||
}, (field || field === '') && (/*#__PURE__*/React.createElement("span", {
|
||||
className: style.label
|
||||
- }, quoteString(field, style.quotesForFieldNames), ":")), /*#__PURE__*/React.createElement("span", {
|
||||
+ }, quoteString(field, style.quotesForFieldNames), ":")), isString(value) && renderStringValue ? renderStringValue(value) : /*#__PURE__*/React.createElement("span", {
|
||||
className: valueStyle
|
||||
}, stringValue), !lastElement && /*#__PURE__*/React.createElement("span", {
|
||||
className: style.punctuation
|
||||
@@ -365,6 +379,8 @@ const JsonView = _ref => {
|
||||
shouldExpandNode = allExpanded,
|
||||
clickToExpandNode = false,
|
||||
beforeExpandChange,
|
||||
+ renderExpandableValue,
|
||||
+ renderStringValue,
|
||||
compactTopLevel,
|
||||
...ariaAttrs
|
||||
} = _ref;
|
||||
@@ -390,7 +406,9 @@ const JsonView = _ref => {
|
||||
shouldExpandNode: shouldExpandNode,
|
||||
clickToExpandNode: clickToExpandNode,
|
||||
beforeExpandChange: beforeExpandChange,
|
||||
- outerRef: outerRef
|
||||
+ outerRef: outerRef,
|
||||
+ renderExpandableValue: renderExpandableValue,
|
||||
+ renderStringValue: renderStringValue
|
||||
});
|
||||
}) : (/*#__PURE__*/React.createElement(DataRender, {
|
||||
value: data,
|
||||
@@ -403,6 +421,8 @@ const JsonView = _ref => {
|
||||
shouldExpandNode: shouldExpandNode,
|
||||
clickToExpandNode: clickToExpandNode,
|
||||
outerRef: outerRef,
|
||||
- beforeExpandChange: beforeExpandChange
|
||||
+ beforeExpandChange: beforeExpandChange,
|
||||
+ renderExpandableValue: renderExpandableValue,
|
||||
+ renderStringValue: renderStringValue
|
||||
})));
|
||||
};
|
||||
diff --git a/dist/index.modern.js b/dist/index.modern.js
|
||||
index e25aea1bc8936b69275b9fbe7011d0466480f99c..f4c509e4459f70080b90695c00f8a17e8f4aac4e 100644
|
||||
--- a/dist/index.modern.js
|
||||
+++ b/dist/index.modern.js
|
||||
@@ -50,7 +50,9 @@ function ExpandableObject(_ref) {
|
||||
shouldExpandNode,
|
||||
clickToExpandNode,
|
||||
outerRef,
|
||||
- beforeExpandChange
|
||||
+ beforeExpandChange,
|
||||
+ renderExpandableValue,
|
||||
+ renderStringValue
|
||||
} = _ref;
|
||||
const shouldExpandNodeCalledRef = useRef(false);
|
||||
const [expanded, setExpanded] = useState(() => shouldExpandNode(level, value, field));
|
||||
@@ -146,7 +148,7 @@ function ExpandableObject(_ref) {
|
||||
onKeyDown: onKeyDown
|
||||
}, quoteString(field, style.quotesForFieldNames), ":")) : (/*#__PURE__*/createElement("span", {
|
||||
className: style.label
|
||||
- }, quoteString(field, style.quotesForFieldNames), ":"))), /*#__PURE__*/createElement("span", {
|
||||
+ }, quoteString(field, style.quotesForFieldNames), ":"))), renderExpandableValue ? renderExpandableValue(value, expanded) : /*#__PURE__*/createElement("span", {
|
||||
className: style.punctuation
|
||||
}, openBracket), expanded ? (/*#__PURE__*/createElement("ul", {
|
||||
id: contentsId,
|
||||
@@ -162,16 +164,19 @@ function ExpandableObject(_ref) {
|
||||
shouldExpandNode: shouldExpandNode,
|
||||
clickToExpandNode: clickToExpandNode,
|
||||
beforeExpandChange: beforeExpandChange,
|
||||
- outerRef: outerRef
|
||||
+ outerRef: outerRef,
|
||||
+ renderExpandableValue: renderExpandableValue,
|
||||
+ renderStringValue: renderStringValue
|
||||
}))))) : (
|
||||
+ renderExpandableValue ? null :
|
||||
/*#__PURE__*/
|
||||
createElement("span", {
|
||||
className: style.collapsedContent,
|
||||
onClick: onClick,
|
||||
onKeyDown: onKeyDown
|
||||
- })), /*#__PURE__*/createElement("span", {
|
||||
+ })), renderExpandableValue ? null : /*#__PURE__*/createElement("span", {
|
||||
className: style.punctuation
|
||||
- }, closeBracket), !lastElement && /*#__PURE__*/createElement("span", {
|
||||
+ }, closeBracket), !renderExpandableValue && !lastElement && /*#__PURE__*/createElement("span", {
|
||||
className: style.punctuation
|
||||
}, ","));
|
||||
}
|
||||
@@ -207,7 +212,9 @@ function JsonObject(_ref3) {
|
||||
clickToExpandNode,
|
||||
level,
|
||||
outerRef,
|
||||
- beforeExpandChange
|
||||
+ beforeExpandChange,
|
||||
+ renderExpandableValue,
|
||||
+ renderStringValue
|
||||
} = _ref3;
|
||||
return ExpandableObject({
|
||||
field,
|
||||
@@ -221,7 +228,9 @@ function JsonObject(_ref3) {
|
||||
clickToExpandNode,
|
||||
data: Object.keys(value).map(key => [key, value[key]]),
|
||||
outerRef,
|
||||
- beforeExpandChange
|
||||
+ beforeExpandChange,
|
||||
+ renderExpandableValue,
|
||||
+ renderStringValue
|
||||
});
|
||||
}
|
||||
function JsonArray(_ref4) {
|
||||
@@ -234,7 +243,9 @@ function JsonArray(_ref4) {
|
||||
shouldExpandNode,
|
||||
clickToExpandNode,
|
||||
outerRef,
|
||||
- beforeExpandChange
|
||||
+ beforeExpandChange,
|
||||
+ renderExpandableValue,
|
||||
+ renderStringValue
|
||||
} = _ref4;
|
||||
return ExpandableObject({
|
||||
field,
|
||||
@@ -246,9 +257,11 @@ function JsonArray(_ref4) {
|
||||
style,
|
||||
shouldExpandNode,
|
||||
clickToExpandNode,
|
||||
- data: value.map(element => [undefined, element]),
|
||||
+ data: value.map((element, index) => [String(index), element]),
|
||||
outerRef,
|
||||
- beforeExpandChange
|
||||
+ beforeExpandChange,
|
||||
+ renderExpandableValue,
|
||||
+ renderStringValue
|
||||
});
|
||||
}
|
||||
function JsonPrimitiveValue(_ref5) {
|
||||
@@ -256,7 +269,8 @@ function JsonPrimitiveValue(_ref5) {
|
||||
field,
|
||||
value,
|
||||
style,
|
||||
- lastElement
|
||||
+ lastElement,
|
||||
+ renderStringValue
|
||||
} = _ref5;
|
||||
let stringValue;
|
||||
let valueStyle = style.otherValue;
|
||||
@@ -291,7 +305,7 @@ function JsonPrimitiveValue(_ref5) {
|
||||
"aria-selected": undefined
|
||||
}, (field || field === '') && (/*#__PURE__*/createElement("span", {
|
||||
className: style.label
|
||||
- }, quoteString(field, style.quotesForFieldNames), ":")), /*#__PURE__*/createElement("span", {
|
||||
+ }, quoteString(field, style.quotesForFieldNames), ":")), isString(value) && renderStringValue ? renderStringValue(value) : /*#__PURE__*/createElement("span", {
|
||||
className: valueStyle
|
||||
}, stringValue), !lastElement && /*#__PURE__*/createElement("span", {
|
||||
className: style.punctuation
|
||||
@@ -365,6 +379,8 @@ const JsonView = _ref => {
|
||||
shouldExpandNode = allExpanded,
|
||||
clickToExpandNode = false,
|
||||
beforeExpandChange,
|
||||
+ renderExpandableValue,
|
||||
+ renderStringValue,
|
||||
compactTopLevel,
|
||||
...ariaAttrs
|
||||
} = _ref;
|
||||
@@ -390,7 +406,9 @@ const JsonView = _ref => {
|
||||
shouldExpandNode: shouldExpandNode,
|
||||
clickToExpandNode: clickToExpandNode,
|
||||
beforeExpandChange: beforeExpandChange,
|
||||
- outerRef: outerRef
|
||||
+ outerRef: outerRef,
|
||||
+ renderExpandableValue: renderExpandableValue,
|
||||
+ renderStringValue: renderStringValue
|
||||
});
|
||||
}) : (/*#__PURE__*/createElement(DataRender, {
|
||||
value: data,
|
||||
@@ -403,6 +421,8 @@ const JsonView = _ref => {
|
||||
shouldExpandNode: shouldExpandNode,
|
||||
clickToExpandNode: clickToExpandNode,
|
||||
outerRef: outerRef,
|
||||
- beforeExpandChange: beforeExpandChange
|
||||
+ beforeExpandChange: beforeExpandChange,
|
||||
+ renderExpandableValue: renderExpandableValue,
|
||||
+ renderStringValue: renderStringValue
|
||||
})));
|
||||
};
|
||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -6,7 +6,6 @@ settings:
|
||||
|
||||
patchedDependencies:
|
||||
'@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946
|
||||
react-json-view-lite@2.5.0: 472dbd828142af74e536f4156c818f0516974e2a7d3d5509ec0dd04b389fb113
|
||||
|
||||
importers:
|
||||
|
||||
@@ -1094,9 +1093,6 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
react-json-view-lite:
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0(patch_hash=472dbd828142af74e536f4156c818f0516974e2a7d3d5509ec0dd04b389fb113)(react@18.3.1)
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@18.3.31)(react@18.3.1)
|
||||
@@ -9732,12 +9728,6 @@ packages:
|
||||
react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
|
||||
react-json-view-lite@2.5.0:
|
||||
resolution: {integrity: sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
|
||||
react-markdown@10.1.0:
|
||||
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
|
||||
peerDependencies:
|
||||
@@ -15040,10 +15030,6 @@ snapshots:
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-json-view-lite@2.5.0(patch_hash=472dbd828142af74e536f4156c818f0516974e2a7d3d5509ec0dd04b389fb113)(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
react-markdown@10.1.0(@types/react@18.3.31)(react@18.3.1):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
@@ -58,4 +58,3 @@ minimumReleaseAgeExclude:
|
||||
|
||||
patchedDependencies:
|
||||
'@earendil-works/pi-tui@0.80.7': patches/@earendil-works__pi-tui@0.80.7.patch
|
||||
react-json-view-lite@2.5.0: patches/react-json-view-lite@2.5.0.patch
|
||||
|
||||
@@ -102,10 +102,7 @@ export default defineConfig({
|
||||
// branches need a browser-grade harness the jsdom lane doesn't cover
|
||||
// yet. TODO(gui): cover and remove as the client test lane matures.
|
||||
'packages/client/ui-trajectory/src/*',
|
||||
// Trajectory's JSON inspector depends on browser geometry, portals,
|
||||
// clipboard behavior, and its compact Markdown projection; keep both
|
||||
// with the same browser-harness debt.
|
||||
'packages/client/ui-primitives/src/JsonTree.tsx',
|
||||
// Trajectory's compact Markdown projection retains deferred branch coverage.
|
||||
'packages/client/ui-primitives/src/markdown/plain-text.ts',
|
||||
'packages/client/ui-question/src/client/QuestionComposer.tsx',
|
||||
'packages/client/web-react/src/*',
|
||||
|
||||
Reference in New Issue
Block a user