@principal-ai/file-city-react 0.5.40 → 0.5.41

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.
Files changed (55) hide show
  1. package/dist/components/FileCity3D/FileCity3D.d.ts +8 -2
  2. package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
  3. package/dist/components/FileCity3D/FileCity3D.js +129 -40
  4. package/dist/components/FileCityExplorer/AddToAreaModal.d.ts +14 -0
  5. package/dist/components/FileCityExplorer/AddToAreaModal.d.ts.map +1 -0
  6. package/dist/components/FileCityExplorer/AddToAreaModal.js +140 -0
  7. package/dist/components/FileCityExplorer/AddToScopeModal.d.ts +14 -0
  8. package/dist/components/FileCityExplorer/AddToScopeModal.d.ts.map +1 -0
  9. package/dist/components/FileCityExplorer/AddToScopeModal.js +176 -0
  10. package/dist/components/FileCityExplorer/FileCityExplorer.d.ts +30 -0
  11. package/dist/components/FileCityExplorer/FileCityExplorer.d.ts.map +1 -0
  12. package/dist/components/FileCityExplorer/FileCityExplorer.js +1045 -0
  13. package/dist/components/FileCityExplorer/ScopeInfoOverlay.d.ts +10 -0
  14. package/dist/components/FileCityExplorer/ScopeInfoOverlay.d.ts.map +1 -0
  15. package/dist/components/FileCityExplorer/ScopeInfoOverlay.js +73 -0
  16. package/dist/components/FileCityExplorer/index.d.ts +3 -0
  17. package/dist/components/FileCityExplorer/index.d.ts.map +1 -0
  18. package/dist/components/FileCityExplorer/index.js +1 -0
  19. package/dist/components/FileCityExplorer/layers.d.ts +16 -0
  20. package/dist/components/FileCityExplorer/layers.d.ts.map +1 -0
  21. package/dist/components/FileCityExplorer/layers.js +61 -0
  22. package/dist/components/FileCityExplorer/model.d.ts +32 -0
  23. package/dist/components/FileCityExplorer/model.d.ts.map +1 -0
  24. package/dist/components/FileCityExplorer/model.js +14 -0
  25. package/dist/components/FileCityExplorer/pathConversion.d.ts +19 -0
  26. package/dist/components/FileCityExplorer/pathConversion.d.ts.map +1 -0
  27. package/dist/components/FileCityExplorer/pathConversion.js +26 -0
  28. package/dist/components/FileCityExplorer/scopeTreePaths.d.ts +21 -0
  29. package/dist/components/FileCityExplorer/scopeTreePaths.d.ts.map +1 -0
  30. package/dist/components/FileCityExplorer/scopeTreePaths.js +42 -0
  31. package/dist/components/FileCityExplorer/styles.d.ts +9 -0
  32. package/dist/components/FileCityExplorer/styles.d.ts.map +1 -0
  33. package/dist/components/FileCityExplorer/styles.js +28 -0
  34. package/dist/utils/folderElevatedPanels.d.ts +3 -1
  35. package/dist/utils/folderElevatedPanels.d.ts.map +1 -1
  36. package/dist/utils/folderElevatedPanels.js +5 -2
  37. package/package.json +2 -1
  38. package/src/components/FileCity3D/FileCity3D.tsx +200 -52
  39. package/src/components/FileCityExplorer/AddToAreaModal.tsx +273 -0
  40. package/src/components/FileCityExplorer/AddToScopeModal.tsx +320 -0
  41. package/src/components/FileCityExplorer/FileCityExplorer.tsx +1457 -0
  42. package/src/components/FileCityExplorer/ScopeInfoOverlay.tsx +229 -0
  43. package/src/components/FileCityExplorer/index.ts +2 -0
  44. package/src/components/FileCityExplorer/layers.ts +72 -0
  45. package/src/components/FileCityExplorer/model.ts +35 -0
  46. package/src/components/FileCityExplorer/pathConversion.ts +32 -0
  47. package/src/components/FileCityExplorer/scopeTreePaths.ts +52 -0
  48. package/src/components/FileCityExplorer/styles.ts +34 -0
  49. package/src/stories/2D3DComparison.stories.tsx +13 -2
  50. package/src/stories/ElevatedScopePanels.stories.tsx +295 -0
  51. package/src/stories/FileCity3D.stories.tsx +24 -3
  52. package/src/stories/FileCityExplorer.stories.tsx +2474 -0
  53. package/src/stories/FileCityExplorerComponent.stories.tsx +59 -0
  54. package/src/utils/folderElevatedPanels.ts +8 -2
  55. package/src/stories/ScopeOverlay.stories.tsx +0 -1610
@@ -0,0 +1,1457 @@
1
+ import React from 'react';
2
+ import { useTheme } from '@principal-ade/industry-theme';
3
+ import {
4
+ FileTree,
5
+ useFileTree,
6
+ useFileTreeSelector,
7
+ type UseFileTreeResult,
8
+ } from '@pierre/trees/react';
9
+ import type { FileTreeDirectoryHandle, FileTreeItemHandle } from '@pierre/trees';
10
+
11
+ import {
12
+ FileCity3D,
13
+ type CityData,
14
+ type CityDistrict,
15
+ type ElevatedScopePanel,
16
+ } from '../FileCity3D';
17
+ import { buildFolderElevatedPanels, buildFolderIndex } from '../../utils/folderElevatedPanels';
18
+
19
+ import { AddToAreaModal } from './AddToAreaModal';
20
+ import { AddToScopeModal } from './AddToScopeModal';
21
+ import { ScopeInfoOverlay } from './ScopeInfoOverlay';
22
+ import { AREA_PANEL_COLOR, buildLayersForScope, pickNamespaceColor } from './layers';
23
+ import type { Namespace, ProjectArea, Scope } from './model';
24
+ import { createPathConverters } from './pathConversion';
25
+ import {
26
+ buildScopeTreePaths,
27
+ parseScopeTreePath,
28
+ type ScopeTreeSelection,
29
+ } from './scopeTreePaths';
30
+ import { makeSectionLabelStyle, withAlpha } from './styles';
31
+
32
+ type FileTreeModel = UseFileTreeResult['model'];
33
+
34
+ /**
35
+ * Narrow a `FileTreeItemHandle` to its directory variant. The library's
36
+ * `isDirectory()` method returns `true`/`false` literals but isn't a
37
+ * `this is X` predicate, so callers can't use it to access directory-only
38
+ * methods (`expand`, `collapse`, `toggle`) without help.
39
+ */
40
+ function asDir(
41
+ handle: FileTreeItemHandle | null | undefined,
42
+ ): FileTreeDirectoryHandle | null {
43
+ return handle && handle.isDirectory() ? (handle as FileTreeDirectoryHandle) : null;
44
+ }
45
+
46
+ export interface FileCityExplorerProps {
47
+ /** City data to render in the 3D canvas. */
48
+ cityData: CityData;
49
+ /**
50
+ * Prefix that scopes/namespace paths are stripped of when read out of
51
+ * `cityData` (e.g. `'electron-app/'`). Pass `''` if the city data is already
52
+ * rooted at the project root.
53
+ */
54
+ packageRoot: string;
55
+ /** Initial scopes (used when no persisted state is found). */
56
+ initialScopes?: Scope[];
57
+ /** Initial areas (used when no persisted state is found). */
58
+ initialAreas?: ProjectArea[];
59
+ /**
60
+ * When set, scopes/areas round-trip through `localStorage` under
61
+ * `${persistKey}.scopes` and `${persistKey}.areas`. When omitted, state is
62
+ * purely in-memory.
63
+ */
64
+ persistKey?: string | null;
65
+ /**
66
+ * Initial focused directory (city path). Defaults to the city root derived
67
+ * from `packageRoot` (with trailing slash stripped).
68
+ */
69
+ initialFocusDirectory?: string | null;
70
+ }
71
+
72
+ export const FileCityExplorer: React.FC<FileCityExplorerProps> = ({
73
+ cityData,
74
+ packageRoot,
75
+ initialScopes,
76
+ initialAreas,
77
+ persistKey,
78
+ initialFocusDirectory,
79
+ }) => {
80
+ const { theme } = useTheme();
81
+ const sectionLabelStyle = makeSectionLabelStyle(theme);
82
+
83
+ // City root without trailing slash — used as the iterative-zoom-out clamp
84
+ // and the default initial focus.
85
+ const packageRootClamp = React.useMemo(
86
+ () => (packageRoot.endsWith('/') ? packageRoot.slice(0, -1) : packageRoot),
87
+ [packageRoot],
88
+ );
89
+
90
+ const { toScopePath, toCityPath } = React.useMemo(
91
+ () => createPathConverters(packageRoot),
92
+ [packageRoot],
93
+ );
94
+
95
+ // City-derived lookups — recomputed only if `cityData` changes.
96
+ const cityPaths = React.useMemo<string[]>(() => {
97
+ const set = new Set<string>();
98
+ for (const b of cityData.buildings) set.add(b.path);
99
+ return Array.from(set).sort();
100
+ }, [cityData]);
101
+
102
+ const cityDirectories = React.useMemo<Set<string>>(
103
+ () => new Set(cityData.districts.map(d => d.path)),
104
+ [cityData],
105
+ );
106
+
107
+ const districtsByPath = React.useMemo<Map<string, CityDistrict>>(
108
+ () => new Map(cityData.districts.map(d => [d.path, d])),
109
+ [cityData],
110
+ );
111
+
112
+ const folderIndex = React.useMemo(() => buildFolderIndex(cityData), [cityData]);
113
+
114
+ // Storage keys derived from persistKey (if set).
115
+ const scopesKey = persistKey ? `${persistKey}.scopes` : null;
116
+ const areasKey = persistKey ? `${persistKey}.areas` : null;
117
+
118
+ const [scopes, setScopes] = React.useState<Scope[]>(() => {
119
+ if (scopesKey && typeof window !== 'undefined') {
120
+ try {
121
+ const raw = window.localStorage.getItem(scopesKey);
122
+ if (raw) {
123
+ const parsed = JSON.parse(raw);
124
+ if (Array.isArray(parsed)) return parsed as Scope[];
125
+ }
126
+ } catch {
127
+ // fall through
128
+ }
129
+ }
130
+ return initialScopes ?? [];
131
+ });
132
+
133
+ const [areas, setAreas] = React.useState<ProjectArea[]>(() => {
134
+ if (areasKey && typeof window !== 'undefined') {
135
+ try {
136
+ const raw = window.localStorage.getItem(areasKey);
137
+ if (raw) {
138
+ const parsed = JSON.parse(raw);
139
+ if (Array.isArray(parsed)) return parsed as ProjectArea[];
140
+ }
141
+ } catch {
142
+ // fall through
143
+ }
144
+ }
145
+ return initialAreas ?? [];
146
+ });
147
+
148
+ React.useEffect(() => {
149
+ if (!scopesKey || typeof window === 'undefined') return;
150
+ try {
151
+ window.localStorage.setItem(scopesKey, JSON.stringify(scopes));
152
+ } catch {
153
+ // ignore quota / serialization errors
154
+ }
155
+ }, [scopes, scopesKey]);
156
+
157
+ React.useEffect(() => {
158
+ if (!areasKey || typeof window === 'undefined') return;
159
+ try {
160
+ window.localStorage.setItem(areasKey, JSON.stringify(areas));
161
+ } catch {
162
+ // ignore quota / serialization errors
163
+ }
164
+ }, [areas, areasKey]);
165
+
166
+ const [focusDirectory, setFocusDirectory] = React.useState<string | null>(
167
+ initialFocusDirectory !== undefined ? initialFocusDirectory : packageRootClamp,
168
+ );
169
+ const [focusPinned, setFocusPinned] = React.useState(false);
170
+ // While pinned, tree/scope selections must not change focusDirectory.
171
+ // Wrapping the setter (rather than gating each call site) keeps the pin
172
+ // honoured even from event handlers we add later.
173
+ const focusPinnedRef = React.useRef(focusPinned);
174
+ React.useEffect(() => {
175
+ focusPinnedRef.current = focusPinned;
176
+ }, [focusPinned]);
177
+ // Keep a ref to focusDirectory so event handlers (e.g. the city's
178
+ // double-click handler) can branch on it without taking a hard dep
179
+ // and re-rebuilding folder panels on every focus change.
180
+ const focusDirectoryRef = React.useRef(focusDirectory);
181
+ React.useEffect(() => {
182
+ focusDirectoryRef.current = focusDirectory;
183
+ }, [focusDirectory]);
184
+ const setFocusDirectoryIfUnpinned = React.useCallback(
185
+ (next: string | null) => {
186
+ if (focusPinnedRef.current) return;
187
+ setFocusDirectory(next);
188
+ },
189
+ [],
190
+ );
191
+ const [selectedPanelFolder, setSelectedPanelFolder] = React.useState<string | null>(null);
192
+ const [showPanelFolderContents, setShowPanelFolderContents] = React.useState(false);
193
+ const [showAddPicker, setShowAddPicker] = React.useState(false);
194
+ const addPickerRef = React.useRef<HTMLDivElement | null>(null);
195
+ // Close the +Add picker on any click outside of it. Listens at the
196
+ // document level so clicks anywhere — canvas, header, other overlays —
197
+ // dismiss the menu just like a native dropdown.
198
+ React.useEffect(() => {
199
+ if (!showAddPicker) return;
200
+ const onPointerDown = (e: MouseEvent) => {
201
+ if (addPickerRef.current?.contains(e.target as Node)) return;
202
+ setShowAddPicker(false);
203
+ };
204
+ document.addEventListener('mousedown', onPointerDown);
205
+ return () => document.removeEventListener('mousedown', onPointerDown);
206
+ }, [showAddPicker]);
207
+ // Anchor for the top-right "Hidden parent layers" panel: tracks the
208
+ // most-recently-interacted folder so the panel always shows the chain of
209
+ // expanded ancestors up from the user's current focus. Updated by
210
+ // umbrella clicks, building clicks, and file tree selections.
211
+ const [parentLayersAnchor, setParentLayersAnchor] = React.useState<string | null>(null);
212
+ // When true, the panel is hidden until the next folder interaction.
213
+ const [parentLayersDismissed, setParentLayersDismissed] = React.useState(false);
214
+
215
+ // Sub-tree of paths under the currently selected panel folder. Computed
216
+ // on demand so we only rebuild when the user actually opens the contents
217
+ // view. Paths are stripped of the folder prefix so the tree renders rooted
218
+ // at the folder itself.
219
+ const panelFolderContentsPaths = React.useMemo(() => {
220
+ if (!selectedPanelFolder || !showPanelFolderContents) return [] as string[];
221
+ const prefix = selectedPanelFolder + '/';
222
+ return cityPaths.filter(p => p.startsWith(prefix))
223
+ .map(p => p.slice(prefix.length))
224
+ .sort();
225
+ }, [selectedPanelFolder, showPanelFolderContents, cityPaths]);
226
+
227
+ const initialPanelFolderPaths = React.useRef<string[]>([]);
228
+ const { model: panelFolderContentsTreeModel } = useFileTree({
229
+ paths: initialPanelFolderPaths.current,
230
+ search: true,
231
+ });
232
+
233
+ // Keep the sub-tree in sync as the selected folder or visibility changes.
234
+ React.useEffect(() => {
235
+ panelFolderContentsTreeModel.resetPaths(panelFolderContentsPaths);
236
+ }, [panelFolderContentsTreeModel, panelFolderContentsPaths]);
237
+
238
+ const [scopeSelection, setScopeSelection] = React.useState<ScopeTreeSelection | null>(null);
239
+ const [showAddModal, setShowAddModal] = React.useState(false);
240
+ const [scopeModalTargetPath, setScopeModalTargetPath] = React.useState<string | null>(null);
241
+ const [modalScopeId, setModalScopeId] = React.useState('');
242
+ const [modalNamespaceName, setModalNamespaceName] = React.useState('');
243
+ const [showAddAreaModal, setShowAddAreaModal] = React.useState(false);
244
+ const [areaModalTargetPath, setAreaModalTargetPath] = React.useState<string | null>(null);
245
+ const [modalAreaName, setModalAreaName] = React.useState('');
246
+ const [modalAreaDescription, setModalAreaDescription] = React.useState('');
247
+ const [activeTab, setActiveTab] = React.useState<'files' | 'scopes'>('files');
248
+
249
+ const initialCityPaths = React.useRef(cityPaths);
250
+ const { model: treeModel } = useFileTree({
251
+ paths: initialCityPaths.current,
252
+ search: true,
253
+ initialExpandedPaths: [],
254
+ onSelectionChange: paths => {
255
+ const selected = paths[0];
256
+ if (!selected) {
257
+ setFocusDirectoryIfUnpinned(null);
258
+ return;
259
+ }
260
+ // Selecting a directory focuses the city on it; selecting a file focuses
261
+ // the file's parent directory (closest ancestor that exists as a district).
262
+ if (cityDirectories.has(selected)) {
263
+ setFocusDirectoryIfUnpinned(selected);
264
+ setParentLayersAnchor(selected);
265
+ setParentLayersDismissed(false);
266
+ return;
267
+ }
268
+ const parts = selected.split('/');
269
+ while (parts.length > 1) {
270
+ parts.pop();
271
+ const candidate = parts.join('/');
272
+ if (cityDirectories.has(candidate)) {
273
+ setFocusDirectoryIfUnpinned(candidate);
274
+ setParentLayersAnchor(selected);
275
+ setParentLayersDismissed(false);
276
+ return;
277
+ }
278
+ }
279
+ setFocusDirectoryIfUnpinned(null);
280
+ },
281
+ });
282
+
283
+ const scopeTreePaths = React.useMemo(() => buildScopeTreePaths(scopes), [scopes]);
284
+ const initialScopeTreePaths = React.useRef(scopeTreePaths);
285
+ const initialExpandedScopeIds = React.useRef(scopes.map(s => s.id));
286
+ const { model: scopeTreeModel } = useFileTree({
287
+ paths: initialScopeTreePaths.current,
288
+ search: true,
289
+ initialExpandedPaths: initialExpandedScopeIds.current,
290
+ onSelectionChange: paths => {
291
+ const selected = paths[0];
292
+ if (!selected) {
293
+ setScopeSelection(null);
294
+ return;
295
+ }
296
+ const parsed = parseScopeTreePath(selected);
297
+ setScopeSelection(parsed);
298
+
299
+ // Selecting a namespace or event also focuses the city on the namespace's
300
+ // first declared path; selecting a bare scope clears the focus.
301
+ if (parsed.namespaceName) {
302
+ const scope = scopes.find(s => s.id === parsed.scopeId);
303
+ const ns = scope?.namespaces.find(n => n.name === parsed.namespaceName);
304
+ if (ns?.paths[0]) setFocusDirectoryIfUnpinned(toCityPath(ns.paths[0]));
305
+ } else {
306
+ setFocusDirectoryIfUnpinned(null);
307
+ }
308
+ },
309
+ });
310
+
311
+ // Keep the scope tree's paths in sync as scopes mutate (the model is created
312
+ // once; later option changes need resetPaths per @pierre/trees docs).
313
+ const isFirstScopeTreeSync = React.useRef(true);
314
+ const pendingExpand = React.useRef<string[]>([]);
315
+ React.useEffect(() => {
316
+ if (isFirstScopeTreeSync.current) {
317
+ isFirstScopeTreeSync.current = false;
318
+ return;
319
+ }
320
+ scopeTreeModel.resetPaths(scopeTreePaths);
321
+ for (const dirPath of pendingExpand.current) {
322
+ asDir(scopeTreeModel.getItem(dirPath))?.expand();
323
+ }
324
+ pendingExpand.current = [];
325
+ }, [scopeTreeModel, scopeTreePaths]);
326
+
327
+ // Track which scope/namespace nodes are expanded in the scope tree. The
328
+ // city panels mirror this: a collapsed scope shows one umbrella tile, an
329
+ // expanded scope shows per-namespace tiles, and an expanded namespace
330
+ // hides its tile so the buildings underneath are visible.
331
+ const treeExpansion = useFileTreeSelector(
332
+ scopeTreeModel,
333
+ React.useCallback(
334
+ (model: FileTreeModel) => {
335
+ const expandedScopes = new Set<string>();
336
+ const expandedNamespaces = new Set<string>();
337
+ for (const scope of scopes) {
338
+ const scopeItem = asDir(model.getItem(scope.id));
339
+ if (scopeItem && scopeItem.isExpanded()) {
340
+ expandedScopes.add(scope.id);
341
+ for (const ns of scope.namespaces) {
342
+ const nsKey = `${scope.id}/${ns.name}`;
343
+ const nsItem = asDir(model.getItem(nsKey));
344
+ if (nsItem && nsItem.isExpanded()) {
345
+ expandedNamespaces.add(nsKey);
346
+ }
347
+ }
348
+ }
349
+ }
350
+ return { expandedScopes, expandedNamespaces };
351
+ },
352
+ [scopes],
353
+ ),
354
+ React.useCallback(
355
+ (
356
+ prev: { expandedScopes: Set<string>; expandedNamespaces: Set<string> },
357
+ next: { expandedScopes: Set<string>; expandedNamespaces: Set<string> },
358
+ ) => {
359
+ if (prev.expandedScopes.size !== next.expandedScopes.size) return false;
360
+ for (const k of prev.expandedScopes) if (!next.expandedScopes.has(k)) return false;
361
+ if (prev.expandedNamespaces.size !== next.expandedNamespaces.size) return false;
362
+ for (const k of prev.expandedNamespaces) if (!next.expandedNamespaces.has(k)) return false;
363
+ return true;
364
+ },
365
+ [],
366
+ ),
367
+ );
368
+
369
+ // Resolve the current scope tree selection into the underlying objects.
370
+ const scopeInfo = React.useMemo(() => {
371
+ if (!scopeSelection) return null;
372
+ const scope = scopes.find(s => s.id === scopeSelection.scopeId);
373
+ if (!scope) return null;
374
+ const ns = scopeSelection.namespaceName
375
+ ? scope.namespaces.find(n => n.name === scopeSelection.namespaceName) ?? null
376
+ : null;
377
+ const ev =
378
+ ns && scopeSelection.eventName
379
+ ? ns.events.find(e => e.name === scopeSelection.eventName) ?? null
380
+ : null;
381
+ return { scope, ns, ev };
382
+ }, [scopeSelection, scopes]);
383
+
384
+ // City highlight layers derive from the active tab:
385
+ // scopes tab → selected scope's namespace fills (+ scope-level borders)
386
+ // files tab → none
387
+ const cityHighlightLayers = React.useMemo(() => {
388
+ if (activeTab === 'scopes') {
389
+ return scopeInfo ? buildLayersForScope(scopeInfo.scope, toCityPath) : undefined;
390
+ }
391
+ return undefined;
392
+ }, [activeTab, scopeInfo, toCityPath]);
393
+
394
+ // Elevated scope panels — driven by the scope tree's expansion state.
395
+ // - Collapsed scope → one gray umbrella tile per scope path.
396
+ // - Expanded scope, collapsed namespace → colored tile per namespace path.
397
+ // - Expanded namespace → no tile (buildings show through).
398
+ const cityElevatedPanels = React.useMemo<ElevatedScopePanel[] | undefined>(() => {
399
+ if (activeTab !== 'scopes') return undefined;
400
+ const panels: ElevatedScopePanel[] = [];
401
+
402
+ for (const scope of scopes) {
403
+ const isScopeExpanded = treeExpansion.expandedScopes.has(scope.id);
404
+
405
+ if (!isScopeExpanded) {
406
+ const onClick = () => asDir(scopeTreeModel.getItem(scope.id))?.toggle();
407
+ for (const sp of scope.paths) {
408
+ const district = districtsByPath.get(toCityPath(sp));
409
+ if (!district) continue;
410
+ panels.push({
411
+ id: `${scope.id}::scope::${sp}`,
412
+ color: theme.colors.textTertiary,
413
+ height: 4,
414
+ thickness: 2,
415
+ bounds: district.worldBounds,
416
+ label: scope.id,
417
+ onClick,
418
+ });
419
+ }
420
+ continue;
421
+ }
422
+
423
+ for (const ns of scope.namespaces) {
424
+ const nsKey = `${scope.id}/${ns.name}`;
425
+ if (treeExpansion.expandedNamespaces.has(nsKey)) continue;
426
+
427
+ const onClick = () => asDir(scopeTreeModel.getItem(nsKey))?.toggle();
428
+ for (const np of ns.paths) {
429
+ const district = districtsByPath.get(toCityPath(np));
430
+ if (!district) continue;
431
+ panels.push({
432
+ id: `${scope.id}::${ns.name}::${np}`,
433
+ color: ns.color,
434
+ height: 4,
435
+ thickness: 2,
436
+ bounds: district.worldBounds,
437
+ label: ns.name,
438
+ onClick,
439
+ });
440
+ }
441
+ }
442
+ }
443
+
444
+ return panels.length > 0 ? panels : undefined;
445
+ }, [activeTab, scopes, scopeTreeModel, treeExpansion, districtsByPath, toCityPath, theme]);
446
+
447
+ // Track which folders are expanded in the file tree. The file-tree tab's
448
+ // elevated panels mirror this: a collapsed folder shows one umbrella tile
449
+ // covering every descendant district; expanding the folder reveals its
450
+ // sub-folder tiles (or the buildings themselves at the leaves).
451
+ const folderTreeExpansion = useFileTreeSelector(
452
+ treeModel,
453
+ React.useCallback((model: FileTreeModel) => {
454
+ const expanded = new Set<string>();
455
+ for (const dir of cityDirectories) {
456
+ const item = asDir(model.getItem(dir));
457
+ if (item && item.isExpanded()) expanded.add(dir);
458
+ }
459
+ return { expanded };
460
+ }, [cityDirectories]),
461
+ React.useCallback(
462
+ (prev: { expanded: Set<string> }, next: { expanded: Set<string> }) => {
463
+ if (prev.expanded.size !== next.expanded.size) return false;
464
+ for (const k of prev.expanded) if (!next.expanded.has(k)) return false;
465
+ return true;
466
+ },
467
+ [],
468
+ ),
469
+ );
470
+
471
+ // Mirror contents-tree expansion onto the main tree so the city's folder
472
+ // umbrellas hide for folders the user expands in the floating contents
473
+ // view. Contents-tree paths are stripped of the selected-folder prefix;
474
+ // we re-prefix them to address the same node in the main model.
475
+ const contentsFolderExpansion = useFileTreeSelector(
476
+ panelFolderContentsTreeModel,
477
+ React.useCallback(
478
+ (model: FileTreeModel) => {
479
+ const expanded = new Set<string>();
480
+ if (!selectedPanelFolder) return { expanded };
481
+ const prefix = selectedPanelFolder + '/';
482
+ for (const dir of cityDirectories) {
483
+ if (!dir.startsWith(prefix)) continue;
484
+ const stripped = dir.slice(prefix.length);
485
+ const item = asDir(model.getItem(stripped));
486
+ if (item && item.isExpanded()) expanded.add(dir);
487
+ }
488
+ return { expanded };
489
+ },
490
+ [selectedPanelFolder, cityDirectories],
491
+ ),
492
+ React.useCallback(
493
+ (prev: { expanded: Set<string> }, next: { expanded: Set<string> }) => {
494
+ if (prev.expanded.size !== next.expanded.size) return false;
495
+ for (const k of prev.expanded) if (!next.expanded.has(k)) return false;
496
+ return true;
497
+ },
498
+ [],
499
+ ),
500
+ );
501
+
502
+ // Diff against the prior mirror so collapses propagate without stomping
503
+ // folders the user expanded directly via city umbrella clicks.
504
+ const prevContentsExpansionRef = React.useRef<Set<string>>(new Set());
505
+ React.useEffect(() => {
506
+ const next = contentsFolderExpansion.expanded;
507
+ const prev = prevContentsExpansionRef.current;
508
+ for (const dir of next) {
509
+ if (!prev.has(dir)) asDir(treeModel.getItem(dir))?.expand();
510
+ }
511
+ for (const dir of prev) {
512
+ if (!next.has(dir)) asDir(treeModel.getItem(dir))?.collapse();
513
+ }
514
+ prevContentsExpansionRef.current = new Set(next);
515
+ }, [contentsFolderExpansion, treeModel]);
516
+
517
+ // Folder city-path → area display name. Lets folder umbrella tiles surface
518
+ // the human-readable area name above the technical path component.
519
+ const areaNameByCityPath = React.useMemo(() => {
520
+ const m = new Map<string, string>();
521
+ for (const area of areas) {
522
+ for (const p of area.paths) m.set(toCityPath(p), area.name);
523
+ }
524
+ return m;
525
+ }, [areas, toCityPath]);
526
+
527
+ const folderElevatedPanels = React.useMemo<ElevatedScopePanel[] | undefined>(() => {
528
+ if (activeTab !== 'files') return undefined;
529
+ const rawPanels = buildFolderElevatedPanels({
530
+ cityData,
531
+ expandedFolders: folderTreeExpansion.expanded,
532
+ onToggleFolder: (folderPath) => {
533
+ // Plain click → surface the clicked folder in the panel-selection
534
+ // card (with an "Open" button) instead of expanding immediately,
535
+ // so the umbrella tile doesn't vanish out from under the click.
536
+ // showPanelFolderContents is intentionally not reset here: if the
537
+ // user already opted into the contents view, switching folders
538
+ // keeps the contents view active for the new folder.
539
+ setSelectedPanelFolder(folderPath);
540
+ setParentLayersAnchor(folderPath);
541
+ setParentLayersDismissed(false);
542
+ },
543
+ onDoubleClickFolder: (folderPath) => {
544
+ // Double-click → focus the camera on this folder. Double-clicking
545
+ // a folder that is *already* the focus pops the focus up by one
546
+ // ancestor (clamped at the package root), giving an iterative
547
+ // "zoom out" gesture as the user keeps double-clicking.
548
+ let next = folderPath;
549
+ let nextSelected = folderPath;
550
+ if (focusDirectoryRef.current === folderPath) {
551
+ const slash = folderPath.lastIndexOf('/');
552
+ next = slash > 0 ? folderPath.slice(0, slash) : packageRootClamp;
553
+ nextSelected = next;
554
+ }
555
+ setSelectedPanelFolder(nextSelected);
556
+ setFocusDirectoryIfUnpinned(next);
557
+ setParentLayersAnchor(nextSelected);
558
+ setParentLayersDismissed(false);
559
+ },
560
+ index: folderIndex,
561
+ });
562
+ const panels: ElevatedScopePanel[] = rawPanels.map(panel => {
563
+ const folderPath = panel.id.startsWith('folder::') ? panel.id.slice('folder::'.length) : null;
564
+ const displayLabel = folderPath ? areaNameByCityPath.get(folderPath) : undefined;
565
+ return displayLabel ? { ...panel, displayLabel } : panel;
566
+ });
567
+ // Selection indicator: render a thin, slightly-larger panel underneath
568
+ // the selected folder's umbrella so an accent ring peeks out around its
569
+ // edges. Inserted *before* the umbrella in the list so the umbrella
570
+ // draws on top — only the inflated rim shows. If the folder is expanded
571
+ // (no umbrella in the panel list) findIndex returns -1 and no ring is
572
+ // drawn, which is exactly what we want.
573
+ if (selectedPanelFolder) {
574
+ const idx = panels.findIndex(p => p.id === `folder::${selectedPanelFolder}`);
575
+ if (idx >= 0) {
576
+ const target = panels[idx];
577
+ const inflate = 4;
578
+ const border: ElevatedScopePanel = {
579
+ id: `folder-border::${selectedPanelFolder}`,
580
+ color: theme.colors.warning,
581
+ height: (target.height ?? 4) - 2,
582
+ thickness: 1,
583
+ bounds: {
584
+ minX: target.bounds.minX - inflate,
585
+ maxX: target.bounds.maxX + inflate,
586
+ minZ: target.bounds.minZ - inflate,
587
+ maxZ: target.bounds.maxZ + inflate,
588
+ },
589
+ };
590
+ const next = [...panels];
591
+ next.splice(idx, 0, border);
592
+ return next;
593
+ }
594
+ }
595
+
596
+ return panels.length > 0 ? panels : undefined;
597
+ }, [
598
+ activeTab,
599
+ cityData,
600
+ selectedPanelFolder,
601
+ treeModel,
602
+ folderTreeExpansion,
603
+ setFocusDirectoryIfUnpinned,
604
+ areaNameByCityPath,
605
+ folderIndex,
606
+ packageRootClamp,
607
+ theme,
608
+ ]);
609
+
610
+ // Cmd-click on a building → surface the chain of expanded ancestor folders
611
+ // (their umbrellas are currently hidden because they're expanded). Each
612
+ // entry in the popup can be clicked to collapse that ancestor, which
613
+ // restores its umbrella so the user can navigate back up.
614
+ const parentLayers = React.useMemo<string[]>(() => {
615
+ if (!parentLayersAnchor) return [];
616
+ const parts = parentLayersAnchor.split('/');
617
+ const out: string[] = [];
618
+ // Walk shallowest → deepest so the list reads outermost-first.
619
+ // Include the anchor itself: if it's expanded, its umbrella is hidden
620
+ // (children took its place), so the user should be able to collapse it
621
+ // back from the panel.
622
+ for (let i = 1; i <= parts.length; i++) {
623
+ const ancestor = parts.slice(0, i).join('/');
624
+ if (folderTreeExpansion.expanded.has(ancestor)) out.push(ancestor);
625
+ }
626
+ return out;
627
+ }, [parentLayersAnchor, folderTreeExpansion]);
628
+
629
+ const handleBuildingClick = React.useCallback(
630
+ (building: { path: string }) => {
631
+ setParentLayersAnchor(building.path);
632
+ setParentLayersDismissed(false);
633
+ },
634
+ [],
635
+ );
636
+
637
+ const collapseFolder = React.useCallback(
638
+ (folderPath: string) => asDir(treeModel.getItem(folderPath))?.collapse(),
639
+ [treeModel],
640
+ );
641
+
642
+ const openAddModal = React.useCallback(
643
+ (targetPath: string, prefillScopeId?: string) => {
644
+ setScopeModalTargetPath(targetPath);
645
+ setModalScopeId(prefillScopeId ?? '');
646
+ setModalNamespaceName('');
647
+ setShowAddModal(true);
648
+ },
649
+ [],
650
+ );
651
+
652
+ // Coverage lookup for the city-panel-clicked folder. Returns scope hits
653
+ // (with the most specific covering namespace, if any) and area hits.
654
+ const panelFolderCoverage = React.useMemo(() => {
655
+ if (!selectedPanelFolder) return null;
656
+ const sp = toScopePath(selectedPanelFolder);
657
+ const covers = (claim: string) => sp === claim || sp.startsWith(claim + '/');
658
+
659
+ const scopeHits: { scope: Scope; namespace: Namespace | null }[] = [];
660
+ for (const scope of scopes) {
661
+ const ns = scope.namespaces.find(n => n.paths.some(covers)) ?? null;
662
+ const scopeLevel = scope.paths.some(covers);
663
+ if (ns || scopeLevel) scopeHits.push({ scope, namespace: ns });
664
+ }
665
+
666
+ const areaHits = areas.filter(a => a.paths.some(covers));
667
+
668
+ return { scopeHits, areaHits };
669
+ }, [selectedPanelFolder, scopes, areas, toScopePath]);
670
+
671
+ const submitAddToScope = React.useCallback(() => {
672
+ if (!scopeModalTargetPath) return;
673
+ const path = toScopePath(scopeModalTargetPath);
674
+ const scopeId = modalScopeId.trim();
675
+ const namespaceName = modalNamespaceName.trim();
676
+ if (!scopeId) return;
677
+
678
+ // Queue branches to auto-expand once the tree re-resets.
679
+ pendingExpand.current = namespaceName ? [scopeId, `${scopeId}/${namespaceName}`] : [scopeId];
680
+
681
+ // Invariant: a scope's `paths` must cover every path claimed by any of
682
+ // its namespaces. If `path` isn't already covered by scope.paths, we add
683
+ // it.
684
+ const ensureScopePathCovers = (scope: Scope): Scope => {
685
+ const covered = scope.paths.some(p => path === p || path.startsWith(p + '/'));
686
+ if (covered) return scope;
687
+ return { ...scope, paths: [...scope.paths, path] };
688
+ };
689
+
690
+ setScopes(prev => {
691
+ const scopeIdx = prev.findIndex(s => s.id === scopeId);
692
+
693
+ // Existing scope.
694
+ if (scopeIdx >= 0) {
695
+ const scope = prev[scopeIdx];
696
+
697
+ // No namespace given → add to scope-level paths.
698
+ if (!namespaceName) {
699
+ if (scope.paths.includes(path)) return prev;
700
+ const next = [...prev];
701
+ next[scopeIdx] = { ...scope, paths: [...scope.paths, path] };
702
+ return next;
703
+ }
704
+
705
+ const nsIdx = scope.namespaces.findIndex(n => n.name === namespaceName);
706
+
707
+ // Existing namespace — push the path if not already there.
708
+ if (nsIdx >= 0) {
709
+ const ns = scope.namespaces[nsIdx];
710
+ if (ns.paths.includes(path)) return prev;
711
+ const newNs = { ...ns, paths: [...ns.paths, path] };
712
+ const newNamespaces = [...scope.namespaces];
713
+ newNamespaces[nsIdx] = newNs;
714
+ const next = [...prev];
715
+ next[scopeIdx] = ensureScopePathCovers({ ...scope, namespaces: newNamespaces });
716
+ return next;
717
+ }
718
+
719
+ // New namespace under existing scope.
720
+ const newNs: Namespace = {
721
+ name: namespaceName,
722
+ description: '(new namespace)',
723
+ color: pickNamespaceColor(prev),
724
+ paths: [path],
725
+ events: [],
726
+ };
727
+ const next = [...prev];
728
+ next[scopeIdx] = ensureScopePathCovers({
729
+ ...scope,
730
+ namespaces: [...scope.namespaces, newNs],
731
+ });
732
+ return next;
733
+ }
734
+
735
+ // Brand-new scope. Scope paths are required, so the path always seeds
736
+ // scope.paths even when a namespace is also being created.
737
+ if (!namespaceName) {
738
+ return [
739
+ ...prev,
740
+ {
741
+ id: scopeId,
742
+ name: scopeId,
743
+ description: '(new scope)',
744
+ paths: [path],
745
+ namespaces: [],
746
+ },
747
+ ];
748
+ }
749
+ const newNs: Namespace = {
750
+ name: namespaceName,
751
+ description: '(new namespace)',
752
+ color: pickNamespaceColor(prev),
753
+ paths: [path],
754
+ events: [],
755
+ };
756
+ return [
757
+ ...prev,
758
+ {
759
+ id: scopeId,
760
+ name: scopeId,
761
+ description: '(new scope)',
762
+ paths: [path],
763
+ namespaces: [newNs],
764
+ },
765
+ ];
766
+ });
767
+
768
+ setShowAddModal(false);
769
+ setScopeModalTargetPath(null);
770
+ }, [scopeModalTargetPath, modalScopeId, modalNamespaceName, toScopePath]);
771
+
772
+ const openAddAreaModal = React.useCallback((targetPath: string) => {
773
+ setAreaModalTargetPath(targetPath);
774
+ setModalAreaName('');
775
+ setModalAreaDescription('');
776
+ setShowAddAreaModal(true);
777
+ }, []);
778
+
779
+ const submitAddToArea = React.useCallback(() => {
780
+ if (!areaModalTargetPath) return;
781
+ const path = toScopePath(areaModalTargetPath);
782
+ const name = modalAreaName.trim();
783
+ const desc = modalAreaDescription.trim();
784
+ if (!name) return;
785
+
786
+ setAreas(prev => {
787
+ const idx = prev.findIndex(a => a.name === name);
788
+ if (idx >= 0) {
789
+ const area = prev[idx];
790
+ if (area.paths.includes(path)) return prev;
791
+ const next = [...prev];
792
+ next[idx] = { ...area, paths: [...area.paths, path] };
793
+ return next;
794
+ }
795
+ return [
796
+ ...prev,
797
+ { name, description: desc || '(new area)', paths: [path] },
798
+ ];
799
+ });
800
+
801
+ setShowAddAreaModal(false);
802
+ setAreaModalTargetPath(null);
803
+ }, [areaModalTargetPath, modalAreaName, modalAreaDescription, toScopePath]);
804
+
805
+ return (
806
+ <div style={{ height: '100vh', display: 'flex', background: theme.colors.background }}>
807
+ <div style={{ flex: 1, position: 'relative', minWidth: 0 }}>
808
+ {/* Canvas wrapper — pushed down by HEADER_HEIGHT so the focus
809
+ bar doesn't occlude the camera's framing area. The 3D camera
810
+ sizes itself to the canvas, so shrinking the canvas is what
811
+ makes focus calculations exclude the header. */}
812
+ <div
813
+ style={{
814
+ position: 'absolute',
815
+ top: 56,
816
+ left: 0,
817
+ right: 0,
818
+ bottom: 0,
819
+ }}
820
+ >
821
+ <FileCity3D
822
+ cityData={cityData}
823
+ height="100%"
824
+ width="100%"
825
+ heightScaling="linear"
826
+ linearScale={0.5}
827
+ backgroundColor={theme.colors.background}
828
+ textColor={theme.colors.textMuted}
829
+ focusDirectory={focusDirectory}
830
+ highlightLayers={cityHighlightLayers}
831
+ elevatedScopePanels={cityElevatedPanels ?? folderElevatedPanels}
832
+ onBuildingClick={handleBuildingClick}
833
+ animation={{
834
+ startFlat: true,
835
+ autoStartDelay: null,
836
+ staggerDelay: 5,
837
+ tension: 150,
838
+ friction: 16,
839
+ }}
840
+ showControls={true}
841
+ />
842
+ </div>
843
+
844
+ {/* Focus directory overlay — pinnable */}
845
+ <div
846
+ style={{
847
+ position: 'absolute',
848
+ top: theme.space[2],
849
+ left: theme.space[2],
850
+ right: theme.space[2],
851
+ padding: '8px 12px',
852
+ background: withAlpha(theme.colors.background, 72),
853
+ backdropFilter: 'blur(8px)',
854
+ WebkitBackdropFilter: 'blur(8px)',
855
+ border: `1px solid ${focusPinned ? theme.colors.warning : theme.colors.border}`,
856
+ borderRadius: theme.radii[3],
857
+ color: theme.colors.text,
858
+ fontFamily: theme.fonts.body,
859
+ fontSize: theme.fontSizes[1],
860
+ zIndex: 100,
861
+ display: 'flex',
862
+ alignItems: 'center',
863
+ gap: 10,
864
+ }}
865
+ >
866
+ <div
867
+ style={{
868
+ flex: 1,
869
+ minWidth: 0,
870
+ display: 'flex',
871
+ alignItems: 'center',
872
+ flexWrap: 'wrap',
873
+ gap: 8,
874
+ }}
875
+ >
876
+ <div style={sectionLabelStyle}>
877
+ Path{focusPinned ? ' (pinned)' : ''}
878
+ </div>
879
+ {focusDirectory ? (
880
+ // Breadcrumb: each ancestor segment that exists as a district
881
+ // is a button. Clicking moves focus to that segment, even
882
+ // while pinned (bypasses the pin guard intentionally).
883
+ // When a folder is selected via the city, the breadcrumb
884
+ // extends past focus to its full path — segments beyond the
885
+ // focus point are styled differently to make it clear which
886
+ // part of the chain is the focus vs. the selection.
887
+ (() => {
888
+ const focusDepth = focusDirectory.split('/').length;
889
+ const deepest =
890
+ selectedPanelFolder &&
891
+ (selectedPanelFolder === focusDirectory ||
892
+ selectedPanelFolder.startsWith(focusDirectory + '/'))
893
+ ? selectedPanelFolder
894
+ : focusDirectory;
895
+ const parts = deepest.split('/');
896
+ const segments: { label: string; path: string; beyondFocus: boolean }[] = [];
897
+ for (let i = 0; i < parts.length; i++) {
898
+ const path = parts.slice(0, i + 1).join('/');
899
+ if (cityDirectories.has(path)) {
900
+ segments.push({ label: parts[i], path, beyondFocus: i + 1 > focusDepth });
901
+ }
902
+ }
903
+ return (
904
+ <div
905
+ style={{
906
+ display: 'flex',
907
+ flexWrap: 'wrap',
908
+ alignItems: 'center',
909
+ gap: 2,
910
+ }}
911
+ >
912
+ {segments.map((seg, i) => {
913
+ const isFocus = seg.path === focusDirectory;
914
+ const isSelectedLeaf =
915
+ seg.path === selectedPanelFolder && seg.beyondFocus;
916
+ const color = isFocus || seg.beyondFocus
917
+ ? theme.colors.text
918
+ : theme.colors.textMuted;
919
+ return (
920
+ <React.Fragment key={seg.path}>
921
+ {i > 0 && (
922
+ <span style={{ color: theme.colors.text, fontFamily: theme.fonts.monospace }}>
923
+ /
924
+ </span>
925
+ )}
926
+ <button
927
+ onClick={() => {
928
+ setFocusDirectory(seg.path);
929
+ setSelectedPanelFolder(seg.path);
930
+ setParentLayersAnchor(seg.path);
931
+ setParentLayersDismissed(false);
932
+ }}
933
+ style={{
934
+ background: 'transparent',
935
+ border: 'none',
936
+ padding: '2px 4px',
937
+ borderRadius: theme.radii[1],
938
+ fontFamily: theme.fonts.monospace,
939
+ fontSize: theme.fontSizes[1],
940
+ color,
941
+ fontWeight: isFocus || isSelectedLeaf
942
+ ? theme.fontWeights.semibold
943
+ : theme.fontWeights.body,
944
+ cursor: 'pointer',
945
+ textDecoration: 'underline',
946
+ textDecorationColor: theme.colors.muted,
947
+ }}
948
+ >
949
+ {seg.label}
950
+ </button>
951
+ </React.Fragment>
952
+ );
953
+ })}
954
+ </div>
955
+ );
956
+ })()
957
+ ) : (
958
+ <div
959
+ style={{
960
+ fontFamily: theme.fonts.monospace,
961
+ fontSize: theme.fontSizes[1],
962
+ color: theme.colors.textTertiary,
963
+ wordBreak: 'break-all',
964
+ }}
965
+ >
966
+ None
967
+ </div>
968
+ )}
969
+ </div>
970
+ {focusDirectory && (
971
+ <button
972
+ onClick={() => setFocusPinned(p => !p)}
973
+ title={
974
+ focusPinned
975
+ ? 'Unpin — selections will move the focus again'
976
+ : 'Pin — keep this focus while navigating the trees'
977
+ }
978
+ style={{
979
+ background: focusPinned ? theme.colors.warning : 'transparent',
980
+ color: focusPinned ? theme.colors.background : theme.colors.textSecondary,
981
+ border: `1px solid ${focusPinned ? theme.colors.warning : theme.colors.border}`,
982
+ borderRadius: theme.radii[2],
983
+ padding: '4px 8px',
984
+ fontSize: theme.fontSizes[0],
985
+ cursor: 'pointer',
986
+ fontWeight: theme.fontWeights.medium,
987
+ flexShrink: 0,
988
+ }}
989
+ >
990
+ {focusPinned ? 'Pinned' : 'Pin'}
991
+ </button>
992
+ )}
993
+ {focusDirectory && (
994
+ <button
995
+ onClick={() => {
996
+ setFocusPinned(false);
997
+ setFocusDirectory(null);
998
+ }}
999
+ title="Clear focus"
1000
+ style={{
1001
+ background: 'transparent',
1002
+ color: theme.colors.textMuted,
1003
+ border: `1px solid ${theme.colors.border}`,
1004
+ borderRadius: theme.radii[2],
1005
+ padding: '4px 8px',
1006
+ fontSize: theme.fontSizes[0],
1007
+ cursor: 'pointer',
1008
+ flexShrink: 0,
1009
+ }}
1010
+ >
1011
+ Clear
1012
+ </button>
1013
+ )}
1014
+ </div>
1015
+
1016
+ {/* Selected-folder card — driven by clicks on city folder panels.
1017
+ Renders below the focus overlay; an "Open" button expands the
1018
+ folder in the file tree (which removes the umbrella tile). */}
1019
+ {activeTab === 'files' && selectedPanelFolder && (
1020
+ <div
1021
+ style={{
1022
+ position: 'absolute',
1023
+ top: 60,
1024
+ left: theme.space[2],
1025
+ padding: '8px 12px',
1026
+ background: withAlpha(theme.colors.background, 72),
1027
+ backdropFilter: 'blur(8px)',
1028
+ WebkitBackdropFilter: 'blur(8px)',
1029
+ border: `1px solid ${theme.colors.border}`,
1030
+ borderRadius: theme.radii[3],
1031
+ color: theme.colors.text,
1032
+ fontFamily: theme.fonts.body,
1033
+ fontSize: theme.fontSizes[0],
1034
+ zIndex: 100,
1035
+ maxWidth: 480,
1036
+ display: 'flex',
1037
+ flexDirection: 'column',
1038
+ gap: theme.space[2],
1039
+ }}
1040
+ >
1041
+ {panelFolderCoverage && (panelFolderCoverage.scopeHits.length > 0 ||
1042
+ panelFolderCoverage.areaHits.length > 0) && (
1043
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
1044
+ {panelFolderCoverage.areaHits.map(area => (
1045
+ <div
1046
+ key={area.name}
1047
+ style={{
1048
+ padding: '6px 8px',
1049
+ background: theme.colors.backgroundDark ?? theme.colors.background,
1050
+ border: `1px dashed ${theme.colors.border}`,
1051
+ borderRadius: theme.radii[2],
1052
+ display: 'flex',
1053
+ flexDirection: 'column',
1054
+ gap: theme.space[1],
1055
+ }}
1056
+ >
1057
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
1058
+ <span
1059
+ style={{
1060
+ fontSize: theme.fontSizes[0],
1061
+ color: theme.colors.textMuted,
1062
+ textTransform: 'uppercase',
1063
+ letterSpacing: 0.5,
1064
+ fontWeight: theme.fontWeights.semibold,
1065
+ }}
1066
+ >
1067
+ Area
1068
+ </span>
1069
+ <span
1070
+ style={{
1071
+ width: 8,
1072
+ height: 8,
1073
+ borderRadius: theme.radii[1],
1074
+ background: AREA_PANEL_COLOR,
1075
+ border: `1px dashed ${theme.colors.textMuted}`,
1076
+ flexShrink: 0,
1077
+ }}
1078
+ />
1079
+ <code style={{ fontSize: theme.fontSizes[0], color: theme.colors.textSecondary }}>{area.name}</code>
1080
+ </div>
1081
+ <div style={{ fontSize: theme.fontSizes[0], color: theme.colors.textMuted, lineHeight: 1.4 }}>
1082
+ {area.description}
1083
+ </div>
1084
+ </div>
1085
+ ))}
1086
+ {panelFolderCoverage.scopeHits.map(({ scope, namespace }) => (
1087
+ <div
1088
+ key={scope.id}
1089
+ style={{
1090
+ padding: '6px 8px',
1091
+ background: theme.colors.backgroundDark ?? theme.colors.background,
1092
+ border: `1px solid ${theme.colors.backgroundSecondary}`,
1093
+ borderRadius: theme.radii[2],
1094
+ display: 'flex',
1095
+ flexDirection: 'column',
1096
+ gap: theme.space[1],
1097
+ }}
1098
+ >
1099
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
1100
+ <span
1101
+ style={{
1102
+ fontSize: theme.fontSizes[0],
1103
+ color: theme.colors.accent,
1104
+ textTransform: 'uppercase',
1105
+ letterSpacing: 0.5,
1106
+ fontWeight: theme.fontWeights.semibold,
1107
+ }}
1108
+ >
1109
+ Scope
1110
+ </span>
1111
+ <code style={{ fontSize: theme.fontSizes[0], color: theme.colors.textSecondary }}>{scope.id}</code>
1112
+ {namespace && (
1113
+ <>
1114
+ <span style={{ color: theme.colors.muted }}>/</span>
1115
+ <span
1116
+ style={{
1117
+ width: 8,
1118
+ height: 8,
1119
+ borderRadius: theme.radii[1],
1120
+ background: namespace.color,
1121
+ flexShrink: 0,
1122
+ }}
1123
+ />
1124
+ <code style={{ fontSize: theme.fontSizes[0], color: theme.colors.textSecondary }}>{namespace.name}</code>
1125
+ </>
1126
+ )}
1127
+ </div>
1128
+ <div style={{ fontSize: theme.fontSizes[0], color: theme.colors.textMuted, lineHeight: 1.4 }}>
1129
+ {namespace ? namespace.description : scope.description}
1130
+ </div>
1131
+ </div>
1132
+ ))}
1133
+ </div>
1134
+ )}
1135
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
1136
+ <div ref={addPickerRef} style={{ position: 'relative' }}>
1137
+ <button
1138
+ onClick={() => setShowAddPicker(v => !v)}
1139
+ title="Add this folder to a scope or area"
1140
+ style={{
1141
+ background: showAddPicker ? theme.colors.backgroundSecondary : 'transparent',
1142
+ color: theme.colors.textSecondary,
1143
+ border: `1px solid ${theme.colors.muted}`,
1144
+ borderRadius: theme.radii[2],
1145
+ padding: '4px 10px',
1146
+ fontSize: theme.fontSizes[0],
1147
+ cursor: 'pointer',
1148
+ fontWeight: theme.fontWeights.medium,
1149
+ }}
1150
+ >
1151
+ + Add
1152
+ </button>
1153
+ {showAddPicker && (
1154
+ <div
1155
+ style={{
1156
+ position: 'absolute',
1157
+ top: 'calc(100% + 4px)',
1158
+ left: 0,
1159
+ background: withAlpha(theme.colors.background, 95),
1160
+ backdropFilter: 'blur(8px)',
1161
+ WebkitBackdropFilter: 'blur(8px)',
1162
+ border: `1px solid ${theme.colors.border}`,
1163
+ borderRadius: theme.radii[2],
1164
+ padding: theme.space[1],
1165
+ display: 'flex',
1166
+ flexDirection: 'column',
1167
+ gap: 2,
1168
+ zIndex: 110,
1169
+ minWidth: 120,
1170
+ boxShadow: theme.shadows[3],
1171
+ }}
1172
+ >
1173
+ <button
1174
+ onClick={() => {
1175
+ setShowAddPicker(false);
1176
+ openAddModal(selectedPanelFolder);
1177
+ }}
1178
+ style={{
1179
+ background: 'transparent',
1180
+ color: theme.colors.textSecondary,
1181
+ border: `1px solid ${theme.colors.accent}`,
1182
+ borderRadius: theme.radii[1],
1183
+ padding: '4px 10px',
1184
+ fontSize: theme.fontSizes[0],
1185
+ cursor: 'pointer',
1186
+ fontWeight: theme.fontWeights.medium,
1187
+ textAlign: 'left',
1188
+ }}
1189
+ >
1190
+ Scope
1191
+ </button>
1192
+ <button
1193
+ onClick={() => {
1194
+ setShowAddPicker(false);
1195
+ openAddAreaModal(selectedPanelFolder);
1196
+ }}
1197
+ style={{
1198
+ background: 'transparent',
1199
+ color: theme.colors.textSecondary,
1200
+ border: `1px dashed ${theme.colors.textMuted}`,
1201
+ borderRadius: theme.radii[1],
1202
+ padding: '4px 10px',
1203
+ fontSize: theme.fontSizes[0],
1204
+ cursor: 'pointer',
1205
+ fontWeight: theme.fontWeights.medium,
1206
+ textAlign: 'left',
1207
+ }}
1208
+ >
1209
+ Area
1210
+ </button>
1211
+ </div>
1212
+ )}
1213
+ </div>
1214
+ <button
1215
+ onClick={() => {
1216
+ const next = !showPanelFolderContents;
1217
+ setShowPanelFolderContents(next);
1218
+ // Mirror the Open/Close behaviour: showing contents
1219
+ // expands the folder in the tree (so the city's umbrella
1220
+ // tile lifts and child buildings become visible);
1221
+ // hiding collapses it again.
1222
+ const item = asDir(treeModel.getItem(selectedPanelFolder));
1223
+ if (!item) return;
1224
+ if (next) item.expand();
1225
+ else item.collapse();
1226
+ }}
1227
+ title="Show files inside this folder"
1228
+ style={{
1229
+ background: showPanelFolderContents ? theme.colors.backgroundSecondary : 'transparent',
1230
+ color: theme.colors.textSecondary,
1231
+ border: `1px solid ${theme.colors.border}`,
1232
+ borderRadius: theme.radii[2],
1233
+ padding: '4px 10px',
1234
+ fontSize: theme.fontSizes[0],
1235
+ cursor: 'pointer',
1236
+ fontWeight: theme.fontWeights.medium,
1237
+ }}
1238
+ >
1239
+ {showPanelFolderContents ? 'Hide contents' : 'Show contents'}
1240
+ </button>
1241
+ </div>
1242
+ {showPanelFolderContents && (
1243
+ <div
1244
+ style={{
1245
+ display: 'flex',
1246
+ flexDirection: 'column',
1247
+ borderTop: `1px solid ${theme.colors.backgroundSecondary}`,
1248
+ paddingTop: theme.space[2],
1249
+ marginTop: 2,
1250
+ minWidth: 320,
1251
+ }}
1252
+ >
1253
+ {panelFolderContentsPaths.length === 0 ? (
1254
+ <div
1255
+ style={{
1256
+ fontSize: theme.fontSizes[0],
1257
+ color: theme.colors.textTertiary,
1258
+ fontStyle: 'italic',
1259
+ padding: '4px 0',
1260
+ }}
1261
+ >
1262
+ No files in this folder.
1263
+ </div>
1264
+ ) : (
1265
+ <div style={{ height: 640, display: 'flex', flexDirection: 'column' }}>
1266
+ <FileTree
1267
+ model={panelFolderContentsTreeModel}
1268
+ style={
1269
+ {
1270
+ flex: 1,
1271
+ minHeight: 0,
1272
+ '--trees-bg-override': 'transparent',
1273
+ '--trees-search-bg-override': 'rgba(0, 0, 0, 0.25)',
1274
+ '--trees-padding-inline-override': '0',
1275
+ '--trees-theme-list-active-selection-bg': withAlpha(theme.colors.primary, 28),
1276
+ '--trees-theme-list-hover-bg': withAlpha(theme.colors.primary, 14),
1277
+ } as React.CSSProperties
1278
+ }
1279
+ />
1280
+ </div>
1281
+ )}
1282
+ </div>
1283
+ )}
1284
+ </div>
1285
+ )}
1286
+
1287
+ {/* Parent-layers popup — surfaces ancestor folders whose umbrellas
1288
+ are currently hidden (because they're expanded). Triggered by
1289
+ Cmd/Ctrl-click on a building. Each entry collapses that
1290
+ ancestor on click so its umbrella reappears. */}
1291
+ {parentLayersAnchor && !parentLayersDismissed && parentLayers.length > 0 && (
1292
+ <div
1293
+ style={{
1294
+ position: 'absolute',
1295
+ top: 72,
1296
+ right: theme.space[2],
1297
+ padding: '10px 12px',
1298
+ background: withAlpha(theme.colors.background, 72),
1299
+ backdropFilter: 'blur(8px)',
1300
+ WebkitBackdropFilter: 'blur(8px)',
1301
+ border: `1px solid ${theme.colors.border}`,
1302
+ borderRadius: theme.radii[3],
1303
+ color: theme.colors.text,
1304
+ fontFamily: theme.fonts.body,
1305
+ fontSize: theme.fontSizes[0],
1306
+ zIndex: 100,
1307
+ maxWidth: 240,
1308
+ display: 'flex',
1309
+ flexDirection: 'column',
1310
+ gap: theme.space[2],
1311
+ boxShadow: theme.shadows[3],
1312
+ }}
1313
+ >
1314
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
1315
+ <div style={{ ...sectionLabelStyle, flex: 1, minWidth: 0 }}>
1316
+ Hidden parent layers
1317
+ </div>
1318
+ <button
1319
+ onClick={() => setParentLayersDismissed(true)}
1320
+ title="Dismiss (reappears on next folder interaction)"
1321
+ style={{
1322
+ background: 'transparent',
1323
+ color: theme.colors.textMuted,
1324
+ border: `1px solid ${theme.colors.border}`,
1325
+ borderRadius: theme.radii[2],
1326
+ padding: '4px 8px',
1327
+ fontSize: theme.fontSizes[0],
1328
+ cursor: 'pointer',
1329
+ flexShrink: 0,
1330
+ }}
1331
+ >
1332
+
1333
+ </button>
1334
+ </div>
1335
+ <div style={{ display: 'flex', flexDirection: 'column', gap: theme.space[1] }}>
1336
+ {parentLayers.map(folderPath => {
1337
+ const label = folderPath.split('/').pop() ?? folderPath;
1338
+ return (
1339
+ <button
1340
+ key={folderPath}
1341
+ onClick={() => collapseFolder(folderPath)}
1342
+ title={`Collapse ${folderPath} — restores its umbrella tile`}
1343
+ style={{
1344
+ display: 'flex',
1345
+ alignItems: 'center',
1346
+ gap: theme.space[2],
1347
+ padding: '6px 8px',
1348
+ background: theme.colors.backgroundSecondary,
1349
+ color: theme.colors.text,
1350
+ border: `1px solid ${theme.colors.border}`,
1351
+ borderRadius: theme.radii[2],
1352
+ cursor: 'pointer',
1353
+ textAlign: 'left',
1354
+ fontFamily: theme.fonts.body,
1355
+ fontSize: theme.fontSizes[0],
1356
+ }}
1357
+ >
1358
+ <span style={{ fontFamily: theme.fonts.monospace, fontWeight: theme.fontWeights.medium }}>{label}</span>
1359
+ </button>
1360
+ );
1361
+ })}
1362
+ </div>
1363
+ </div>
1364
+ )}
1365
+
1366
+ {/* Mode switch — swap which feature layer the canvas renders */}
1367
+ <div
1368
+ style={{
1369
+ position: 'absolute',
1370
+ bottom: theme.space[3],
1371
+ left: '50%',
1372
+ transform: 'translateX(-50%)',
1373
+ display: 'flex',
1374
+ background: withAlpha(theme.colors.background, 92),
1375
+ border: `1px solid ${theme.colors.backgroundSecondary}`,
1376
+ borderRadius: theme.radii[3],
1377
+ overflow: 'hidden',
1378
+ fontFamily: theme.fonts.body,
1379
+ fontSize: theme.fontSizes[0],
1380
+ boxShadow: theme.shadows[2],
1381
+ zIndex: 10,
1382
+ }}
1383
+ >
1384
+ {(
1385
+ [
1386
+ { id: 'files' as const, label: 'Files', accent: theme.colors.primary },
1387
+ { id: 'scopes' as const, label: 'Scopes', accent: theme.colors.accent },
1388
+ ]
1389
+ ).map((opt, i) => {
1390
+ const active = activeTab === opt.id;
1391
+ return (
1392
+ <button
1393
+ key={opt.id}
1394
+ onClick={() => setActiveTab(opt.id)}
1395
+ style={{
1396
+ padding: '8px 16px',
1397
+ background: active ? opt.accent : 'transparent',
1398
+ color: active ? theme.colors.textOnPrimary : theme.colors.textSecondary,
1399
+ border: 'none',
1400
+ borderLeft: i === 0 ? 'none' : `1px solid ${theme.colors.backgroundSecondary}`,
1401
+ cursor: 'pointer',
1402
+ fontWeight: active ? theme.fontWeights.semibold : theme.fontWeights.body,
1403
+ fontFamily: 'inherit',
1404
+ fontSize: 'inherit',
1405
+ }}
1406
+ >
1407
+ {opt.label}
1408
+ </button>
1409
+ );
1410
+ })}
1411
+ </div>
1412
+
1413
+ {/* Info overlay — driven by scope tree selection */}
1414
+ {activeTab === 'scopes' && scopeInfo && <ScopeInfoOverlay info={scopeInfo} />}
1415
+ </div>
1416
+
1417
+ {/* Add-to-scope modal */}
1418
+ {showAddModal && scopeModalTargetPath && (
1419
+ <AddToScopeModal
1420
+ path={toScopePath(scopeModalTargetPath)}
1421
+ scopes={scopes}
1422
+ scopeId={modalScopeId}
1423
+ namespaceName={modalNamespaceName}
1424
+ onScopeIdChange={setModalScopeId}
1425
+ onNamespaceNameChange={setModalNamespaceName}
1426
+ onPickExisting={(s, n) => {
1427
+ setModalScopeId(s);
1428
+ setModalNamespaceName(n);
1429
+ }}
1430
+ onSubmit={submitAddToScope}
1431
+ onClose={() => {
1432
+ setShowAddModal(false);
1433
+ setScopeModalTargetPath(null);
1434
+ }}
1435
+ />
1436
+ )}
1437
+
1438
+ {/* Add-to-area modal */}
1439
+ {showAddAreaModal && areaModalTargetPath && (
1440
+ <AddToAreaModal
1441
+ path={toScopePath(areaModalTargetPath)}
1442
+ areas={areas}
1443
+ areaName={modalAreaName}
1444
+ description={modalAreaDescription}
1445
+ onAreaNameChange={setModalAreaName}
1446
+ onDescriptionChange={setModalAreaDescription}
1447
+ onPickExisting={name => setModalAreaName(name)}
1448
+ onSubmit={submitAddToArea}
1449
+ onClose={() => {
1450
+ setShowAddAreaModal(false);
1451
+ setAreaModalTargetPath(null);
1452
+ }}
1453
+ />
1454
+ )}
1455
+ </div>
1456
+ );
1457
+ };