@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.
- package/CHANGELOG.md +107 -0
- package/package.json +38 -23
- package/src/__tests__/visual-tree-integration.test.tsx +315 -0
- package/src/__tests__/workspace-shell.test.tsx +142 -0
- package/src/app/client-plugin.tsx +37 -0
- package/src/app/create-app.tsx +49 -5
- package/src/app/resolvers-context.tsx +29 -0
- package/src/app/tree-providers-context.tsx +68 -0
- package/src/layout/__tests__/visual-tree.test.tsx +300 -0
- package/src/layout/avatar.tsx +1 -1
- package/src/layout/editor-panel.tsx +104 -0
- package/src/layout/target-resolver-stub.tsx +75 -0
- package/src/layout/target-url.ts +86 -0
- package/src/layout/tree-node-renderer.tsx +386 -0
- package/src/layout/visual-tree.tsx +398 -0
- package/src/layout/workspace-shell.tsx +31 -6
|
@@ -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
|
+
}
|