@changfenhuang/dsh-genui 0.9.9 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/SKILL.md +128 -6
- package/lib/assets/echarts-core.js +43 -0
- package/lib/assets/{echarts.js → echarts-full.js} +1 -1
- package/lib/assets/mermaid.js +1 -1
- package/lib/client.js +5 -3
- package/lib/index.js +230 -69
- package/lib/types/client/EChartNode.d.ts +9 -0
- package/lib/types/client/GenuiBlock.d.ts +3 -4
- package/lib/types/client/asset-echarts-core.d.ts +1 -0
- package/lib/types/client/asset-echarts.d.ts +3 -2
- package/lib/types/client/asset-loader.d.ts +2 -2
- package/lib/types/client/blocks/advanced.d.ts +0 -3
- package/lib/types/client/blocks/charts.d.ts +23 -12
- package/lib/types/client/blocks/state.d.ts +5 -3
- package/lib/types/client/echarts-lazy.d.ts +4 -1
- package/lib/types/client/genui-runtime/schema.d.ts +11 -1
- package/lib/types/client/inline.d.ts +29 -0
- package/lib/types/client/spec.d.ts +76 -6
- package/lib/types/plugin/index.d.ts +1 -1
- package/package.json +4 -3
- package/src/client/EChartNode.tsx +233 -19
- package/src/client/GenuiBlock.module.css +895 -145
- package/src/client/GenuiBlock.tsx +31 -23
- package/src/client/PlotBlock.module.css +17 -1
- package/src/client/PlotBlock.tsx +33 -1
- package/src/client/asset-echarts-core.ts +39 -0
- package/src/client/asset-echarts.ts +4 -3
- package/src/client/asset-loader.ts +5 -3
- package/src/client/blocks/advanced.tsx +60 -16
- package/src/client/blocks/charts.tsx +761 -144
- package/src/client/blocks/render-node.tsx +252 -11
- package/src/client/blocks/state.ts +5 -3
- package/src/client/dom-fence.tsx +60 -6
- package/src/client/echarts-lazy.ts +11 -2
- package/src/client/fence-render.tsx +6 -5
- package/src/client/gallery.ts +78 -2
- package/src/client/genui-runtime/schema.ts +45 -11
- package/src/client/guard.ts +161 -23
- package/src/client/index.tsx +14 -3
- package/src/client/inline.ts +73 -0
- package/src/client/mermaid-core.ts +42 -5
- package/src/client/spec.ts +79 -6
- package/src/plugin/index.ts +33 -45
- package/src/plugin/tool.ts +5 -1
- package/tsdown.config.ts +2 -1
|
@@ -67,11 +67,9 @@ function specEquivalent(a: GenuiSpec, b: GenuiSpec): boolean {
|
|
|
67
67
|
return JSON.stringify(a) === JSON.stringify(b)
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/** Stateful implementation.
|
|
71
|
-
*
|
|
72
|
-
|
|
73
|
-
* that enforces this invariant for every caller. */
|
|
74
|
-
function GenuiBlockInstance({ spec, stateKey }: GenuiBlockProps) {
|
|
70
|
+
/** Stateful implementation. Streaming state adopts its first durable key
|
|
71
|
+
* when the reply settles; switching an existing durable key starts fresh. */
|
|
72
|
+
function GenuiBlockInstance({ spec, stateKey, animateEntrance = true }: GenuiBlockProps) {
|
|
75
73
|
const gap = spec.gap ?? 16
|
|
76
74
|
const onAction = useDebouncedAction(useGenuiAction())
|
|
77
75
|
// Grouped radios and grouped checkboxes record their local selections here;
|
|
@@ -109,16 +107,13 @@ function GenuiBlockInstance({ spec, stateKey }: GenuiBlockProps) {
|
|
|
109
107
|
})
|
|
110
108
|
}, [])
|
|
111
109
|
const setField = useCallback((id: string, value: string) => {
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
return prev[id] === value ? prev : { ...prev, [id]: value }
|
|
121
|
-
})
|
|
110
|
+
// Registry presence means "the user has touched this field" — a blank
|
|
111
|
+
// value is stored as '' instead of deleting the entry, so a user who
|
|
112
|
+
// CLEARS a model-provided default does not get the default back on the
|
|
113
|
+
// next mount. Blank values are still excluded from submit collection by
|
|
114
|
+
// SubmitNode's filledFields filter (and from persistence for secrets), so
|
|
115
|
+
// this does not change what the model receives.
|
|
116
|
+
setFields(prev => (prev[id] === value ? prev : { ...prev, [id]: value }))
|
|
122
117
|
}, [])
|
|
123
118
|
const registerSecretField = useCallback((id: string) => {
|
|
124
119
|
setSecretFields(prev => (prev.has(id) ? prev : new Set(prev).add(id)))
|
|
@@ -187,7 +182,14 @@ function GenuiBlockInstance({ spec, stateKey }: GenuiBlockProps) {
|
|
|
187
182
|
<div
|
|
188
183
|
key={i}
|
|
189
184
|
className={css.reveal}
|
|
190
|
-
style={{
|
|
185
|
+
style={{
|
|
186
|
+
animationDelay: `${Math.min(i * 90, 720)}ms`,
|
|
187
|
+
...(!animateEntrance || persisted !== null ? { animation: 'none' } : {}),
|
|
188
|
+
}}
|
|
189
|
+
onAnimationEnd={event => {
|
|
190
|
+
// Reattaching this DOM node must not replay its completed entrance.
|
|
191
|
+
if (event.target === event.currentTarget) event.currentTarget.style.animation = 'none'
|
|
192
|
+
}}
|
|
191
193
|
>
|
|
192
194
|
{renderNode(c, i, trackedAction, 0, answersState)}
|
|
193
195
|
</div>
|
|
@@ -199,12 +201,18 @@ function GenuiBlockInstance({ spec, stateKey }: GenuiBlockProps) {
|
|
|
199
201
|
|
|
200
202
|
/**
|
|
201
203
|
* Render a GenUI spec as an inline block. `stateKey` is also the durable
|
|
202
|
-
* component identity
|
|
203
|
-
*
|
|
204
|
-
* key
|
|
205
|
-
* volatile instance, preserving local interaction state as the spec grows.
|
|
204
|
+
* component identity. A streaming instance adopts its first durable key so
|
|
205
|
+
* inputs and pending actions survive settling. Leaving an existing durable
|
|
206
|
+
* key remounts, keeping different blocks' interaction state isolated.
|
|
206
207
|
*/
|
|
207
208
|
export const GenuiBlock = memo(function GenuiBlock(props: GenuiBlockProps) {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
const [identity, setIdentity] = useState({ stateKey: props.stateKey, generation: 0 })
|
|
210
|
+
if (identity.stateKey !== props.stateKey) {
|
|
211
|
+
setIdentity({
|
|
212
|
+
stateKey: props.stateKey,
|
|
213
|
+
generation: identity.generation + (identity.stateKey === undefined ? 0 : 1),
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
return <GenuiBlockInstance key={identity.generation} {...props} />
|
|
217
|
+
}, (prev, next) => prev.stateKey === next.stateKey
|
|
218
|
+
&& prev.animateEntrance === next.animateEntrance && specEquivalent(prev.spec, next.spec))
|
|
@@ -105,7 +105,23 @@
|
|
|
105
105
|
font-family: var(--dsl-g-font-mono);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
.surface { cursor: grab; touch-action:
|
|
108
|
+
.surface { cursor: grab; touch-action: pan-y; }
|
|
109
|
+
/* The current x-domain only shows when it differs from the initial one, next
|
|
110
|
+
to the way back. Without this, an accidental zoom left the reader with a
|
|
111
|
+
domain nobody asked for and no visible affordance to restore it. */
|
|
112
|
+
.plotTools { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0 0 4px; }
|
|
113
|
+
.plotHint { font-size: 12.5px; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; }
|
|
114
|
+
.plotReset {
|
|
115
|
+
padding: 2px 9px;
|
|
116
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
|
|
117
|
+
border-radius: 999px;
|
|
118
|
+
background: transparent;
|
|
119
|
+
color: var(--dsw-alias-label-secondary);
|
|
120
|
+
font-family: inherit;
|
|
121
|
+
font-size: 12.5px;
|
|
122
|
+
cursor: pointer;
|
|
123
|
+
}
|
|
124
|
+
.plotReset:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-state-business-primary, #4f8ef7); }
|
|
109
125
|
.surface:active { cursor: grabbing; }
|
|
110
126
|
|
|
111
127
|
.sliders { display: flex; flex-direction: column; gap: var(--dsl-g-gap-sm); }
|
package/src/client/PlotBlock.tsx
CHANGED
|
@@ -300,6 +300,9 @@ export const PlotBlock = memo(function PlotBlock({
|
|
|
300
300
|
const onPointerMove = (e: React.PointerEvent): void => {
|
|
301
301
|
const d = dragRef.current
|
|
302
302
|
if (d === null) return
|
|
303
|
+
// A few pixels of slack: a plain click or a text-selection drag must not
|
|
304
|
+
// shift the domain.
|
|
305
|
+
if (Math.abs(e.clientX - d.startX) < 4) return
|
|
303
306
|
const span = d.xMax - d.xMin
|
|
304
307
|
const dx = ((d.startX - e.clientX) / plotW) * span
|
|
305
308
|
setView(prev => ({ xMin: d.xMin + dx, xMax: d.xMax + dx, yMin: prev.yMin, yMax: prev.yMax }))
|
|
@@ -310,11 +313,18 @@ export const PlotBlock = memo(function PlotBlock({
|
|
|
310
313
|
// onWheel as a passive listener, where preventDefault() is ignored and the
|
|
311
314
|
// event bubbles up to the scroll container. Register natively with
|
|
312
315
|
// { passive: false } instead — the same pattern as the 3D scene viewer.
|
|
316
|
+
// Wheel zoom requires a modifier (Cmd/Ctrl). Without one the wheel is left
|
|
317
|
+
// alone so the CONVERSATION scrolls: an unmodified wheel over the chart used
|
|
318
|
+
// to be swallowed by the plot, so scrolling the page silently rescaled the
|
|
319
|
+
// chart instead (a reader could end up looking at a domain nobody asked for,
|
|
320
|
+
// with no way back). The #63 requirement still holds for the modified case —
|
|
321
|
+
// a native non-passive listener keeps the event from reaching the host.
|
|
313
322
|
const svgRef = useRef<SVGSVGElement | null>(null)
|
|
314
323
|
useEffect(() => {
|
|
315
324
|
const el = svgRef.current
|
|
316
325
|
if (el === null) return
|
|
317
326
|
const onWheel = (e: WheelEvent): void => {
|
|
327
|
+
if (!e.metaKey && !e.ctrlKey) return
|
|
318
328
|
e.preventDefault()
|
|
319
329
|
const span = xMax - xMin
|
|
320
330
|
const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15
|
|
@@ -326,13 +336,35 @@ export const PlotBlock = memo(function PlotBlock({
|
|
|
326
336
|
}
|
|
327
337
|
el.addEventListener('wheel', onWheel, { passive: false })
|
|
328
338
|
return () => el.removeEventListener('wheel', onWheel)
|
|
329
|
-
}, [xMin, xMax])
|
|
339
|
+
}, [xMin, xMax, fromX])
|
|
330
340
|
|
|
331
341
|
const hasParams = series.some(s => (s.params?.length ?? 0) > 0)
|
|
332
342
|
|
|
343
|
+
// Zoom/pan move the x window; without a way back a reader who zoomed by
|
|
344
|
+
// accident was stuck with a domain nobody asked for. The header therefore
|
|
345
|
+
// shows the CURRENT domain whenever it differs from the initial one and
|
|
346
|
+
// offers a one-click return.
|
|
347
|
+
const initialX = useRef({ min: propXMin, max: propXMax })
|
|
348
|
+
const viewMoved = Math.abs(xMin - initialX.current.min) > 1e-6 || Math.abs(xMax - initialX.current.max) > 1e-6
|
|
349
|
+
const resetView = (): void => {
|
|
350
|
+
setView(prev => ({ ...prev, xMin: initialX.current.min, xMax: initialX.current.max }))
|
|
351
|
+
}
|
|
352
|
+
|
|
333
353
|
return (
|
|
334
354
|
<div className={css.block} data-genui-plot>
|
|
335
355
|
{title !== undefined && <div className={css.title}>{title}</div>}
|
|
356
|
+
{hasData && hasValidRange && (
|
|
357
|
+
<div className={css.plotTools}>
|
|
358
|
+
<span className={css.plotHint}>
|
|
359
|
+
{viewMoved
|
|
360
|
+
? `x ∈ [${formatTick(xMin)}, ${formatTick(xMax)}]`
|
|
361
|
+
: '⌘/Ctrl + 滚轮缩放 · 拖拽平移'}
|
|
362
|
+
</span>
|
|
363
|
+
{viewMoved && (
|
|
364
|
+
<button type="button" className={css.plotReset} onClick={resetView}>↺ 回到初始区间</button>
|
|
365
|
+
)}
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
336
368
|
{hasData && hasValidRange ? (
|
|
337
369
|
<svg
|
|
338
370
|
width="100%"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECharts CORE asset bundle — the tree-shaken engine used by the common
|
|
3
|
+
* presets (bar / line / area / pie / scatter / bigline).
|
|
4
|
+
*
|
|
5
|
+
* Progressive disclosure for the engine: the full ECharts build is ~1 MB, and
|
|
6
|
+
* most answers only ever draw a bar or line chart. This bundle registers the
|
|
7
|
+
* same `createChart` surface with only the chart types and components those
|
|
8
|
+
* presets touch, so a basic chart downloads a fraction of the payload; the
|
|
9
|
+
* full engine (`echarts-full.js`) is fetched only when a spec needs a type
|
|
10
|
+
* outside this set or ships a raw `option`.
|
|
11
|
+
* @module @changfenhuang/dsh-genui/client/asset-echarts-core
|
|
12
|
+
*/
|
|
13
|
+
import * as echarts from 'echarts/core'
|
|
14
|
+
import { BarChart, LineChart, PieChart, ScatterChart } from 'echarts/charts'
|
|
15
|
+
import {
|
|
16
|
+
DataZoomComponent, DatasetComponent, GridComponent, LegendComponent, MarkAreaComponent,
|
|
17
|
+
MarkLineComponent, MarkPointComponent, TitleComponent, TooltipComponent, TransformComponent,
|
|
18
|
+
} from 'echarts/components'
|
|
19
|
+
import { CanvasRenderer } from 'echarts/renderers'
|
|
20
|
+
import type { EChartsCoreOption, EChartsType } from 'echarts/core'
|
|
21
|
+
|
|
22
|
+
echarts.use([
|
|
23
|
+
BarChart, LineChart, PieChart, ScatterChart,
|
|
24
|
+
GridComponent, TooltipComponent, LegendComponent, TitleComponent,
|
|
25
|
+
DataZoomComponent, MarkLineComponent, MarkAreaComponent, MarkPointComponent,
|
|
26
|
+
DatasetComponent, TransformComponent,
|
|
27
|
+
CanvasRenderer,
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
function createChart(el: HTMLElement, option: EChartsCoreOption, opts?: { height?: number }): EChartsType {
|
|
31
|
+
const initOpts = opts !== undefined && opts.height !== undefined ? { height: opts.height } : undefined
|
|
32
|
+
const instance = echarts.init(el, undefined, initOpts)
|
|
33
|
+
instance.setOption(option)
|
|
34
|
+
return instance
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const win = globalThis as unknown as { __GenuiAssets__?: Record<string, unknown> }
|
|
38
|
+
const assets = win.__GenuiAssets__ ?? (win.__GenuiAssets__ = {})
|
|
39
|
+
assets.echartsCore = { createChart }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ECharts asset-bundle entry:
|
|
3
|
-
*
|
|
2
|
+
* ECharts FULL asset-bundle entry: the complete engine, fetched only when a
|
|
3
|
+
* spec needs a chart type outside the core set or ships a raw `option`.
|
|
4
|
+
* Registers on `window.__GenuiAssets__.echartsFull`. Built as a standalone IIFE into
|
|
4
5
|
* `lib/assets/echarts.js` and served by the plugin's node-half route; loaded
|
|
5
6
|
* on demand by echarts-lazy when a spec contains an `echart` node.
|
|
6
7
|
* @module @changfenhuang/dsh-genui/client/asset-echarts
|
|
@@ -22,4 +23,4 @@ function createChart(el: HTMLElement, option: EChartsCoreOption, opts?: { height
|
|
|
22
23
|
|
|
23
24
|
const win = globalThis as unknown as { __GenuiAssets__?: Record<string, unknown> }
|
|
24
25
|
const assets = win.__GenuiAssets__ ?? (win.__GenuiAssets__ = {})
|
|
25
|
-
assets.
|
|
26
|
+
assets.echartsFull = { createChart }
|
|
@@ -49,10 +49,10 @@ const pending = new Map<string, Promise<Record<string, unknown>>>()
|
|
|
49
49
|
* file: repeated requests (several mermaid nodes, re-renders) share one
|
|
50
50
|
* script load; a failed load stays failed for the page (the component shows
|
|
51
51
|
* its fallback).
|
|
52
|
-
* @param name - 'mermaid', 'three', or 'echarts'.
|
|
52
|
+
* @param name - 'mermaid', 'three', 'echarts-core', or 'echarts-full'.
|
|
53
53
|
* @returns the registered engine surface.
|
|
54
54
|
*/
|
|
55
|
-
export function loadGenuiAsset<T>(name: 'mermaid' | 'three' | 'echarts'): Promise<T> {
|
|
55
|
+
export function loadGenuiAsset<T>(name: 'mermaid' | 'three' | 'echarts-core' | 'echarts-full'): Promise<T> {
|
|
56
56
|
const file = `${name}.js`
|
|
57
57
|
const existing = pending.get(file)
|
|
58
58
|
if (existing !== undefined) return existing as Promise<T>
|
|
@@ -62,7 +62,9 @@ export function loadGenuiAsset<T>(name: 'mermaid' | 'three' | 'echarts'): Promis
|
|
|
62
62
|
script.async = true
|
|
63
63
|
script.onload = () => {
|
|
64
64
|
const global = (window as unknown as AssetGlobal).__GenuiAssets__ ?? {}
|
|
65
|
-
|
|
65
|
+
// Asset files are kebab-case; the keys they register are camelCase.
|
|
66
|
+
const key = name.replace(/-(\w)/g, (_m, ch: string) => ch.toUpperCase())
|
|
67
|
+
const api = global[key]
|
|
66
68
|
if (api === undefined) {
|
|
67
69
|
reject(new Error(`genui asset '${file}' loaded but registered no '${name}' engine`))
|
|
68
70
|
return
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { memo, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
8
8
|
import { CodeBlock, DiffBlock, JsonTree, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
9
|
+
import { renderInline } from '../inline.ts'
|
|
9
10
|
import css from '../GenuiBlock.module.css'
|
|
10
11
|
import { GENUI_LIMITS } from '../genui-runtime/index.ts'
|
|
11
12
|
import { PlotBlock } from '../PlotBlock.tsx'
|
|
@@ -27,8 +28,8 @@ export const CalloutNode = memo(function CalloutNode({ node }: { node: GenuiCall
|
|
|
27
28
|
const toneClass = CALLOUT_TONES[tone] ?? css.calloutInfo
|
|
28
29
|
return (
|
|
29
30
|
<div className={`${css.callout} ${toneClass}`} data-genui-callout>
|
|
30
|
-
{node.title !== undefined && <div className={css.calloutTitle}>{node.title}</div>}
|
|
31
|
-
<div className={css.calloutBody}>{node.content}</div>
|
|
31
|
+
{node.title !== undefined && <div className={css.calloutTitle}>{renderInline(node.title)}</div>}
|
|
32
|
+
<div className={css.calloutBody}>{renderInline(node.content)}</div>
|
|
32
33
|
</div>
|
|
33
34
|
)
|
|
34
35
|
})
|
|
@@ -64,7 +65,7 @@ export const KeyValueNode = memo(function KeyValueNode({ node }: { node: GenuiKe
|
|
|
64
65
|
{pairs.map((pair, i) => (
|
|
65
66
|
<div key={i} className={css.kvRow}>
|
|
66
67
|
<dt className={css.kvKey}>{pair.key}</dt>
|
|
67
|
-
<dd className={css.kvValue}>{pair.value}</dd>
|
|
68
|
+
<dd className={css.kvValue}>{renderInline(pair.value)}</dd>
|
|
68
69
|
</div>
|
|
69
70
|
))}
|
|
70
71
|
</dl>
|
|
@@ -202,7 +203,7 @@ export function AccordionNode({ node, onAction, depth = 0, answers }: {
|
|
|
202
203
|
onClick={() => setOpen(open === i ? null : i)}
|
|
203
204
|
>
|
|
204
205
|
<span className={css.accTitle}>{item.title}</span>
|
|
205
|
-
<span className={css.accChevron}
|
|
206
|
+
<span className={css.accChevron} data-open={open === i} aria-hidden>▸</span>
|
|
206
207
|
</button>
|
|
207
208
|
{open === i && (
|
|
208
209
|
<div className={css.accBody} id={`${uid}-body-${i}`} aria-labelledby={`${uid}-head-${i}`}>
|
|
@@ -356,6 +357,24 @@ export const TimelineNode = memo(function TimelineNode({ node }: { node: GenuiTi
|
|
|
356
357
|
/** FileTree: indented tree of files and folders. Directory rows are LOCAL
|
|
357
358
|
* collapsible (spec.ts promised "collapsible children"; this makes it true)
|
|
358
359
|
* — click a dir to fold/unfold, default fully open. Zero model round trip. */
|
|
360
|
+
/** 14px folder / file glyphs: an emoji-free way to tell rows apart at a glance. */
|
|
361
|
+
function FolderGlyph() {
|
|
362
|
+
return (
|
|
363
|
+
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
|
364
|
+
<path d="M1.6 3.4h4.1l1.3 1.7h7.4v7.2a1 1 0 0 1-1 1H2.6a1 1 0 0 1-1-1z" fill="currentColor" opacity="0.85" />
|
|
365
|
+
</svg>
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function FileGlyph() {
|
|
370
|
+
return (
|
|
371
|
+
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
|
372
|
+
<path d="M3.8 1.6h4.9L13 5.9v8.5a1 1 0 0 1-1 1H4.8a1 1 0 0 1-1-1z" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
|
373
|
+
<path d="M8.7 1.7v4.3h4.2" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
|
374
|
+
</svg>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
359
378
|
export const FileTreeNode = memo(function FileTreeNode({ node }: { node: GenuiFileTree }) {
|
|
360
379
|
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
|
|
361
380
|
const toggle = (k: string): void => {
|
|
@@ -366,22 +385,43 @@ export const FileTreeNode = memo(function FileTreeNode({ node }: { node: GenuiFi
|
|
|
366
385
|
return next
|
|
367
386
|
})
|
|
368
387
|
}
|
|
388
|
+
// Children live in their OWN container under the row. They used to be
|
|
389
|
+
// appended inside `.ftRow` (display: flex), so a nested tree rendered
|
|
390
|
+
// side-by-side on one line instead of stacking.
|
|
369
391
|
const renderNode = (n: GenuiFileTreeNode, depth: number, path: string): ReactNode => {
|
|
370
392
|
if (depth > GENUI_LIMITS.maxTreeDepth) return null
|
|
371
393
|
const isDir = n.type === 'dir' || (n.children !== undefined && n.children.length > 0)
|
|
372
394
|
const folded = isDir && collapsed.has(path)
|
|
373
395
|
return (
|
|
374
|
-
<div key={path}
|
|
375
|
-
<
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
396
|
+
<div key={path}>
|
|
397
|
+
<div className={css.ftRow} style={{ paddingLeft: `${depth * 14 + 4}px` }}>
|
|
398
|
+
{Array.from({ length: depth }, (_v, i) => (
|
|
399
|
+
<span key={i} className={css.ftGuide} style={{ left: `${4 + i * 14 + 6}px` }} aria-hidden />
|
|
400
|
+
))}
|
|
401
|
+
<button
|
|
402
|
+
type="button"
|
|
403
|
+
className={css.ftNameBtn}
|
|
404
|
+
aria-expanded={isDir ? !folded : undefined}
|
|
405
|
+
onClick={isDir ? () => toggle(path) : undefined}
|
|
406
|
+
>
|
|
407
|
+
<span
|
|
408
|
+
className={css.ftChevron}
|
|
409
|
+
data-open={isDir ? String(!folded) : undefined}
|
|
410
|
+
aria-hidden
|
|
411
|
+
>
|
|
412
|
+
{isDir ? '▸' : ''}
|
|
413
|
+
</span>
|
|
414
|
+
<span className={`${css.ftGlyph} ${isDir ? css.ftGlyphDir : ''}`} aria-hidden>
|
|
415
|
+
{isDir ? <FolderGlyph /> : <FileGlyph />}
|
|
416
|
+
</span>
|
|
417
|
+
<span className={`${css.ftName} ${isDir ? css.ftDir : ''}`}>{n.name}</span>
|
|
418
|
+
</button>
|
|
419
|
+
</div>
|
|
420
|
+
{isDir && !folded && (
|
|
421
|
+
<div className={css.ftChildren}>
|
|
422
|
+
{(n.children ?? []).map((c, ci) => renderNode(c, depth + 1, `${path}/${ci}`))}
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
385
425
|
</div>
|
|
386
426
|
)
|
|
387
427
|
}
|
|
@@ -432,7 +472,11 @@ export const QuizNode = memo(function QuizNode({ node, onAction }: {
|
|
|
432
472
|
}
|
|
433
473
|
}}
|
|
434
474
|
>
|
|
435
|
-
|
|
475
|
+
{/* Before answering: a radio affordance so the row reads as a
|
|
476
|
+
choice. After: ✓ on the right answer, ✗ on a wrong pick. */}
|
|
477
|
+
<span className={css.quizMarker}>
|
|
478
|
+
{answered ? (opt.correct === true ? '✓' : isChosen ? '✗' : '') : isChosen ? '●' : '○'}
|
|
479
|
+
</span>
|
|
436
480
|
{opt.label}
|
|
437
481
|
</button>
|
|
438
482
|
)
|