@cosmicdrift/kumiko-renderer-web 0.2.3 → 0.3.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 +59 -0
- package/package.json +26 -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 +30 -0
- package/src/app/create-app.tsx +45 -5
- package/src/app/resolvers-context.tsx +29 -0
- package/src/app/tree-providers-context.tsx +44 -0
- package/src/layout/__tests__/visual-tree.test.tsx +287 -0
- package/src/layout/avatar.tsx +1 -1
- package/src/layout/editor-panel.tsx +91 -0
- package/src/layout/target-resolver-stub.tsx +53 -0
- package/src/layout/tree-node-renderer.tsx +292 -0
- package/src/layout/visual-tree.tsx +238 -0
- package/src/layout/workspace-shell.tsx +31 -5
|
@@ -0,0 +1,292 @@
|
|
|
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
|
+
TreeAction,
|
|
23
|
+
TreeChildrenSubscribe,
|
|
24
|
+
TreeContext,
|
|
25
|
+
TreeNode,
|
|
26
|
+
TreeNodeState,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
28
|
+
import { ChevronDown, ChevronRight, Plus } from "lucide-react";
|
|
29
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
30
|
+
import { cn } from "../lib/cn";
|
|
31
|
+
import { dispatchTarget } from "./target-resolver-stub";
|
|
32
|
+
|
|
33
|
+
// State → Tailwind-Klassen-Mapping. „filled" ist no-op (default-text).
|
|
34
|
+
// Restliche Werte signalisieren visuell: stub = leise, empty = stark
|
|
35
|
+
// gedimmt + italic, loading = pulse-animation, error = destruktiv-Farbe.
|
|
36
|
+
const STATE_CLASSES: Readonly<Record<TreeNodeState, string>> = {
|
|
37
|
+
filled: "",
|
|
38
|
+
stub: "opacity-55",
|
|
39
|
+
empty: "opacity-50 italic",
|
|
40
|
+
loading: "animate-pulse",
|
|
41
|
+
error: "text-destructive",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// TypeGuard für TreeChildrenSubscribe-Form. Nach `Array.isArray()`-check
|
|
45
|
+
// kann TS den Function-Branch nicht automatisch narrowen, daher dieser
|
|
46
|
+
// explizite Guard statt `as`-Cast (siehe Memory `[Type Assertions]` und
|
|
47
|
+
// build-target.ts:isArgsObject als Vorbild).
|
|
48
|
+
function isSubscribeFn(c: readonly TreeNode[] | TreeChildrenSubscribe): c is TreeChildrenSubscribe {
|
|
49
|
+
return typeof c === "function";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type TreeNodeRendererProps = {
|
|
53
|
+
readonly node: TreeNode;
|
|
54
|
+
readonly ctx: TreeContext;
|
|
55
|
+
readonly path: string;
|
|
56
|
+
readonly expanded: ReadonlySet<string>;
|
|
57
|
+
readonly onToggle: (path: string) => void;
|
|
58
|
+
readonly depth?: number;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function TreeNodeRenderer({
|
|
62
|
+
node,
|
|
63
|
+
ctx,
|
|
64
|
+
path,
|
|
65
|
+
expanded,
|
|
66
|
+
onToggle,
|
|
67
|
+
depth = 0,
|
|
68
|
+
}: TreeNodeRendererProps): ReactNode {
|
|
69
|
+
const isExpanded = expanded.has(path);
|
|
70
|
+
const hasChildren = node.children !== undefined;
|
|
71
|
+
|
|
72
|
+
// Dynamic-Children-Subscribe: nur wenn ausgeklappt UND Function-Form.
|
|
73
|
+
// null = noch nicht emitted (zeige loading), Array = letzter Emit.
|
|
74
|
+
//
|
|
75
|
+
// **Identity-Assumption** (TODO V.1.2 verifizieren mit echtem Provider):
|
|
76
|
+
// node.children muss als Function-Reference stabil über Re-Renders sein,
|
|
77
|
+
// sonst trigger der useEffect ständig unsubscribe+resubscribe ("re-
|
|
78
|
+
// subscribe-Storm"). Recommended-Pattern für Provider-Authors: Function
|
|
79
|
+
// als top-level-const oder useMemo'd, NICHT inline-Closure pro Emit.
|
|
80
|
+
// Bei first violation in V.1.2 entweder useMemo hier oder path-Cache
|
|
81
|
+
// im VisualTree-Top-Level — Entscheidung wenn realer Trigger sichtbar.
|
|
82
|
+
const [dynamicChildren, setDynamicChildren] = useState<readonly TreeNode[] | null>(null);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!isExpanded) return;
|
|
85
|
+
if (node.children === undefined) return;
|
|
86
|
+
if (!isSubscribeFn(node.children)) return; // static-Array-Pfad in ChildrenView
|
|
87
|
+
const subscribe = node.children(ctx);
|
|
88
|
+
const unsubscribe = subscribe(setDynamicChildren);
|
|
89
|
+
return unsubscribe;
|
|
90
|
+
}, [isExpanded, node.children, ctx]);
|
|
91
|
+
|
|
92
|
+
const stateClass = STATE_CLASSES[node.state ?? "filled"];
|
|
93
|
+
const indentStyle = { paddingLeft: `${depth * 12 + 8}px` };
|
|
94
|
+
|
|
95
|
+
const handleRowClick = (): void => {
|
|
96
|
+
if (hasChildren) {
|
|
97
|
+
onToggle(path);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (node.target !== undefined) {
|
|
101
|
+
dispatchTarget(node.target);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div data-kumiko-tree-node={path}>
|
|
107
|
+
{/* Outer Row als <div role="button"> statt <button>: native <button>
|
|
108
|
+
darf laut HTML-Spec keine geschachtelten <button>-Children
|
|
109
|
+
enthalten — die HoverActions würden sonst ungültiges HTML
|
|
110
|
+
erzeugen. role+tabIndex+keyDown gibt äquivalente a11y. TODO V.1.2:
|
|
111
|
+
Arrow-key-navigation zwischen Tree-Siblings (ARIA-tree-Pattern). */}
|
|
112
|
+
{/* biome-ignore lint/a11y/useSemanticElements: nested <button> wäre invalid HTML — siehe HoverActions */}
|
|
113
|
+
<div
|
|
114
|
+
className={cn(
|
|
115
|
+
"group flex w-full items-center gap-1.5 py-1 pr-2 cursor-pointer hover:bg-accent/30 rounded-sm",
|
|
116
|
+
stateClass,
|
|
117
|
+
)}
|
|
118
|
+
style={indentStyle}
|
|
119
|
+
onClick={handleRowClick}
|
|
120
|
+
onKeyDown={(e) => {
|
|
121
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
handleRowClick();
|
|
124
|
+
}
|
|
125
|
+
}}
|
|
126
|
+
role="button"
|
|
127
|
+
tabIndex={0}
|
|
128
|
+
aria-expanded={hasChildren ? isExpanded : undefined}
|
|
129
|
+
>
|
|
130
|
+
<ChevronGlyph hasChildren={hasChildren} expanded={isExpanded} />
|
|
131
|
+
{node.icon !== undefined && (
|
|
132
|
+
<span aria-hidden className="size-3.5">
|
|
133
|
+
{node.icon}
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
136
|
+
<span className="flex-1 truncate text-sm">{node.label}</span>
|
|
137
|
+
<HoverActions
|
|
138
|
+
actions={node.actions}
|
|
139
|
+
createAction={node.state === "empty" ? node.createAction : undefined}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
{isExpanded && (
|
|
143
|
+
<ChildrenView
|
|
144
|
+
node={node}
|
|
145
|
+
ctx={ctx}
|
|
146
|
+
path={path}
|
|
147
|
+
expanded={expanded}
|
|
148
|
+
onToggle={onToggle}
|
|
149
|
+
depth={depth}
|
|
150
|
+
dynamicChildren={dynamicChildren}
|
|
151
|
+
/>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function ChevronGlyph({
|
|
158
|
+
hasChildren,
|
|
159
|
+
expanded,
|
|
160
|
+
}: {
|
|
161
|
+
readonly hasChildren: boolean;
|
|
162
|
+
readonly expanded: boolean;
|
|
163
|
+
}): ReactNode {
|
|
164
|
+
if (!hasChildren) return <span aria-hidden className="size-3.5" />;
|
|
165
|
+
return expanded ? (
|
|
166
|
+
<ChevronDown aria-hidden className="size-3.5 shrink-0" />
|
|
167
|
+
) : (
|
|
168
|
+
<ChevronRight aria-hidden className="size-3.5 shrink-0" />
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function HoverActions({
|
|
173
|
+
actions,
|
|
174
|
+
createAction,
|
|
175
|
+
}: {
|
|
176
|
+
readonly actions?: readonly TreeAction[];
|
|
177
|
+
readonly createAction?: TreeAction;
|
|
178
|
+
}): ReactNode {
|
|
179
|
+
const has = (actions !== undefined && actions.length > 0) || createAction !== undefined;
|
|
180
|
+
if (!has) return null;
|
|
181
|
+
return (
|
|
182
|
+
<span className="invisible group-hover:visible flex items-center gap-1 shrink-0">
|
|
183
|
+
{createAction !== undefined && (
|
|
184
|
+
<ActionButton action={createAction} icon={<Plus className="size-3.5" />} />
|
|
185
|
+
)}
|
|
186
|
+
{actions?.map((a) => (
|
|
187
|
+
<ActionButton key={a.label} action={a} icon={<span aria-hidden>{a.icon}</span>} />
|
|
188
|
+
))}
|
|
189
|
+
</span>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function ActionButton({
|
|
194
|
+
action,
|
|
195
|
+
icon,
|
|
196
|
+
}: {
|
|
197
|
+
readonly action: TreeAction;
|
|
198
|
+
readonly icon: ReactNode;
|
|
199
|
+
}): ReactNode {
|
|
200
|
+
return (
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
aria-label={action.label}
|
|
204
|
+
className="p-0.5 hover:bg-accent rounded"
|
|
205
|
+
onClick={(e) => {
|
|
206
|
+
// Stop the event so the parent-row's onClick (which would
|
|
207
|
+
// toggle / dispatch the row's own target) doesn't fire.
|
|
208
|
+
e.stopPropagation();
|
|
209
|
+
dispatchTarget(action.target);
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
{icon}
|
|
213
|
+
</button>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function ChildrenView({
|
|
218
|
+
node,
|
|
219
|
+
ctx,
|
|
220
|
+
path,
|
|
221
|
+
expanded,
|
|
222
|
+
onToggle,
|
|
223
|
+
depth,
|
|
224
|
+
dynamicChildren,
|
|
225
|
+
}: {
|
|
226
|
+
readonly node: TreeNode;
|
|
227
|
+
readonly ctx: TreeContext;
|
|
228
|
+
readonly path: string;
|
|
229
|
+
readonly expanded: ReadonlySet<string>;
|
|
230
|
+
readonly onToggle: (path: string) => void;
|
|
231
|
+
readonly depth: number;
|
|
232
|
+
readonly dynamicChildren: readonly TreeNode[] | null;
|
|
233
|
+
}): ReactNode {
|
|
234
|
+
// Array.isArray narrow't TS automatisch auf readonly TreeNode[] — kein
|
|
235
|
+
// as-Cast nötig (Memory `[Type Assertions]`).
|
|
236
|
+
if (Array.isArray(node.children)) {
|
|
237
|
+
const children = node.children;
|
|
238
|
+
return (
|
|
239
|
+
<>
|
|
240
|
+
{children.map((child, idx) => {
|
|
241
|
+
// Path: idx als stabiler Disambiguator falls Provider doppelte
|
|
242
|
+
// Labels liefert (Provider-Bug, aber React-Keys müssen unique
|
|
243
|
+
// sein sonst silent state-corruption). Provider-Liefer-Order
|
|
244
|
+
// ist stabil — idx ist hier kein „array-shift"-Risk wie bei
|
|
245
|
+
// user-rearrangeable Lists.
|
|
246
|
+
const childPath = `${path}/${idx}-${child.label}`;
|
|
247
|
+
return (
|
|
248
|
+
<TreeNodeRenderer
|
|
249
|
+
key={childPath}
|
|
250
|
+
node={child}
|
|
251
|
+
ctx={ctx}
|
|
252
|
+
path={childPath}
|
|
253
|
+
expanded={expanded}
|
|
254
|
+
onToggle={onToggle}
|
|
255
|
+
depth={depth + 1}
|
|
256
|
+
/>
|
|
257
|
+
);
|
|
258
|
+
})}
|
|
259
|
+
</>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
// Dynamic-children-Pfad: noch nicht emitted → Lade-Zeile, dann Liste.
|
|
263
|
+
if (dynamicChildren === null) {
|
|
264
|
+
return (
|
|
265
|
+
<div
|
|
266
|
+
className="text-xs text-muted-foreground italic py-1"
|
|
267
|
+
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
|
268
|
+
>
|
|
269
|
+
Lädt …
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return (
|
|
274
|
+
<>
|
|
275
|
+
{dynamicChildren.map((child, idx) => {
|
|
276
|
+
// Selbe idx-Disambiguator-Logik wie ChildrenView static-Branch.
|
|
277
|
+
const childPath = `${path}/${idx}-${child.label}`;
|
|
278
|
+
return (
|
|
279
|
+
<TreeNodeRenderer
|
|
280
|
+
key={childPath}
|
|
281
|
+
node={child}
|
|
282
|
+
ctx={ctx}
|
|
283
|
+
path={childPath}
|
|
284
|
+
expanded={expanded}
|
|
285
|
+
onToggle={onToggle}
|
|
286
|
+
depth={depth + 1}
|
|
287
|
+
/>
|
|
288
|
+
);
|
|
289
|
+
})}
|
|
290
|
+
</>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// VisualTree — Top-Level-Component für `r.workspace({ navigation: "tree" })`-
|
|
3
|
+
// Workspaces. Ersetzt VisualTreeStub aus Phase 0 Schicht 3.
|
|
4
|
+
//
|
|
5
|
+
// **Pflichten** (visual-tree.md V.1.1-A):
|
|
6
|
+
// 1. Provider-Iteration via `useTreeProviders()`-Hook
|
|
7
|
+
// 2. Subscribe-Wiring pro Provider mit unsubscribe-Cleanup beim Unmount
|
|
8
|
+
// 3. Top-Level-Reihenfolge alphabetisch nach featureName
|
|
9
|
+
// 4. Children-Lazy-Load (TreeNodeRenderer subscribed wenn Knoten ausgeklappt)
|
|
10
|
+
// 5. localStorage-Persistenz für expanded-Set pro Workspace
|
|
11
|
+
//
|
|
12
|
+
// **Empty-State**: keine Provider registriert → Hint statt leerem
|
|
13
|
+
// `<aside>` (Memory `[Sicherheit > Convenience]`: explicit empty statt
|
|
14
|
+
// silent fallback). Apps die navigation:"tree" deklarieren aber keinen
|
|
15
|
+
// clientFeatures.treeProvider liefern, kriegen eine sichtbare Diagnose.
|
|
16
|
+
//
|
|
17
|
+
// **Tenant-Source V.1.1**: TreeContext.tenantId ist auf SYSTEM_TENANT_ID
|
|
18
|
+
// gepinnt. Provider die echten Tenant brauchen kommen V.1.2 (text-content)
|
|
19
|
+
// — dann wird der Tenant-Source via Auth-Layer / TenantContext / Prop
|
|
20
|
+
// verdrahtet. V.1.1 zeigt das Wiring durch, kein konkreter Tenant-Bedarf.
|
|
21
|
+
//
|
|
22
|
+
// Siehe visual-tree.md V.1.1-A.
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
TenantId,
|
|
26
|
+
TreeChildrenSubscribe,
|
|
27
|
+
TreeContext,
|
|
28
|
+
TreeNode,
|
|
29
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
30
|
+
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
|
31
|
+
import { useTreeProviders } from "../app/tree-providers-context";
|
|
32
|
+
import { TreeNodeRenderer } from "./tree-node-renderer";
|
|
33
|
+
|
|
34
|
+
const EXPANDED_STORAGE_PREFIX = "kumiko:visual-tree:expanded:";
|
|
35
|
+
|
|
36
|
+
// V.1.1-Pin: TenantId für die Default-Tree-Context. Lokal als Branded-
|
|
37
|
+
// Type-Construction (Memory `[Type Assertions]`) statt Value-Import aus
|
|
38
|
+
// /engine — der engine-Barrel ist `runtime`-klassifiziert und ein client-
|
|
39
|
+
// Modul darf ihn nicht als Wert konsumieren. **Sync-Pflicht**: muss mit
|
|
40
|
+
// SYSTEM_TENANT_ID aus engine/types/identifiers.ts identisch bleiben;
|
|
41
|
+
// ein Mismatch zeigt sich als „falscher Tenant" in Provider-Requests.
|
|
42
|
+
// V.1.2 ersetzt den Pin durch echten Tenant-Source (TenantContext oder
|
|
43
|
+
// Auth-Layer) sobald der erste Provider tenant-spezifische Daten braucht.
|
|
44
|
+
const SYSTEM_TENANT_ID_PIN = "00000000-0000-4000-8000-000000000000" as TenantId;
|
|
45
|
+
|
|
46
|
+
const DEFAULT_TREE_CTX: TreeContext = Object.freeze({ tenantId: SYSTEM_TENANT_ID_PIN });
|
|
47
|
+
|
|
48
|
+
export type VisualTreeProps = {
|
|
49
|
+
/** Workspace-ID des aktiven `navigation:"tree"`-Workspaces. Wird als
|
|
50
|
+
* Schlüssel für localStorage-Persistenz verwendet (separates
|
|
51
|
+
* Expand-State pro Workspace, weil ein User mehrere Visual-Workspaces
|
|
52
|
+
* haben kann). */
|
|
53
|
+
readonly workspaceId: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function VisualTree({ workspaceId }: VisualTreeProps): ReactNode {
|
|
57
|
+
const providers = useTreeProviders();
|
|
58
|
+
const sortedProviders = useMemo(
|
|
59
|
+
() => [...providers.entries()].sort(([a], [b]) => a.localeCompare(b)),
|
|
60
|
+
[providers],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => loadExpanded(workspaceId));
|
|
64
|
+
|
|
65
|
+
// localStorage-Persistenz: jeder Toggle persistiert sofort. Workspace-
|
|
66
|
+
// Switch lädt anderen Set neu (siehe 2nd useEffect).
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
saveExpanded(workspaceId, expanded);
|
|
69
|
+
}, [workspaceId, expanded]);
|
|
70
|
+
|
|
71
|
+
// Workspace-Switch: expanded-Set neu laden (User hat anderen Tree-
|
|
72
|
+
// Workspace ausgewählt, dort gilt anderer Set).
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
setExpanded(loadExpanded(workspaceId));
|
|
75
|
+
}, [workspaceId]);
|
|
76
|
+
|
|
77
|
+
const handleToggle = (path: string): void => {
|
|
78
|
+
setExpanded((prev) => {
|
|
79
|
+
const next = new Set(prev);
|
|
80
|
+
if (next.has(path)) {
|
|
81
|
+
next.delete(path);
|
|
82
|
+
} else {
|
|
83
|
+
next.add(path);
|
|
84
|
+
}
|
|
85
|
+
return next;
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
if (sortedProviders.length === 0) {
|
|
90
|
+
return <EmptyState />;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<aside
|
|
95
|
+
aria-label="Visual Tree"
|
|
96
|
+
data-kumiko-layout="visual-tree"
|
|
97
|
+
className="flex flex-col text-sm overflow-y-auto"
|
|
98
|
+
>
|
|
99
|
+
{sortedProviders.map(([featureName, provider]) => (
|
|
100
|
+
<ProviderBranch
|
|
101
|
+
key={featureName}
|
|
102
|
+
featureName={featureName}
|
|
103
|
+
provider={provider}
|
|
104
|
+
ctx={DEFAULT_TREE_CTX}
|
|
105
|
+
expanded={expanded}
|
|
106
|
+
onToggle={handleToggle}
|
|
107
|
+
/>
|
|
108
|
+
))}
|
|
109
|
+
</aside>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ProviderBranch — eine Sub-Section pro registrertem TreeProvider.
|
|
114
|
+
// Jeder Provider liefert eine readonly TreeNode[] über Subscribe; jeder
|
|
115
|
+
// Top-Level-Knoten wird via TreeNodeRenderer gerendert.
|
|
116
|
+
function ProviderBranch({
|
|
117
|
+
featureName,
|
|
118
|
+
provider,
|
|
119
|
+
ctx,
|
|
120
|
+
expanded,
|
|
121
|
+
onToggle,
|
|
122
|
+
}: {
|
|
123
|
+
readonly featureName: string;
|
|
124
|
+
readonly provider: TreeChildrenSubscribe;
|
|
125
|
+
readonly ctx: TreeContext;
|
|
126
|
+
readonly expanded: ReadonlySet<string>;
|
|
127
|
+
readonly onToggle: (path: string) => void;
|
|
128
|
+
}): ReactNode {
|
|
129
|
+
// null = noch nicht emitted (initial-Loading). Ein Provider der
|
|
130
|
+
// niemals emittet bleibt damit sichtbar als „lädt …" und nicht
|
|
131
|
+
// unsichtbar — Memory `[Sicherheit > Convenience]`.
|
|
132
|
+
const [nodes, setNodes] = useState<readonly TreeNode[] | null>(null);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
// TODO V.1.2: Subscribe-Error-Handling. Drei Error-Surfaces sind heute
|
|
136
|
+
// nicht abgedeckt: (1) provider(ctx) wirft synchron, (2) subscribe(emit)
|
|
137
|
+
// wirft synchron, (3) Provider-internes SSE/fetch wirft → emit nie
|
|
138
|
+
// gefeuert, „lädt …" bleibt unendlich. Fix-Shape hängt vom
|
|
139
|
+
// V.1.2-Consumer (text-content) ab — transient-network-blip vs.
|
|
140
|
+
// tenant-misconfig haben unterschiedliche Recovery-Pfade. Reload-
|
|
141
|
+
// Action im Knoten (state="error" + retry-Button) als minimaler
|
|
142
|
+
// Plan, aber ohne konkreten Failure-Mode-Anker keine richtige Spec.
|
|
143
|
+
const subscribe = provider(ctx);
|
|
144
|
+
const unsubscribe = subscribe(setNodes);
|
|
145
|
+
return unsubscribe;
|
|
146
|
+
}, [provider, ctx]);
|
|
147
|
+
|
|
148
|
+
if (nodes === null) {
|
|
149
|
+
return (
|
|
150
|
+
<div
|
|
151
|
+
data-kumiko-tree-branch={featureName}
|
|
152
|
+
data-kumiko-tree-state="loading"
|
|
153
|
+
className="text-xs text-muted-foreground italic px-2 py-1"
|
|
154
|
+
>
|
|
155
|
+
{featureName}: lädt …
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div data-kumiko-tree-branch={featureName}>
|
|
162
|
+
{nodes.map((node, idx) => {
|
|
163
|
+
// Selbe idx-Disambiguator-Logik wie TreeNodeRenderer.ChildrenView.
|
|
164
|
+
const nodePath = `${featureName}/${idx}-${node.label}`;
|
|
165
|
+
return (
|
|
166
|
+
<TreeNodeRenderer
|
|
167
|
+
key={nodePath}
|
|
168
|
+
node={node}
|
|
169
|
+
ctx={ctx}
|
|
170
|
+
path={nodePath}
|
|
171
|
+
expanded={expanded}
|
|
172
|
+
onToggle={onToggle}
|
|
173
|
+
depth={0}
|
|
174
|
+
/>
|
|
175
|
+
);
|
|
176
|
+
})}
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function EmptyState(): ReactNode {
|
|
182
|
+
// <section> + aria-label + tabIndex=0 macht den Empty-State per Tab
|
|
183
|
+
// erreichbar — sonst wäre die Diagnose-Message für Keyboard-Nutzer
|
|
184
|
+
// unsichtbar/nicht-fokussierbar. <section> ist semantisch korrekt für
|
|
185
|
+
// „informational region", Biome akzeptiert tabIndex hier (im Gegensatz
|
|
186
|
+
// zu <aside> oder bare <div role="region">).
|
|
187
|
+
// TODO V.1.2: Volle Arrow-Key-Navigation zwischen Tree-Siblings (siehe
|
|
188
|
+
// ARIA-tree-Pattern). Heute nur Tab-Reach + native button-Tastatur.
|
|
189
|
+
return (
|
|
190
|
+
<section
|
|
191
|
+
aria-label="Visual Tree (no providers)"
|
|
192
|
+
data-kumiko-layout="visual-tree-empty"
|
|
193
|
+
className="p-4 text-sm text-muted-foreground"
|
|
194
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: Empty-State ist eine Diagnose-Region die Keyboard-Nutzer per Tab erreichen können müssen
|
|
195
|
+
tabIndex={0}
|
|
196
|
+
>
|
|
197
|
+
<p className="m-0 font-semibold">Keine Tree-Provider aktiv</p>
|
|
198
|
+
<p className="mt-2">
|
|
199
|
+
Dieser Workspace deklariert <code>navigation: "tree"</code>, aber kein registriertes
|
|
200
|
+
Client-Feature liefert einen <code>treeProvider</code>. Siehe{" "}
|
|
201
|
+
<code>
|
|
202
|
+
createKumikoApp({"{"}clientFeatures: [...]{"}"})
|
|
203
|
+
</code>
|
|
204
|
+
.
|
|
205
|
+
</p>
|
|
206
|
+
</section>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// localStorage-Helpers. Stille Failure bei Storage-Errors (Quota,
|
|
211
|
+
// Privacy-Mode) — Tree funktioniert dann ohne Persistenz, was ok ist.
|
|
212
|
+
|
|
213
|
+
function storageKey(workspaceId: string): string {
|
|
214
|
+
return `${EXPANDED_STORAGE_PREFIX}${workspaceId}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function loadExpanded(workspaceId: string): ReadonlySet<string> {
|
|
218
|
+
if (typeof window === "undefined") return new Set();
|
|
219
|
+
try {
|
|
220
|
+
const raw = window.localStorage.getItem(storageKey(workspaceId));
|
|
221
|
+
if (raw === null) return new Set();
|
|
222
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
223
|
+
if (!Array.isArray(parsed)) return new Set();
|
|
224
|
+
return new Set(parsed.filter((p): p is string => typeof p === "string"));
|
|
225
|
+
} catch {
|
|
226
|
+
return new Set();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function saveExpanded(workspaceId: string, expanded: ReadonlySet<string>): void {
|
|
231
|
+
if (typeof window === "undefined") return;
|
|
232
|
+
try {
|
|
233
|
+
window.localStorage.setItem(storageKey(workspaceId), JSON.stringify([...expanded]));
|
|
234
|
+
} catch {
|
|
235
|
+
// Privacy-Mode / Quota-Errors → ignorieren, Tree läuft ohne
|
|
236
|
+
// Persistenz weiter.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -33,10 +33,13 @@ import type { AccessRule } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
|
33
33
|
import type { AppSchema, FeatureSchema, WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
|
|
34
34
|
import { qualifyNavId, toAppSchema, useNav } from "@cosmicdrift/kumiko-renderer";
|
|
35
35
|
import { type ReactNode, useCallback, useLayoutEffect, useMemo } from "react";
|
|
36
|
+
import { useResolvers } from "../app/resolvers-context";
|
|
36
37
|
import { AppLayout } from "./app-layout";
|
|
38
|
+
import { EditorPanel } from "./editor-panel";
|
|
37
39
|
import { lastSegment, NavTree } from "./nav-tree";
|
|
38
40
|
import { Sidebar } from "./sidebar";
|
|
39
41
|
import { Topbar } from "./topbar";
|
|
42
|
+
import { VisualTree } from "./visual-tree";
|
|
40
43
|
import { WorkspaceSwitcher } from "./workspace-switcher";
|
|
41
44
|
|
|
42
45
|
export type WorkspaceShellUser = {
|
|
@@ -182,19 +185,42 @@ export function WorkspaceShell({
|
|
|
182
185
|
<WorkspaceSwitcher workspaces={visible} activeId={activeId} onSelect={handleSelect} />
|
|
183
186
|
);
|
|
184
187
|
|
|
188
|
+
// Sidebar-Content-Switch: workspace mit `navigation: "tree"` (opt-in)
|
|
189
|
+
// mountet die VisualTree-Component statt NavTree. Default (kein
|
|
190
|
+
// navigation gesetzt oder navigation="nav") behält das existing
|
|
191
|
+
// NavTree-Verhalten — kein Breaking-Change für Apps die Visual-Tree
|
|
192
|
+
// nicht aktivieren.
|
|
193
|
+
//
|
|
194
|
+
// VisualTree konsumiert TreeProviders via Context (siehe
|
|
195
|
+
// app/tree-providers-context.tsx) — App-Author registriert seine
|
|
196
|
+
// clientFeatures.treeProvider via createKumikoApp, kein zusätzlicher
|
|
197
|
+
// Prop hier nötig. Workspace-ID wird durchgereicht für die
|
|
198
|
+
// localStorage-Persistenz des expanded-Set.
|
|
199
|
+
// Siehe docs/plans/architecture/visual-tree.md A1 + V.1.1-A.
|
|
200
|
+
const resolvers = useResolvers();
|
|
201
|
+
const isTreeMode = activeWorkspace?.definition.navigation === "tree";
|
|
202
|
+
|
|
203
|
+
const sidebarContent =
|
|
204
|
+
activeWorkspace?.definition.navigation === "tree" ? (
|
|
205
|
+
<VisualTree workspaceId={activeWorkspace.definition.id} />
|
|
206
|
+
) : (
|
|
207
|
+
<NavTree
|
|
208
|
+
schema={app}
|
|
209
|
+
{...(user !== undefined && { user })}
|
|
210
|
+
{...(allowedNavQns !== undefined && { allowedNavQns })}
|
|
211
|
+
/>
|
|
212
|
+
);
|
|
213
|
+
|
|
185
214
|
return (
|
|
186
215
|
<AppLayout
|
|
187
216
|
topbar={<Topbar start={brand} center={switcher || undefined} end={topbarActions} />}
|
|
188
217
|
sidebar={
|
|
189
218
|
<Sidebar {...(sidebarFooter !== undefined && { footer: sidebarFooter })}>
|
|
190
|
-
|
|
191
|
-
schema={app}
|
|
192
|
-
{...(user !== undefined && { user })}
|
|
193
|
-
{...(allowedNavQns !== undefined && { allowedNavQns })}
|
|
194
|
-
/>
|
|
219
|
+
{sidebarContent}
|
|
195
220
|
</Sidebar>
|
|
196
221
|
}
|
|
197
222
|
>
|
|
223
|
+
{isTreeMode && <EditorPanel resolvers={resolvers} />}
|
|
198
224
|
{children}
|
|
199
225
|
</AppLayout>
|
|
200
226
|
);
|