@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.
- package/dist/components/FileCity3D/FileCity3D.d.ts +8 -2
- package/dist/components/FileCity3D/FileCity3D.d.ts.map +1 -1
- package/dist/components/FileCity3D/FileCity3D.js +129 -40
- package/dist/components/FileCityExplorer/AddToAreaModal.d.ts +14 -0
- package/dist/components/FileCityExplorer/AddToAreaModal.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/AddToAreaModal.js +140 -0
- package/dist/components/FileCityExplorer/AddToScopeModal.d.ts +14 -0
- package/dist/components/FileCityExplorer/AddToScopeModal.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/AddToScopeModal.js +176 -0
- package/dist/components/FileCityExplorer/FileCityExplorer.d.ts +30 -0
- package/dist/components/FileCityExplorer/FileCityExplorer.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/FileCityExplorer.js +1045 -0
- package/dist/components/FileCityExplorer/ScopeInfoOverlay.d.ts +10 -0
- package/dist/components/FileCityExplorer/ScopeInfoOverlay.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/ScopeInfoOverlay.js +73 -0
- package/dist/components/FileCityExplorer/index.d.ts +3 -0
- package/dist/components/FileCityExplorer/index.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/index.js +1 -0
- package/dist/components/FileCityExplorer/layers.d.ts +16 -0
- package/dist/components/FileCityExplorer/layers.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/layers.js +61 -0
- package/dist/components/FileCityExplorer/model.d.ts +32 -0
- package/dist/components/FileCityExplorer/model.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/model.js +14 -0
- package/dist/components/FileCityExplorer/pathConversion.d.ts +19 -0
- package/dist/components/FileCityExplorer/pathConversion.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/pathConversion.js +26 -0
- package/dist/components/FileCityExplorer/scopeTreePaths.d.ts +21 -0
- package/dist/components/FileCityExplorer/scopeTreePaths.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/scopeTreePaths.js +42 -0
- package/dist/components/FileCityExplorer/styles.d.ts +9 -0
- package/dist/components/FileCityExplorer/styles.d.ts.map +1 -0
- package/dist/components/FileCityExplorer/styles.js +28 -0
- package/dist/utils/folderElevatedPanels.d.ts +3 -1
- package/dist/utils/folderElevatedPanels.d.ts.map +1 -1
- package/dist/utils/folderElevatedPanels.js +5 -2
- package/package.json +2 -1
- package/src/components/FileCity3D/FileCity3D.tsx +200 -52
- package/src/components/FileCityExplorer/AddToAreaModal.tsx +273 -0
- package/src/components/FileCityExplorer/AddToScopeModal.tsx +320 -0
- package/src/components/FileCityExplorer/FileCityExplorer.tsx +1457 -0
- package/src/components/FileCityExplorer/ScopeInfoOverlay.tsx +229 -0
- package/src/components/FileCityExplorer/index.ts +2 -0
- package/src/components/FileCityExplorer/layers.ts +72 -0
- package/src/components/FileCityExplorer/model.ts +35 -0
- package/src/components/FileCityExplorer/pathConversion.ts +32 -0
- package/src/components/FileCityExplorer/scopeTreePaths.ts +52 -0
- package/src/components/FileCityExplorer/styles.ts +34 -0
- package/src/stories/2D3DComparison.stories.tsx +13 -2
- package/src/stories/ElevatedScopePanels.stories.tsx +295 -0
- package/src/stories/FileCity3D.stories.tsx +24 -3
- package/src/stories/FileCityExplorer.stories.tsx +2474 -0
- package/src/stories/FileCityExplorerComponent.stories.tsx +59 -0
- package/src/utils/folderElevatedPanels.ts +8 -2
- 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'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
|
+
};
|