@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,398 @@
|
|
|
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**: Provider sind session-bound; Backend liest tenantId
|
|
18
|
+
// aus session bei jedem fetch/dispatch. V.1.1 hatte ein TreeContext-Arg
|
|
19
|
+
// mit hardcoded pinned tenantId, das vom einzigen Consumer (text-content)
|
|
20
|
+
// ignoriert wurde. SR2-Rip 2026-05-18: Dead-API entfernt. Tenant-aware
|
|
21
|
+
// Provider re-introduce wenn realer Bedarf (siehe tree-node.ts comment).
|
|
22
|
+
//
|
|
23
|
+
// Siehe visual-tree.md V.1.1-A.
|
|
24
|
+
|
|
25
|
+
import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
|
+
import { useLiveEvents } from "@cosmicdrift/kumiko-renderer";
|
|
27
|
+
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
|
28
|
+
import { useTreeEntities, useTreeProviders } from "../app/tree-providers-context";
|
|
29
|
+
import { TreeNodeRenderer } from "./tree-node-renderer";
|
|
30
|
+
|
|
31
|
+
const EXPANDED_STORAGE_PREFIX = "kumiko:visual-tree:expanded:";
|
|
32
|
+
|
|
33
|
+
// Stable-reference empty-list — vermeidet useEffect-deps-Trigger durch
|
|
34
|
+
// jedes Render (sonst würde `[].length === 0` short-circuit doch der
|
|
35
|
+
// Identity-Vergleich subscribeLive-effect destabilisieren).
|
|
36
|
+
const EMPTY_ENTITY_LIST: readonly string[] = [];
|
|
37
|
+
|
|
38
|
+
export type VisualTreeProps = {
|
|
39
|
+
/** Workspace-ID des aktiven `navigation:"tree"`-Workspaces. Wird als
|
|
40
|
+
* Schlüssel für localStorage-Persistenz verwendet (separates
|
|
41
|
+
* Expand-State pro Workspace, weil ein User mehrere Visual-Workspaces
|
|
42
|
+
* haben kann). */
|
|
43
|
+
readonly workspaceId: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function VisualTree({ workspaceId }: VisualTreeProps): ReactNode {
|
|
47
|
+
const providers = useTreeProviders();
|
|
48
|
+
const treeEntities = useTreeEntities();
|
|
49
|
+
const sortedProviders = useMemo(
|
|
50
|
+
() => [...providers.entries()].sort(([a], [b]) => a.localeCompare(b)),
|
|
51
|
+
[providers],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => loadExpanded(workspaceId));
|
|
55
|
+
|
|
56
|
+
// V.1.6c Roving-tabindex: nur ein treeitem hat tabIndex=0, Tab cyclt
|
|
57
|
+
// damit aus dem Tree heraus statt durch alle Items. focusedPath
|
|
58
|
+
// tracked welches item current-focused ist; Arrow-Keys updaten es via
|
|
59
|
+
// native onFocus-event (siehe TreeNodeRenderer).
|
|
60
|
+
const [focusedPath, setFocusedPath] = useState<string | undefined>(undefined);
|
|
61
|
+
const asideRef = useRef<HTMLElement | null>(null);
|
|
62
|
+
|
|
63
|
+
// Beim ersten Render (oder content-Change) ist focusedPath
|
|
64
|
+
// undefined → ALLE Rows hätten tabIndex=-1 und Tab könnte Tree nicht
|
|
65
|
+
// betreten. Post-mount setzen wir das erste sichtbare treeitem als
|
|
66
|
+
// initial focus. Plus: wenn focusedPath einen Pfad zeigt der nicht
|
|
67
|
+
// mehr existiert (Tree-Refresh), fall back to first.
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (asideRef.current === null) return;
|
|
70
|
+
const items = asideRef.current.querySelectorAll<HTMLElement>('[role="treeitem"]');
|
|
71
|
+
if (items.length === 0) return;
|
|
72
|
+
if (focusedPath === undefined) {
|
|
73
|
+
const firstPath = items[0]?.dataset["kumikoTreePath"];
|
|
74
|
+
if (firstPath !== undefined) setFocusedPath(firstPath);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const stillExists = Array.from(items).some(
|
|
78
|
+
(el) => el.dataset["kumikoTreePath"] === focusedPath,
|
|
79
|
+
);
|
|
80
|
+
if (!stillExists) {
|
|
81
|
+
const firstPath = items[0]?.dataset["kumikoTreePath"];
|
|
82
|
+
if (firstPath !== undefined) setFocusedPath(firstPath);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// localStorage-Persistenz: jeder Toggle persistiert sofort. Workspace-
|
|
87
|
+
// Switch lädt anderen Set neu (siehe 2nd useEffect).
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
saveExpanded(workspaceId, expanded);
|
|
90
|
+
}, [workspaceId, expanded]);
|
|
91
|
+
|
|
92
|
+
// Workspace-Switch: expanded-Set neu laden (User hat anderen Tree-
|
|
93
|
+
// Workspace ausgewählt, dort gilt anderer Set).
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
setExpanded(loadExpanded(workspaceId));
|
|
96
|
+
}, [workspaceId]);
|
|
97
|
+
|
|
98
|
+
const handleToggle = (path: string): void => {
|
|
99
|
+
setExpanded((prev) => {
|
|
100
|
+
const next = new Set(prev);
|
|
101
|
+
if (next.has(path)) {
|
|
102
|
+
next.delete(path);
|
|
103
|
+
} else {
|
|
104
|
+
next.add(path);
|
|
105
|
+
}
|
|
106
|
+
return next;
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (sortedProviders.length === 0) {
|
|
111
|
+
return <EmptyState />;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// V.1.5a ARIA-Tree-Keyboard-Nav: arrow-keys navigieren zwischen
|
|
115
|
+
// sichtbaren treeitems via DOM-query (Source of Truth = was im DOM
|
|
116
|
+
// sichtbar ist, inkl. expand/collapse-State). Tab kommt aus dem Tree
|
|
117
|
+
// heraus (kein Trap); innerhalb wird Arrow-Key erwartet.
|
|
118
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLElement>): void => {
|
|
119
|
+
const target = e.target as HTMLElement;
|
|
120
|
+
if (target.getAttribute("role") !== "treeitem") return;
|
|
121
|
+
|
|
122
|
+
const items = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('[role="treeitem"]'));
|
|
123
|
+
const idx = items.indexOf(target);
|
|
124
|
+
if (idx < 0) return;
|
|
125
|
+
const path = target.dataset["kumikoTreePath"];
|
|
126
|
+
const hasChildren = target.dataset["kumikoTreeHasChildren"] === "true";
|
|
127
|
+
const isExpanded = target.getAttribute("aria-expanded") === "true";
|
|
128
|
+
|
|
129
|
+
switch (e.key) {
|
|
130
|
+
case "ArrowDown": {
|
|
131
|
+
if (idx < items.length - 1) {
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
items[idx + 1]?.focus();
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case "ArrowUp": {
|
|
138
|
+
if (idx > 0) {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
items[idx - 1]?.focus();
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case "ArrowRight": {
|
|
145
|
+
if (hasChildren && !isExpanded && path !== undefined) {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
handleToggle(path);
|
|
148
|
+
} else if (hasChildren && isExpanded && idx < items.length - 1) {
|
|
149
|
+
// Already expanded → move to first child (next visible item
|
|
150
|
+
// ist by DOM-order der erste child).
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
items[idx + 1]?.focus();
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "ArrowLeft": {
|
|
157
|
+
if (hasChildren && isExpanded && path !== undefined) {
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
handleToggle(path);
|
|
160
|
+
}
|
|
161
|
+
// V.1.5a: kein parent-jump (würde flat-list-traversal brauchen,
|
|
162
|
+
// ARIA-Tree-Pattern would expect that — geht V.1.5b mit roving-
|
|
163
|
+
// tabindex). Aktuell ArrowLeft auf collapsed-item: no-op.
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case "Home": {
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
items[0]?.focus();
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "End": {
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
items[items.length - 1]?.focus();
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<aside
|
|
181
|
+
ref={asideRef}
|
|
182
|
+
aria-label="Visual Tree"
|
|
183
|
+
data-kumiko-layout="visual-tree"
|
|
184
|
+
className="flex flex-col text-sm overflow-y-auto"
|
|
185
|
+
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: ARIA-tree pattern requires role=tree on container; <aside> is the right semantic outer element for a sidebar
|
|
186
|
+
role="tree"
|
|
187
|
+
onKeyDown={handleKeyDown}
|
|
188
|
+
onFocus={(e) => {
|
|
189
|
+
// Native focus auf einem treeitem (via Mouse-Click oder Arrow-
|
|
190
|
+
// Key's focus()) → setze focusedPath. Bubble-up vom child-row
|
|
191
|
+
// landet hier, e.target ist das treeitem.
|
|
192
|
+
const target = e.target as HTMLElement;
|
|
193
|
+
if (target.getAttribute("role") !== "treeitem") return;
|
|
194
|
+
const path = target.dataset["kumikoTreePath"];
|
|
195
|
+
if (path !== undefined && path !== focusedPath) setFocusedPath(path);
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
{sortedProviders.map(([featureName, provider]) => (
|
|
199
|
+
<ProviderBranch
|
|
200
|
+
key={featureName}
|
|
201
|
+
featureName={featureName}
|
|
202
|
+
provider={provider}
|
|
203
|
+
entities={treeEntities.get(featureName) ?? EMPTY_ENTITY_LIST}
|
|
204
|
+
expanded={expanded}
|
|
205
|
+
onToggle={handleToggle}
|
|
206
|
+
focusedPath={focusedPath}
|
|
207
|
+
/>
|
|
208
|
+
))}
|
|
209
|
+
</aside>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ProviderBranch — eine Sub-Section pro registrertem TreeProvider.
|
|
214
|
+
// Jeder Provider liefert eine readonly TreeNode[] über Subscribe; jeder
|
|
215
|
+
// Top-Level-Knoten wird via TreeNodeRenderer gerendert.
|
|
216
|
+
//
|
|
217
|
+
// **V.1.4 Subscribe-Error-Handling**: drei Error-Surfaces sind abgedeckt:
|
|
218
|
+
// 1. provider() throws synchron → try/catch im useEffect setzt error
|
|
219
|
+
// 2. subscribe(emit) throws synchron → same try/catch
|
|
220
|
+
// 3. Provider-interner SSE/fetch fail't async → Provider muss
|
|
221
|
+
// `emit({ error: ... })` rufen statt empty-emit. Heutige Convention:
|
|
222
|
+
// Provider können einen Marker-TreeNode mit state="error" emitten
|
|
223
|
+
// (siehe text-content client-plugin: catch + emit([])). Recovery-
|
|
224
|
+
// Pfad ist Retry-Button im error-banner, der `attempt` increments
|
|
225
|
+
// → useEffect re-fires (deps haben attempt drin) → provider neu
|
|
226
|
+
// aufgerufen.
|
|
227
|
+
function ProviderBranch({
|
|
228
|
+
featureName,
|
|
229
|
+
provider,
|
|
230
|
+
entities,
|
|
231
|
+
expanded,
|
|
232
|
+
onToggle,
|
|
233
|
+
focusedPath,
|
|
234
|
+
}: {
|
|
235
|
+
readonly featureName: string;
|
|
236
|
+
readonly provider: TreeChildrenSubscribe;
|
|
237
|
+
readonly entities: readonly string[];
|
|
238
|
+
readonly expanded: ReadonlySet<string>;
|
|
239
|
+
readonly onToggle: (path: string) => void;
|
|
240
|
+
readonly focusedPath: string | undefined;
|
|
241
|
+
}): ReactNode {
|
|
242
|
+
// null = noch nicht emitted (initial-Loading). Ein Provider der
|
|
243
|
+
// niemals emittet bleibt damit sichtbar als „lädt …" und nicht
|
|
244
|
+
// unsichtbar — Memory `[Sicherheit > Convenience]`.
|
|
245
|
+
const [nodes, setNodes] = useState<readonly TreeNode[] | null>(null);
|
|
246
|
+
const [error, setError] = useState<string | null>(null);
|
|
247
|
+
const [attempt, setAttempt] = useState(0);
|
|
248
|
+
|
|
249
|
+
// V.1.5b SSE-Tree-Refresh: subscribe live-events für die gelisteten
|
|
250
|
+
// entities, increment attempt → useEffect re-fires → provider re-mountet.
|
|
251
|
+
// Gleiche Mechanik wie der retry-button, daher single trigger.
|
|
252
|
+
const subscribeLive = useLiveEvents();
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (entities.length === 0) return;
|
|
255
|
+
const unsubs = entities.map((entityName) =>
|
|
256
|
+
subscribeLive(entityName, () => setAttempt((n) => n + 1)),
|
|
257
|
+
);
|
|
258
|
+
return () => {
|
|
259
|
+
for (const u of unsubs) u();
|
|
260
|
+
};
|
|
261
|
+
}, [entities, subscribeLive]);
|
|
262
|
+
|
|
263
|
+
// `attempt` ist absichtlich in den deps: Retry-Button increments
|
|
264
|
+
// attempt → useEffect re-fires → provider neu aufgerufen. Biome's
|
|
265
|
+
// static-analysis sieht attempt nicht im body und meldet es als
|
|
266
|
+
// unnecessary — semantisch ist es der Trigger.
|
|
267
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: attempt triggers retry
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
setError(null);
|
|
270
|
+
setNodes(null);
|
|
271
|
+
try {
|
|
272
|
+
const subscribe = provider();
|
|
273
|
+
try {
|
|
274
|
+
const unsubscribe = subscribe(setNodes, (e) =>
|
|
275
|
+
setError(e instanceof Error ? e.message : String(e)),
|
|
276
|
+
);
|
|
277
|
+
return unsubscribe;
|
|
278
|
+
} catch (e) {
|
|
279
|
+
setError(e instanceof Error ? e.message : "Subscribe fehlgeschlagen.");
|
|
280
|
+
}
|
|
281
|
+
} catch (e) {
|
|
282
|
+
setError(e instanceof Error ? e.message : "Provider-Init fehlgeschlagen.");
|
|
283
|
+
}
|
|
284
|
+
return undefined;
|
|
285
|
+
}, [provider, attempt]);
|
|
286
|
+
|
|
287
|
+
if (error !== null) {
|
|
288
|
+
return (
|
|
289
|
+
<div
|
|
290
|
+
data-kumiko-tree-branch={featureName}
|
|
291
|
+
data-kumiko-tree-state="error"
|
|
292
|
+
className="flex items-center gap-2 px-2 py-1 text-xs text-destructive"
|
|
293
|
+
>
|
|
294
|
+
<span className="flex-1">
|
|
295
|
+
{featureName}: {error}
|
|
296
|
+
</span>
|
|
297
|
+
<button
|
|
298
|
+
type="button"
|
|
299
|
+
onClick={() => setAttempt((n) => n + 1)}
|
|
300
|
+
className="rounded border border-destructive/40 px-2 py-0.5 hover:bg-destructive/10"
|
|
301
|
+
>
|
|
302
|
+
Neu laden
|
|
303
|
+
</button>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (nodes === null) {
|
|
309
|
+
return (
|
|
310
|
+
<div
|
|
311
|
+
data-kumiko-tree-branch={featureName}
|
|
312
|
+
data-kumiko-tree-state="loading"
|
|
313
|
+
className="text-xs text-muted-foreground italic px-2 py-1"
|
|
314
|
+
>
|
|
315
|
+
{featureName}: lädt …
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<div data-kumiko-tree-branch={featureName}>
|
|
322
|
+
{nodes.map((node, idx) => {
|
|
323
|
+
// Selbe idx-Disambiguator-Logik wie TreeNodeRenderer.ChildrenView.
|
|
324
|
+
const nodePath = `${featureName}/${idx}-${node.label}`;
|
|
325
|
+
return (
|
|
326
|
+
<TreeNodeRenderer
|
|
327
|
+
key={nodePath}
|
|
328
|
+
node={node}
|
|
329
|
+
path={nodePath}
|
|
330
|
+
expanded={expanded}
|
|
331
|
+
onToggle={onToggle}
|
|
332
|
+
depth={0}
|
|
333
|
+
focusedPath={focusedPath}
|
|
334
|
+
/>
|
|
335
|
+
);
|
|
336
|
+
})}
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function EmptyState(): ReactNode {
|
|
342
|
+
// <section> + aria-label + tabIndex=0 macht den Empty-State per Tab
|
|
343
|
+
// erreichbar — sonst wäre die Diagnose-Message für Keyboard-Nutzer
|
|
344
|
+
// unsichtbar/nicht-fokussierbar. <section> ist semantisch korrekt für
|
|
345
|
+
// „informational region", Biome akzeptiert tabIndex hier (im Gegensatz
|
|
346
|
+
// zu <aside> oder bare <div role="region">).
|
|
347
|
+
// TODO V.1.2: Volle Arrow-Key-Navigation zwischen Tree-Siblings (siehe
|
|
348
|
+
// ARIA-tree-Pattern). Heute nur Tab-Reach + native button-Tastatur.
|
|
349
|
+
return (
|
|
350
|
+
<section
|
|
351
|
+
aria-label="Visual Tree (no providers)"
|
|
352
|
+
data-kumiko-layout="visual-tree-empty"
|
|
353
|
+
className="p-4 text-sm text-muted-foreground"
|
|
354
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: Empty-State ist eine Diagnose-Region die Keyboard-Nutzer per Tab erreichen können müssen
|
|
355
|
+
tabIndex={0}
|
|
356
|
+
>
|
|
357
|
+
<p className="m-0 font-semibold">Keine Tree-Provider aktiv</p>
|
|
358
|
+
<p className="mt-2">
|
|
359
|
+
Dieser Workspace deklariert <code>navigation: "tree"</code>, aber kein registriertes
|
|
360
|
+
Client-Feature liefert einen <code>treeProvider</code>. Siehe{" "}
|
|
361
|
+
<code>
|
|
362
|
+
createKumikoApp({"{"}clientFeatures: [...]{"}"})
|
|
363
|
+
</code>
|
|
364
|
+
.
|
|
365
|
+
</p>
|
|
366
|
+
</section>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// localStorage-Helpers. Stille Failure bei Storage-Errors (Quota,
|
|
371
|
+
// Privacy-Mode) — Tree funktioniert dann ohne Persistenz, was ok ist.
|
|
372
|
+
|
|
373
|
+
function storageKey(workspaceId: string): string {
|
|
374
|
+
return `${EXPANDED_STORAGE_PREFIX}${workspaceId}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function loadExpanded(workspaceId: string): ReadonlySet<string> {
|
|
378
|
+
if (typeof window === "undefined") return new Set();
|
|
379
|
+
try {
|
|
380
|
+
const raw = window.localStorage.getItem(storageKey(workspaceId));
|
|
381
|
+
if (raw === null) return new Set();
|
|
382
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
383
|
+
if (!Array.isArray(parsed)) return new Set();
|
|
384
|
+
return new Set(parsed.filter((p): p is string => typeof p === "string"));
|
|
385
|
+
} catch {
|
|
386
|
+
return new Set();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function saveExpanded(workspaceId: string, expanded: ReadonlySet<string>): void {
|
|
391
|
+
if (typeof window === "undefined") return;
|
|
392
|
+
try {
|
|
393
|
+
window.localStorage.setItem(storageKey(workspaceId), JSON.stringify([...expanded]));
|
|
394
|
+
} catch {
|
|
395
|
+
// Privacy-Mode / Quota-Errors → ignorieren, Tree läuft ohne
|
|
396
|
+
// Persistenz weiter.
|
|
397
|
+
}
|
|
398
|
+
}
|
|
@@ -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,20 +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
|
>
|
|
198
|
-
{children}
|
|
223
|
+
{isTreeMode ? <EditorPanel resolvers={resolvers} /> : children}
|
|
199
224
|
</AppLayout>
|
|
200
225
|
);
|
|
201
226
|
}
|