@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,2474 @@
1
+ import React from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
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
+ type FileTreeModel = UseFileTreeResult['model'];
12
+
13
+ /**
14
+ * Narrow a `FileTreeItemHandle` to its directory variant. The library's
15
+ * `isDirectory()` method returns `true`/`false` literals but isn't a
16
+ * `this is X` predicate, so callers can't use it to access directory-only
17
+ * methods (`expand`, `collapse`, `toggle`) without help.
18
+ */
19
+ function asDir(
20
+ handle: FileTreeItemHandle | null | undefined,
21
+ ): FileTreeDirectoryHandle | null {
22
+ return handle && handle.isDirectory() ? (handle as FileTreeDirectoryHandle) : null;
23
+ }
24
+ import {
25
+ FileCity3D,
26
+ type CityData,
27
+ type CityDistrict,
28
+ type ElevatedScopePanel,
29
+ type HighlightLayer,
30
+ } from '../components/FileCity3D';
31
+ import { buildFolderElevatedPanels, buildFolderIndex } from '../utils/folderElevatedPanels';
32
+ import type { EventNamespaceNode, ProjectArea } from '@principal-ai/principal-view-core';
33
+
34
+ import electronAppCityData from '../../../../assets/electron-app-city-data.json';
35
+
36
+ /**
37
+ * @deprecated FROZEN — do not modify.
38
+ *
39
+ * This is the original prototype-as-story implementation of the explorer.
40
+ * It has been extracted into a real component at
41
+ * `packages/react/src/components/FileCityExplorer/`, exercised via
42
+ * `FileCityExplorerComponent.stories.tsx`. This file is kept only for
43
+ * side-by-side comparison while the new component is shaken out.
44
+ *
45
+ * Slated for deletion once the extracted component is confirmed equivalent
46
+ * (see "Pending cleanup" in `docs/file-city-explorer.md`). Make changes to
47
+ * the new component, not here.
48
+ *
49
+ * --- Original notes ---
50
+ *
51
+ * Scope / Namespace Overlay experiments
52
+ *
53
+ * Prototypes the mapping described in docs/scope-namespace-overlay.md:
54
+ *
55
+ * scope → LayerGroup (toggleable lens on the city)
56
+ * namespace → HighlightLayer (one stable color, directory items)
57
+ * paths[] → LayerItem { type: 'directory', renderStrategy: 'fill' }
58
+ *
59
+ * The scopes/namespaces below are hand-authored against paths that exist in
60
+ * the electron-app city data — they stand in for what would eventually be
61
+ * parsed from principal-view-core-library *.events.canvas files.
62
+ */
63
+
64
+ const meta: Meta<typeof FileCity3D> = {
65
+ title: 'Deprecated/FileCityExplorer (legacy story)',
66
+ component: FileCity3D,
67
+ parameters: { layout: 'fullscreen' },
68
+ };
69
+
70
+ export default meta;
71
+ type Story = StoryObj<typeof FileCity3D>;
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Scope / namespace model
75
+ //
76
+ // `Event` and `Namespace` are derived from the upstream
77
+ // `EventNamespaceNode['namespace']` shape in `@principal-ai/principal-view-core`.
78
+ // `Namespace` adds a UI-required `color` (palette pick); `Event` is unchanged.
79
+ //
80
+ // `Scope` stays local: it flattens namespaces inline (`scope.namespaces[]`),
81
+ // which the upstream canvas-node split (`OtelScopeNode` + per-scope
82
+ // `*.events.canvas`) doesn't model. Treat as an explorer view-model.
83
+ //
84
+ // `ProjectArea` is imported directly from upstream.
85
+ // ---------------------------------------------------------------------------
86
+
87
+ type Event = EventNamespaceNode['namespace']['events'][number];
88
+
89
+ type Namespace = EventNamespaceNode['namespace'] & {
90
+ /** UI palette pick — not part of the upstream canvas-node shape. */
91
+ color: string;
92
+ /** Required here even though upstream allows `paths?` — explorer always sets it. */
93
+ paths: string[];
94
+ };
95
+
96
+ interface Scope {
97
+ /** Maps to `OtelScopeNode.otel.scope` upstream. */
98
+ id: string;
99
+ name: string;
100
+ description: string;
101
+ /** Scope-level paths — corresponds to optional `OtelScopeNode.paths` upstream. */
102
+ paths: string[];
103
+ namespaces: Namespace[];
104
+ }
105
+
106
+ const SCOPES_STORAGE_KEY = 'file-city.scope-overlay.scopes';
107
+
108
+ function loadScopesFromStorage(): Scope[] {
109
+ if (typeof window === 'undefined') return [];
110
+ try {
111
+ const raw = window.localStorage.getItem(SCOPES_STORAGE_KEY);
112
+ if (!raw) return [];
113
+ const parsed = JSON.parse(raw);
114
+ return Array.isArray(parsed) ? (parsed as Scope[]) : [];
115
+ } catch {
116
+ return [];
117
+ }
118
+ }
119
+
120
+ function saveScopesToStorage(scopes: readonly Scope[]): void {
121
+ if (typeof window === 'undefined') return;
122
+ try {
123
+ window.localStorage.setItem(SCOPES_STORAGE_KEY, JSON.stringify(scopes));
124
+ } catch {
125
+ // ignore quota / serialization errors in the story
126
+ }
127
+ }
128
+
129
+ const AREAS_STORAGE_KEY = 'file-city.scope-overlay.areas';
130
+
131
+ const DEFAULT_AREAS: ProjectArea[] = [
132
+ {
133
+ name: 'Documentation',
134
+ description: 'Project docs, READMEs, and design notes — not OTEL-instrumented.',
135
+ paths: ['docs'],
136
+ },
137
+ {
138
+ name: 'Build & tooling',
139
+ description: 'Build scripts, bundler config, and developer tooling.',
140
+ paths: ['scripts', 'build'],
141
+ },
142
+ ];
143
+
144
+ function loadAreasFromStorage(): ProjectArea[] {
145
+ if (typeof window === 'undefined') return DEFAULT_AREAS;
146
+ try {
147
+ const raw = window.localStorage.getItem(AREAS_STORAGE_KEY);
148
+ if (!raw) return DEFAULT_AREAS;
149
+ const parsed = JSON.parse(raw);
150
+ return Array.isArray(parsed) ? (parsed as ProjectArea[]) : DEFAULT_AREAS;
151
+ } catch {
152
+ return DEFAULT_AREAS;
153
+ }
154
+ }
155
+
156
+ function saveAreasToStorage(areas: readonly ProjectArea[]): void {
157
+ if (typeof window === 'undefined') return;
158
+ try {
159
+ window.localStorage.setItem(AREAS_STORAGE_KEY, JSON.stringify(areas));
160
+ } catch {
161
+ // ignore quota / serialization errors in the story
162
+ }
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // File tree paths (extracted once from city data)
167
+ // ---------------------------------------------------------------------------
168
+
169
+ const ELECTRON_PATHS: string[] = (() => {
170
+ const set = new Set<string>();
171
+ for (const b of (electronAppCityData as CityData).buildings) set.add(b.path);
172
+ return Array.from(set).sort();
173
+ })();
174
+
175
+ const ELECTRON_DIRECTORIES: Set<string> = new Set(
176
+ (electronAppCityData as CityData).districts.map(d => d.path),
177
+ );
178
+
179
+ const ELECTRON_DISTRICTS_BY_PATH: Map<string, CityDistrict> = new Map(
180
+ (electronAppCityData as CityData).districts.map(d => [d.path, d]),
181
+ );
182
+
183
+ /**
184
+ * Pre-built folder index (children, bounds, file counts) for the static
185
+ * electron-app city. Cached once at module load so the story doesn't
186
+ * recompute on every render.
187
+ */
188
+ const ELECTRON_FOLDER_INDEX = buildFolderIndex(electronAppCityData as CityData);
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Scope tree paths
192
+ // ---------------------------------------------------------------------------
193
+
194
+ interface ScopeTreeSelection {
195
+ scopeId: string;
196
+ namespaceName?: string;
197
+ eventName?: string;
198
+ }
199
+
200
+ /**
201
+ * Sentinel leaves used when a scope has no namespaces or a namespace has no
202
+ * events — the trees library infers directories from paths, so empty branches
203
+ * need a placeholder leaf to render.
204
+ */
205
+ const EMPTY_NS_SENTINEL = '(no namespaces)';
206
+ const EMPTY_EVENTS_SENTINEL = '(no events)';
207
+
208
+ /**
209
+ * Build canonical paths for the scope tree: `<scope.id>/<namespace.name>/<event.name>`.
210
+ * Scopes are top-level directories, namespaces children, events leaves.
211
+ * Empty scopes/namespaces emit a sentinel leaf so they still appear.
212
+ */
213
+ function buildScopeTreePaths(scopes: readonly Scope[]): string[] {
214
+ const out: string[] = [];
215
+ for (const scope of scopes) {
216
+ if (scope.namespaces.length === 0) {
217
+ out.push(`${scope.id}/${EMPTY_NS_SENTINEL}`);
218
+ continue;
219
+ }
220
+ for (const ns of scope.namespaces) {
221
+ if (ns.events.length === 0) {
222
+ out.push(`${scope.id}/${ns.name}/${EMPTY_EVENTS_SENTINEL}`);
223
+ continue;
224
+ }
225
+ for (const ev of ns.events) {
226
+ out.push(`${scope.id}/${ns.name}/${ev.name}`);
227
+ }
228
+ }
229
+ }
230
+ return out;
231
+ }
232
+
233
+ function parseScopeTreePath(path: string): ScopeTreeSelection {
234
+ const [scopeId, namespaceName, eventName] = path.split('/');
235
+ const result: ScopeTreeSelection = { scopeId };
236
+ if (namespaceName && namespaceName !== EMPTY_NS_SENTINEL) {
237
+ result.namespaceName = namespaceName;
238
+ }
239
+ if (eventName && eventName !== EMPTY_EVENTS_SENTINEL) {
240
+ result.eventName = eventName;
241
+ }
242
+ return result;
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Info overlay component
247
+ // ---------------------------------------------------------------------------
248
+
249
+ const SEVERITY_BG: Record<NonNullable<Event['severity']>, string> = {
250
+ ERROR: '#7f1d1d',
251
+ WARN: '#78350f',
252
+ INFO: '#1e3a8a',
253
+ };
254
+ const DEFAULT_SEVERITY_BG = '#1e293b';
255
+
256
+ const overlayStyle: React.CSSProperties = {
257
+ position: 'absolute',
258
+ top: 16,
259
+ left: 16,
260
+ width: 360,
261
+ maxHeight: 'calc(100vh - 32px)',
262
+ overflowY: 'auto',
263
+ background: 'rgba(15, 23, 42, 0.72)',
264
+ backdropFilter: 'blur(8px)',
265
+ WebkitBackdropFilter: 'blur(8px)',
266
+ border: '1px solid #334155',
267
+ borderRadius: 8,
268
+ color: '#e2e8f0',
269
+ fontFamily: 'system-ui, sans-serif',
270
+ fontSize: 14,
271
+ zIndex: 100,
272
+ boxShadow: '0 10px 30px rgba(0,0,0,0.4)',
273
+ };
274
+
275
+ const sectionLabelStyle: React.CSSProperties = {
276
+ fontSize: 12,
277
+ color: '#64748b',
278
+ textTransform: 'uppercase',
279
+ letterSpacing: 0.5,
280
+ };
281
+
282
+ const ScopeInfoOverlay: React.FC<{
283
+ info: { scope: Scope; ns: Namespace | null; ev: Event | null };
284
+ }> = ({ info }) => {
285
+ const { scope, ns, ev } = info;
286
+
287
+ // Event leaf selected — show event detail.
288
+ if (ns && ev) {
289
+ return (
290
+ <div style={overlayStyle}>
291
+ <div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
292
+ <div style={sectionLabelStyle}>Event</div>
293
+ <div style={{ fontFamily: 'monospace', fontSize: 14, marginTop: 6 }}>
294
+ {ev.name}
295
+ </div>
296
+ {ev.severity && (
297
+ <div
298
+ style={{
299
+ display: 'inline-block',
300
+ fontSize: 12,
301
+ marginTop: 8,
302
+ padding: '2px 6px',
303
+ borderRadius: 3,
304
+ background: SEVERITY_BG[ev.severity] ?? DEFAULT_SEVERITY_BG,
305
+ color: '#fde68a',
306
+ }}
307
+ >
308
+ {ev.severity}
309
+ </div>
310
+ )}
311
+ {ev.description && (
312
+ <div style={{ fontSize: 12, color: '#cbd5e1', marginTop: 10, lineHeight: 1.5 }}>
313
+ {ev.description}
314
+ </div>
315
+ )}
316
+ </div>
317
+ <div style={{ padding: '14px 16px' }}>
318
+ <div style={sectionLabelStyle}>Owning namespace</div>
319
+ <div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
320
+ <span
321
+ style={{ width: 12, height: 12, borderRadius: 3, background: ns.color, flexShrink: 0 }}
322
+ />
323
+ <span style={{ fontFamily: 'monospace', fontSize: 14 }}>{ns.name}</span>
324
+ </div>
325
+ <div style={{ fontSize: 12, color: '#64748b', marginTop: 14, fontStyle: 'italic' }}>
326
+ Files-per-event mapping not wired yet — for now the event highlights its parent
327
+ namespace&apos;s paths.
328
+ </div>
329
+ </div>
330
+ </div>
331
+ );
332
+ }
333
+
334
+ // Namespace selected — show namespace detail.
335
+ if (ns) {
336
+ return (
337
+ <div style={overlayStyle}>
338
+ <div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
339
+ <div style={sectionLabelStyle}>Namespace</div>
340
+ <div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
341
+ <span
342
+ style={{ width: 12, height: 12, borderRadius: 3, background: ns.color, flexShrink: 0 }}
343
+ />
344
+ <span style={{ fontFamily: 'monospace', fontSize: 14 }}>{ns.name}</span>
345
+ </div>
346
+ <div style={{ fontSize: 12, color: '#94a3b8', marginTop: 8, lineHeight: 1.5 }}>
347
+ {ns.description}
348
+ </div>
349
+ <div style={{ fontSize: 12, color: '#64748b', marginTop: 8 }}>
350
+ in <span style={{ fontFamily: 'monospace' }}>{scope.id}</span>
351
+ </div>
352
+ </div>
353
+ <div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
354
+ <div style={sectionLabelStyle}>Claimed paths ({ns.paths.length})</div>
355
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 6 }}>
356
+ {ns.paths.map(p => (
357
+ <code
358
+ key={p}
359
+ style={{
360
+ fontSize: 12,
361
+ color: '#cbd5e1',
362
+ background: '#0b1220',
363
+ padding: '4px 6px',
364
+ borderRadius: 4,
365
+ wordBreak: 'break-all',
366
+ }}
367
+ >
368
+ {p}
369
+ </code>
370
+ ))}
371
+ </div>
372
+ </div>
373
+ <div style={{ padding: '14px 16px' }}>
374
+ <div style={sectionLabelStyle}>Events ({ns.events.length})</div>
375
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 6 }}>
376
+ {ns.events.map(e => (
377
+ <div
378
+ key={e.name}
379
+ style={{
380
+ display: 'flex',
381
+ alignItems: 'center',
382
+ gap: 6,
383
+ padding: '4px 6px',
384
+ background: '#0b1220',
385
+ borderRadius: 4,
386
+ }}
387
+ >
388
+ {e.severity && (
389
+ <span
390
+ style={{
391
+ fontSize: 12,
392
+ padding: '1px 4px',
393
+ borderRadius: 2,
394
+ background: SEVERITY_BG[e.severity] ?? DEFAULT_SEVERITY_BG,
395
+ color: '#fde68a',
396
+ flexShrink: 0,
397
+ }}
398
+ >
399
+ {e.severity}
400
+ </span>
401
+ )}
402
+ <code style={{ fontSize: 12, color: '#cbd5e1' }}>
403
+ {e.name}
404
+ </code>
405
+ </div>
406
+ ))}
407
+ </div>
408
+ </div>
409
+ </div>
410
+ );
411
+ }
412
+
413
+ // Scope selected — show scope summary.
414
+ const totalEvents = scope.namespaces.reduce((n, x) => n + x.events.length, 0);
415
+ return (
416
+ <div style={overlayStyle}>
417
+ <div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
418
+ <div style={sectionLabelStyle}>Scope</div>
419
+ <div style={{ fontFamily: 'monospace', fontSize: 14, marginTop: 6 }}>{scope.id}</div>
420
+ <div style={{ fontSize: 12, color: '#94a3b8', marginTop: 8, lineHeight: 1.5 }}>
421
+ {scope.description}
422
+ </div>
423
+ <div style={{ display: 'flex', gap: 16, marginTop: 12, fontSize: 12, color: '#64748b' }}>
424
+ <div>
425
+ <div>{scope.paths.length}</div>
426
+ <div style={sectionLabelStyle}>scope paths</div>
427
+ </div>
428
+ <div>
429
+ <div>{scope.namespaces.length}</div>
430
+ <div style={sectionLabelStyle}>namespaces</div>
431
+ </div>
432
+ <div>
433
+ <div>{totalEvents}</div>
434
+ <div style={sectionLabelStyle}>events</div>
435
+ </div>
436
+ </div>
437
+ </div>
438
+ {scope.paths.length > 0 && (
439
+ <div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
440
+ <div style={sectionLabelStyle}>Scope-level paths ({scope.paths.length})</div>
441
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 6 }}>
442
+ {scope.paths.map(p => (
443
+ <code
444
+ key={p}
445
+ style={{
446
+ fontSize: 12,
447
+ color: '#cbd5e1',
448
+ background: '#0b1220',
449
+ padding: '4px 6px',
450
+ borderRadius: 4,
451
+ wordBreak: 'break-all',
452
+ }}
453
+ >
454
+ {p}
455
+ </code>
456
+ ))}
457
+ </div>
458
+ </div>
459
+ )}
460
+ <div style={{ padding: '14px 16px' }}>
461
+ <div style={sectionLabelStyle}>Namespaces</div>
462
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
463
+ {scope.namespaces.map(n => (
464
+ <div
465
+ key={n.name}
466
+ style={{ padding: 8, background: '#0b1220', borderRadius: 6 }}
467
+ >
468
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
469
+ <span
470
+ style={{
471
+ width: 10,
472
+ height: 10,
473
+ borderRadius: 2,
474
+ background: n.color,
475
+ flexShrink: 0,
476
+ }}
477
+ />
478
+ <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{n.name}</span>
479
+ <span style={{ fontSize: 12, color: '#64748b', marginLeft: 'auto' }}>
480
+ {n.events.length} event{n.events.length === 1 ? '' : 's'}
481
+ </span>
482
+ </div>
483
+ <div
484
+ style={{
485
+ fontSize: 12,
486
+ color: '#64748b',
487
+ fontFamily: 'monospace',
488
+ marginTop: 4,
489
+ wordBreak: 'break-all',
490
+ }}
491
+ >
492
+ {n.paths.join(' · ')}
493
+ </div>
494
+ </div>
495
+ ))}
496
+ </div>
497
+ </div>
498
+ </div>
499
+ );
500
+ };
501
+
502
+ const AREA_PANEL_COLOR = '#64748b';
503
+
504
+ // ---------------------------------------------------------------------------
505
+ // Add-to-scope modal
506
+ // ---------------------------------------------------------------------------
507
+
508
+ const AddToScopeModal: React.FC<{
509
+ path: string;
510
+ scopes: readonly Scope[];
511
+ scopeId: string;
512
+ namespaceName: string;
513
+ onScopeIdChange: (value: string) => void;
514
+ onNamespaceNameChange: (value: string) => void;
515
+ onPickExisting: (scopeId: string, namespaceName: string) => void;
516
+ onSubmit: () => void;
517
+ onClose: () => void;
518
+ }> = ({
519
+ path,
520
+ scopes,
521
+ scopeId,
522
+ namespaceName,
523
+ onScopeIdChange,
524
+ onNamespaceNameChange,
525
+ onPickExisting,
526
+ onSubmit,
527
+ onClose,
528
+ }) => {
529
+ React.useEffect(() => {
530
+ const onKey = (e: KeyboardEvent) => {
531
+ if (e.key === 'Escape') onClose();
532
+ };
533
+ window.addEventListener('keydown', onKey);
534
+ return () => window.removeEventListener('keydown', onKey);
535
+ }, [onClose]);
536
+
537
+ const trimmedScope = scopeId.trim();
538
+ const trimmedNs = namespaceName.trim();
539
+ const canSubmit = trimmedScope.length > 0;
540
+
541
+ // Determine what the submit will do, for the action label.
542
+ const targetScope = scopes.find(s => s.id === trimmedScope);
543
+ const targetNs = trimmedNs
544
+ ? targetScope?.namespaces.find(n => n.name === trimmedNs) ?? null
545
+ : null;
546
+ const alreadyClaimed = trimmedNs
547
+ ? targetNs?.paths.includes(path) ?? false
548
+ : targetScope?.paths.includes(path) ?? false;
549
+
550
+ let actionLabel = 'Add';
551
+ if (alreadyClaimed) actionLabel = 'Already added';
552
+ else if (!targetScope && !trimmedNs) actionLabel = 'Create scope';
553
+ else if (!targetScope) actionLabel = 'Create scope + namespace';
554
+ else if (!trimmedNs) actionLabel = 'Add to scope';
555
+ else if (!targetNs) actionLabel = 'Create namespace';
556
+ else actionLabel = 'Add path';
557
+
558
+ return (
559
+ <div
560
+ onClick={onClose}
561
+ style={{
562
+ position: 'fixed',
563
+ inset: 0,
564
+ background: 'rgba(0, 0, 0, 0.55)',
565
+ display: 'flex',
566
+ alignItems: 'center',
567
+ justifyContent: 'center',
568
+ zIndex: 1000,
569
+ fontFamily: 'system-ui, sans-serif',
570
+ }}
571
+ >
572
+ <div
573
+ onClick={e => e.stopPropagation()}
574
+ style={{
575
+ width: 520,
576
+ maxHeight: 'min(80vh, 700px)',
577
+ display: 'flex',
578
+ flexDirection: 'column',
579
+ background: '#0f172a',
580
+ color: '#e2e8f0',
581
+ borderRadius: 8,
582
+ border: '1px solid #334155',
583
+ boxShadow: '0 20px 60px rgba(0,0,0,0.6)',
584
+ overflow: 'hidden',
585
+ }}
586
+ >
587
+ <div
588
+ style={{
589
+ padding: '14px 18px',
590
+ borderBottom: '1px solid #1e293b',
591
+ display: 'flex',
592
+ justifyContent: 'space-between',
593
+ alignItems: 'flex-start',
594
+ gap: 12,
595
+ }}
596
+ >
597
+ <div>
598
+ <div style={sectionLabelStyle}>Add to scope</div>
599
+ <div
600
+ style={{
601
+ fontFamily: 'monospace',
602
+ fontSize: 12,
603
+ color: '#94a3b8',
604
+ marginTop: 6,
605
+ wordBreak: 'break-all',
606
+ }}
607
+ >
608
+ {path}
609
+ </div>
610
+ </div>
611
+ <button
612
+ onClick={onClose}
613
+ style={{
614
+ background: 'transparent',
615
+ border: 'none',
616
+ color: '#64748b',
617
+ fontSize: 20,
618
+ cursor: 'pointer',
619
+ lineHeight: 1,
620
+ padding: 0,
621
+ }}
622
+ aria-label="Close"
623
+ >
624
+ ×
625
+ </button>
626
+ </div>
627
+
628
+ <div
629
+ style={{
630
+ padding: '14px 18px',
631
+ borderBottom: '1px solid #1e293b',
632
+ display: 'flex',
633
+ flexDirection: 'column',
634
+ gap: 12,
635
+ }}
636
+ >
637
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
638
+ <span style={sectionLabelStyle}>Scope</span>
639
+ <input
640
+ type="text"
641
+ value={scopeId}
642
+ list="scope-id-options"
643
+ autoFocus
644
+ placeholder="e.g. principal-view.cli"
645
+ onChange={e => onScopeIdChange(e.target.value)}
646
+ onKeyDown={e => {
647
+ if (e.key === 'Enter' && canSubmit && !alreadyClaimed) onSubmit();
648
+ }}
649
+ style={{
650
+ padding: '8px 10px',
651
+ background: '#0b1220',
652
+ color: '#e2e8f0',
653
+ border: '1px solid #334155',
654
+ borderRadius: 4,
655
+ fontFamily: 'monospace',
656
+ fontSize: 14,
657
+ }}
658
+ />
659
+ <datalist id="scope-id-options">
660
+ {scopes.map(s => (
661
+ <option key={s.id} value={s.id} />
662
+ ))}
663
+ </datalist>
664
+ </label>
665
+
666
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
667
+ <span style={sectionLabelStyle}>Namespace (optional)</span>
668
+ <input
669
+ type="text"
670
+ value={namespaceName}
671
+ placeholder="leave blank to add to scope itself"
672
+ onChange={e => onNamespaceNameChange(e.target.value)}
673
+ onKeyDown={e => {
674
+ if (e.key === 'Enter' && canSubmit && !alreadyClaimed) onSubmit();
675
+ }}
676
+ style={{
677
+ padding: '8px 10px',
678
+ background: '#0b1220',
679
+ color: '#e2e8f0',
680
+ border: '1px solid #334155',
681
+ borderRadius: 4,
682
+ fontFamily: 'monospace',
683
+ fontSize: 14,
684
+ }}
685
+ />
686
+ </label>
687
+
688
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
689
+ <button
690
+ onClick={onClose}
691
+ style={{
692
+ padding: '8px 14px',
693
+ background: 'transparent',
694
+ color: '#cbd5e1',
695
+ border: '1px solid #334155',
696
+ borderRadius: 4,
697
+ cursor: 'pointer',
698
+ fontSize: 14,
699
+ }}
700
+ >
701
+ Cancel
702
+ </button>
703
+ <button
704
+ onClick={onSubmit}
705
+ disabled={!canSubmit || alreadyClaimed}
706
+ style={{
707
+ padding: '8px 14px',
708
+ background: !canSubmit || alreadyClaimed ? '#1e293b' : '#3b82f6',
709
+ color: !canSubmit || alreadyClaimed ? '#475569' : '#ffffff',
710
+ border: '1px solid #334155',
711
+ borderRadius: 4,
712
+ cursor: !canSubmit || alreadyClaimed ? 'not-allowed' : 'pointer',
713
+ fontSize: 14,
714
+ fontWeight: 500,
715
+ }}
716
+ >
717
+ {actionLabel}
718
+ </button>
719
+ </div>
720
+ </div>
721
+
722
+ <div
723
+ style={{
724
+ padding: '14px 18px',
725
+ overflowY: 'auto',
726
+ flex: 1,
727
+ }}
728
+ >
729
+ <div style={sectionLabelStyle}>Existing scopes (click to prefill)</div>
730
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 8 }}>
731
+ {scopes.map(scope => (
732
+ <div key={scope.id}>
733
+ <div
734
+ style={{
735
+ fontFamily: 'monospace',
736
+ fontSize: 12,
737
+ color: '#cbd5e1',
738
+ marginBottom: 6,
739
+ }}
740
+ >
741
+ {scope.id}
742
+ </div>
743
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
744
+ <button
745
+ onClick={() => onPickExisting(scope.id, '')}
746
+ title={
747
+ scope.paths.includes(path)
748
+ ? 'Scope already claims this path'
749
+ : 'Prefill (scope-level)'
750
+ }
751
+ style={{
752
+ fontSize: 12,
753
+ padding: '3px 7px',
754
+ background: scope.paths.includes(path) ? '#0f172a' : '#1e293b',
755
+ color: scope.paths.includes(path) ? '#475569' : '#cbd5e1',
756
+ border: '1px dashed #475569',
757
+ borderRadius: 3,
758
+ cursor: 'pointer',
759
+ display: 'flex',
760
+ alignItems: 'center',
761
+ gap: 5,
762
+ fontStyle: 'italic',
763
+ opacity: scope.paths.includes(path) ? 0.6 : 1,
764
+ }}
765
+ >
766
+ (scope-level)
767
+ {scope.paths.includes(path) && (
768
+ <span style={{ marginLeft: 4, fontSize: 12 }}>✓</span>
769
+ )}
770
+ </button>
771
+ {scope.namespaces.map(ns => {
772
+ const claims = ns.paths.includes(path);
773
+ return (
774
+ <button
775
+ key={ns.name}
776
+ onClick={() => onPickExisting(scope.id, ns.name)}
777
+ title={claims ? 'Already claims this path' : 'Prefill inputs'}
778
+ style={{
779
+ fontSize: 12,
780
+ padding: '3px 7px',
781
+ background: claims ? '#0f172a' : '#1e293b',
782
+ color: claims ? '#475569' : '#e2e8f0',
783
+ border: '1px solid #334155',
784
+ borderRadius: 3,
785
+ cursor: 'pointer',
786
+ display: 'flex',
787
+ alignItems: 'center',
788
+ gap: 5,
789
+ opacity: claims ? 0.6 : 1,
790
+ }}
791
+ >
792
+ <span
793
+ style={{
794
+ width: 8,
795
+ height: 8,
796
+ borderRadius: 2,
797
+ background: ns.color,
798
+ flexShrink: 0,
799
+ }}
800
+ />
801
+ {ns.name}
802
+ {claims && <span style={{ marginLeft: 4, fontSize: 12 }}>✓</span>}
803
+ </button>
804
+ );
805
+ })}
806
+ </div>
807
+ </div>
808
+ ))}
809
+ </div>
810
+ </div>
811
+ </div>
812
+ </div>
813
+ );
814
+ };
815
+
816
+ // ---------------------------------------------------------------------------
817
+ // Add-to-area modal
818
+ // ---------------------------------------------------------------------------
819
+
820
+ const AddToAreaModal: React.FC<{
821
+ path: string;
822
+ areas: readonly ProjectArea[];
823
+ areaName: string;
824
+ description: string;
825
+ onAreaNameChange: (value: string) => void;
826
+ onDescriptionChange: (value: string) => void;
827
+ onPickExisting: (areaName: string) => void;
828
+ onSubmit: () => void;
829
+ onClose: () => void;
830
+ }> = ({
831
+ path,
832
+ areas,
833
+ areaName,
834
+ description,
835
+ onAreaNameChange,
836
+ onDescriptionChange,
837
+ onPickExisting,
838
+ onSubmit,
839
+ onClose,
840
+ }) => {
841
+ React.useEffect(() => {
842
+ const onKey = (e: KeyboardEvent) => {
843
+ if (e.key === 'Escape') onClose();
844
+ };
845
+ window.addEventListener('keydown', onKey);
846
+ return () => window.removeEventListener('keydown', onKey);
847
+ }, [onClose]);
848
+
849
+ const trimmedName = areaName.trim();
850
+ const canSubmit = trimmedName.length > 0;
851
+ const targetArea = areas.find(a => a.name === trimmedName);
852
+ const alreadyClaimed = targetArea?.paths.includes(path) ?? false;
853
+
854
+ let actionLabel = 'Add';
855
+ if (alreadyClaimed) actionLabel = 'Already added';
856
+ else if (!targetArea) actionLabel = 'Create area';
857
+ else actionLabel = 'Add path';
858
+
859
+ return (
860
+ <div
861
+ onClick={onClose}
862
+ style={{
863
+ position: 'fixed',
864
+ inset: 0,
865
+ background: 'rgba(0, 0, 0, 0.55)',
866
+ display: 'flex',
867
+ alignItems: 'center',
868
+ justifyContent: 'center',
869
+ zIndex: 1000,
870
+ fontFamily: 'system-ui, sans-serif',
871
+ }}
872
+ >
873
+ <div
874
+ onClick={e => e.stopPropagation()}
875
+ style={{
876
+ width: 520,
877
+ maxHeight: 'min(80vh, 700px)',
878
+ display: 'flex',
879
+ flexDirection: 'column',
880
+ background: '#0f172a',
881
+ color: '#e2e8f0',
882
+ borderRadius: 8,
883
+ border: '1px solid #334155',
884
+ boxShadow: '0 20px 60px rgba(0,0,0,0.6)',
885
+ overflow: 'hidden',
886
+ }}
887
+ >
888
+ <div
889
+ style={{
890
+ padding: '14px 18px',
891
+ borderBottom: '1px solid #1e293b',
892
+ display: 'flex',
893
+ justifyContent: 'space-between',
894
+ alignItems: 'flex-start',
895
+ gap: 12,
896
+ }}
897
+ >
898
+ <div>
899
+ <div style={sectionLabelStyle}>Add to area</div>
900
+ <div
901
+ style={{
902
+ fontFamily: 'monospace',
903
+ fontSize: 12,
904
+ color: '#94a3b8',
905
+ marginTop: 6,
906
+ wordBreak: 'break-all',
907
+ }}
908
+ >
909
+ {path}
910
+ </div>
911
+ </div>
912
+ <button
913
+ onClick={onClose}
914
+ style={{
915
+ background: 'transparent',
916
+ border: 'none',
917
+ color: '#64748b',
918
+ fontSize: 20,
919
+ cursor: 'pointer',
920
+ lineHeight: 1,
921
+ padding: 0,
922
+ }}
923
+ aria-label="Close"
924
+ >
925
+ ×
926
+ </button>
927
+ </div>
928
+
929
+ <div
930
+ style={{
931
+ padding: '14px 18px',
932
+ borderBottom: '1px solid #1e293b',
933
+ display: 'flex',
934
+ flexDirection: 'column',
935
+ gap: 12,
936
+ }}
937
+ >
938
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
939
+ <span style={sectionLabelStyle}>Area</span>
940
+ <input
941
+ type="text"
942
+ value={areaName}
943
+ list="area-name-options"
944
+ autoFocus
945
+ placeholder="e.g. Documentation"
946
+ onChange={e => onAreaNameChange(e.target.value)}
947
+ onKeyDown={e => {
948
+ if (e.key === 'Enter' && canSubmit && !alreadyClaimed) onSubmit();
949
+ }}
950
+ style={{
951
+ padding: '8px 10px',
952
+ background: '#0b1220',
953
+ color: '#e2e8f0',
954
+ border: '1px solid #334155',
955
+ borderRadius: 4,
956
+ fontFamily: 'monospace',
957
+ fontSize: 14,
958
+ }}
959
+ />
960
+ <datalist id="area-name-options">
961
+ {areas.map(a => (
962
+ <option key={a.name} value={a.name} />
963
+ ))}
964
+ </datalist>
965
+ </label>
966
+
967
+ {!targetArea && trimmedName.length > 0 && (
968
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
969
+ <span style={sectionLabelStyle}>Description (optional)</span>
970
+ <input
971
+ type="text"
972
+ value={description}
973
+ placeholder="Why this area exists, what it covers"
974
+ onChange={e => onDescriptionChange(e.target.value)}
975
+ onKeyDown={e => {
976
+ if (e.key === 'Enter' && canSubmit) onSubmit();
977
+ }}
978
+ style={{
979
+ padding: '8px 10px',
980
+ background: '#0b1220',
981
+ color: '#e2e8f0',
982
+ border: '1px solid #334155',
983
+ borderRadius: 4,
984
+ fontSize: 14,
985
+ }}
986
+ />
987
+ </label>
988
+ )}
989
+
990
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
991
+ <button
992
+ onClick={onClose}
993
+ style={{
994
+ padding: '8px 14px',
995
+ background: 'transparent',
996
+ color: '#cbd5e1',
997
+ border: '1px solid #334155',
998
+ borderRadius: 4,
999
+ cursor: 'pointer',
1000
+ fontSize: 14,
1001
+ }}
1002
+ >
1003
+ Cancel
1004
+ </button>
1005
+ <button
1006
+ onClick={onSubmit}
1007
+ disabled={!canSubmit || alreadyClaimed}
1008
+ style={{
1009
+ padding: '8px 14px',
1010
+ background: !canSubmit || alreadyClaimed ? '#1e293b' : '#94a3b8',
1011
+ color: !canSubmit || alreadyClaimed ? '#475569' : '#0f172a',
1012
+ border: '1px solid #334155',
1013
+ borderRadius: 4,
1014
+ cursor: !canSubmit || alreadyClaimed ? 'not-allowed' : 'pointer',
1015
+ fontSize: 14,
1016
+ fontWeight: 500,
1017
+ }}
1018
+ >
1019
+ {actionLabel}
1020
+ </button>
1021
+ </div>
1022
+ </div>
1023
+
1024
+ <div style={{ padding: '14px 18px', overflowY: 'auto', flex: 1 }}>
1025
+ <div style={sectionLabelStyle}>Existing areas (click to prefill)</div>
1026
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
1027
+ {areas.length === 0 && (
1028
+ <div style={{ fontSize: 12, color: '#64748b', fontStyle: 'italic' }}>
1029
+ No areas yet. Type a name above to create the first one.
1030
+ </div>
1031
+ )}
1032
+ {areas.map(area => {
1033
+ const claims = area.paths.includes(path);
1034
+ return (
1035
+ <button
1036
+ key={area.name}
1037
+ onClick={() => onPickExisting(area.name)}
1038
+ title={claims ? 'Area already claims this path' : 'Prefill the area name'}
1039
+ style={{
1040
+ fontSize: 12,
1041
+ padding: '6px 10px',
1042
+ background: claims ? '#0f172a' : '#1e293b',
1043
+ color: claims ? '#475569' : '#e2e8f0',
1044
+ border: '1px solid #334155',
1045
+ borderRadius: 4,
1046
+ cursor: 'pointer',
1047
+ textAlign: 'left',
1048
+ display: 'flex',
1049
+ alignItems: 'center',
1050
+ gap: 8,
1051
+ opacity: claims ? 0.6 : 1,
1052
+ }}
1053
+ >
1054
+ <span
1055
+ style={{
1056
+ width: 10,
1057
+ height: 10,
1058
+ borderRadius: 2,
1059
+ background: AREA_PANEL_COLOR,
1060
+ border: '1px dashed #94a3b8',
1061
+ flexShrink: 0,
1062
+ }}
1063
+ />
1064
+ <span style={{ fontFamily: 'monospace' }}>{area.name}</span>
1065
+ <span
1066
+ style={{
1067
+ marginLeft: 'auto',
1068
+ fontSize: 12,
1069
+ color: '#64748b',
1070
+ }}
1071
+ >
1072
+ {area.paths.length} path{area.paths.length === 1 ? '' : 's'}
1073
+ </span>
1074
+ {claims && <span style={{ marginLeft: 4, fontSize: 12 }}>✓</span>}
1075
+ </button>
1076
+ );
1077
+ })}
1078
+ </div>
1079
+ </div>
1080
+ </div>
1081
+ </div>
1082
+ );
1083
+ };
1084
+
1085
+ // ---------------------------------------------------------------------------
1086
+ // Single-scope explorer
1087
+ // ---------------------------------------------------------------------------
1088
+
1089
+ /**
1090
+ * The electron-app city is rooted at `electron-app/` in the JSON data, but
1091
+ * scope/namespace paths are authored relative to the package root (matching
1092
+ * how principal-view canvases are stored). These helpers convert between the
1093
+ * two representations.
1094
+ */
1095
+ const PACKAGE_ROOT = 'electron-app/';
1096
+
1097
+ function toScopePath(cityPath: string): string {
1098
+ let p = cityPath.endsWith('/') ? cityPath.slice(0, -1) : cityPath;
1099
+ if (p.startsWith(PACKAGE_ROOT)) p = p.slice(PACKAGE_ROOT.length);
1100
+ return p;
1101
+ }
1102
+
1103
+ function toCityPath(scopePath: string): string {
1104
+ return scopePath.startsWith(PACKAGE_ROOT) ? scopePath : PACKAGE_ROOT + scopePath;
1105
+ }
1106
+
1107
+ const NAMESPACE_PALETTE = [
1108
+ '#22c55e',
1109
+ '#3b82f6',
1110
+ '#f59e0b',
1111
+ '#ec4899',
1112
+ '#8b5cf6',
1113
+ '#06b6d4',
1114
+ '#ef4444',
1115
+ '#14b8a6',
1116
+ '#a855f7',
1117
+ '#eab308',
1118
+ ];
1119
+
1120
+ function pickNamespaceColor(scopes: readonly Scope[]): string {
1121
+ const used = new Set(scopes.flatMap(s => s.namespaces.map(n => n.color)));
1122
+ return NAMESPACE_PALETTE.find(c => !used.has(c)) ?? NAMESPACE_PALETTE[scopes.length % NAMESPACE_PALETTE.length];
1123
+ }
1124
+
1125
+ /**
1126
+ * Build highlight layers for a scope: one fill layer per namespace plus a
1127
+ * border-only layer for scope-level paths. Priority is path depth (longest-
1128
+ * prefix wins) per the partition convention in docs/scope-namespace-overlay.md.
1129
+ */
1130
+ function buildLayersForScope(scope: Scope): HighlightLayer[] {
1131
+ const layers: HighlightLayer[] = scope.namespaces.map(ns => {
1132
+ const maxDepth = Math.max(1, ...ns.paths.map(p => p.split('/').length));
1133
+ return {
1134
+ id: `${scope.id}::${ns.name}`,
1135
+ name: ns.name,
1136
+ enabled: true,
1137
+ color: ns.color,
1138
+ opacity: 0.55,
1139
+ priority: maxDepth,
1140
+ items: ns.paths.map(p => ({
1141
+ path: toCityPath(p),
1142
+ type: 'directory' as const,
1143
+ renderStrategy: 'fill' as const,
1144
+ })),
1145
+ };
1146
+ });
1147
+ if (scope.paths.length > 0) {
1148
+ layers.push({
1149
+ id: `${scope.id}::__scope__`,
1150
+ name: `${scope.id} (scope-level)`,
1151
+ enabled: true,
1152
+ color: '#64748b',
1153
+ opacity: 0.4,
1154
+ priority: 0,
1155
+ items: scope.paths.map(p => ({
1156
+ path: toCityPath(p),
1157
+ type: 'directory' as const,
1158
+ renderStrategy: 'fill' as const,
1159
+ })),
1160
+ });
1161
+ }
1162
+ return layers;
1163
+ }
1164
+
1165
+ const FileCityExplorerTemplate: React.FC = () => {
1166
+ const [scopes, setScopes] = React.useState<Scope[]>(loadScopesFromStorage);
1167
+ const [areas, setAreas] = React.useState<ProjectArea[]>(loadAreasFromStorage);
1168
+
1169
+ React.useEffect(() => {
1170
+ saveScopesToStorage(scopes);
1171
+ }, [scopes]);
1172
+ React.useEffect(() => {
1173
+ saveAreasToStorage(areas);
1174
+ }, [areas]);
1175
+ const [focusDirectory, setFocusDirectory] = React.useState<string | null>('electron-app');
1176
+ const [focusPinned, setFocusPinned] = React.useState(false);
1177
+ // While pinned, tree/scope selections must not change focusDirectory.
1178
+ // Wrapping the setter (rather than gating each call site) keeps the pin
1179
+ // honoured even from event handlers we add later.
1180
+ const focusPinnedRef = React.useRef(focusPinned);
1181
+ React.useEffect(() => {
1182
+ focusPinnedRef.current = focusPinned;
1183
+ }, [focusPinned]);
1184
+ // Keep a ref to focusDirectory so event handlers (e.g. the city's
1185
+ // double-click handler) can branch on it without taking a hard dep
1186
+ // and re-rebuilding folder panels on every focus change.
1187
+ const focusDirectoryRef = React.useRef(focusDirectory);
1188
+ React.useEffect(() => {
1189
+ focusDirectoryRef.current = focusDirectory;
1190
+ }, [focusDirectory]);
1191
+ const setFocusDirectoryIfUnpinned = React.useCallback(
1192
+ (next: string | null) => {
1193
+ if (focusPinnedRef.current) return;
1194
+ setFocusDirectory(next);
1195
+ },
1196
+ [],
1197
+ );
1198
+ const [selectedPanelFolder, setSelectedPanelFolder] = React.useState<string | null>(null);
1199
+ const [showPanelFolderContents, setShowPanelFolderContents] = React.useState(false);
1200
+ const [showAddPicker, setShowAddPicker] = React.useState(false);
1201
+ const addPickerRef = React.useRef<HTMLDivElement | null>(null);
1202
+ // Close the +Add picker on any click outside of it. Listens at the
1203
+ // document level so clicks anywhere — canvas, header, other overlays —
1204
+ // dismiss the menu just like a native dropdown.
1205
+ React.useEffect(() => {
1206
+ if (!showAddPicker) return;
1207
+ const onPointerDown = (e: MouseEvent) => {
1208
+ if (addPickerRef.current?.contains(e.target as Node)) return;
1209
+ setShowAddPicker(false);
1210
+ };
1211
+ document.addEventListener('mousedown', onPointerDown);
1212
+ return () => document.removeEventListener('mousedown', onPointerDown);
1213
+ }, [showAddPicker]);
1214
+ // Anchor for the top-right "Hidden parent layers" panel: tracks the
1215
+ // most-recently-interacted folder so the panel always shows the chain of
1216
+ // expanded ancestors up from the user's current focus. Updated by
1217
+ // umbrella clicks, building clicks, and file tree selections.
1218
+ const [parentLayersAnchor, setParentLayersAnchor] = React.useState<string | null>(null);
1219
+ // When true, the panel is hidden until the next folder interaction.
1220
+ const [parentLayersDismissed, setParentLayersDismissed] = React.useState(false);
1221
+
1222
+ // Sub-tree of paths under the currently selected panel folder. Computed
1223
+ // on demand so we only rebuild when the user actually opens the contents
1224
+ // view. Paths are stripped of the folder prefix so the tree renders rooted
1225
+ // at the folder itself.
1226
+ const panelFolderContentsPaths = React.useMemo(() => {
1227
+ if (!selectedPanelFolder || !showPanelFolderContents) return [] as string[];
1228
+ const prefix = selectedPanelFolder + '/';
1229
+ return ELECTRON_PATHS.filter(p => p.startsWith(prefix))
1230
+ .map(p => p.slice(prefix.length))
1231
+ .sort();
1232
+ }, [selectedPanelFolder, showPanelFolderContents]);
1233
+
1234
+ const initialPanelFolderPaths = React.useRef<string[]>([]);
1235
+ const { model: panelFolderContentsTreeModel } = useFileTree({
1236
+ paths: initialPanelFolderPaths.current,
1237
+ search: true,
1238
+ });
1239
+
1240
+ // Keep the sub-tree in sync as the selected folder or visibility changes.
1241
+ React.useEffect(() => {
1242
+ panelFolderContentsTreeModel.resetPaths(panelFolderContentsPaths);
1243
+ }, [panelFolderContentsTreeModel, panelFolderContentsPaths]);
1244
+
1245
+ const [scopeSelection, setScopeSelection] = React.useState<ScopeTreeSelection | null>(null);
1246
+ const [showAddModal, setShowAddModal] = React.useState(false);
1247
+ const [scopeModalTargetPath, setScopeModalTargetPath] = React.useState<string | null>(null);
1248
+ const [modalScopeId, setModalScopeId] = React.useState('');
1249
+ const [modalNamespaceName, setModalNamespaceName] = React.useState('');
1250
+ const [showAddAreaModal, setShowAddAreaModal] = React.useState(false);
1251
+ const [areaModalTargetPath, setAreaModalTargetPath] = React.useState<string | null>(null);
1252
+ const [modalAreaName, setModalAreaName] = React.useState('');
1253
+ const [modalAreaDescription, setModalAreaDescription] = React.useState('');
1254
+ const [activeTab, setActiveTab] = React.useState<'files' | 'scopes'>('files');
1255
+
1256
+ const { model: treeModel } = useFileTree({
1257
+ paths: ELECTRON_PATHS,
1258
+ search: true,
1259
+ initialExpandedPaths: [],
1260
+ onSelectionChange: paths => {
1261
+ const selected = paths[0];
1262
+ if (!selected) {
1263
+ setFocusDirectoryIfUnpinned(null);
1264
+ return;
1265
+ }
1266
+ // Selecting a directory focuses the city on it; selecting a file focuses
1267
+ // the file's parent directory (closest ancestor that exists as a district).
1268
+ if (ELECTRON_DIRECTORIES.has(selected)) {
1269
+ setFocusDirectoryIfUnpinned(selected);
1270
+ setParentLayersAnchor(selected);
1271
+ setParentLayersDismissed(false);
1272
+ return;
1273
+ }
1274
+ const parts = selected.split('/');
1275
+ while (parts.length > 1) {
1276
+ parts.pop();
1277
+ const candidate = parts.join('/');
1278
+ if (ELECTRON_DIRECTORIES.has(candidate)) {
1279
+ setFocusDirectoryIfUnpinned(candidate);
1280
+ setParentLayersAnchor(selected);
1281
+ setParentLayersDismissed(false);
1282
+ return;
1283
+ }
1284
+ }
1285
+ setFocusDirectoryIfUnpinned(null);
1286
+ },
1287
+ });
1288
+
1289
+ const scopeTreePaths = React.useMemo(() => buildScopeTreePaths(scopes), [scopes]);
1290
+ const initialScopeTreePaths = React.useRef(scopeTreePaths);
1291
+ const initialExpandedScopeIds = React.useRef(scopes.map(s => s.id));
1292
+ const { model: scopeTreeModel } = useFileTree({
1293
+ paths: initialScopeTreePaths.current,
1294
+ search: true,
1295
+ initialExpandedPaths: initialExpandedScopeIds.current,
1296
+ onSelectionChange: paths => {
1297
+ const selected = paths[0];
1298
+ if (!selected) {
1299
+ setScopeSelection(null);
1300
+ return;
1301
+ }
1302
+ const parsed = parseScopeTreePath(selected);
1303
+ setScopeSelection(parsed);
1304
+
1305
+ // Selecting a namespace or event also focuses the city on the namespace's
1306
+ // first declared path; selecting a bare scope clears the focus.
1307
+ if (parsed.namespaceName) {
1308
+ const scope = scopes.find(s => s.id === parsed.scopeId);
1309
+ const ns = scope?.namespaces.find(n => n.name === parsed.namespaceName);
1310
+ if (ns?.paths[0]) setFocusDirectoryIfUnpinned(toCityPath(ns.paths[0]));
1311
+ } else {
1312
+ setFocusDirectoryIfUnpinned(null);
1313
+ }
1314
+ },
1315
+ });
1316
+
1317
+ // Keep the scope tree's paths in sync as scopes mutate (the model is created
1318
+ // once; later option changes need resetPaths per @pierre/trees docs).
1319
+ const isFirstScopeTreeSync = React.useRef(true);
1320
+ const pendingExpand = React.useRef<string[]>([]);
1321
+ React.useEffect(() => {
1322
+ if (isFirstScopeTreeSync.current) {
1323
+ isFirstScopeTreeSync.current = false;
1324
+ return;
1325
+ }
1326
+ scopeTreeModel.resetPaths(scopeTreePaths);
1327
+ for (const dirPath of pendingExpand.current) {
1328
+ asDir(scopeTreeModel.getItem(dirPath))?.expand();
1329
+ }
1330
+ pendingExpand.current = [];
1331
+ }, [scopeTreeModel, scopeTreePaths]);
1332
+
1333
+ // Track which scope/namespace nodes are expanded in the scope tree. The
1334
+ // city panels mirror this: a collapsed scope shows one umbrella tile, an
1335
+ // expanded scope shows per-namespace tiles, and an expanded namespace
1336
+ // hides its tile so the buildings underneath are visible.
1337
+ const treeExpansion = useFileTreeSelector(
1338
+ scopeTreeModel,
1339
+ React.useCallback(
1340
+ (model: FileTreeModel) => {
1341
+ const expandedScopes = new Set<string>();
1342
+ const expandedNamespaces = new Set<string>();
1343
+ for (const scope of scopes) {
1344
+ const scopeItem = asDir(model.getItem(scope.id));
1345
+ if (scopeItem && scopeItem.isExpanded()) {
1346
+ expandedScopes.add(scope.id);
1347
+ for (const ns of scope.namespaces) {
1348
+ const nsKey = `${scope.id}/${ns.name}`;
1349
+ const nsItem = asDir(model.getItem(nsKey));
1350
+ if (nsItem && nsItem.isExpanded()) {
1351
+ expandedNamespaces.add(nsKey);
1352
+ }
1353
+ }
1354
+ }
1355
+ }
1356
+ return { expandedScopes, expandedNamespaces };
1357
+ },
1358
+ [scopes],
1359
+ ),
1360
+ React.useCallback(
1361
+ (
1362
+ prev: { expandedScopes: Set<string>; expandedNamespaces: Set<string> },
1363
+ next: { expandedScopes: Set<string>; expandedNamespaces: Set<string> },
1364
+ ) => {
1365
+ if (prev.expandedScopes.size !== next.expandedScopes.size) return false;
1366
+ for (const k of prev.expandedScopes) if (!next.expandedScopes.has(k)) return false;
1367
+ if (prev.expandedNamespaces.size !== next.expandedNamespaces.size) return false;
1368
+ for (const k of prev.expandedNamespaces) if (!next.expandedNamespaces.has(k)) return false;
1369
+ return true;
1370
+ },
1371
+ [],
1372
+ ),
1373
+ );
1374
+
1375
+ // Resolve the current scope tree selection into the underlying objects.
1376
+ const scopeInfo = React.useMemo(() => {
1377
+ if (!scopeSelection) return null;
1378
+ const scope = scopes.find(s => s.id === scopeSelection.scopeId);
1379
+ if (!scope) return null;
1380
+ const ns = scopeSelection.namespaceName
1381
+ ? scope.namespaces.find(n => n.name === scopeSelection.namespaceName) ?? null
1382
+ : null;
1383
+ const ev =
1384
+ ns && scopeSelection.eventName
1385
+ ? ns.events.find(e => e.name === scopeSelection.eventName) ?? null
1386
+ : null;
1387
+ return { scope, ns, ev };
1388
+ }, [scopeSelection, scopes]);
1389
+
1390
+ // City highlight layers derive from the active tab:
1391
+ // scopes tab → selected scope's namespace fills (+ scope-level borders)
1392
+ // files tab → none
1393
+ const cityHighlightLayers = React.useMemo(() => {
1394
+ if (activeTab === 'scopes') {
1395
+ return scopeInfo ? buildLayersForScope(scopeInfo.scope) : undefined;
1396
+ }
1397
+ return undefined;
1398
+ }, [activeTab, scopeInfo]);
1399
+
1400
+ // Elevated scope panels — driven by the scope tree's expansion state.
1401
+ // - Collapsed scope → one gray umbrella tile per scope path.
1402
+ // - Expanded scope, collapsed namespace → colored tile per namespace path.
1403
+ // - Expanded namespace → no tile (buildings show through).
1404
+ const cityElevatedPanels = React.useMemo<ElevatedScopePanel[] | undefined>(() => {
1405
+ if (activeTab !== 'scopes') return undefined;
1406
+ const panels: ElevatedScopePanel[] = [];
1407
+
1408
+ for (const scope of scopes) {
1409
+ const isScopeExpanded = treeExpansion.expandedScopes.has(scope.id);
1410
+
1411
+ if (!isScopeExpanded) {
1412
+ const onClick = () => asDir(scopeTreeModel.getItem(scope.id))?.toggle();
1413
+ for (const sp of scope.paths) {
1414
+ const district = ELECTRON_DISTRICTS_BY_PATH.get(toCityPath(sp));
1415
+ if (!district) continue;
1416
+ panels.push({
1417
+ id: `${scope.id}::scope::${sp}`,
1418
+ color: '#64748b',
1419
+ height: 4,
1420
+ thickness: 2,
1421
+ bounds: district.worldBounds,
1422
+ label: scope.id,
1423
+ onClick,
1424
+ });
1425
+ }
1426
+ continue;
1427
+ }
1428
+
1429
+ for (const ns of scope.namespaces) {
1430
+ const nsKey = `${scope.id}/${ns.name}`;
1431
+ if (treeExpansion.expandedNamespaces.has(nsKey)) continue;
1432
+
1433
+ const onClick = () => asDir(scopeTreeModel.getItem(nsKey))?.toggle();
1434
+ for (const np of ns.paths) {
1435
+ const district = ELECTRON_DISTRICTS_BY_PATH.get(toCityPath(np));
1436
+ if (!district) continue;
1437
+ panels.push({
1438
+ id: `${scope.id}::${ns.name}::${np}`,
1439
+ color: ns.color,
1440
+ height: 4,
1441
+ thickness: 2,
1442
+ bounds: district.worldBounds,
1443
+ label: ns.name,
1444
+ onClick,
1445
+ });
1446
+ }
1447
+ }
1448
+ }
1449
+
1450
+ return panels.length > 0 ? panels : undefined;
1451
+ }, [activeTab, scopes, scopeTreeModel, treeExpansion]);
1452
+
1453
+ // Track which folders are expanded in the file tree. The file-tree tab's
1454
+ // elevated panels mirror this: a collapsed folder shows one umbrella tile
1455
+ // covering every descendant district; expanding the folder reveals its
1456
+ // sub-folder tiles (or the buildings themselves at the leaves).
1457
+ const folderTreeExpansion = useFileTreeSelector(
1458
+ treeModel,
1459
+ React.useCallback((model: FileTreeModel) => {
1460
+ const expanded = new Set<string>();
1461
+ for (const dir of ELECTRON_DIRECTORIES) {
1462
+ const item = asDir(model.getItem(dir));
1463
+ if (item && item.isExpanded()) expanded.add(dir);
1464
+ }
1465
+ return { expanded };
1466
+ }, []),
1467
+ React.useCallback(
1468
+ (prev: { expanded: Set<string> }, next: { expanded: Set<string> }) => {
1469
+ if (prev.expanded.size !== next.expanded.size) return false;
1470
+ for (const k of prev.expanded) if (!next.expanded.has(k)) return false;
1471
+ return true;
1472
+ },
1473
+ [],
1474
+ ),
1475
+ );
1476
+
1477
+ // Mirror contents-tree expansion onto the main tree so the city's folder
1478
+ // umbrellas hide for folders the user expands in the floating contents
1479
+ // view. Contents-tree paths are stripped of the selected-folder prefix;
1480
+ // we re-prefix them to address the same node in the main model.
1481
+ const contentsFolderExpansion = useFileTreeSelector(
1482
+ panelFolderContentsTreeModel,
1483
+ React.useCallback(
1484
+ (model: FileTreeModel) => {
1485
+ const expanded = new Set<string>();
1486
+ if (!selectedPanelFolder) return { expanded };
1487
+ const prefix = selectedPanelFolder + '/';
1488
+ for (const dir of ELECTRON_DIRECTORIES) {
1489
+ if (!dir.startsWith(prefix)) continue;
1490
+ const stripped = dir.slice(prefix.length);
1491
+ const item = asDir(model.getItem(stripped));
1492
+ if (item && item.isExpanded()) expanded.add(dir);
1493
+ }
1494
+ return { expanded };
1495
+ },
1496
+ [selectedPanelFolder],
1497
+ ),
1498
+ React.useCallback(
1499
+ (prev: { expanded: Set<string> }, next: { expanded: Set<string> }) => {
1500
+ if (prev.expanded.size !== next.expanded.size) return false;
1501
+ for (const k of prev.expanded) if (!next.expanded.has(k)) return false;
1502
+ return true;
1503
+ },
1504
+ [],
1505
+ ),
1506
+ );
1507
+
1508
+ // Diff against the prior mirror so collapses propagate without stomping
1509
+ // folders the user expanded directly via city umbrella clicks.
1510
+ const prevContentsExpansionRef = React.useRef<Set<string>>(new Set());
1511
+ React.useEffect(() => {
1512
+ const next = contentsFolderExpansion.expanded;
1513
+ const prev = prevContentsExpansionRef.current;
1514
+ for (const dir of next) {
1515
+ if (!prev.has(dir)) asDir(treeModel.getItem(dir))?.expand();
1516
+ }
1517
+ for (const dir of prev) {
1518
+ if (!next.has(dir)) asDir(treeModel.getItem(dir))?.collapse();
1519
+ }
1520
+ prevContentsExpansionRef.current = new Set(next);
1521
+ }, [contentsFolderExpansion, treeModel]);
1522
+
1523
+ // Folder city-path → area display name. Lets folder umbrella tiles surface
1524
+ // the human-readable area name above the technical path component.
1525
+ const areaNameByCityPath = React.useMemo(() => {
1526
+ const m = new Map<string, string>();
1527
+ for (const area of areas) {
1528
+ for (const p of area.paths) m.set(toCityPath(p), area.name);
1529
+ }
1530
+ return m;
1531
+ }, [areas]);
1532
+
1533
+ const folderElevatedPanels = React.useMemo<ElevatedScopePanel[] | undefined>(() => {
1534
+ if (activeTab !== 'files') return undefined;
1535
+ const rawPanels = buildFolderElevatedPanels({
1536
+ cityData: electronAppCityData as CityData,
1537
+ expandedFolders: folderTreeExpansion.expanded,
1538
+ onToggleFolder: (folderPath) => {
1539
+ // Plain click → surface the clicked folder in the panel-selection
1540
+ // card (with an "Open" button) instead of expanding immediately,
1541
+ // so the umbrella tile doesn't vanish out from under the click.
1542
+ // showPanelFolderContents is intentionally not reset here: if the
1543
+ // user already opted into the contents view, switching folders
1544
+ // keeps the contents view active for the new folder.
1545
+ setSelectedPanelFolder(folderPath);
1546
+ setParentLayersAnchor(folderPath);
1547
+ setParentLayersDismissed(false);
1548
+ },
1549
+ onDoubleClickFolder: (folderPath) => {
1550
+ // Double-click → focus the camera on this folder. Double-clicking
1551
+ // a folder that is *already* the focus pops the focus up by one
1552
+ // ancestor (clamped at the package root), giving an iterative
1553
+ // "zoom out" gesture as the user keeps double-clicking.
1554
+ let next = folderPath;
1555
+ let nextSelected = folderPath;
1556
+ if (focusDirectoryRef.current === folderPath) {
1557
+ const slash = folderPath.lastIndexOf('/');
1558
+ next = slash > 0 ? folderPath.slice(0, slash) : 'electron-app';
1559
+ nextSelected = next;
1560
+ }
1561
+ setSelectedPanelFolder(nextSelected);
1562
+ setFocusDirectoryIfUnpinned(next);
1563
+ setParentLayersAnchor(nextSelected);
1564
+ setParentLayersDismissed(false);
1565
+ },
1566
+ index: ELECTRON_FOLDER_INDEX,
1567
+ });
1568
+ const panels: ElevatedScopePanel[] = rawPanels.map(panel => {
1569
+ const folderPath = panel.id.startsWith('folder::') ? panel.id.slice('folder::'.length) : null;
1570
+ const displayLabel = folderPath ? areaNameByCityPath.get(folderPath) : undefined;
1571
+ return displayLabel ? { ...panel, displayLabel } : panel;
1572
+ });
1573
+ // Selection indicator: render a thin, slightly-larger panel underneath
1574
+ // the selected folder's umbrella so an accent ring peeks out around its
1575
+ // edges. Inserted *before* the umbrella in the list so the umbrella
1576
+ // draws on top — only the inflated rim shows. If the folder is expanded
1577
+ // (no umbrella in the panel list) findIndex returns -1 and no ring is
1578
+ // drawn, which is exactly what we want.
1579
+ if (selectedPanelFolder) {
1580
+ const idx = panels.findIndex(p => p.id === `folder::${selectedPanelFolder}`);
1581
+ if (idx >= 0) {
1582
+ const target = panels[idx];
1583
+ const inflate = 4;
1584
+ const border: ElevatedScopePanel = {
1585
+ id: `folder-border::${selectedPanelFolder}`,
1586
+ color: '#fbbf24',
1587
+ height: (target.height ?? 4) - 2,
1588
+ thickness: 1,
1589
+ bounds: {
1590
+ minX: target.bounds.minX - inflate,
1591
+ maxX: target.bounds.maxX + inflate,
1592
+ minZ: target.bounds.minZ - inflate,
1593
+ maxZ: target.bounds.maxZ + inflate,
1594
+ },
1595
+ };
1596
+ const next = [...panels];
1597
+ next.splice(idx, 0, border);
1598
+ return next;
1599
+ }
1600
+ }
1601
+
1602
+ return panels.length > 0 ? panels : undefined;
1603
+ }, [
1604
+ activeTab,
1605
+ selectedPanelFolder,
1606
+ treeModel,
1607
+ folderTreeExpansion,
1608
+ setFocusDirectoryIfUnpinned,
1609
+ areaNameByCityPath,
1610
+ ]);
1611
+
1612
+ // Cmd-click on a building → surface the chain of expanded ancestor folders
1613
+ // (their umbrellas are currently hidden because they're expanded). Each
1614
+ // entry in the popup can be clicked to collapse that ancestor, which
1615
+ // restores its umbrella so the user can navigate back up.
1616
+ const parentLayers = React.useMemo<string[]>(() => {
1617
+ if (!parentLayersAnchor) return [];
1618
+ const parts = parentLayersAnchor.split('/');
1619
+ const out: string[] = [];
1620
+ // Walk shallowest → deepest so the list reads outermost-first.
1621
+ // Include the anchor itself: if it's expanded, its umbrella is hidden
1622
+ // (children took its place), so the user should be able to collapse it
1623
+ // back from the panel.
1624
+ for (let i = 1; i <= parts.length; i++) {
1625
+ const ancestor = parts.slice(0, i).join('/');
1626
+ if (folderTreeExpansion.expanded.has(ancestor)) out.push(ancestor);
1627
+ }
1628
+ return out;
1629
+ }, [parentLayersAnchor, folderTreeExpansion]);
1630
+
1631
+ const handleBuildingClick = React.useCallback(
1632
+ (building: { path: string }) => {
1633
+ setParentLayersAnchor(building.path);
1634
+ setParentLayersDismissed(false);
1635
+ },
1636
+ [],
1637
+ );
1638
+
1639
+ const collapseFolder = React.useCallback(
1640
+ (folderPath: string) => asDir(treeModel.getItem(folderPath))?.collapse(),
1641
+ [treeModel],
1642
+ );
1643
+
1644
+ const openAddModal = React.useCallback(
1645
+ (targetPath: string, prefillScopeId?: string) => {
1646
+ setScopeModalTargetPath(targetPath);
1647
+ setModalScopeId(prefillScopeId ?? '');
1648
+ setModalNamespaceName('');
1649
+ setShowAddModal(true);
1650
+ },
1651
+ [],
1652
+ );
1653
+
1654
+ // Coverage lookup for the city-panel-clicked folder. Returns scope hits
1655
+ // (with the most specific covering namespace, if any) and area hits.
1656
+ const panelFolderCoverage = React.useMemo(() => {
1657
+ if (!selectedPanelFolder) return null;
1658
+ const sp = toScopePath(selectedPanelFolder);
1659
+ const covers = (claim: string) => sp === claim || sp.startsWith(claim + '/');
1660
+
1661
+ const scopeHits: { scope: Scope; namespace: Namespace | null }[] = [];
1662
+ for (const scope of scopes) {
1663
+ const ns = scope.namespaces.find(n => n.paths.some(covers)) ?? null;
1664
+ const scopeLevel = scope.paths.some(covers);
1665
+ if (ns || scopeLevel) scopeHits.push({ scope, namespace: ns });
1666
+ }
1667
+
1668
+ const areaHits = areas.filter(a => a.paths.some(covers));
1669
+
1670
+ return { scopeHits, areaHits };
1671
+ }, [selectedPanelFolder, scopes, areas]);
1672
+
1673
+ const submitAddToScope = React.useCallback(() => {
1674
+ if (!scopeModalTargetPath) return;
1675
+ const path = toScopePath(scopeModalTargetPath);
1676
+ const scopeId = modalScopeId.trim();
1677
+ const namespaceName = modalNamespaceName.trim();
1678
+ if (!scopeId) return;
1679
+
1680
+ // Queue branches to auto-expand once the tree re-resets.
1681
+ pendingExpand.current = namespaceName ? [scopeId, `${scopeId}/${namespaceName}`] : [scopeId];
1682
+
1683
+ // Invariant: a scope's `paths` must cover every path claimed by any of
1684
+ // its namespaces. If `path` isn't already covered by scope.paths, we add
1685
+ // it.
1686
+ const ensureScopePathCovers = (scope: Scope): Scope => {
1687
+ const covered = scope.paths.some(p => path === p || path.startsWith(p + '/'));
1688
+ if (covered) return scope;
1689
+ return { ...scope, paths: [...scope.paths, path] };
1690
+ };
1691
+
1692
+ setScopes(prev => {
1693
+ const scopeIdx = prev.findIndex(s => s.id === scopeId);
1694
+
1695
+ // Existing scope.
1696
+ if (scopeIdx >= 0) {
1697
+ const scope = prev[scopeIdx];
1698
+
1699
+ // No namespace given → add to scope-level paths.
1700
+ if (!namespaceName) {
1701
+ if (scope.paths.includes(path)) return prev;
1702
+ const next = [...prev];
1703
+ next[scopeIdx] = { ...scope, paths: [...scope.paths, path] };
1704
+ return next;
1705
+ }
1706
+
1707
+ const nsIdx = scope.namespaces.findIndex(n => n.name === namespaceName);
1708
+
1709
+ // Existing namespace — push the path if not already there.
1710
+ if (nsIdx >= 0) {
1711
+ const ns = scope.namespaces[nsIdx];
1712
+ if (ns.paths.includes(path)) return prev;
1713
+ const newNs = { ...ns, paths: [...ns.paths, path] };
1714
+ const newNamespaces = [...scope.namespaces];
1715
+ newNamespaces[nsIdx] = newNs;
1716
+ const next = [...prev];
1717
+ next[scopeIdx] = ensureScopePathCovers({ ...scope, namespaces: newNamespaces });
1718
+ return next;
1719
+ }
1720
+
1721
+ // New namespace under existing scope.
1722
+ const newNs: Namespace = {
1723
+ name: namespaceName,
1724
+ description: '(new namespace)',
1725
+ color: pickNamespaceColor(prev),
1726
+ paths: [path],
1727
+ events: [],
1728
+ };
1729
+ const next = [...prev];
1730
+ next[scopeIdx] = ensureScopePathCovers({
1731
+ ...scope,
1732
+ namespaces: [...scope.namespaces, newNs],
1733
+ });
1734
+ return next;
1735
+ }
1736
+
1737
+ // Brand-new scope. Scope paths are required, so the path always seeds
1738
+ // scope.paths even when a namespace is also being created.
1739
+ if (!namespaceName) {
1740
+ return [
1741
+ ...prev,
1742
+ {
1743
+ id: scopeId,
1744
+ name: scopeId,
1745
+ description: '(new scope)',
1746
+ paths: [path],
1747
+ namespaces: [],
1748
+ },
1749
+ ];
1750
+ }
1751
+ const newNs: Namespace = {
1752
+ name: namespaceName,
1753
+ description: '(new namespace)',
1754
+ color: pickNamespaceColor(prev),
1755
+ paths: [path],
1756
+ events: [],
1757
+ };
1758
+ return [
1759
+ ...prev,
1760
+ {
1761
+ id: scopeId,
1762
+ name: scopeId,
1763
+ description: '(new scope)',
1764
+ paths: [path],
1765
+ namespaces: [newNs],
1766
+ },
1767
+ ];
1768
+ });
1769
+
1770
+ setShowAddModal(false);
1771
+ setScopeModalTargetPath(null);
1772
+ }, [scopeModalTargetPath, modalScopeId, modalNamespaceName]);
1773
+
1774
+ const openAddAreaModal = React.useCallback((targetPath: string) => {
1775
+ setAreaModalTargetPath(targetPath);
1776
+ setModalAreaName('');
1777
+ setModalAreaDescription('');
1778
+ setShowAddAreaModal(true);
1779
+ }, []);
1780
+
1781
+ const submitAddToArea = React.useCallback(() => {
1782
+ if (!areaModalTargetPath) return;
1783
+ const path = toScopePath(areaModalTargetPath);
1784
+ const name = modalAreaName.trim();
1785
+ const desc = modalAreaDescription.trim();
1786
+ if (!name) return;
1787
+
1788
+ setAreas(prev => {
1789
+ const idx = prev.findIndex(a => a.name === name);
1790
+ if (idx >= 0) {
1791
+ const area = prev[idx];
1792
+ if (area.paths.includes(path)) return prev;
1793
+ const next = [...prev];
1794
+ next[idx] = { ...area, paths: [...area.paths, path] };
1795
+ return next;
1796
+ }
1797
+ return [
1798
+ ...prev,
1799
+ { name, description: desc || '(new area)', paths: [path] },
1800
+ ];
1801
+ });
1802
+
1803
+ setShowAddAreaModal(false);
1804
+ setAreaModalTargetPath(null);
1805
+ }, [areaModalTargetPath, modalAreaName, modalAreaDescription]);
1806
+
1807
+ return (
1808
+ <div style={{ height: '100vh', display: 'flex', background: '#0f172a' }}>
1809
+ <div style={{ flex: 1, position: 'relative', minWidth: 0 }}>
1810
+ {/* Canvas wrapper — pushed down by HEADER_HEIGHT so the focus
1811
+ bar doesn't occlude the camera's framing area. The 3D camera
1812
+ sizes itself to the canvas, so shrinking the canvas is what
1813
+ makes focus calculations exclude the header. */}
1814
+ <div
1815
+ style={{
1816
+ position: 'absolute',
1817
+ top: 56,
1818
+ left: 0,
1819
+ right: 0,
1820
+ bottom: 0,
1821
+ }}
1822
+ >
1823
+ <FileCity3D
1824
+ cityData={electronAppCityData as CityData}
1825
+ height="100%"
1826
+ width="100%"
1827
+ heightScaling="linear"
1828
+ linearScale={0.5}
1829
+ focusDirectory={focusDirectory}
1830
+ highlightLayers={cityHighlightLayers}
1831
+ elevatedScopePanels={cityElevatedPanels ?? folderElevatedPanels}
1832
+ onBuildingClick={handleBuildingClick}
1833
+ animation={{
1834
+ startFlat: true,
1835
+ autoStartDelay: null,
1836
+ staggerDelay: 5,
1837
+ tension: 150,
1838
+ friction: 16,
1839
+ }}
1840
+ showControls={true}
1841
+ />
1842
+ </div>
1843
+
1844
+ {/* Focus directory overlay — pinnable */}
1845
+ <div
1846
+ style={{
1847
+ position: 'absolute',
1848
+ top: 8,
1849
+ left: 8,
1850
+ right: 8,
1851
+ padding: '8px 12px',
1852
+ background: 'rgba(15, 23, 42, 0.72)',
1853
+ backdropFilter: 'blur(8px)',
1854
+ WebkitBackdropFilter: 'blur(8px)',
1855
+ border: `1px solid ${focusPinned ? '#fbbf24' : '#334155'}`,
1856
+ borderRadius: 6,
1857
+ color: '#e2e8f0',
1858
+ fontFamily: 'system-ui, sans-serif',
1859
+ fontSize: 14,
1860
+ zIndex: 100,
1861
+ display: 'flex',
1862
+ alignItems: 'center',
1863
+ gap: 10,
1864
+ }}
1865
+ >
1866
+ <div
1867
+ style={{
1868
+ flex: 1,
1869
+ minWidth: 0,
1870
+ display: 'flex',
1871
+ alignItems: 'center',
1872
+ flexWrap: 'wrap',
1873
+ gap: 8,
1874
+ }}
1875
+ >
1876
+ <div style={sectionLabelStyle}>
1877
+ Path{focusPinned ? ' (pinned)' : ''}
1878
+ </div>
1879
+ {focusDirectory ? (
1880
+ // Breadcrumb: each ancestor segment that exists as a district
1881
+ // is a button. Clicking moves focus to that segment, even
1882
+ // while pinned (bypasses the pin guard intentionally).
1883
+ // When a folder is selected via the city, the breadcrumb
1884
+ // extends past focus to its full path — segments beyond the
1885
+ // focus point are styled differently to make it clear which
1886
+ // part of the chain is the focus vs. the selection.
1887
+ (() => {
1888
+ const focusDepth = focusDirectory.split('/').length;
1889
+ const deepest =
1890
+ selectedPanelFolder &&
1891
+ (selectedPanelFolder === focusDirectory ||
1892
+ selectedPanelFolder.startsWith(focusDirectory + '/'))
1893
+ ? selectedPanelFolder
1894
+ : focusDirectory;
1895
+ const parts = deepest.split('/');
1896
+ const segments: { label: string; path: string; beyondFocus: boolean }[] = [];
1897
+ for (let i = 0; i < parts.length; i++) {
1898
+ const path = parts.slice(0, i + 1).join('/');
1899
+ if (ELECTRON_DIRECTORIES.has(path)) {
1900
+ segments.push({ label: parts[i], path, beyondFocus: i + 1 > focusDepth });
1901
+ }
1902
+ }
1903
+ return (
1904
+ <div
1905
+ style={{
1906
+ display: 'flex',
1907
+ flexWrap: 'wrap',
1908
+ alignItems: 'center',
1909
+ gap: 2,
1910
+ }}
1911
+ >
1912
+ {segments.map((seg, i) => {
1913
+ const isFocus = seg.path === focusDirectory;
1914
+ const isSelectedLeaf =
1915
+ seg.path === selectedPanelFolder && seg.beyondFocus;
1916
+ const color = isFocus
1917
+ ? '#fde68a'
1918
+ : seg.beyondFocus
1919
+ ? isSelectedLeaf
1920
+ ? '#a5f3fc'
1921
+ : '#67e8f9'
1922
+ : '#94a3b8';
1923
+ return (
1924
+ <React.Fragment key={seg.path}>
1925
+ {i > 0 && (
1926
+ <span style={{ color: '#475569', fontFamily: 'monospace' }}>
1927
+ /
1928
+ </span>
1929
+ )}
1930
+ <button
1931
+ onClick={() => {
1932
+ setFocusDirectory(seg.path);
1933
+ setSelectedPanelFolder(seg.path);
1934
+ setParentLayersAnchor(seg.path);
1935
+ setParentLayersDismissed(false);
1936
+ }}
1937
+ style={{
1938
+ background: 'transparent',
1939
+ border: 'none',
1940
+ padding: '2px 4px',
1941
+ borderRadius: 3,
1942
+ fontFamily: 'monospace',
1943
+ fontSize: 14,
1944
+ color,
1945
+ fontWeight: isFocus || isSelectedLeaf ? 600 : 400,
1946
+ cursor: 'pointer',
1947
+ textDecoration: 'underline',
1948
+ textDecorationColor: '#475569',
1949
+ }}
1950
+ >
1951
+ {seg.label}
1952
+ </button>
1953
+ </React.Fragment>
1954
+ );
1955
+ })}
1956
+ </div>
1957
+ );
1958
+ })()
1959
+ ) : (
1960
+ <div
1961
+ style={{
1962
+ fontFamily: 'monospace',
1963
+ fontSize: 14,
1964
+ color: '#64748b',
1965
+ wordBreak: 'break-all',
1966
+ }}
1967
+ >
1968
+ None
1969
+ </div>
1970
+ )}
1971
+ </div>
1972
+ {focusDirectory && (
1973
+ <button
1974
+ onClick={() => setFocusPinned(p => !p)}
1975
+ title={
1976
+ focusPinned
1977
+ ? 'Unpin — selections will move the focus again'
1978
+ : 'Pin — keep this focus while navigating the trees'
1979
+ }
1980
+ style={{
1981
+ background: focusPinned ? '#fbbf24' : 'transparent',
1982
+ color: focusPinned ? '#0f172a' : '#cbd5e1',
1983
+ border: `1px solid ${focusPinned ? '#fbbf24' : '#334155'}`,
1984
+ borderRadius: 4,
1985
+ padding: '4px 8px',
1986
+ fontSize: 12,
1987
+ cursor: 'pointer',
1988
+ fontWeight: 500,
1989
+ flexShrink: 0,
1990
+ }}
1991
+ >
1992
+ {focusPinned ? 'Pinned' : 'Pin'}
1993
+ </button>
1994
+ )}
1995
+ {focusDirectory && (
1996
+ <button
1997
+ onClick={() => {
1998
+ setFocusPinned(false);
1999
+ setFocusDirectory(null);
2000
+ }}
2001
+ title="Clear focus"
2002
+ style={{
2003
+ background: 'transparent',
2004
+ color: '#94a3b8',
2005
+ border: '1px solid #334155',
2006
+ borderRadius: 4,
2007
+ padding: '4px 8px',
2008
+ fontSize: 12,
2009
+ cursor: 'pointer',
2010
+ flexShrink: 0,
2011
+ }}
2012
+ >
2013
+ Clear
2014
+ </button>
2015
+ )}
2016
+ </div>
2017
+
2018
+ {/* Selected-folder card — driven by clicks on city folder panels.
2019
+ Renders below the focus overlay; an "Open" button expands the
2020
+ folder in the file tree (which removes the umbrella tile). */}
2021
+ {activeTab === 'files' && selectedPanelFolder && (
2022
+ <div
2023
+ style={{
2024
+ position: 'absolute',
2025
+ top: 60,
2026
+ left: 8,
2027
+ padding: '8px 12px',
2028
+ background: 'rgba(15, 23, 42, 0.72)',
2029
+ backdropFilter: 'blur(8px)',
2030
+ WebkitBackdropFilter: 'blur(8px)',
2031
+ border: '1px solid #334155',
2032
+ borderRadius: 6,
2033
+ color: '#e2e8f0',
2034
+ fontFamily: 'system-ui, sans-serif',
2035
+ fontSize: 12,
2036
+ zIndex: 100,
2037
+ maxWidth: 480,
2038
+ display: 'flex',
2039
+ flexDirection: 'column',
2040
+ gap: 8,
2041
+ }}
2042
+ >
2043
+ {panelFolderCoverage && (panelFolderCoverage.scopeHits.length > 0 ||
2044
+ panelFolderCoverage.areaHits.length > 0) && (
2045
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
2046
+ {panelFolderCoverage.areaHits.map(area => (
2047
+ <div
2048
+ key={area.name}
2049
+ style={{
2050
+ padding: '6px 8px',
2051
+ background: '#0b1220',
2052
+ border: '1px dashed #334155',
2053
+ borderRadius: 4,
2054
+ display: 'flex',
2055
+ flexDirection: 'column',
2056
+ gap: 4,
2057
+ }}
2058
+ >
2059
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
2060
+ <span
2061
+ style={{
2062
+ fontSize: 12,
2063
+ color: '#94a3b8',
2064
+ textTransform: 'uppercase',
2065
+ letterSpacing: 0.5,
2066
+ fontWeight: 600,
2067
+ }}
2068
+ >
2069
+ Area
2070
+ </span>
2071
+ <span
2072
+ style={{
2073
+ width: 8,
2074
+ height: 8,
2075
+ borderRadius: 2,
2076
+ background: AREA_PANEL_COLOR,
2077
+ border: '1px dashed #94a3b8',
2078
+ flexShrink: 0,
2079
+ }}
2080
+ />
2081
+ <code style={{ fontSize: 12, color: '#cbd5e1' }}>{area.name}</code>
2082
+ </div>
2083
+ <div style={{ fontSize: 12, color: '#94a3b8', lineHeight: 1.4 }}>
2084
+ {area.description}
2085
+ </div>
2086
+ </div>
2087
+ ))}
2088
+ {panelFolderCoverage.scopeHits.map(({ scope, namespace }) => (
2089
+ <div
2090
+ key={scope.id}
2091
+ style={{
2092
+ padding: '6px 8px',
2093
+ background: '#0b1220',
2094
+ border: '1px solid #1e293b',
2095
+ borderRadius: 4,
2096
+ display: 'flex',
2097
+ flexDirection: 'column',
2098
+ gap: 4,
2099
+ }}
2100
+ >
2101
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
2102
+ <span
2103
+ style={{
2104
+ fontSize: 12,
2105
+ color: '#a855f7',
2106
+ textTransform: 'uppercase',
2107
+ letterSpacing: 0.5,
2108
+ fontWeight: 600,
2109
+ }}
2110
+ >
2111
+ Scope
2112
+ </span>
2113
+ <code style={{ fontSize: 12, color: '#cbd5e1' }}>{scope.id}</code>
2114
+ {namespace && (
2115
+ <>
2116
+ <span style={{ color: '#475569' }}>/</span>
2117
+ <span
2118
+ style={{
2119
+ width: 8,
2120
+ height: 8,
2121
+ borderRadius: 2,
2122
+ background: namespace.color,
2123
+ flexShrink: 0,
2124
+ }}
2125
+ />
2126
+ <code style={{ fontSize: 12, color: '#cbd5e1' }}>{namespace.name}</code>
2127
+ </>
2128
+ )}
2129
+ </div>
2130
+ <div style={{ fontSize: 12, color: '#94a3b8', lineHeight: 1.4 }}>
2131
+ {namespace ? namespace.description : scope.description}
2132
+ </div>
2133
+ </div>
2134
+ ))}
2135
+ </div>
2136
+ )}
2137
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
2138
+ <div ref={addPickerRef} style={{ position: 'relative' }}>
2139
+ <button
2140
+ onClick={() => setShowAddPicker(v => !v)}
2141
+ title="Add this folder to a scope or area"
2142
+ style={{
2143
+ background: showAddPicker ? '#1e293b' : 'transparent',
2144
+ color: '#cbd5e1',
2145
+ border: '1px solid #475569',
2146
+ borderRadius: 4,
2147
+ padding: '4px 10px',
2148
+ fontSize: 12,
2149
+ cursor: 'pointer',
2150
+ fontWeight: 500,
2151
+ }}
2152
+ >
2153
+ + Add
2154
+ </button>
2155
+ {showAddPicker && (
2156
+ <div
2157
+ style={{
2158
+ position: 'absolute',
2159
+ top: 'calc(100% + 4px)',
2160
+ left: 0,
2161
+ background: 'rgba(15, 23, 42, 0.95)',
2162
+ backdropFilter: 'blur(8px)',
2163
+ WebkitBackdropFilter: 'blur(8px)',
2164
+ border: '1px solid #334155',
2165
+ borderRadius: 4,
2166
+ padding: 4,
2167
+ display: 'flex',
2168
+ flexDirection: 'column',
2169
+ gap: 2,
2170
+ zIndex: 110,
2171
+ minWidth: 120,
2172
+ boxShadow: '0 6px 18px rgba(0,0,0,0.4)',
2173
+ }}
2174
+ >
2175
+ <button
2176
+ onClick={() => {
2177
+ setShowAddPicker(false);
2178
+ openAddModal(selectedPanelFolder);
2179
+ }}
2180
+ style={{
2181
+ background: 'transparent',
2182
+ color: '#cbd5e1',
2183
+ border: '1px solid #a855f7',
2184
+ borderRadius: 3,
2185
+ padding: '4px 10px',
2186
+ fontSize: 12,
2187
+ cursor: 'pointer',
2188
+ fontWeight: 500,
2189
+ textAlign: 'left',
2190
+ }}
2191
+ >
2192
+ Scope
2193
+ </button>
2194
+ <button
2195
+ onClick={() => {
2196
+ setShowAddPicker(false);
2197
+ openAddAreaModal(selectedPanelFolder);
2198
+ }}
2199
+ style={{
2200
+ background: 'transparent',
2201
+ color: '#cbd5e1',
2202
+ border: '1px dashed #94a3b8',
2203
+ borderRadius: 3,
2204
+ padding: '4px 10px',
2205
+ fontSize: 12,
2206
+ cursor: 'pointer',
2207
+ fontWeight: 500,
2208
+ textAlign: 'left',
2209
+ }}
2210
+ >
2211
+ Area
2212
+ </button>
2213
+ </div>
2214
+ )}
2215
+ </div>
2216
+ <button
2217
+ onClick={() => {
2218
+ const next = !showPanelFolderContents;
2219
+ setShowPanelFolderContents(next);
2220
+ // Mirror the Open/Close behaviour: showing contents
2221
+ // expands the folder in the tree (so the city's umbrella
2222
+ // tile lifts and child buildings become visible);
2223
+ // hiding collapses it again.
2224
+ const item = asDir(treeModel.getItem(selectedPanelFolder));
2225
+ if (!item) return;
2226
+ if (next) item.expand();
2227
+ else item.collapse();
2228
+ }}
2229
+ title="Show files inside this folder"
2230
+ style={{
2231
+ background: showPanelFolderContents ? '#1e293b' : 'transparent',
2232
+ color: '#cbd5e1',
2233
+ border: '1px solid #334155',
2234
+ borderRadius: 4,
2235
+ padding: '4px 10px',
2236
+ fontSize: 12,
2237
+ cursor: 'pointer',
2238
+ fontWeight: 500,
2239
+ }}
2240
+ >
2241
+ {showPanelFolderContents ? 'Hide contents' : 'Show contents'}
2242
+ </button>
2243
+ </div>
2244
+ {showPanelFolderContents && (
2245
+ <div
2246
+ style={{
2247
+ display: 'flex',
2248
+ flexDirection: 'column',
2249
+ borderTop: '1px solid #1e293b',
2250
+ paddingTop: 8,
2251
+ marginTop: 2,
2252
+ minWidth: 320,
2253
+ }}
2254
+ >
2255
+ {panelFolderContentsPaths.length === 0 ? (
2256
+ <div
2257
+ style={{
2258
+ fontSize: 12,
2259
+ color: '#64748b',
2260
+ fontStyle: 'italic',
2261
+ padding: '4px 0',
2262
+ }}
2263
+ >
2264
+ No files in this folder.
2265
+ </div>
2266
+ ) : (
2267
+ <div style={{ height: 640, display: 'flex', flexDirection: 'column' }}>
2268
+ <FileTree
2269
+ model={panelFolderContentsTreeModel}
2270
+ style={
2271
+ {
2272
+ flex: 1,
2273
+ minHeight: 0,
2274
+ '--trees-bg-override': 'transparent',
2275
+ '--trees-search-bg-override': 'rgba(0, 0, 0, 0.25)',
2276
+ '--trees-padding-inline-override': '0',
2277
+ '--trees-theme-list-active-selection-bg':
2278
+ 'color-mix(in oklab, #3b82f6 28%, transparent)',
2279
+ '--trees-theme-list-hover-bg':
2280
+ 'color-mix(in oklab, #3b82f6 14%, transparent)',
2281
+ } as React.CSSProperties
2282
+ }
2283
+ />
2284
+ </div>
2285
+ )}
2286
+ </div>
2287
+ )}
2288
+ </div>
2289
+ )}
2290
+
2291
+ {/* Parent-layers popup — surfaces ancestor folders whose umbrellas
2292
+ are currently hidden (because they're expanded). Triggered by
2293
+ Cmd/Ctrl-click on a building. Each entry collapses that
2294
+ ancestor on click so its umbrella reappears. */}
2295
+ {parentLayersAnchor && !parentLayersDismissed && parentLayers.length > 0 && (
2296
+ <div
2297
+ style={{
2298
+ position: 'absolute',
2299
+ top: 72,
2300
+ right: 8,
2301
+ padding: '10px 12px',
2302
+ background: 'rgba(15, 23, 42, 0.72)',
2303
+ backdropFilter: 'blur(8px)',
2304
+ WebkitBackdropFilter: 'blur(8px)',
2305
+ border: '1px solid #334155',
2306
+ borderRadius: 6,
2307
+ color: '#e2e8f0',
2308
+ fontFamily: 'system-ui, sans-serif',
2309
+ fontSize: 12,
2310
+ zIndex: 100,
2311
+ maxWidth: 240,
2312
+ display: 'flex',
2313
+ flexDirection: 'column',
2314
+ gap: 8,
2315
+ boxShadow: '0 10px 30px rgba(0,0,0,0.4)',
2316
+ }}
2317
+ >
2318
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
2319
+ <div style={{ ...sectionLabelStyle, flex: 1, minWidth: 0 }}>
2320
+ Hidden parent layers
2321
+ </div>
2322
+ <button
2323
+ onClick={() => setParentLayersDismissed(true)}
2324
+ title="Dismiss (reappears on next folder interaction)"
2325
+ style={{
2326
+ background: 'transparent',
2327
+ color: '#94a3b8',
2328
+ border: '1px solid #334155',
2329
+ borderRadius: 4,
2330
+ padding: '4px 8px',
2331
+ fontSize: 12,
2332
+ cursor: 'pointer',
2333
+ flexShrink: 0,
2334
+ }}
2335
+ >
2336
+
2337
+ </button>
2338
+ </div>
2339
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
2340
+ {parentLayers.map(folderPath => {
2341
+ const label = folderPath.split('/').pop() ?? folderPath;
2342
+ return (
2343
+ <button
2344
+ key={folderPath}
2345
+ onClick={() => collapseFolder(folderPath)}
2346
+ title={`Collapse ${folderPath} — restores its umbrella tile`}
2347
+ style={{
2348
+ display: 'flex',
2349
+ alignItems: 'center',
2350
+ gap: 8,
2351
+ padding: '6px 8px',
2352
+ background: '#1e293b',
2353
+ color: '#e2e8f0',
2354
+ border: '1px solid #334155',
2355
+ borderRadius: 4,
2356
+ cursor: 'pointer',
2357
+ textAlign: 'left',
2358
+ fontFamily: 'system-ui, sans-serif',
2359
+ fontSize: 12,
2360
+ }}
2361
+ >
2362
+ <span style={{ fontFamily: 'monospace', fontWeight: 500 }}>{label}</span>
2363
+ </button>
2364
+ );
2365
+ })}
2366
+ </div>
2367
+ </div>
2368
+ )}
2369
+
2370
+ {/* Mode switch — swap which feature layer the canvas renders */}
2371
+ <div
2372
+ style={{
2373
+ position: 'absolute',
2374
+ bottom: 16,
2375
+ left: '50%',
2376
+ transform: 'translateX(-50%)',
2377
+ display: 'flex',
2378
+ background: 'rgba(15, 23, 42, 0.92)',
2379
+ border: '1px solid #1e293b',
2380
+ borderRadius: 6,
2381
+ overflow: 'hidden',
2382
+ fontFamily: 'system-ui, sans-serif',
2383
+ fontSize: 12,
2384
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
2385
+ zIndex: 10,
2386
+ }}
2387
+ >
2388
+ {(
2389
+ [
2390
+ { id: 'files' as const, label: 'Files', accent: '#3b82f6' },
2391
+ { id: 'scopes' as const, label: 'Scopes', accent: '#a855f7' },
2392
+ ]
2393
+ ).map((opt, i) => {
2394
+ const active = activeTab === opt.id;
2395
+ return (
2396
+ <button
2397
+ key={opt.id}
2398
+ onClick={() => setActiveTab(opt.id)}
2399
+ style={{
2400
+ padding: '8px 16px',
2401
+ background: active ? opt.accent : 'transparent',
2402
+ color: active ? '#ffffff' : '#cbd5e1',
2403
+ border: 'none',
2404
+ borderLeft: i === 0 ? 'none' : '1px solid #1e293b',
2405
+ cursor: 'pointer',
2406
+ fontWeight: active ? 600 : 400,
2407
+ fontFamily: 'inherit',
2408
+ fontSize: 'inherit',
2409
+ }}
2410
+ >
2411
+ {opt.label}
2412
+ </button>
2413
+ );
2414
+ })}
2415
+ </div>
2416
+
2417
+ {/* Info overlay — driven by scope tree selection */}
2418
+ {activeTab === 'scopes' && scopeInfo && <ScopeInfoOverlay info={scopeInfo} />}
2419
+ </div>
2420
+
2421
+ {/* Add-to-scope modal */}
2422
+ {showAddModal && scopeModalTargetPath && (
2423
+ <AddToScopeModal
2424
+ path={toScopePath(scopeModalTargetPath)}
2425
+ scopes={scopes}
2426
+ scopeId={modalScopeId}
2427
+ namespaceName={modalNamespaceName}
2428
+ onScopeIdChange={setModalScopeId}
2429
+ onNamespaceNameChange={setModalNamespaceName}
2430
+ onPickExisting={(s, n) => {
2431
+ setModalScopeId(s);
2432
+ setModalNamespaceName(n);
2433
+ }}
2434
+ onSubmit={submitAddToScope}
2435
+ onClose={() => {
2436
+ setShowAddModal(false);
2437
+ setScopeModalTargetPath(null);
2438
+ }}
2439
+ />
2440
+ )}
2441
+
2442
+ {/* Add-to-area modal */}
2443
+ {showAddAreaModal && areaModalTargetPath && (
2444
+ <AddToAreaModal
2445
+ path={toScopePath(areaModalTargetPath)}
2446
+ areas={areas}
2447
+ areaName={modalAreaName}
2448
+ description={modalAreaDescription}
2449
+ onAreaNameChange={setModalAreaName}
2450
+ onDescriptionChange={setModalAreaDescription}
2451
+ onPickExisting={name => setModalAreaName(name)}
2452
+ onSubmit={submitAddToArea}
2453
+ onClose={() => {
2454
+ setShowAddAreaModal(false);
2455
+ setAreaModalTargetPath(null);
2456
+ }}
2457
+ />
2458
+ )}
2459
+ </div>
2460
+ );
2461
+ };
2462
+
2463
+ export const Default: Story = {
2464
+ render: () => <FileCityExplorerTemplate />,
2465
+ parameters: {
2466
+ docs: {
2467
+ description: {
2468
+ story:
2469
+ 'Author scopes, namespaces, and areas over the electron-app city. Click folders to ' +
2470
+ 'add coverage, switch between Files and Scopes views, and inspect existing coverage.',
2471
+ },
2472
+ },
2473
+ },
2474
+ };