@cosmicdrift/kumiko-renderer-web 0.2.3 → 0.4.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.
@@ -0,0 +1,75 @@
1
+ // TargetResolver — V.1.2: Multi-Listener-TargetDispatch mit Test-Hook.
2
+ // V.1.4b: URL-State-Bridge via useDispatchTarget-Hook. Production schreibt
3
+ // target in nav.searchParams (F5-recovery); Subscribe-Stream bleibt für
4
+ // Test-Hooks (setDispatchListener) und Apps die kein NavProvider haben.
5
+ //
6
+ // Dispatch-Priority (V.1.2):
7
+ // 1. Test-Listener (setDispatchListener) — exklusiv, kein Production-
8
+ // Subscriber läuft während Tests (Test-Isolation).
9
+ // 2. Production-Subscriber (subscribeTargetDispatches) — alle
10
+ // registrierten Production-Subscriber.
11
+ // 3. Kein Test-Listener + keine Subscriber → console.debug fallback
12
+ // (damit unhandled Klicks sichtbar bleiben).
13
+ //
14
+ // **useDispatchTarget (V.1.4b)** ist der empfohlene Production-Pfad.
15
+ // TreeNodeRenderer ruft den Hook in seinem Click-Handler — er schreibt
16
+ // URL via nav.setSearchParams UND ruft den globalen dispatchTarget für
17
+ // Test-Listener-Kompatibilität.
18
+ //
19
+ // Siehe visual-tree.md V.1.2 + V.1.4b.
20
+
21
+ import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
22
+ import { useNav } from "@cosmicdrift/kumiko-renderer";
23
+ import { useCallback } from "react";
24
+ import { serializeTarget } from "./target-url";
25
+
26
+ type DispatchListener = (target: TargetRef) => void;
27
+
28
+ let testListener: DispatchListener | undefined;
29
+ const subscribers = new Set<DispatchListener>();
30
+
31
+ export function dispatchTarget(target: TargetRef): void {
32
+ if (testListener !== undefined) {
33
+ testListener(target);
34
+ return;
35
+ }
36
+ if (subscribers.size > 0) {
37
+ for (const fn of subscribers) {
38
+ fn(target);
39
+ }
40
+ return;
41
+ }
42
+ // biome-ignore lint/suspicious/noConsole: fallback wenn kein Subscriber registered
43
+ console.debug("[VisualTree] target dispatched (unhandled)", target);
44
+ }
45
+
46
+ /** Test-Hook: Exklusiver Spy. Returnt cleanup. */
47
+ export function setDispatchListener(fn: DispatchListener): () => void {
48
+ testListener = fn;
49
+ return () => {
50
+ testListener = undefined;
51
+ };
52
+ }
53
+
54
+ /** Production-Subscriber registrieren. Returnt unsubscribe. Läuft nur
55
+ * wenn kein Test-Listener aktiv (Test-Isolation). */
56
+ export function subscribeTargetDispatches(fn: DispatchListener): () => void {
57
+ subscribers.add(fn);
58
+ return () => {
59
+ subscribers.delete(fn);
60
+ };
61
+ }
62
+
63
+ /** V.1.4b: empfohlener Production-Dispatch. Schreibt target in
64
+ * nav.searchParams (URL-State, F5-fähig) UND ruft dispatchTarget für
65
+ * Test-Listener + legacy-Subscribers. */
66
+ export function useDispatchTarget(): (target: TargetRef) => void {
67
+ const nav = useNav();
68
+ return useCallback(
69
+ (target: TargetRef) => {
70
+ nav.setSearchParams(serializeTarget(target, nav.searchParams));
71
+ dispatchTarget(target);
72
+ },
73
+ [nav],
74
+ );
75
+ }
@@ -0,0 +1,86 @@
1
+ // @runtime client
2
+ // URL-Bridge für Visual-Tree-Targets. Serialisiert TargetRef in
3
+ // Search-Params + parsed zurück.
4
+ //
5
+ // **URL-Shape**:
6
+ // ?t=<featureId>:<action>&a_<argKey1>=<value1>&a_<argKey2>=<value2>...
7
+ //
8
+ // Beispiel: text-content edit "imprint/de"
9
+ // ?t=text-content:edit&a_slug=imprint&a_lang=de
10
+ //
11
+ // **Warum prefix `a_`**: vermeidet Naming-Konflikt mit anderen Query-
12
+ // Params (Pagination, Sort, Filter). Plus klare Trennung target-meta
13
+ // (`t`) vs. action-args (`a_*`).
14
+ //
15
+ // **Warum nicht JSON-encoded**: URL bleibt lesbar + bookmark-fähig.
16
+ // JSON-base64 wäre robust für arbitrary Shapes aber unleserlich. arg-
17
+ // values sind heute nur primitive strings (text-content: slug, lang) —
18
+ // V.1.5 kann auf JSON wechseln wenn nested-args echten Bedarf zeigen.
19
+
20
+ import type { TargetRef } from "@cosmicdrift/kumiko-framework/engine";
21
+
22
+ const TARGET_PARAM = "t";
23
+ const ARG_PREFIX = "a_";
24
+
25
+ /** Build a search-params update for `setSearchParams`. Clears all
26
+ * existing `a_*` keys plus `t` (so wechsel target nicht alte args
27
+ * liegen lässt). Returns null-value entries to clear, plus the new
28
+ * target entries. */
29
+ export function serializeTarget(
30
+ target: TargetRef,
31
+ currentParams: Readonly<Record<string, string>>,
32
+ ): Readonly<Record<string, string | null>> {
33
+ const updates: Record<string, string | null> = {};
34
+ // Clear all previous arg-keys (vermeidet stale args bei target-switch).
35
+ for (const key of Object.keys(currentParams)) {
36
+ if (key.startsWith(ARG_PREFIX)) updates[key] = null;
37
+ }
38
+ updates[TARGET_PARAM] = `${target.featureId}:${target.action}`;
39
+ if (target.args !== undefined) {
40
+ for (const [k, v] of Object.entries(target.args)) {
41
+ // V.1.4b: nur string-args. Numbers/booleans werden via String()
42
+ // koerziert; nested objects/arrays werden NICHT supported (würde
43
+ // JSON-encoded URL brauchen, siehe Header-Comment).
44
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
45
+ updates[`${ARG_PREFIX}${k}`] = String(v);
46
+ }
47
+ }
48
+ }
49
+ return updates;
50
+ }
51
+
52
+ /** Inverse — parse the current search-params back into a TargetRef.
53
+ * Returns undefined wenn der `t`-Key fehlt (kein active target). */
54
+ export function parseTargetFromSearchParams(
55
+ params: Readonly<Record<string, string>>,
56
+ ): TargetRef | undefined {
57
+ const t = params[TARGET_PARAM];
58
+ if (t === undefined || t === "") return undefined;
59
+ const sepIdx = t.indexOf(":");
60
+ if (sepIdx < 0) return undefined;
61
+ const featureId = t.slice(0, sepIdx);
62
+ const action = t.slice(sepIdx + 1);
63
+ if (featureId === "" || action === "") return undefined;
64
+
65
+ const args: Record<string, string> = {};
66
+ let hasArgs = false;
67
+ for (const [key, value] of Object.entries(params)) {
68
+ if (key.startsWith(ARG_PREFIX)) {
69
+ args[key.slice(ARG_PREFIX.length)] = value;
70
+ hasArgs = true;
71
+ }
72
+ }
73
+ return hasArgs ? { featureId, action, args } : { featureId, action };
74
+ }
75
+
76
+ /** Returns null-valued updates that clear target + all args. Used vom
77
+ * Close-Button + onUnmount. */
78
+ export function clearTargetSearchParams(
79
+ currentParams: Readonly<Record<string, string>>,
80
+ ): Readonly<Record<string, string | null>> {
81
+ const updates: Record<string, string | null> = { [TARGET_PARAM]: null };
82
+ for (const key of Object.keys(currentParams)) {
83
+ if (key.startsWith(ARG_PREFIX)) updates[key] = null;
84
+ }
85
+ return updates;
86
+ }
@@ -0,0 +1,386 @@
1
+ // TreeNodeRenderer — recursive Pro-Knoten-Component für den Visual-Tree.
2
+ //
3
+ // **Pflichten** (visual-tree.md V.1.1-C):
4
+ // 1. Render `[icon] [label] [actions]` row mit State-abhängigen Klassen
5
+ // 2. Click-Dispatch: onClick mit gesetztem `node.target` → dispatchTarget
6
+ // 3. Hover-Actions rechts (CSS-only, hover-visible)
7
+ // 4. Children rekursiv: static-Array direkt, TreeChildrenSubscribe lazy
8
+ // mit subscribe/unsubscribe an expand/collapse
9
+ // 5. Skeleton-Affordance: state="empty" + createAction → automatic
10
+ // "+"-Icon, dispatcht createAction.target
11
+ //
12
+ // **Expand-State** lebt nicht hier sondern im VisualTree (Top-Level)
13
+ // damit localStorage-Persistenz pro Workspace eine Stelle hat. Renderer
14
+ // kriegt `expanded: Set<path>` + `onToggle(path)` als Props.
15
+ //
16
+ // **Path** = Workspace-eindeutiger String (parent-path + child-index oder
17
+ // node.label-segment). Stable über Re-Renders, eindeutig pro Knoten.
18
+ //
19
+ // Siehe visual-tree.md V.1.1-C + A4 (TreeNode-Type-Definition).
20
+
21
+ import type {
22
+ TargetRef,
23
+ TreeAction,
24
+ TreeChildrenSubscribe,
25
+ TreeNode,
26
+ TreeNodeState,
27
+ } from "@cosmicdrift/kumiko-framework/engine";
28
+ import { useNav } from "@cosmicdrift/kumiko-renderer";
29
+ import { ChevronDown, ChevronRight, File, Folder, Plus } from "lucide-react";
30
+ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
31
+ import { cn } from "../lib/cn";
32
+ import { useDispatchTarget } from "./target-resolver-stub";
33
+ import { parseTargetFromSearchParams } from "./target-url";
34
+
35
+ // Icon-Registry (V.1.2-Stub): Provider liefern symbolische String-Keys
36
+ // (`node.icon = "folder"`), Renderer mappt auf das lucide-Component.
37
+ // Unknown Keys → kein Render (sauber leerer Slot, kein plain-string-
38
+ // Overlap im 14px-Container). V.1.3+ erweitert Registry um App-
39
+ // erweiterbare Custom-Icons; aktuelles Set deckt Tree-Folder/File-Bedarf
40
+ // vom V.1.2-Consumer (text-content groupBlocksBySlugPrefix → "folder")
41
+ // und legal-pages-Slugs (no icon set).
42
+ const NODE_ICONS: Readonly<Record<string, typeof Folder>> = {
43
+ folder: Folder,
44
+ file: File,
45
+ };
46
+
47
+ // State → Tailwind-Klassen-Mapping. „filled" ist no-op (default-text).
48
+ // Restliche Werte signalisieren visuell: stub = leise, empty = stark
49
+ // gedimmt + italic, loading = pulse-animation, error = destruktiv-Farbe.
50
+ const STATE_CLASSES: Readonly<Record<TreeNodeState, string>> = {
51
+ filled: "",
52
+ stub: "opacity-55",
53
+ empty: "opacity-50 italic",
54
+ loading: "animate-pulse",
55
+ error: "text-destructive",
56
+ };
57
+
58
+ // TypeGuard für TreeChildrenSubscribe-Form. Nach `Array.isArray()`-check
59
+ // kann TS den Function-Branch nicht automatisch narrowen, daher dieser
60
+ // explizite Guard statt `as`-Cast (siehe Memory `[Type Assertions]` und
61
+ // build-target.ts:isArgsObject als Vorbild).
62
+ function isSubscribeFn(c: readonly TreeNode[] | TreeChildrenSubscribe): c is TreeChildrenSubscribe {
63
+ return typeof c === "function";
64
+ }
65
+
66
+ // V.1.5c Target-Vergleich: TreeNode ist „active" wenn sein target dem
67
+ // aktiven Target aus der URL entspricht. Vergleich ist deep-shallow:
68
+ // featureId + action exakt, args als flat-Record mit shallow-equal
69
+ // (args sind heute nur primitives). null/undefined-tolerant.
70
+ function targetsEqual(a: TargetRef, b: TargetRef | undefined): boolean {
71
+ if (b === undefined) return false;
72
+ if (a.featureId !== b.featureId) return false;
73
+ if (a.action !== b.action) return false;
74
+ const aKeys = a.args ? Object.keys(a.args) : [];
75
+ const bKeys = b.args ? Object.keys(b.args) : [];
76
+ if (aKeys.length !== bKeys.length) return false;
77
+ for (const k of aKeys) {
78
+ if (a.args?.[k] !== b.args?.[k]) return false;
79
+ }
80
+ return true;
81
+ }
82
+
83
+ export type TreeNodeRendererProps = {
84
+ readonly node: TreeNode;
85
+ readonly path: string;
86
+ readonly expanded: ReadonlySet<string>;
87
+ readonly onToggle: (path: string) => void;
88
+ readonly depth?: number;
89
+ /** V.1.6c Roving-tabindex: nur das focused-Treeitem hat tabIndex=0,
90
+ * alle anderen tabIndex=-1. Wenn undefined → kein item focused
91
+ * (transient bei mount, VisualTree useEffect setzt's). */
92
+ readonly focusedPath?: string;
93
+ };
94
+
95
+ export function TreeNodeRenderer({
96
+ node,
97
+ path,
98
+ expanded,
99
+ onToggle,
100
+ depth = 0,
101
+ focusedPath,
102
+ }: TreeNodeRendererProps): ReactNode {
103
+ const isExpanded = expanded.has(path);
104
+ const hasChildren = node.children !== undefined;
105
+
106
+ // Dynamic-Children-Subscribe: nur wenn ausgeklappt UND Function-Form.
107
+ // null = noch nicht emitted (zeige loading), Array = letzter Emit.
108
+ //
109
+ // **Identity-Assumption** (TODO V.1.2 verifizieren mit echtem Provider):
110
+ // node.children muss als Function-Reference stabil über Re-Renders sein,
111
+ // sonst trigger der useEffect ständig unsubscribe+resubscribe ("re-
112
+ // subscribe-Storm"). Recommended-Pattern für Provider-Authors: Function
113
+ // als top-level-const oder useMemo'd, NICHT inline-Closure pro Emit.
114
+ // Bei first violation in V.1.2 entweder useMemo hier oder path-Cache
115
+ // im VisualTree-Top-Level — Entscheidung wenn realer Trigger sichtbar.
116
+ const [dynamicChildren, setDynamicChildren] = useState<readonly TreeNode[] | null>(null);
117
+ useEffect(() => {
118
+ if (!isExpanded) return;
119
+ if (node.children === undefined) return;
120
+ if (!isSubscribeFn(node.children)) return; // static-Array-Pfad in ChildrenView
121
+ const subscribe = node.children();
122
+ const unsubscribe = subscribe(setDynamicChildren);
123
+ return unsubscribe;
124
+ }, [isExpanded, node.children]);
125
+
126
+ const stateClass = STATE_CLASSES[node.state ?? "filled"];
127
+ const indentStyle = { paddingLeft: `${depth * 12 + 8}px` };
128
+ const dispatch = useDispatchTarget();
129
+ const nav = useNav();
130
+
131
+ // V.1.5c Active-Node-Highlight: TreeItem ist „selected" wenn sein
132
+ // target dem aktiven Target aus der URL entspricht. Vergleich via
133
+ // featureId+action+args (deep-shallow, args ist flat-Record). Plus
134
+ // scrollIntoView wenn active-Knoten nicht im Viewport ist (F5 mit
135
+ // tief im Tree liegendem Target → User sieht ihn nicht ohne Scroll).
136
+ const activeTarget = useMemo(
137
+ () => parseTargetFromSearchParams(nav.searchParams),
138
+ [nav.searchParams],
139
+ );
140
+ const isActive = node.target !== undefined && targetsEqual(node.target, activeTarget);
141
+ const rowRef = useRef<HTMLDivElement | null>(null);
142
+ useEffect(() => {
143
+ if (isActive && rowRef.current) {
144
+ rowRef.current.scrollIntoView({ block: "nearest", inline: "nearest" });
145
+ }
146
+ }, [isActive]);
147
+
148
+ const handleRowClick = (): void => {
149
+ if (hasChildren) {
150
+ onToggle(path);
151
+ return;
152
+ }
153
+ if (node.target !== undefined) {
154
+ dispatch(node.target);
155
+ }
156
+ };
157
+
158
+ // VS-Code-style indent-guides: ein 1px vertikaler border per
159
+ // ancestor-depth. Outer wrapper ist relative + die Lines sind absolute
160
+ // mit position pro depth-step (12px); top=0 bottom=0 streckt sie über
161
+ // Row + alle children (rekursiv wrapper wrappt children mit drin).
162
+ const indentGuides =
163
+ depth > 0
164
+ ? Array.from({ length: depth }, (_, i) => (
165
+ <div
166
+ // biome-ignore lint/suspicious/noArrayIndexKey: stable index = depth-level
167
+ key={i}
168
+ aria-hidden
169
+ className="absolute top-0 bottom-0 w-px bg-border/60 pointer-events-none"
170
+ style={{ left: `${i * 12 + 13}px` }}
171
+ />
172
+ ))
173
+ : null;
174
+
175
+ return (
176
+ <div data-kumiko-tree-node={path} className="relative">
177
+ {indentGuides}
178
+ {/* Outer Row als <div role="treeitem">: V.1.5a ARIA-tree-Pattern.
179
+ role + tabIndex=0 + aria-expanded gibt Screenreader Tree-
180
+ Semantik. Arrow-Key-Navigation läuft auf dem aside-Container
181
+ via querySelectorAll('[role=treeitem]') — siehe visual-tree.tsx.
182
+ div+role statt native button weil nested <button> in
183
+ HoverActions invalid HTML wäre. */}
184
+ <div
185
+ ref={rowRef}
186
+ className={cn(
187
+ // VS-Code-ähnlich: compact spacing, full-row click-area,
188
+ // hover subtle, active filled. Active sticky über hover via
189
+ // class-order. focus-ring nur bei Keyboard (focus-visible).
190
+ "group flex w-full items-center gap-1.5 py-0.5 pr-2 cursor-pointer rounded-sm relative",
191
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
192
+ // Explicit VS-Code-Blau für active (statt theme-`bg-accent`,
193
+ // weil publicstatus's accent near-white ist → kaum sichtbar).
194
+ // border-l-2 als VS-Code-Marker-Bar links.
195
+ isActive
196
+ ? "bg-blue-100 dark:bg-blue-900/40 border-l-2 border-l-blue-500"
197
+ : cn("hover:bg-muted/60", stateClass),
198
+ )}
199
+ style={indentStyle}
200
+ onClick={handleRowClick}
201
+ onKeyDown={(e) => {
202
+ if (e.key === "Enter" || e.key === " ") {
203
+ e.preventDefault();
204
+ handleRowClick();
205
+ }
206
+ }}
207
+ role="treeitem"
208
+ // V.1.6c Roving-tabindex: nur das focused-treeitem hat
209
+ // tabIndex=0, alle anderen tabIndex=-1. focusedPath=undefined
210
+ // ist transient (VisualTree useEffect setzt's post-mount auf
211
+ // erstes treeitem); während dieser Phase haben alle tabIndex=-1
212
+ // und Tab-Reach geht nicht durch — minimal-Window, akzeptabel.
213
+ tabIndex={focusedPath === path ? 0 : -1}
214
+ aria-expanded={hasChildren ? isExpanded : undefined}
215
+ aria-selected={isActive ? true : undefined}
216
+ data-kumiko-tree-path={path}
217
+ data-kumiko-tree-has-children={hasChildren ? "true" : "false"}
218
+ data-kumiko-tree-active={isActive ? "true" : undefined}
219
+ >
220
+ <ChevronGlyph hasChildren={hasChildren} expanded={isExpanded} />
221
+ {node.icon !== undefined &&
222
+ (() => {
223
+ const IconComponent = NODE_ICONS[node.icon];
224
+ if (IconComponent === undefined) return null;
225
+ // VS-Code-typische Icon-Color: folder yellow/amber, file
226
+ // muted. Plus fill für folder gibt geschlossenem-Folder-Look.
227
+ const iconColor = node.icon === "folder" ? "text-amber-500" : "text-muted-foreground";
228
+ return <IconComponent aria-hidden className={cn("size-3.5 shrink-0", iconColor)} />;
229
+ })()}
230
+ <span className="flex-1 truncate text-sm">{node.label}</span>
231
+ <HoverActions
232
+ actions={node.actions}
233
+ createAction={node.state === "empty" ? node.createAction : undefined}
234
+ />
235
+ </div>
236
+ {isExpanded && (
237
+ <ChildrenView
238
+ node={node}
239
+ path={path}
240
+ expanded={expanded}
241
+ onToggle={onToggle}
242
+ depth={depth}
243
+ dynamicChildren={dynamicChildren}
244
+ focusedPath={focusedPath}
245
+ />
246
+ )}
247
+ </div>
248
+ );
249
+ }
250
+
251
+ function ChevronGlyph({
252
+ hasChildren,
253
+ expanded,
254
+ }: {
255
+ readonly hasChildren: boolean;
256
+ readonly expanded: boolean;
257
+ }): ReactNode {
258
+ if (!hasChildren) return <span aria-hidden className="size-3.5" />;
259
+ return expanded ? (
260
+ <ChevronDown aria-hidden className="size-3.5 shrink-0" />
261
+ ) : (
262
+ <ChevronRight aria-hidden className="size-3.5 shrink-0" />
263
+ );
264
+ }
265
+
266
+ function HoverActions({
267
+ actions,
268
+ createAction,
269
+ }: {
270
+ readonly actions?: readonly TreeAction[];
271
+ readonly createAction?: TreeAction;
272
+ }): ReactNode {
273
+ const has = (actions !== undefined && actions.length > 0) || createAction !== undefined;
274
+ if (!has) return null;
275
+ return (
276
+ <span className="invisible group-hover:visible flex items-center gap-1 shrink-0">
277
+ {createAction !== undefined && (
278
+ <ActionButton action={createAction} icon={<Plus className="size-3.5" />} />
279
+ )}
280
+ {actions?.map((a) => (
281
+ <ActionButton key={a.label} action={a} icon={<span aria-hidden>{a.icon}</span>} />
282
+ ))}
283
+ </span>
284
+ );
285
+ }
286
+
287
+ function ActionButton({
288
+ action,
289
+ icon,
290
+ }: {
291
+ readonly action: TreeAction;
292
+ readonly icon: ReactNode;
293
+ }): ReactNode {
294
+ const dispatch = useDispatchTarget();
295
+ return (
296
+ <button
297
+ type="button"
298
+ aria-label={action.label}
299
+ className="p-0.5 hover:bg-accent rounded"
300
+ onClick={(e) => {
301
+ // Stop the event so the parent-row's onClick (which would
302
+ // toggle / dispatch the row's own target) doesn't fire.
303
+ e.stopPropagation();
304
+ dispatch(action.target);
305
+ }}
306
+ >
307
+ {icon}
308
+ </button>
309
+ );
310
+ }
311
+
312
+ function ChildrenView({
313
+ node,
314
+ path,
315
+ expanded,
316
+ onToggle,
317
+ depth,
318
+ dynamicChildren,
319
+ focusedPath,
320
+ }: {
321
+ readonly node: TreeNode;
322
+ readonly path: string;
323
+ readonly expanded: ReadonlySet<string>;
324
+ readonly onToggle: (path: string) => void;
325
+ readonly depth: number;
326
+ readonly dynamicChildren: readonly TreeNode[] | null;
327
+ readonly focusedPath: string | undefined;
328
+ }): ReactNode {
329
+ // Array.isArray narrow't TS automatisch auf readonly TreeNode[] — kein
330
+ // as-Cast nötig (Memory `[Type Assertions]`).
331
+ if (Array.isArray(node.children)) {
332
+ const children = node.children;
333
+ return (
334
+ <>
335
+ {children.map((child, idx) => {
336
+ // Path: idx als stabiler Disambiguator falls Provider doppelte
337
+ // Labels liefert (Provider-Bug, aber React-Keys müssen unique
338
+ // sein sonst silent state-corruption). Provider-Liefer-Order
339
+ // ist stabil — idx ist hier kein „array-shift"-Risk wie bei
340
+ // user-rearrangeable Lists.
341
+ const childPath = `${path}/${idx}-${child.label}`;
342
+ return (
343
+ <TreeNodeRenderer
344
+ key={childPath}
345
+ node={child}
346
+ path={childPath}
347
+ expanded={expanded}
348
+ onToggle={onToggle}
349
+ depth={depth + 1}
350
+ focusedPath={focusedPath}
351
+ />
352
+ );
353
+ })}
354
+ </>
355
+ );
356
+ }
357
+ // Dynamic-children-Pfad: noch nicht emitted → Lade-Zeile, dann Liste.
358
+ if (dynamicChildren === null) {
359
+ return (
360
+ <div
361
+ className="text-xs text-muted-foreground italic py-1"
362
+ style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
363
+ >
364
+ Lädt …
365
+ </div>
366
+ );
367
+ }
368
+ return (
369
+ <>
370
+ {dynamicChildren.map((child, idx) => {
371
+ // Selbe idx-Disambiguator-Logik wie ChildrenView static-Branch.
372
+ const childPath = `${path}/${idx}-${child.label}`;
373
+ return (
374
+ <TreeNodeRenderer
375
+ key={childPath}
376
+ node={child}
377
+ path={childPath}
378
+ expanded={expanded}
379
+ onToggle={onToggle}
380
+ depth={depth + 1}
381
+ />
382
+ );
383
+ })}
384
+ </>
385
+ );
386
+ }