@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,1045 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { useTheme } from '@principal-ade/industry-theme';
4
+ import { FileTree, useFileTree, useFileTreeSelector, } from '@pierre/trees/react';
5
+ import { FileCity3D, } from '../FileCity3D';
6
+ import { buildFolderElevatedPanels, buildFolderIndex } from '../../utils/folderElevatedPanels';
7
+ import { AddToAreaModal } from './AddToAreaModal';
8
+ import { AddToScopeModal } from './AddToScopeModal';
9
+ import { ScopeInfoOverlay } from './ScopeInfoOverlay';
10
+ import { AREA_PANEL_COLOR, buildLayersForScope, pickNamespaceColor } from './layers';
11
+ import { createPathConverters } from './pathConversion';
12
+ import { buildScopeTreePaths, parseScopeTreePath, } from './scopeTreePaths';
13
+ import { makeSectionLabelStyle, withAlpha } from './styles';
14
+ /**
15
+ * Narrow a `FileTreeItemHandle` to its directory variant. The library's
16
+ * `isDirectory()` method returns `true`/`false` literals but isn't a
17
+ * `this is X` predicate, so callers can't use it to access directory-only
18
+ * methods (`expand`, `collapse`, `toggle`) without help.
19
+ */
20
+ function asDir(handle) {
21
+ return handle && handle.isDirectory() ? handle : null;
22
+ }
23
+ export const FileCityExplorer = ({ cityData, packageRoot, initialScopes, initialAreas, persistKey, initialFocusDirectory, }) => {
24
+ const { theme } = useTheme();
25
+ const sectionLabelStyle = makeSectionLabelStyle(theme);
26
+ // City root without trailing slash — used as the iterative-zoom-out clamp
27
+ // and the default initial focus.
28
+ const packageRootClamp = React.useMemo(() => (packageRoot.endsWith('/') ? packageRoot.slice(0, -1) : packageRoot), [packageRoot]);
29
+ const { toScopePath, toCityPath } = React.useMemo(() => createPathConverters(packageRoot), [packageRoot]);
30
+ // City-derived lookups — recomputed only if `cityData` changes.
31
+ const cityPaths = React.useMemo(() => {
32
+ const set = new Set();
33
+ for (const b of cityData.buildings)
34
+ set.add(b.path);
35
+ return Array.from(set).sort();
36
+ }, [cityData]);
37
+ const cityDirectories = React.useMemo(() => new Set(cityData.districts.map(d => d.path)), [cityData]);
38
+ const districtsByPath = React.useMemo(() => new Map(cityData.districts.map(d => [d.path, d])), [cityData]);
39
+ const folderIndex = React.useMemo(() => buildFolderIndex(cityData), [cityData]);
40
+ // Storage keys derived from persistKey (if set).
41
+ const scopesKey = persistKey ? `${persistKey}.scopes` : null;
42
+ const areasKey = persistKey ? `${persistKey}.areas` : null;
43
+ const [scopes, setScopes] = React.useState(() => {
44
+ if (scopesKey && typeof window !== 'undefined') {
45
+ try {
46
+ const raw = window.localStorage.getItem(scopesKey);
47
+ if (raw) {
48
+ const parsed = JSON.parse(raw);
49
+ if (Array.isArray(parsed))
50
+ return parsed;
51
+ }
52
+ }
53
+ catch {
54
+ // fall through
55
+ }
56
+ }
57
+ return initialScopes ?? [];
58
+ });
59
+ const [areas, setAreas] = React.useState(() => {
60
+ if (areasKey && typeof window !== 'undefined') {
61
+ try {
62
+ const raw = window.localStorage.getItem(areasKey);
63
+ if (raw) {
64
+ const parsed = JSON.parse(raw);
65
+ if (Array.isArray(parsed))
66
+ return parsed;
67
+ }
68
+ }
69
+ catch {
70
+ // fall through
71
+ }
72
+ }
73
+ return initialAreas ?? [];
74
+ });
75
+ React.useEffect(() => {
76
+ if (!scopesKey || typeof window === 'undefined')
77
+ return;
78
+ try {
79
+ window.localStorage.setItem(scopesKey, JSON.stringify(scopes));
80
+ }
81
+ catch {
82
+ // ignore quota / serialization errors
83
+ }
84
+ }, [scopes, scopesKey]);
85
+ React.useEffect(() => {
86
+ if (!areasKey || typeof window === 'undefined')
87
+ return;
88
+ try {
89
+ window.localStorage.setItem(areasKey, JSON.stringify(areas));
90
+ }
91
+ catch {
92
+ // ignore quota / serialization errors
93
+ }
94
+ }, [areas, areasKey]);
95
+ const [focusDirectory, setFocusDirectory] = React.useState(initialFocusDirectory !== undefined ? initialFocusDirectory : packageRootClamp);
96
+ const [focusPinned, setFocusPinned] = React.useState(false);
97
+ // While pinned, tree/scope selections must not change focusDirectory.
98
+ // Wrapping the setter (rather than gating each call site) keeps the pin
99
+ // honoured even from event handlers we add later.
100
+ const focusPinnedRef = React.useRef(focusPinned);
101
+ React.useEffect(() => {
102
+ focusPinnedRef.current = focusPinned;
103
+ }, [focusPinned]);
104
+ // Keep a ref to focusDirectory so event handlers (e.g. the city's
105
+ // double-click handler) can branch on it without taking a hard dep
106
+ // and re-rebuilding folder panels on every focus change.
107
+ const focusDirectoryRef = React.useRef(focusDirectory);
108
+ React.useEffect(() => {
109
+ focusDirectoryRef.current = focusDirectory;
110
+ }, [focusDirectory]);
111
+ const setFocusDirectoryIfUnpinned = React.useCallback((next) => {
112
+ if (focusPinnedRef.current)
113
+ return;
114
+ setFocusDirectory(next);
115
+ }, []);
116
+ const [selectedPanelFolder, setSelectedPanelFolder] = React.useState(null);
117
+ const [showPanelFolderContents, setShowPanelFolderContents] = React.useState(false);
118
+ const [showAddPicker, setShowAddPicker] = React.useState(false);
119
+ const addPickerRef = React.useRef(null);
120
+ // Close the +Add picker on any click outside of it. Listens at the
121
+ // document level so clicks anywhere — canvas, header, other overlays —
122
+ // dismiss the menu just like a native dropdown.
123
+ React.useEffect(() => {
124
+ if (!showAddPicker)
125
+ return;
126
+ const onPointerDown = (e) => {
127
+ if (addPickerRef.current?.contains(e.target))
128
+ return;
129
+ setShowAddPicker(false);
130
+ };
131
+ document.addEventListener('mousedown', onPointerDown);
132
+ return () => document.removeEventListener('mousedown', onPointerDown);
133
+ }, [showAddPicker]);
134
+ // Anchor for the top-right "Hidden parent layers" panel: tracks the
135
+ // most-recently-interacted folder so the panel always shows the chain of
136
+ // expanded ancestors up from the user's current focus. Updated by
137
+ // umbrella clicks, building clicks, and file tree selections.
138
+ const [parentLayersAnchor, setParentLayersAnchor] = React.useState(null);
139
+ // When true, the panel is hidden until the next folder interaction.
140
+ const [parentLayersDismissed, setParentLayersDismissed] = React.useState(false);
141
+ // Sub-tree of paths under the currently selected panel folder. Computed
142
+ // on demand so we only rebuild when the user actually opens the contents
143
+ // view. Paths are stripped of the folder prefix so the tree renders rooted
144
+ // at the folder itself.
145
+ const panelFolderContentsPaths = React.useMemo(() => {
146
+ if (!selectedPanelFolder || !showPanelFolderContents)
147
+ return [];
148
+ const prefix = selectedPanelFolder + '/';
149
+ return cityPaths.filter(p => p.startsWith(prefix))
150
+ .map(p => p.slice(prefix.length))
151
+ .sort();
152
+ }, [selectedPanelFolder, showPanelFolderContents, cityPaths]);
153
+ const initialPanelFolderPaths = React.useRef([]);
154
+ const { model: panelFolderContentsTreeModel } = useFileTree({
155
+ paths: initialPanelFolderPaths.current,
156
+ search: true,
157
+ });
158
+ // Keep the sub-tree in sync as the selected folder or visibility changes.
159
+ React.useEffect(() => {
160
+ panelFolderContentsTreeModel.resetPaths(panelFolderContentsPaths);
161
+ }, [panelFolderContentsTreeModel, panelFolderContentsPaths]);
162
+ const [scopeSelection, setScopeSelection] = React.useState(null);
163
+ const [showAddModal, setShowAddModal] = React.useState(false);
164
+ const [scopeModalTargetPath, setScopeModalTargetPath] = React.useState(null);
165
+ const [modalScopeId, setModalScopeId] = React.useState('');
166
+ const [modalNamespaceName, setModalNamespaceName] = React.useState('');
167
+ const [showAddAreaModal, setShowAddAreaModal] = React.useState(false);
168
+ const [areaModalTargetPath, setAreaModalTargetPath] = React.useState(null);
169
+ const [modalAreaName, setModalAreaName] = React.useState('');
170
+ const [modalAreaDescription, setModalAreaDescription] = React.useState('');
171
+ const [activeTab, setActiveTab] = React.useState('files');
172
+ const initialCityPaths = React.useRef(cityPaths);
173
+ const { model: treeModel } = useFileTree({
174
+ paths: initialCityPaths.current,
175
+ search: true,
176
+ initialExpandedPaths: [],
177
+ onSelectionChange: paths => {
178
+ const selected = paths[0];
179
+ if (!selected) {
180
+ setFocusDirectoryIfUnpinned(null);
181
+ return;
182
+ }
183
+ // Selecting a directory focuses the city on it; selecting a file focuses
184
+ // the file's parent directory (closest ancestor that exists as a district).
185
+ if (cityDirectories.has(selected)) {
186
+ setFocusDirectoryIfUnpinned(selected);
187
+ setParentLayersAnchor(selected);
188
+ setParentLayersDismissed(false);
189
+ return;
190
+ }
191
+ const parts = selected.split('/');
192
+ while (parts.length > 1) {
193
+ parts.pop();
194
+ const candidate = parts.join('/');
195
+ if (cityDirectories.has(candidate)) {
196
+ setFocusDirectoryIfUnpinned(candidate);
197
+ setParentLayersAnchor(selected);
198
+ setParentLayersDismissed(false);
199
+ return;
200
+ }
201
+ }
202
+ setFocusDirectoryIfUnpinned(null);
203
+ },
204
+ });
205
+ const scopeTreePaths = React.useMemo(() => buildScopeTreePaths(scopes), [scopes]);
206
+ const initialScopeTreePaths = React.useRef(scopeTreePaths);
207
+ const initialExpandedScopeIds = React.useRef(scopes.map(s => s.id));
208
+ const { model: scopeTreeModel } = useFileTree({
209
+ paths: initialScopeTreePaths.current,
210
+ search: true,
211
+ initialExpandedPaths: initialExpandedScopeIds.current,
212
+ onSelectionChange: paths => {
213
+ const selected = paths[0];
214
+ if (!selected) {
215
+ setScopeSelection(null);
216
+ return;
217
+ }
218
+ const parsed = parseScopeTreePath(selected);
219
+ setScopeSelection(parsed);
220
+ // Selecting a namespace or event also focuses the city on the namespace's
221
+ // first declared path; selecting a bare scope clears the focus.
222
+ if (parsed.namespaceName) {
223
+ const scope = scopes.find(s => s.id === parsed.scopeId);
224
+ const ns = scope?.namespaces.find(n => n.name === parsed.namespaceName);
225
+ if (ns?.paths[0])
226
+ setFocusDirectoryIfUnpinned(toCityPath(ns.paths[0]));
227
+ }
228
+ else {
229
+ setFocusDirectoryIfUnpinned(null);
230
+ }
231
+ },
232
+ });
233
+ // Keep the scope tree's paths in sync as scopes mutate (the model is created
234
+ // once; later option changes need resetPaths per @pierre/trees docs).
235
+ const isFirstScopeTreeSync = React.useRef(true);
236
+ const pendingExpand = React.useRef([]);
237
+ React.useEffect(() => {
238
+ if (isFirstScopeTreeSync.current) {
239
+ isFirstScopeTreeSync.current = false;
240
+ return;
241
+ }
242
+ scopeTreeModel.resetPaths(scopeTreePaths);
243
+ for (const dirPath of pendingExpand.current) {
244
+ asDir(scopeTreeModel.getItem(dirPath))?.expand();
245
+ }
246
+ pendingExpand.current = [];
247
+ }, [scopeTreeModel, scopeTreePaths]);
248
+ // Track which scope/namespace nodes are expanded in the scope tree. The
249
+ // city panels mirror this: a collapsed scope shows one umbrella tile, an
250
+ // expanded scope shows per-namespace tiles, and an expanded namespace
251
+ // hides its tile so the buildings underneath are visible.
252
+ const treeExpansion = useFileTreeSelector(scopeTreeModel, React.useCallback((model) => {
253
+ const expandedScopes = new Set();
254
+ const expandedNamespaces = new Set();
255
+ for (const scope of scopes) {
256
+ const scopeItem = asDir(model.getItem(scope.id));
257
+ if (scopeItem && scopeItem.isExpanded()) {
258
+ expandedScopes.add(scope.id);
259
+ for (const ns of scope.namespaces) {
260
+ const nsKey = `${scope.id}/${ns.name}`;
261
+ const nsItem = asDir(model.getItem(nsKey));
262
+ if (nsItem && nsItem.isExpanded()) {
263
+ expandedNamespaces.add(nsKey);
264
+ }
265
+ }
266
+ }
267
+ }
268
+ return { expandedScopes, expandedNamespaces };
269
+ }, [scopes]), React.useCallback((prev, next) => {
270
+ if (prev.expandedScopes.size !== next.expandedScopes.size)
271
+ return false;
272
+ for (const k of prev.expandedScopes)
273
+ if (!next.expandedScopes.has(k))
274
+ return false;
275
+ if (prev.expandedNamespaces.size !== next.expandedNamespaces.size)
276
+ return false;
277
+ for (const k of prev.expandedNamespaces)
278
+ if (!next.expandedNamespaces.has(k))
279
+ return false;
280
+ return true;
281
+ }, []));
282
+ // Resolve the current scope tree selection into the underlying objects.
283
+ const scopeInfo = React.useMemo(() => {
284
+ if (!scopeSelection)
285
+ return null;
286
+ const scope = scopes.find(s => s.id === scopeSelection.scopeId);
287
+ if (!scope)
288
+ return null;
289
+ const ns = scopeSelection.namespaceName
290
+ ? scope.namespaces.find(n => n.name === scopeSelection.namespaceName) ?? null
291
+ : null;
292
+ const ev = ns && scopeSelection.eventName
293
+ ? ns.events.find(e => e.name === scopeSelection.eventName) ?? null
294
+ : null;
295
+ return { scope, ns, ev };
296
+ }, [scopeSelection, scopes]);
297
+ // City highlight layers derive from the active tab:
298
+ // scopes tab → selected scope's namespace fills (+ scope-level borders)
299
+ // files tab → none
300
+ const cityHighlightLayers = React.useMemo(() => {
301
+ if (activeTab === 'scopes') {
302
+ return scopeInfo ? buildLayersForScope(scopeInfo.scope, toCityPath) : undefined;
303
+ }
304
+ return undefined;
305
+ }, [activeTab, scopeInfo, toCityPath]);
306
+ // Elevated scope panels — driven by the scope tree's expansion state.
307
+ // - Collapsed scope → one gray umbrella tile per scope path.
308
+ // - Expanded scope, collapsed namespace → colored tile per namespace path.
309
+ // - Expanded namespace → no tile (buildings show through).
310
+ const cityElevatedPanels = React.useMemo(() => {
311
+ if (activeTab !== 'scopes')
312
+ return undefined;
313
+ const panels = [];
314
+ for (const scope of scopes) {
315
+ const isScopeExpanded = treeExpansion.expandedScopes.has(scope.id);
316
+ if (!isScopeExpanded) {
317
+ const onClick = () => asDir(scopeTreeModel.getItem(scope.id))?.toggle();
318
+ for (const sp of scope.paths) {
319
+ const district = districtsByPath.get(toCityPath(sp));
320
+ if (!district)
321
+ continue;
322
+ panels.push({
323
+ id: `${scope.id}::scope::${sp}`,
324
+ color: theme.colors.textTertiary,
325
+ height: 4,
326
+ thickness: 2,
327
+ bounds: district.worldBounds,
328
+ label: scope.id,
329
+ onClick,
330
+ });
331
+ }
332
+ continue;
333
+ }
334
+ for (const ns of scope.namespaces) {
335
+ const nsKey = `${scope.id}/${ns.name}`;
336
+ if (treeExpansion.expandedNamespaces.has(nsKey))
337
+ continue;
338
+ const onClick = () => asDir(scopeTreeModel.getItem(nsKey))?.toggle();
339
+ for (const np of ns.paths) {
340
+ const district = districtsByPath.get(toCityPath(np));
341
+ if (!district)
342
+ continue;
343
+ panels.push({
344
+ id: `${scope.id}::${ns.name}::${np}`,
345
+ color: ns.color,
346
+ height: 4,
347
+ thickness: 2,
348
+ bounds: district.worldBounds,
349
+ label: ns.name,
350
+ onClick,
351
+ });
352
+ }
353
+ }
354
+ }
355
+ return panels.length > 0 ? panels : undefined;
356
+ }, [activeTab, scopes, scopeTreeModel, treeExpansion, districtsByPath, toCityPath, theme]);
357
+ // Track which folders are expanded in the file tree. The file-tree tab's
358
+ // elevated panels mirror this: a collapsed folder shows one umbrella tile
359
+ // covering every descendant district; expanding the folder reveals its
360
+ // sub-folder tiles (or the buildings themselves at the leaves).
361
+ const folderTreeExpansion = useFileTreeSelector(treeModel, React.useCallback((model) => {
362
+ const expanded = new Set();
363
+ for (const dir of cityDirectories) {
364
+ const item = asDir(model.getItem(dir));
365
+ if (item && item.isExpanded())
366
+ expanded.add(dir);
367
+ }
368
+ return { expanded };
369
+ }, [cityDirectories]), React.useCallback((prev, next) => {
370
+ if (prev.expanded.size !== next.expanded.size)
371
+ return false;
372
+ for (const k of prev.expanded)
373
+ if (!next.expanded.has(k))
374
+ return false;
375
+ return true;
376
+ }, []));
377
+ // Mirror contents-tree expansion onto the main tree so the city's folder
378
+ // umbrellas hide for folders the user expands in the floating contents
379
+ // view. Contents-tree paths are stripped of the selected-folder prefix;
380
+ // we re-prefix them to address the same node in the main model.
381
+ const contentsFolderExpansion = useFileTreeSelector(panelFolderContentsTreeModel, React.useCallback((model) => {
382
+ const expanded = new Set();
383
+ if (!selectedPanelFolder)
384
+ return { expanded };
385
+ const prefix = selectedPanelFolder + '/';
386
+ for (const dir of cityDirectories) {
387
+ if (!dir.startsWith(prefix))
388
+ continue;
389
+ const stripped = dir.slice(prefix.length);
390
+ const item = asDir(model.getItem(stripped));
391
+ if (item && item.isExpanded())
392
+ expanded.add(dir);
393
+ }
394
+ return { expanded };
395
+ }, [selectedPanelFolder, cityDirectories]), React.useCallback((prev, next) => {
396
+ if (prev.expanded.size !== next.expanded.size)
397
+ return false;
398
+ for (const k of prev.expanded)
399
+ if (!next.expanded.has(k))
400
+ return false;
401
+ return true;
402
+ }, []));
403
+ // Diff against the prior mirror so collapses propagate without stomping
404
+ // folders the user expanded directly via city umbrella clicks.
405
+ const prevContentsExpansionRef = React.useRef(new Set());
406
+ React.useEffect(() => {
407
+ const next = contentsFolderExpansion.expanded;
408
+ const prev = prevContentsExpansionRef.current;
409
+ for (const dir of next) {
410
+ if (!prev.has(dir))
411
+ asDir(treeModel.getItem(dir))?.expand();
412
+ }
413
+ for (const dir of prev) {
414
+ if (!next.has(dir))
415
+ asDir(treeModel.getItem(dir))?.collapse();
416
+ }
417
+ prevContentsExpansionRef.current = new Set(next);
418
+ }, [contentsFolderExpansion, treeModel]);
419
+ // Folder city-path → area display name. Lets folder umbrella tiles surface
420
+ // the human-readable area name above the technical path component.
421
+ const areaNameByCityPath = React.useMemo(() => {
422
+ const m = new Map();
423
+ for (const area of areas) {
424
+ for (const p of area.paths)
425
+ m.set(toCityPath(p), area.name);
426
+ }
427
+ return m;
428
+ }, [areas, toCityPath]);
429
+ const folderElevatedPanels = React.useMemo(() => {
430
+ if (activeTab !== 'files')
431
+ return undefined;
432
+ const rawPanels = buildFolderElevatedPanels({
433
+ cityData,
434
+ expandedFolders: folderTreeExpansion.expanded,
435
+ onToggleFolder: (folderPath) => {
436
+ // Plain click → surface the clicked folder in the panel-selection
437
+ // card (with an "Open" button) instead of expanding immediately,
438
+ // so the umbrella tile doesn't vanish out from under the click.
439
+ // showPanelFolderContents is intentionally not reset here: if the
440
+ // user already opted into the contents view, switching folders
441
+ // keeps the contents view active for the new folder.
442
+ setSelectedPanelFolder(folderPath);
443
+ setParentLayersAnchor(folderPath);
444
+ setParentLayersDismissed(false);
445
+ },
446
+ onDoubleClickFolder: (folderPath) => {
447
+ // Double-click → focus the camera on this folder. Double-clicking
448
+ // a folder that is *already* the focus pops the focus up by one
449
+ // ancestor (clamped at the package root), giving an iterative
450
+ // "zoom out" gesture as the user keeps double-clicking.
451
+ let next = folderPath;
452
+ let nextSelected = folderPath;
453
+ if (focusDirectoryRef.current === folderPath) {
454
+ const slash = folderPath.lastIndexOf('/');
455
+ next = slash > 0 ? folderPath.slice(0, slash) : packageRootClamp;
456
+ nextSelected = next;
457
+ }
458
+ setSelectedPanelFolder(nextSelected);
459
+ setFocusDirectoryIfUnpinned(next);
460
+ setParentLayersAnchor(nextSelected);
461
+ setParentLayersDismissed(false);
462
+ },
463
+ index: folderIndex,
464
+ });
465
+ const panels = rawPanels.map(panel => {
466
+ const folderPath = panel.id.startsWith('folder::') ? panel.id.slice('folder::'.length) : null;
467
+ const displayLabel = folderPath ? areaNameByCityPath.get(folderPath) : undefined;
468
+ return displayLabel ? { ...panel, displayLabel } : panel;
469
+ });
470
+ // Selection indicator: render a thin, slightly-larger panel underneath
471
+ // the selected folder's umbrella so an accent ring peeks out around its
472
+ // edges. Inserted *before* the umbrella in the list so the umbrella
473
+ // draws on top — only the inflated rim shows. If the folder is expanded
474
+ // (no umbrella in the panel list) findIndex returns -1 and no ring is
475
+ // drawn, which is exactly what we want.
476
+ if (selectedPanelFolder) {
477
+ const idx = panels.findIndex(p => p.id === `folder::${selectedPanelFolder}`);
478
+ if (idx >= 0) {
479
+ const target = panels[idx];
480
+ const inflate = 4;
481
+ const border = {
482
+ id: `folder-border::${selectedPanelFolder}`,
483
+ color: theme.colors.warning,
484
+ height: (target.height ?? 4) - 2,
485
+ thickness: 1,
486
+ bounds: {
487
+ minX: target.bounds.minX - inflate,
488
+ maxX: target.bounds.maxX + inflate,
489
+ minZ: target.bounds.minZ - inflate,
490
+ maxZ: target.bounds.maxZ + inflate,
491
+ },
492
+ };
493
+ const next = [...panels];
494
+ next.splice(idx, 0, border);
495
+ return next;
496
+ }
497
+ }
498
+ return panels.length > 0 ? panels : undefined;
499
+ }, [
500
+ activeTab,
501
+ cityData,
502
+ selectedPanelFolder,
503
+ treeModel,
504
+ folderTreeExpansion,
505
+ setFocusDirectoryIfUnpinned,
506
+ areaNameByCityPath,
507
+ folderIndex,
508
+ packageRootClamp,
509
+ theme,
510
+ ]);
511
+ // Cmd-click on a building → surface the chain of expanded ancestor folders
512
+ // (their umbrellas are currently hidden because they're expanded). Each
513
+ // entry in the popup can be clicked to collapse that ancestor, which
514
+ // restores its umbrella so the user can navigate back up.
515
+ const parentLayers = React.useMemo(() => {
516
+ if (!parentLayersAnchor)
517
+ return [];
518
+ const parts = parentLayersAnchor.split('/');
519
+ const out = [];
520
+ // Walk shallowest → deepest so the list reads outermost-first.
521
+ // Include the anchor itself: if it's expanded, its umbrella is hidden
522
+ // (children took its place), so the user should be able to collapse it
523
+ // back from the panel.
524
+ for (let i = 1; i <= parts.length; i++) {
525
+ const ancestor = parts.slice(0, i).join('/');
526
+ if (folderTreeExpansion.expanded.has(ancestor))
527
+ out.push(ancestor);
528
+ }
529
+ return out;
530
+ }, [parentLayersAnchor, folderTreeExpansion]);
531
+ const handleBuildingClick = React.useCallback((building) => {
532
+ setParentLayersAnchor(building.path);
533
+ setParentLayersDismissed(false);
534
+ }, []);
535
+ const collapseFolder = React.useCallback((folderPath) => asDir(treeModel.getItem(folderPath))?.collapse(), [treeModel]);
536
+ const openAddModal = React.useCallback((targetPath, prefillScopeId) => {
537
+ setScopeModalTargetPath(targetPath);
538
+ setModalScopeId(prefillScopeId ?? '');
539
+ setModalNamespaceName('');
540
+ setShowAddModal(true);
541
+ }, []);
542
+ // Coverage lookup for the city-panel-clicked folder. Returns scope hits
543
+ // (with the most specific covering namespace, if any) and area hits.
544
+ const panelFolderCoverage = React.useMemo(() => {
545
+ if (!selectedPanelFolder)
546
+ return null;
547
+ const sp = toScopePath(selectedPanelFolder);
548
+ const covers = (claim) => sp === claim || sp.startsWith(claim + '/');
549
+ const scopeHits = [];
550
+ for (const scope of scopes) {
551
+ const ns = scope.namespaces.find(n => n.paths.some(covers)) ?? null;
552
+ const scopeLevel = scope.paths.some(covers);
553
+ if (ns || scopeLevel)
554
+ scopeHits.push({ scope, namespace: ns });
555
+ }
556
+ const areaHits = areas.filter(a => a.paths.some(covers));
557
+ return { scopeHits, areaHits };
558
+ }, [selectedPanelFolder, scopes, areas, toScopePath]);
559
+ const submitAddToScope = React.useCallback(() => {
560
+ if (!scopeModalTargetPath)
561
+ return;
562
+ const path = toScopePath(scopeModalTargetPath);
563
+ const scopeId = modalScopeId.trim();
564
+ const namespaceName = modalNamespaceName.trim();
565
+ if (!scopeId)
566
+ return;
567
+ // Queue branches to auto-expand once the tree re-resets.
568
+ pendingExpand.current = namespaceName ? [scopeId, `${scopeId}/${namespaceName}`] : [scopeId];
569
+ // Invariant: a scope's `paths` must cover every path claimed by any of
570
+ // its namespaces. If `path` isn't already covered by scope.paths, we add
571
+ // it.
572
+ const ensureScopePathCovers = (scope) => {
573
+ const covered = scope.paths.some(p => path === p || path.startsWith(p + '/'));
574
+ if (covered)
575
+ return scope;
576
+ return { ...scope, paths: [...scope.paths, path] };
577
+ };
578
+ setScopes(prev => {
579
+ const scopeIdx = prev.findIndex(s => s.id === scopeId);
580
+ // Existing scope.
581
+ if (scopeIdx >= 0) {
582
+ const scope = prev[scopeIdx];
583
+ // No namespace given → add to scope-level paths.
584
+ if (!namespaceName) {
585
+ if (scope.paths.includes(path))
586
+ return prev;
587
+ const next = [...prev];
588
+ next[scopeIdx] = { ...scope, paths: [...scope.paths, path] };
589
+ return next;
590
+ }
591
+ const nsIdx = scope.namespaces.findIndex(n => n.name === namespaceName);
592
+ // Existing namespace — push the path if not already there.
593
+ if (nsIdx >= 0) {
594
+ const ns = scope.namespaces[nsIdx];
595
+ if (ns.paths.includes(path))
596
+ return prev;
597
+ const newNs = { ...ns, paths: [...ns.paths, path] };
598
+ const newNamespaces = [...scope.namespaces];
599
+ newNamespaces[nsIdx] = newNs;
600
+ const next = [...prev];
601
+ next[scopeIdx] = ensureScopePathCovers({ ...scope, namespaces: newNamespaces });
602
+ return next;
603
+ }
604
+ // New namespace under existing scope.
605
+ const newNs = {
606
+ name: namespaceName,
607
+ description: '(new namespace)',
608
+ color: pickNamespaceColor(prev),
609
+ paths: [path],
610
+ events: [],
611
+ };
612
+ const next = [...prev];
613
+ next[scopeIdx] = ensureScopePathCovers({
614
+ ...scope,
615
+ namespaces: [...scope.namespaces, newNs],
616
+ });
617
+ return next;
618
+ }
619
+ // Brand-new scope. Scope paths are required, so the path always seeds
620
+ // scope.paths even when a namespace is also being created.
621
+ if (!namespaceName) {
622
+ return [
623
+ ...prev,
624
+ {
625
+ id: scopeId,
626
+ name: scopeId,
627
+ description: '(new scope)',
628
+ paths: [path],
629
+ namespaces: [],
630
+ },
631
+ ];
632
+ }
633
+ const newNs = {
634
+ name: namespaceName,
635
+ description: '(new namespace)',
636
+ color: pickNamespaceColor(prev),
637
+ paths: [path],
638
+ events: [],
639
+ };
640
+ return [
641
+ ...prev,
642
+ {
643
+ id: scopeId,
644
+ name: scopeId,
645
+ description: '(new scope)',
646
+ paths: [path],
647
+ namespaces: [newNs],
648
+ },
649
+ ];
650
+ });
651
+ setShowAddModal(false);
652
+ setScopeModalTargetPath(null);
653
+ }, [scopeModalTargetPath, modalScopeId, modalNamespaceName, toScopePath]);
654
+ const openAddAreaModal = React.useCallback((targetPath) => {
655
+ setAreaModalTargetPath(targetPath);
656
+ setModalAreaName('');
657
+ setModalAreaDescription('');
658
+ setShowAddAreaModal(true);
659
+ }, []);
660
+ const submitAddToArea = React.useCallback(() => {
661
+ if (!areaModalTargetPath)
662
+ return;
663
+ const path = toScopePath(areaModalTargetPath);
664
+ const name = modalAreaName.trim();
665
+ const desc = modalAreaDescription.trim();
666
+ if (!name)
667
+ return;
668
+ setAreas(prev => {
669
+ const idx = prev.findIndex(a => a.name === name);
670
+ if (idx >= 0) {
671
+ const area = prev[idx];
672
+ if (area.paths.includes(path))
673
+ return prev;
674
+ const next = [...prev];
675
+ next[idx] = { ...area, paths: [...area.paths, path] };
676
+ return next;
677
+ }
678
+ return [
679
+ ...prev,
680
+ { name, description: desc || '(new area)', paths: [path] },
681
+ ];
682
+ });
683
+ setShowAddAreaModal(false);
684
+ setAreaModalTargetPath(null);
685
+ }, [areaModalTargetPath, modalAreaName, modalAreaDescription, toScopePath]);
686
+ return (_jsxs("div", { style: { height: '100vh', display: 'flex', background: theme.colors.background }, children: [_jsxs("div", { style: { flex: 1, position: 'relative', minWidth: 0 }, children: [_jsx("div", { style: {
687
+ position: 'absolute',
688
+ top: 56,
689
+ left: 0,
690
+ right: 0,
691
+ bottom: 0,
692
+ }, children: _jsx(FileCity3D, { cityData: cityData, height: "100%", width: "100%", heightScaling: "linear", linearScale: 0.5, backgroundColor: theme.colors.background, textColor: theme.colors.textMuted, focusDirectory: focusDirectory, highlightLayers: cityHighlightLayers, elevatedScopePanels: cityElevatedPanels ?? folderElevatedPanels, onBuildingClick: handleBuildingClick, animation: {
693
+ startFlat: true,
694
+ autoStartDelay: null,
695
+ staggerDelay: 5,
696
+ tension: 150,
697
+ friction: 16,
698
+ }, showControls: true }) }), _jsxs("div", { style: {
699
+ position: 'absolute',
700
+ top: theme.space[2],
701
+ left: theme.space[2],
702
+ right: theme.space[2],
703
+ padding: '8px 12px',
704
+ background: withAlpha(theme.colors.background, 72),
705
+ backdropFilter: 'blur(8px)',
706
+ WebkitBackdropFilter: 'blur(8px)',
707
+ border: `1px solid ${focusPinned ? theme.colors.warning : theme.colors.border}`,
708
+ borderRadius: theme.radii[3],
709
+ color: theme.colors.text,
710
+ fontFamily: theme.fonts.body,
711
+ fontSize: theme.fontSizes[1],
712
+ zIndex: 100,
713
+ display: 'flex',
714
+ alignItems: 'center',
715
+ gap: 10,
716
+ }, children: [_jsxs("div", { style: {
717
+ flex: 1,
718
+ minWidth: 0,
719
+ display: 'flex',
720
+ alignItems: 'center',
721
+ flexWrap: 'wrap',
722
+ gap: 8,
723
+ }, children: [_jsxs("div", { style: sectionLabelStyle, children: ["Path", focusPinned ? ' (pinned)' : ''] }), focusDirectory ? (
724
+ // Breadcrumb: each ancestor segment that exists as a district
725
+ // is a button. Clicking moves focus to that segment, even
726
+ // while pinned (bypasses the pin guard intentionally).
727
+ // When a folder is selected via the city, the breadcrumb
728
+ // extends past focus to its full path — segments beyond the
729
+ // focus point are styled differently to make it clear which
730
+ // part of the chain is the focus vs. the selection.
731
+ (() => {
732
+ const focusDepth = focusDirectory.split('/').length;
733
+ const deepest = selectedPanelFolder &&
734
+ (selectedPanelFolder === focusDirectory ||
735
+ selectedPanelFolder.startsWith(focusDirectory + '/'))
736
+ ? selectedPanelFolder
737
+ : focusDirectory;
738
+ const parts = deepest.split('/');
739
+ const segments = [];
740
+ for (let i = 0; i < parts.length; i++) {
741
+ const path = parts.slice(0, i + 1).join('/');
742
+ if (cityDirectories.has(path)) {
743
+ segments.push({ label: parts[i], path, beyondFocus: i + 1 > focusDepth });
744
+ }
745
+ }
746
+ return (_jsx("div", { style: {
747
+ display: 'flex',
748
+ flexWrap: 'wrap',
749
+ alignItems: 'center',
750
+ gap: 2,
751
+ }, children: segments.map((seg, i) => {
752
+ const isFocus = seg.path === focusDirectory;
753
+ const isSelectedLeaf = seg.path === selectedPanelFolder && seg.beyondFocus;
754
+ const color = isFocus || seg.beyondFocus
755
+ ? theme.colors.text
756
+ : theme.colors.textMuted;
757
+ return (_jsxs(React.Fragment, { children: [i > 0 && (_jsx("span", { style: { color: theme.colors.text, fontFamily: theme.fonts.monospace }, children: "/" })), _jsx("button", { onClick: () => {
758
+ setFocusDirectory(seg.path);
759
+ setSelectedPanelFolder(seg.path);
760
+ setParentLayersAnchor(seg.path);
761
+ setParentLayersDismissed(false);
762
+ }, style: {
763
+ background: 'transparent',
764
+ border: 'none',
765
+ padding: '2px 4px',
766
+ borderRadius: theme.radii[1],
767
+ fontFamily: theme.fonts.monospace,
768
+ fontSize: theme.fontSizes[1],
769
+ color,
770
+ fontWeight: isFocus || isSelectedLeaf
771
+ ? theme.fontWeights.semibold
772
+ : theme.fontWeights.body,
773
+ cursor: 'pointer',
774
+ textDecoration: 'underline',
775
+ textDecorationColor: theme.colors.muted,
776
+ }, children: seg.label })] }, seg.path));
777
+ }) }));
778
+ })()) : (_jsx("div", { style: {
779
+ fontFamily: theme.fonts.monospace,
780
+ fontSize: theme.fontSizes[1],
781
+ color: theme.colors.textTertiary,
782
+ wordBreak: 'break-all',
783
+ }, children: "None" }))] }), focusDirectory && (_jsx("button", { onClick: () => setFocusPinned(p => !p), title: focusPinned
784
+ ? 'Unpin — selections will move the focus again'
785
+ : 'Pin — keep this focus while navigating the trees', style: {
786
+ background: focusPinned ? theme.colors.warning : 'transparent',
787
+ color: focusPinned ? theme.colors.background : theme.colors.textSecondary,
788
+ border: `1px solid ${focusPinned ? theme.colors.warning : theme.colors.border}`,
789
+ borderRadius: theme.radii[2],
790
+ padding: '4px 8px',
791
+ fontSize: theme.fontSizes[0],
792
+ cursor: 'pointer',
793
+ fontWeight: theme.fontWeights.medium,
794
+ flexShrink: 0,
795
+ }, children: focusPinned ? 'Pinned' : 'Pin' })), focusDirectory && (_jsx("button", { onClick: () => {
796
+ setFocusPinned(false);
797
+ setFocusDirectory(null);
798
+ }, title: "Clear focus", style: {
799
+ background: 'transparent',
800
+ color: theme.colors.textMuted,
801
+ border: `1px solid ${theme.colors.border}`,
802
+ borderRadius: theme.radii[2],
803
+ padding: '4px 8px',
804
+ fontSize: theme.fontSizes[0],
805
+ cursor: 'pointer',
806
+ flexShrink: 0,
807
+ }, children: "Clear" }))] }), activeTab === 'files' && selectedPanelFolder && (_jsxs("div", { style: {
808
+ position: 'absolute',
809
+ top: 60,
810
+ left: theme.space[2],
811
+ padding: '8px 12px',
812
+ background: withAlpha(theme.colors.background, 72),
813
+ backdropFilter: 'blur(8px)',
814
+ WebkitBackdropFilter: 'blur(8px)',
815
+ border: `1px solid ${theme.colors.border}`,
816
+ borderRadius: theme.radii[3],
817
+ color: theme.colors.text,
818
+ fontFamily: theme.fonts.body,
819
+ fontSize: theme.fontSizes[0],
820
+ zIndex: 100,
821
+ maxWidth: 480,
822
+ display: 'flex',
823
+ flexDirection: 'column',
824
+ gap: theme.space[2],
825
+ }, children: [panelFolderCoverage && (panelFolderCoverage.scopeHits.length > 0 ||
826
+ panelFolderCoverage.areaHits.length > 0) && (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 6 }, children: [panelFolderCoverage.areaHits.map(area => (_jsxs("div", { style: {
827
+ padding: '6px 8px',
828
+ background: theme.colors.backgroundDark ?? theme.colors.background,
829
+ border: `1px dashed ${theme.colors.border}`,
830
+ borderRadius: theme.radii[2],
831
+ display: 'flex',
832
+ flexDirection: 'column',
833
+ gap: theme.space[1],
834
+ }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: {
835
+ fontSize: theme.fontSizes[0],
836
+ color: theme.colors.textMuted,
837
+ textTransform: 'uppercase',
838
+ letterSpacing: 0.5,
839
+ fontWeight: theme.fontWeights.semibold,
840
+ }, children: "Area" }), _jsx("span", { style: {
841
+ width: 8,
842
+ height: 8,
843
+ borderRadius: theme.radii[1],
844
+ background: AREA_PANEL_COLOR,
845
+ border: `1px dashed ${theme.colors.textMuted}`,
846
+ flexShrink: 0,
847
+ } }), _jsx("code", { style: { fontSize: theme.fontSizes[0], color: theme.colors.textSecondary }, children: area.name })] }), _jsx("div", { style: { fontSize: theme.fontSizes[0], color: theme.colors.textMuted, lineHeight: 1.4 }, children: area.description })] }, area.name))), panelFolderCoverage.scopeHits.map(({ scope, namespace }) => (_jsxs("div", { style: {
848
+ padding: '6px 8px',
849
+ background: theme.colors.backgroundDark ?? theme.colors.background,
850
+ border: `1px solid ${theme.colors.backgroundSecondary}`,
851
+ borderRadius: theme.radii[2],
852
+ display: 'flex',
853
+ flexDirection: 'column',
854
+ gap: theme.space[1],
855
+ }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 6 }, children: [_jsx("span", { style: {
856
+ fontSize: theme.fontSizes[0],
857
+ color: theme.colors.accent,
858
+ textTransform: 'uppercase',
859
+ letterSpacing: 0.5,
860
+ fontWeight: theme.fontWeights.semibold,
861
+ }, children: "Scope" }), _jsx("code", { style: { fontSize: theme.fontSizes[0], color: theme.colors.textSecondary }, children: scope.id }), namespace && (_jsxs(_Fragment, { children: [_jsx("span", { style: { color: theme.colors.muted }, children: "/" }), _jsx("span", { style: {
862
+ width: 8,
863
+ height: 8,
864
+ borderRadius: theme.radii[1],
865
+ background: namespace.color,
866
+ flexShrink: 0,
867
+ } }), _jsx("code", { style: { fontSize: theme.fontSizes[0], color: theme.colors.textSecondary }, children: namespace.name })] }))] }), _jsx("div", { style: { fontSize: theme.fontSizes[0], color: theme.colors.textMuted, lineHeight: 1.4 }, children: namespace ? namespace.description : scope.description })] }, scope.id)))] })), _jsxs("div", { style: { display: 'flex', gap: 6, flexWrap: 'wrap' }, children: [_jsxs("div", { ref: addPickerRef, style: { position: 'relative' }, children: [_jsx("button", { onClick: () => setShowAddPicker(v => !v), title: "Add this folder to a scope or area", style: {
868
+ background: showAddPicker ? theme.colors.backgroundSecondary : 'transparent',
869
+ color: theme.colors.textSecondary,
870
+ border: `1px solid ${theme.colors.muted}`,
871
+ borderRadius: theme.radii[2],
872
+ padding: '4px 10px',
873
+ fontSize: theme.fontSizes[0],
874
+ cursor: 'pointer',
875
+ fontWeight: theme.fontWeights.medium,
876
+ }, children: "+ Add" }), showAddPicker && (_jsxs("div", { style: {
877
+ position: 'absolute',
878
+ top: 'calc(100% + 4px)',
879
+ left: 0,
880
+ background: withAlpha(theme.colors.background, 95),
881
+ backdropFilter: 'blur(8px)',
882
+ WebkitBackdropFilter: 'blur(8px)',
883
+ border: `1px solid ${theme.colors.border}`,
884
+ borderRadius: theme.radii[2],
885
+ padding: theme.space[1],
886
+ display: 'flex',
887
+ flexDirection: 'column',
888
+ gap: 2,
889
+ zIndex: 110,
890
+ minWidth: 120,
891
+ boxShadow: theme.shadows[3],
892
+ }, children: [_jsx("button", { onClick: () => {
893
+ setShowAddPicker(false);
894
+ openAddModal(selectedPanelFolder);
895
+ }, style: {
896
+ background: 'transparent',
897
+ color: theme.colors.textSecondary,
898
+ border: `1px solid ${theme.colors.accent}`,
899
+ borderRadius: theme.radii[1],
900
+ padding: '4px 10px',
901
+ fontSize: theme.fontSizes[0],
902
+ cursor: 'pointer',
903
+ fontWeight: theme.fontWeights.medium,
904
+ textAlign: 'left',
905
+ }, children: "Scope" }), _jsx("button", { onClick: () => {
906
+ setShowAddPicker(false);
907
+ openAddAreaModal(selectedPanelFolder);
908
+ }, style: {
909
+ background: 'transparent',
910
+ color: theme.colors.textSecondary,
911
+ border: `1px dashed ${theme.colors.textMuted}`,
912
+ borderRadius: theme.radii[1],
913
+ padding: '4px 10px',
914
+ fontSize: theme.fontSizes[0],
915
+ cursor: 'pointer',
916
+ fontWeight: theme.fontWeights.medium,
917
+ textAlign: 'left',
918
+ }, children: "Area" })] }))] }), _jsx("button", { onClick: () => {
919
+ const next = !showPanelFolderContents;
920
+ setShowPanelFolderContents(next);
921
+ // Mirror the Open/Close behaviour: showing contents
922
+ // expands the folder in the tree (so the city's umbrella
923
+ // tile lifts and child buildings become visible);
924
+ // hiding collapses it again.
925
+ const item = asDir(treeModel.getItem(selectedPanelFolder));
926
+ if (!item)
927
+ return;
928
+ if (next)
929
+ item.expand();
930
+ else
931
+ item.collapse();
932
+ }, title: "Show files inside this folder", style: {
933
+ background: showPanelFolderContents ? theme.colors.backgroundSecondary : 'transparent',
934
+ color: theme.colors.textSecondary,
935
+ border: `1px solid ${theme.colors.border}`,
936
+ borderRadius: theme.radii[2],
937
+ padding: '4px 10px',
938
+ fontSize: theme.fontSizes[0],
939
+ cursor: 'pointer',
940
+ fontWeight: theme.fontWeights.medium,
941
+ }, children: showPanelFolderContents ? 'Hide contents' : 'Show contents' })] }), showPanelFolderContents && (_jsx("div", { style: {
942
+ display: 'flex',
943
+ flexDirection: 'column',
944
+ borderTop: `1px solid ${theme.colors.backgroundSecondary}`,
945
+ paddingTop: theme.space[2],
946
+ marginTop: 2,
947
+ minWidth: 320,
948
+ }, children: panelFolderContentsPaths.length === 0 ? (_jsx("div", { style: {
949
+ fontSize: theme.fontSizes[0],
950
+ color: theme.colors.textTertiary,
951
+ fontStyle: 'italic',
952
+ padding: '4px 0',
953
+ }, children: "No files in this folder." })) : (_jsx("div", { style: { height: 640, display: 'flex', flexDirection: 'column' }, children: _jsx(FileTree, { model: panelFolderContentsTreeModel, style: {
954
+ flex: 1,
955
+ minHeight: 0,
956
+ '--trees-bg-override': 'transparent',
957
+ '--trees-search-bg-override': 'rgba(0, 0, 0, 0.25)',
958
+ '--trees-padding-inline-override': '0',
959
+ '--trees-theme-list-active-selection-bg': withAlpha(theme.colors.primary, 28),
960
+ '--trees-theme-list-hover-bg': withAlpha(theme.colors.primary, 14),
961
+ } }) })) }))] })), parentLayersAnchor && !parentLayersDismissed && parentLayers.length > 0 && (_jsxs("div", { style: {
962
+ position: 'absolute',
963
+ top: 72,
964
+ right: theme.space[2],
965
+ padding: '10px 12px',
966
+ background: withAlpha(theme.colors.background, 72),
967
+ backdropFilter: 'blur(8px)',
968
+ WebkitBackdropFilter: 'blur(8px)',
969
+ border: `1px solid ${theme.colors.border}`,
970
+ borderRadius: theme.radii[3],
971
+ color: theme.colors.text,
972
+ fontFamily: theme.fonts.body,
973
+ fontSize: theme.fontSizes[0],
974
+ zIndex: 100,
975
+ maxWidth: 240,
976
+ display: 'flex',
977
+ flexDirection: 'column',
978
+ gap: theme.space[2],
979
+ boxShadow: theme.shadows[3],
980
+ }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 10 }, children: [_jsx("div", { style: { ...sectionLabelStyle, flex: 1, minWidth: 0 }, children: "Hidden parent layers" }), _jsx("button", { onClick: () => setParentLayersDismissed(true), title: "Dismiss (reappears on next folder interaction)", style: {
981
+ background: 'transparent',
982
+ color: theme.colors.textMuted,
983
+ border: `1px solid ${theme.colors.border}`,
984
+ borderRadius: theme.radii[2],
985
+ padding: '4px 8px',
986
+ fontSize: theme.fontSizes[0],
987
+ cursor: 'pointer',
988
+ flexShrink: 0,
989
+ }, children: "\u2715" })] }), _jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: theme.space[1] }, children: parentLayers.map(folderPath => {
990
+ const label = folderPath.split('/').pop() ?? folderPath;
991
+ return (_jsx("button", { onClick: () => collapseFolder(folderPath), title: `Collapse ${folderPath} — restores its umbrella tile`, style: {
992
+ display: 'flex',
993
+ alignItems: 'center',
994
+ gap: theme.space[2],
995
+ padding: '6px 8px',
996
+ background: theme.colors.backgroundSecondary,
997
+ color: theme.colors.text,
998
+ border: `1px solid ${theme.colors.border}`,
999
+ borderRadius: theme.radii[2],
1000
+ cursor: 'pointer',
1001
+ textAlign: 'left',
1002
+ fontFamily: theme.fonts.body,
1003
+ fontSize: theme.fontSizes[0],
1004
+ }, children: _jsx("span", { style: { fontFamily: theme.fonts.monospace, fontWeight: theme.fontWeights.medium }, children: label }) }, folderPath));
1005
+ }) })] })), _jsx("div", { style: {
1006
+ position: 'absolute',
1007
+ bottom: theme.space[3],
1008
+ left: '50%',
1009
+ transform: 'translateX(-50%)',
1010
+ display: 'flex',
1011
+ background: withAlpha(theme.colors.background, 92),
1012
+ border: `1px solid ${theme.colors.backgroundSecondary}`,
1013
+ borderRadius: theme.radii[3],
1014
+ overflow: 'hidden',
1015
+ fontFamily: theme.fonts.body,
1016
+ fontSize: theme.fontSizes[0],
1017
+ boxShadow: theme.shadows[2],
1018
+ zIndex: 10,
1019
+ }, children: ([
1020
+ { id: 'files', label: 'Files', accent: theme.colors.primary },
1021
+ { id: 'scopes', label: 'Scopes', accent: theme.colors.accent },
1022
+ ]).map((opt, i) => {
1023
+ const active = activeTab === opt.id;
1024
+ return (_jsx("button", { onClick: () => setActiveTab(opt.id), style: {
1025
+ padding: '8px 16px',
1026
+ background: active ? opt.accent : 'transparent',
1027
+ color: active ? theme.colors.textOnPrimary : theme.colors.textSecondary,
1028
+ border: 'none',
1029
+ borderLeft: i === 0 ? 'none' : `1px solid ${theme.colors.backgroundSecondary}`,
1030
+ cursor: 'pointer',
1031
+ fontWeight: active ? theme.fontWeights.semibold : theme.fontWeights.body,
1032
+ fontFamily: 'inherit',
1033
+ fontSize: 'inherit',
1034
+ }, children: opt.label }, opt.id));
1035
+ }) }), activeTab === 'scopes' && scopeInfo && _jsx(ScopeInfoOverlay, { info: scopeInfo })] }), showAddModal && scopeModalTargetPath && (_jsx(AddToScopeModal, { path: toScopePath(scopeModalTargetPath), scopes: scopes, scopeId: modalScopeId, namespaceName: modalNamespaceName, onScopeIdChange: setModalScopeId, onNamespaceNameChange: setModalNamespaceName, onPickExisting: (s, n) => {
1036
+ setModalScopeId(s);
1037
+ setModalNamespaceName(n);
1038
+ }, onSubmit: submitAddToScope, onClose: () => {
1039
+ setShowAddModal(false);
1040
+ setScopeModalTargetPath(null);
1041
+ } })), showAddAreaModal && areaModalTargetPath && (_jsx(AddToAreaModal, { path: toScopePath(areaModalTargetPath), areas: areas, areaName: modalAreaName, description: modalAreaDescription, onAreaNameChange: setModalAreaName, onDescriptionChange: setModalAreaDescription, onPickExisting: name => setModalAreaName(name), onSubmit: submitAddToArea, onClose: () => {
1042
+ setShowAddAreaModal(false);
1043
+ setAreaModalTargetPath(null);
1044
+ } }))] }));
1045
+ };