@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
|
@@ -1,1610 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import type { Meta, StoryObj } from '@storybook/react';
|
|
3
|
-
import {
|
|
4
|
-
FileTree,
|
|
5
|
-
useFileTree,
|
|
6
|
-
useFileTreeSelection,
|
|
7
|
-
useFileTreeSelector,
|
|
8
|
-
} from '@pierre/trees/react';
|
|
9
|
-
import {
|
|
10
|
-
FileCity3D,
|
|
11
|
-
type CityData,
|
|
12
|
-
type CityDistrict,
|
|
13
|
-
type ElevatedScopePanel,
|
|
14
|
-
type HighlightLayer,
|
|
15
|
-
} from '../components/FileCity3D';
|
|
16
|
-
import { createFileColorHighlightLayers } from '../utils/fileColorHighlightLayers';
|
|
17
|
-
import { buildFolderElevatedPanels, buildFolderIndex } from '../utils/folderElevatedPanels';
|
|
18
|
-
|
|
19
|
-
import electronAppCityData from '../../../../assets/electron-app-city-data.json';
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Scope / Namespace Overlay experiments
|
|
23
|
-
*
|
|
24
|
-
* Prototypes the mapping described in docs/scope-namespace-overlay.md:
|
|
25
|
-
*
|
|
26
|
-
* scope → LayerGroup (toggleable lens on the city)
|
|
27
|
-
* namespace → HighlightLayer (one stable color, directory items)
|
|
28
|
-
* paths[] → LayerItem { type: 'directory', renderStrategy: 'fill' }
|
|
29
|
-
*
|
|
30
|
-
* The scopes/namespaces below are hand-authored against paths that exist in
|
|
31
|
-
* the electron-app city data — they stand in for what would eventually be
|
|
32
|
-
* parsed from principal-view-core-library *.events.canvas files.
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
const meta: Meta<typeof FileCity3D> = {
|
|
36
|
-
title: 'Experiments/ScopeOverlay',
|
|
37
|
-
component: FileCity3D,
|
|
38
|
-
parameters: { layout: 'fullscreen' },
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export default meta;
|
|
42
|
-
type Story = StoryObj<typeof FileCity3D>;
|
|
43
|
-
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
// Mock scope / namespace model
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
|
|
48
|
-
interface MockEvent {
|
|
49
|
-
/** Action portion of the event name — full name is `${namespace}.${action}`. */
|
|
50
|
-
action: string;
|
|
51
|
-
severity: 'info' | 'warn' | 'error';
|
|
52
|
-
description: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
interface MockNamespace {
|
|
56
|
-
name: string;
|
|
57
|
-
description: string;
|
|
58
|
-
color: string;
|
|
59
|
-
paths: string[];
|
|
60
|
-
/**
|
|
61
|
-
* Events on this namespace. Per the doc, events are namespace-level metadata —
|
|
62
|
-
* they don't claim files at this level. (Files-per-event is a future layer.)
|
|
63
|
-
*/
|
|
64
|
-
events: MockEvent[];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
interface MockScope {
|
|
68
|
-
id: string;
|
|
69
|
-
name: string;
|
|
70
|
-
description: string;
|
|
71
|
-
/**
|
|
72
|
-
* Scope-level paths (mirrors planned addition in principal-view-core-library).
|
|
73
|
-
* Cover everything not claimed by a more specific namespace.
|
|
74
|
-
*/
|
|
75
|
-
paths: string[];
|
|
76
|
-
namespaces: MockNamespace[];
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const SCOPES_STORAGE_KEY = 'file-city.scope-overlay.scopes';
|
|
80
|
-
|
|
81
|
-
function loadScopesFromStorage(): MockScope[] {
|
|
82
|
-
if (typeof window === 'undefined') return [];
|
|
83
|
-
try {
|
|
84
|
-
const raw = window.localStorage.getItem(SCOPES_STORAGE_KEY);
|
|
85
|
-
if (!raw) return [];
|
|
86
|
-
const parsed = JSON.parse(raw);
|
|
87
|
-
return Array.isArray(parsed) ? (parsed as MockScope[]) : [];
|
|
88
|
-
} catch {
|
|
89
|
-
return [];
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function saveScopesToStorage(scopes: readonly MockScope[]): void {
|
|
94
|
-
if (typeof window === 'undefined') return;
|
|
95
|
-
try {
|
|
96
|
-
window.localStorage.setItem(SCOPES_STORAGE_KEY, JSON.stringify(scopes));
|
|
97
|
-
} catch {
|
|
98
|
-
// ignore quota / serialization errors in the story
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// ---------------------------------------------------------------------------
|
|
103
|
-
// File tree paths (extracted once from city data)
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
|
|
106
|
-
const ELECTRON_PATHS: string[] = (() => {
|
|
107
|
-
const set = new Set<string>();
|
|
108
|
-
for (const b of (electronAppCityData as CityData).buildings) set.add(b.path);
|
|
109
|
-
return Array.from(set).sort();
|
|
110
|
-
})();
|
|
111
|
-
|
|
112
|
-
const ELECTRON_DIRECTORIES: Set<string> = new Set(
|
|
113
|
-
(electronAppCityData as CityData).districts.map(d => d.path),
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
const ELECTRON_DISTRICTS_BY_PATH: Map<string, CityDistrict> = new Map(
|
|
117
|
-
(electronAppCityData as CityData).districts.map(d => [d.path, d]),
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Pre-built folder index (children, bounds, file counts) for the static
|
|
122
|
-
* electron-app city. Cached once at module load so the story doesn't
|
|
123
|
-
* recompute on every render.
|
|
124
|
-
*/
|
|
125
|
-
const ELECTRON_FOLDER_INDEX = buildFolderIndex(electronAppCityData as CityData);
|
|
126
|
-
|
|
127
|
-
// ---------------------------------------------------------------------------
|
|
128
|
-
// Scope tree paths
|
|
129
|
-
// ---------------------------------------------------------------------------
|
|
130
|
-
|
|
131
|
-
interface ScopeTreeSelection {
|
|
132
|
-
scopeId: string;
|
|
133
|
-
namespaceName?: string;
|
|
134
|
-
eventAction?: string;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Sentinel leaves used when a scope has no namespaces or a namespace has no
|
|
139
|
-
* events — the trees library infers directories from paths, so empty branches
|
|
140
|
-
* need a placeholder leaf to render.
|
|
141
|
-
*/
|
|
142
|
-
const EMPTY_NS_SENTINEL = '(no namespaces)';
|
|
143
|
-
const EMPTY_EVENTS_SENTINEL = '(no events)';
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Build canonical paths for the scope tree: `<scope.id>/<namespace.name>/<event.action>`.
|
|
147
|
-
* Scopes are top-level directories, namespaces children, events leaves.
|
|
148
|
-
* Empty scopes/namespaces emit a sentinel leaf so they still appear.
|
|
149
|
-
*/
|
|
150
|
-
function buildScopeTreePaths(scopes: readonly MockScope[]): string[] {
|
|
151
|
-
const out: string[] = [];
|
|
152
|
-
for (const scope of scopes) {
|
|
153
|
-
if (scope.namespaces.length === 0) {
|
|
154
|
-
out.push(`${scope.id}/${EMPTY_NS_SENTINEL}`);
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
for (const ns of scope.namespaces) {
|
|
158
|
-
if (ns.events.length === 0) {
|
|
159
|
-
out.push(`${scope.id}/${ns.name}/${EMPTY_EVENTS_SENTINEL}`);
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
for (const ev of ns.events) {
|
|
163
|
-
out.push(`${scope.id}/${ns.name}/${ev.action}`);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
return out;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function parseScopeTreePath(path: string): ScopeTreeSelection {
|
|
171
|
-
const [scopeId, namespaceName, eventAction] = path.split('/');
|
|
172
|
-
const result: ScopeTreeSelection = { scopeId };
|
|
173
|
-
if (namespaceName && namespaceName !== EMPTY_NS_SENTINEL) {
|
|
174
|
-
result.namespaceName = namespaceName;
|
|
175
|
-
}
|
|
176
|
-
if (eventAction && eventAction !== EMPTY_EVENTS_SENTINEL) {
|
|
177
|
-
result.eventAction = eventAction;
|
|
178
|
-
}
|
|
179
|
-
return result;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ---------------------------------------------------------------------------
|
|
183
|
-
// Info overlay component
|
|
184
|
-
// ---------------------------------------------------------------------------
|
|
185
|
-
|
|
186
|
-
const SEVERITY_BG: Record<MockEvent['severity'], string> = {
|
|
187
|
-
error: '#7f1d1d',
|
|
188
|
-
warn: '#78350f',
|
|
189
|
-
info: '#1e3a8a',
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
const overlayStyle: React.CSSProperties = {
|
|
193
|
-
position: 'absolute',
|
|
194
|
-
top: 16,
|
|
195
|
-
right: 16,
|
|
196
|
-
width: 360,
|
|
197
|
-
maxHeight: 'calc(100vh - 32px)',
|
|
198
|
-
overflowY: 'auto',
|
|
199
|
-
background: 'rgba(15, 23, 42, 0.96)',
|
|
200
|
-
border: '1px solid #334155',
|
|
201
|
-
borderRadius: 8,
|
|
202
|
-
color: '#e2e8f0',
|
|
203
|
-
fontFamily: 'system-ui, sans-serif',
|
|
204
|
-
fontSize: 13,
|
|
205
|
-
zIndex: 100,
|
|
206
|
-
boxShadow: '0 10px 30px rgba(0,0,0,0.4)',
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
const sectionLabelStyle: React.CSSProperties = {
|
|
210
|
-
fontSize: 11,
|
|
211
|
-
color: '#64748b',
|
|
212
|
-
textTransform: 'uppercase',
|
|
213
|
-
letterSpacing: 0.5,
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
const ScopeInfoOverlay: React.FC<{
|
|
217
|
-
info: { scope: MockScope; ns: MockNamespace | null; ev: MockEvent | null };
|
|
218
|
-
}> = ({ info }) => {
|
|
219
|
-
const { scope, ns, ev } = info;
|
|
220
|
-
|
|
221
|
-
// Event leaf selected — show event detail.
|
|
222
|
-
if (ns && ev) {
|
|
223
|
-
return (
|
|
224
|
-
<div style={overlayStyle}>
|
|
225
|
-
<div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
|
|
226
|
-
<div style={sectionLabelStyle}>Event</div>
|
|
227
|
-
<div style={{ fontFamily: 'monospace', fontSize: 14, marginTop: 6 }}>
|
|
228
|
-
{ns.name}.{ev.action}
|
|
229
|
-
</div>
|
|
230
|
-
<div
|
|
231
|
-
style={{
|
|
232
|
-
display: 'inline-block',
|
|
233
|
-
fontSize: 10,
|
|
234
|
-
marginTop: 8,
|
|
235
|
-
padding: '2px 6px',
|
|
236
|
-
borderRadius: 3,
|
|
237
|
-
background: SEVERITY_BG[ev.severity],
|
|
238
|
-
color: '#fde68a',
|
|
239
|
-
}}
|
|
240
|
-
>
|
|
241
|
-
{ev.severity}
|
|
242
|
-
</div>
|
|
243
|
-
<div style={{ fontSize: 12, color: '#cbd5e1', marginTop: 10, lineHeight: 1.5 }}>
|
|
244
|
-
{ev.description}
|
|
245
|
-
</div>
|
|
246
|
-
</div>
|
|
247
|
-
<div style={{ padding: '14px 16px' }}>
|
|
248
|
-
<div style={sectionLabelStyle}>Owning namespace</div>
|
|
249
|
-
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
250
|
-
<span
|
|
251
|
-
style={{ width: 12, height: 12, borderRadius: 3, background: ns.color, flexShrink: 0 }}
|
|
252
|
-
/>
|
|
253
|
-
<span style={{ fontFamily: 'monospace', fontSize: 13 }}>{ns.name}</span>
|
|
254
|
-
</div>
|
|
255
|
-
<div style={{ fontSize: 10, color: '#64748b', marginTop: 14, fontStyle: 'italic' }}>
|
|
256
|
-
Files-per-event mapping not wired yet — for now the event highlights its parent
|
|
257
|
-
namespace's paths.
|
|
258
|
-
</div>
|
|
259
|
-
</div>
|
|
260
|
-
</div>
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Namespace selected — show namespace detail.
|
|
265
|
-
if (ns) {
|
|
266
|
-
return (
|
|
267
|
-
<div style={overlayStyle}>
|
|
268
|
-
<div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
|
|
269
|
-
<div style={sectionLabelStyle}>Namespace</div>
|
|
270
|
-
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
271
|
-
<span
|
|
272
|
-
style={{ width: 12, height: 12, borderRadius: 3, background: ns.color, flexShrink: 0 }}
|
|
273
|
-
/>
|
|
274
|
-
<span style={{ fontFamily: 'monospace', fontSize: 14 }}>{ns.name}</span>
|
|
275
|
-
</div>
|
|
276
|
-
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 8, lineHeight: 1.5 }}>
|
|
277
|
-
{ns.description}
|
|
278
|
-
</div>
|
|
279
|
-
<div style={{ fontSize: 11, color: '#64748b', marginTop: 8 }}>
|
|
280
|
-
in <span style={{ fontFamily: 'monospace' }}>{scope.id}</span>
|
|
281
|
-
</div>
|
|
282
|
-
</div>
|
|
283
|
-
<div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
|
|
284
|
-
<div style={sectionLabelStyle}>Claimed paths ({ns.paths.length})</div>
|
|
285
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 6 }}>
|
|
286
|
-
{ns.paths.map(p => (
|
|
287
|
-
<code
|
|
288
|
-
key={p}
|
|
289
|
-
style={{
|
|
290
|
-
fontSize: 11,
|
|
291
|
-
color: '#cbd5e1',
|
|
292
|
-
background: '#0b1220',
|
|
293
|
-
padding: '4px 6px',
|
|
294
|
-
borderRadius: 4,
|
|
295
|
-
wordBreak: 'break-all',
|
|
296
|
-
}}
|
|
297
|
-
>
|
|
298
|
-
{p}
|
|
299
|
-
</code>
|
|
300
|
-
))}
|
|
301
|
-
</div>
|
|
302
|
-
</div>
|
|
303
|
-
<div style={{ padding: '14px 16px' }}>
|
|
304
|
-
<div style={sectionLabelStyle}>Events ({ns.events.length})</div>
|
|
305
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 6 }}>
|
|
306
|
-
{ns.events.map(e => (
|
|
307
|
-
<div
|
|
308
|
-
key={e.action}
|
|
309
|
-
style={{
|
|
310
|
-
display: 'flex',
|
|
311
|
-
alignItems: 'center',
|
|
312
|
-
gap: 6,
|
|
313
|
-
padding: '4px 6px',
|
|
314
|
-
background: '#0b1220',
|
|
315
|
-
borderRadius: 4,
|
|
316
|
-
}}
|
|
317
|
-
>
|
|
318
|
-
<span
|
|
319
|
-
style={{
|
|
320
|
-
fontSize: 9,
|
|
321
|
-
padding: '1px 4px',
|
|
322
|
-
borderRadius: 2,
|
|
323
|
-
background: SEVERITY_BG[e.severity],
|
|
324
|
-
color: '#fde68a',
|
|
325
|
-
flexShrink: 0,
|
|
326
|
-
}}
|
|
327
|
-
>
|
|
328
|
-
{e.severity}
|
|
329
|
-
</span>
|
|
330
|
-
<code style={{ fontSize: 11, color: '#cbd5e1' }}>
|
|
331
|
-
{ns.name}.{e.action}
|
|
332
|
-
</code>
|
|
333
|
-
</div>
|
|
334
|
-
))}
|
|
335
|
-
</div>
|
|
336
|
-
</div>
|
|
337
|
-
</div>
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// Scope selected — show scope summary.
|
|
342
|
-
const totalEvents = scope.namespaces.reduce((n, x) => n + x.events.length, 0);
|
|
343
|
-
return (
|
|
344
|
-
<div style={overlayStyle}>
|
|
345
|
-
<div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
|
|
346
|
-
<div style={sectionLabelStyle}>Scope</div>
|
|
347
|
-
<div style={{ fontFamily: 'monospace', fontSize: 14, marginTop: 6 }}>{scope.id}</div>
|
|
348
|
-
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 8, lineHeight: 1.5 }}>
|
|
349
|
-
{scope.description}
|
|
350
|
-
</div>
|
|
351
|
-
<div style={{ display: 'flex', gap: 16, marginTop: 12, fontSize: 11, color: '#64748b' }}>
|
|
352
|
-
<div>
|
|
353
|
-
<div>{scope.paths.length}</div>
|
|
354
|
-
<div style={sectionLabelStyle}>scope paths</div>
|
|
355
|
-
</div>
|
|
356
|
-
<div>
|
|
357
|
-
<div>{scope.namespaces.length}</div>
|
|
358
|
-
<div style={sectionLabelStyle}>namespaces</div>
|
|
359
|
-
</div>
|
|
360
|
-
<div>
|
|
361
|
-
<div>{totalEvents}</div>
|
|
362
|
-
<div style={sectionLabelStyle}>events</div>
|
|
363
|
-
</div>
|
|
364
|
-
</div>
|
|
365
|
-
</div>
|
|
366
|
-
{scope.paths.length > 0 && (
|
|
367
|
-
<div style={{ padding: '14px 16px', borderBottom: '1px solid #1e293b' }}>
|
|
368
|
-
<div style={sectionLabelStyle}>Scope-level paths ({scope.paths.length})</div>
|
|
369
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 6 }}>
|
|
370
|
-
{scope.paths.map(p => (
|
|
371
|
-
<code
|
|
372
|
-
key={p}
|
|
373
|
-
style={{
|
|
374
|
-
fontSize: 11,
|
|
375
|
-
color: '#cbd5e1',
|
|
376
|
-
background: '#0b1220',
|
|
377
|
-
padding: '4px 6px',
|
|
378
|
-
borderRadius: 4,
|
|
379
|
-
wordBreak: 'break-all',
|
|
380
|
-
}}
|
|
381
|
-
>
|
|
382
|
-
{p}
|
|
383
|
-
</code>
|
|
384
|
-
))}
|
|
385
|
-
</div>
|
|
386
|
-
</div>
|
|
387
|
-
)}
|
|
388
|
-
<div style={{ padding: '14px 16px' }}>
|
|
389
|
-
<div style={sectionLabelStyle}>Namespaces</div>
|
|
390
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
|
391
|
-
{scope.namespaces.map(n => (
|
|
392
|
-
<div
|
|
393
|
-
key={n.name}
|
|
394
|
-
style={{ padding: 8, background: '#0b1220', borderRadius: 6 }}
|
|
395
|
-
>
|
|
396
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
397
|
-
<span
|
|
398
|
-
style={{
|
|
399
|
-
width: 10,
|
|
400
|
-
height: 10,
|
|
401
|
-
borderRadius: 2,
|
|
402
|
-
background: n.color,
|
|
403
|
-
flexShrink: 0,
|
|
404
|
-
}}
|
|
405
|
-
/>
|
|
406
|
-
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{n.name}</span>
|
|
407
|
-
<span style={{ fontSize: 10, color: '#64748b', marginLeft: 'auto' }}>
|
|
408
|
-
{n.events.length} event{n.events.length === 1 ? '' : 's'}
|
|
409
|
-
</span>
|
|
410
|
-
</div>
|
|
411
|
-
<div
|
|
412
|
-
style={{
|
|
413
|
-
fontSize: 10,
|
|
414
|
-
color: '#64748b',
|
|
415
|
-
fontFamily: 'monospace',
|
|
416
|
-
marginTop: 4,
|
|
417
|
-
wordBreak: 'break-all',
|
|
418
|
-
}}
|
|
419
|
-
>
|
|
420
|
-
{n.paths.join(' · ')}
|
|
421
|
-
</div>
|
|
422
|
-
</div>
|
|
423
|
-
))}
|
|
424
|
-
</div>
|
|
425
|
-
</div>
|
|
426
|
-
</div>
|
|
427
|
-
);
|
|
428
|
-
};
|
|
429
|
-
|
|
430
|
-
// ---------------------------------------------------------------------------
|
|
431
|
-
// Add-to-scope modal
|
|
432
|
-
// ---------------------------------------------------------------------------
|
|
433
|
-
|
|
434
|
-
const AddToScopeModal: React.FC<{
|
|
435
|
-
path: string;
|
|
436
|
-
scopes: readonly MockScope[];
|
|
437
|
-
scopeId: string;
|
|
438
|
-
namespaceName: string;
|
|
439
|
-
onScopeIdChange: (value: string) => void;
|
|
440
|
-
onNamespaceNameChange: (value: string) => void;
|
|
441
|
-
onPickExisting: (scopeId: string, namespaceName: string) => void;
|
|
442
|
-
onSubmit: () => void;
|
|
443
|
-
onClose: () => void;
|
|
444
|
-
}> = ({
|
|
445
|
-
path,
|
|
446
|
-
scopes,
|
|
447
|
-
scopeId,
|
|
448
|
-
namespaceName,
|
|
449
|
-
onScopeIdChange,
|
|
450
|
-
onNamespaceNameChange,
|
|
451
|
-
onPickExisting,
|
|
452
|
-
onSubmit,
|
|
453
|
-
onClose,
|
|
454
|
-
}) => {
|
|
455
|
-
React.useEffect(() => {
|
|
456
|
-
const onKey = (e: KeyboardEvent) => {
|
|
457
|
-
if (e.key === 'Escape') onClose();
|
|
458
|
-
};
|
|
459
|
-
window.addEventListener('keydown', onKey);
|
|
460
|
-
return () => window.removeEventListener('keydown', onKey);
|
|
461
|
-
}, [onClose]);
|
|
462
|
-
|
|
463
|
-
const trimmedScope = scopeId.trim();
|
|
464
|
-
const trimmedNs = namespaceName.trim();
|
|
465
|
-
const canSubmit = trimmedScope.length > 0;
|
|
466
|
-
|
|
467
|
-
// Determine what the submit will do, for the action label.
|
|
468
|
-
const targetScope = scopes.find(s => s.id === trimmedScope);
|
|
469
|
-
const targetNs = trimmedNs
|
|
470
|
-
? targetScope?.namespaces.find(n => n.name === trimmedNs) ?? null
|
|
471
|
-
: null;
|
|
472
|
-
const alreadyClaimed = trimmedNs
|
|
473
|
-
? targetNs?.paths.includes(path) ?? false
|
|
474
|
-
: targetScope?.paths.includes(path) ?? false;
|
|
475
|
-
|
|
476
|
-
let actionLabel = 'Add';
|
|
477
|
-
if (alreadyClaimed) actionLabel = 'Already added';
|
|
478
|
-
else if (!targetScope && !trimmedNs) actionLabel = 'Create scope';
|
|
479
|
-
else if (!targetScope) actionLabel = 'Create scope + namespace';
|
|
480
|
-
else if (!trimmedNs) actionLabel = 'Add to scope';
|
|
481
|
-
else if (!targetNs) actionLabel = 'Create namespace';
|
|
482
|
-
else actionLabel = 'Add path';
|
|
483
|
-
|
|
484
|
-
return (
|
|
485
|
-
<div
|
|
486
|
-
onClick={onClose}
|
|
487
|
-
style={{
|
|
488
|
-
position: 'fixed',
|
|
489
|
-
inset: 0,
|
|
490
|
-
background: 'rgba(0, 0, 0, 0.55)',
|
|
491
|
-
display: 'flex',
|
|
492
|
-
alignItems: 'center',
|
|
493
|
-
justifyContent: 'center',
|
|
494
|
-
zIndex: 1000,
|
|
495
|
-
fontFamily: 'system-ui, sans-serif',
|
|
496
|
-
}}
|
|
497
|
-
>
|
|
498
|
-
<div
|
|
499
|
-
onClick={e => e.stopPropagation()}
|
|
500
|
-
style={{
|
|
501
|
-
width: 520,
|
|
502
|
-
maxHeight: 'min(80vh, 700px)',
|
|
503
|
-
display: 'flex',
|
|
504
|
-
flexDirection: 'column',
|
|
505
|
-
background: '#0f172a',
|
|
506
|
-
color: '#e2e8f0',
|
|
507
|
-
borderRadius: 8,
|
|
508
|
-
border: '1px solid #334155',
|
|
509
|
-
boxShadow: '0 20px 60px rgba(0,0,0,0.6)',
|
|
510
|
-
overflow: 'hidden',
|
|
511
|
-
}}
|
|
512
|
-
>
|
|
513
|
-
<div
|
|
514
|
-
style={{
|
|
515
|
-
padding: '14px 18px',
|
|
516
|
-
borderBottom: '1px solid #1e293b',
|
|
517
|
-
display: 'flex',
|
|
518
|
-
justifyContent: 'space-between',
|
|
519
|
-
alignItems: 'flex-start',
|
|
520
|
-
gap: 12,
|
|
521
|
-
}}
|
|
522
|
-
>
|
|
523
|
-
<div>
|
|
524
|
-
<div style={sectionLabelStyle}>Add to scope</div>
|
|
525
|
-
<div
|
|
526
|
-
style={{
|
|
527
|
-
fontFamily: 'monospace',
|
|
528
|
-
fontSize: 12,
|
|
529
|
-
color: '#94a3b8',
|
|
530
|
-
marginTop: 6,
|
|
531
|
-
wordBreak: 'break-all',
|
|
532
|
-
}}
|
|
533
|
-
>
|
|
534
|
-
{path}
|
|
535
|
-
</div>
|
|
536
|
-
</div>
|
|
537
|
-
<button
|
|
538
|
-
onClick={onClose}
|
|
539
|
-
style={{
|
|
540
|
-
background: 'transparent',
|
|
541
|
-
border: 'none',
|
|
542
|
-
color: '#64748b',
|
|
543
|
-
fontSize: 20,
|
|
544
|
-
cursor: 'pointer',
|
|
545
|
-
lineHeight: 1,
|
|
546
|
-
padding: 0,
|
|
547
|
-
}}
|
|
548
|
-
aria-label="Close"
|
|
549
|
-
>
|
|
550
|
-
×
|
|
551
|
-
</button>
|
|
552
|
-
</div>
|
|
553
|
-
|
|
554
|
-
<div
|
|
555
|
-
style={{
|
|
556
|
-
padding: '14px 18px',
|
|
557
|
-
borderBottom: '1px solid #1e293b',
|
|
558
|
-
display: 'flex',
|
|
559
|
-
flexDirection: 'column',
|
|
560
|
-
gap: 12,
|
|
561
|
-
}}
|
|
562
|
-
>
|
|
563
|
-
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
564
|
-
<span style={sectionLabelStyle}>Scope</span>
|
|
565
|
-
<input
|
|
566
|
-
type="text"
|
|
567
|
-
value={scopeId}
|
|
568
|
-
list="scope-id-options"
|
|
569
|
-
autoFocus
|
|
570
|
-
placeholder="e.g. principal-view.cli"
|
|
571
|
-
onChange={e => onScopeIdChange(e.target.value)}
|
|
572
|
-
onKeyDown={e => {
|
|
573
|
-
if (e.key === 'Enter' && canSubmit && !alreadyClaimed) onSubmit();
|
|
574
|
-
}}
|
|
575
|
-
style={{
|
|
576
|
-
padding: '8px 10px',
|
|
577
|
-
background: '#0b1220',
|
|
578
|
-
color: '#e2e8f0',
|
|
579
|
-
border: '1px solid #334155',
|
|
580
|
-
borderRadius: 4,
|
|
581
|
-
fontFamily: 'monospace',
|
|
582
|
-
fontSize: 13,
|
|
583
|
-
}}
|
|
584
|
-
/>
|
|
585
|
-
<datalist id="scope-id-options">
|
|
586
|
-
{scopes.map(s => (
|
|
587
|
-
<option key={s.id} value={s.id} />
|
|
588
|
-
))}
|
|
589
|
-
</datalist>
|
|
590
|
-
</label>
|
|
591
|
-
|
|
592
|
-
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
593
|
-
<span style={sectionLabelStyle}>Namespace (optional)</span>
|
|
594
|
-
<input
|
|
595
|
-
type="text"
|
|
596
|
-
value={namespaceName}
|
|
597
|
-
placeholder="leave blank to add to scope itself"
|
|
598
|
-
onChange={e => onNamespaceNameChange(e.target.value)}
|
|
599
|
-
onKeyDown={e => {
|
|
600
|
-
if (e.key === 'Enter' && canSubmit && !alreadyClaimed) onSubmit();
|
|
601
|
-
}}
|
|
602
|
-
style={{
|
|
603
|
-
padding: '8px 10px',
|
|
604
|
-
background: '#0b1220',
|
|
605
|
-
color: '#e2e8f0',
|
|
606
|
-
border: '1px solid #334155',
|
|
607
|
-
borderRadius: 4,
|
|
608
|
-
fontFamily: 'monospace',
|
|
609
|
-
fontSize: 13,
|
|
610
|
-
}}
|
|
611
|
-
/>
|
|
612
|
-
</label>
|
|
613
|
-
|
|
614
|
-
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
|
615
|
-
<button
|
|
616
|
-
onClick={onClose}
|
|
617
|
-
style={{
|
|
618
|
-
padding: '8px 14px',
|
|
619
|
-
background: 'transparent',
|
|
620
|
-
color: '#cbd5e1',
|
|
621
|
-
border: '1px solid #334155',
|
|
622
|
-
borderRadius: 4,
|
|
623
|
-
cursor: 'pointer',
|
|
624
|
-
fontSize: 13,
|
|
625
|
-
}}
|
|
626
|
-
>
|
|
627
|
-
Cancel
|
|
628
|
-
</button>
|
|
629
|
-
<button
|
|
630
|
-
onClick={onSubmit}
|
|
631
|
-
disabled={!canSubmit || alreadyClaimed}
|
|
632
|
-
style={{
|
|
633
|
-
padding: '8px 14px',
|
|
634
|
-
background: !canSubmit || alreadyClaimed ? '#1e293b' : '#3b82f6',
|
|
635
|
-
color: !canSubmit || alreadyClaimed ? '#475569' : '#ffffff',
|
|
636
|
-
border: '1px solid #334155',
|
|
637
|
-
borderRadius: 4,
|
|
638
|
-
cursor: !canSubmit || alreadyClaimed ? 'not-allowed' : 'pointer',
|
|
639
|
-
fontSize: 13,
|
|
640
|
-
fontWeight: 500,
|
|
641
|
-
}}
|
|
642
|
-
>
|
|
643
|
-
{actionLabel}
|
|
644
|
-
</button>
|
|
645
|
-
</div>
|
|
646
|
-
</div>
|
|
647
|
-
|
|
648
|
-
<div
|
|
649
|
-
style={{
|
|
650
|
-
padding: '14px 18px',
|
|
651
|
-
overflowY: 'auto',
|
|
652
|
-
flex: 1,
|
|
653
|
-
}}
|
|
654
|
-
>
|
|
655
|
-
<div style={sectionLabelStyle}>Existing scopes (click to prefill)</div>
|
|
656
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 8 }}>
|
|
657
|
-
{scopes.map(scope => (
|
|
658
|
-
<div key={scope.id}>
|
|
659
|
-
<div
|
|
660
|
-
style={{
|
|
661
|
-
fontFamily: 'monospace',
|
|
662
|
-
fontSize: 12,
|
|
663
|
-
color: '#cbd5e1',
|
|
664
|
-
marginBottom: 6,
|
|
665
|
-
}}
|
|
666
|
-
>
|
|
667
|
-
{scope.id}
|
|
668
|
-
</div>
|
|
669
|
-
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
|
670
|
-
<button
|
|
671
|
-
onClick={() => onPickExisting(scope.id, '')}
|
|
672
|
-
title={
|
|
673
|
-
scope.paths.includes(path)
|
|
674
|
-
? 'Scope already claims this path'
|
|
675
|
-
: 'Prefill (scope-level)'
|
|
676
|
-
}
|
|
677
|
-
style={{
|
|
678
|
-
fontSize: 11,
|
|
679
|
-
padding: '3px 7px',
|
|
680
|
-
background: scope.paths.includes(path) ? '#0f172a' : '#1e293b',
|
|
681
|
-
color: scope.paths.includes(path) ? '#475569' : '#cbd5e1',
|
|
682
|
-
border: '1px dashed #475569',
|
|
683
|
-
borderRadius: 3,
|
|
684
|
-
cursor: 'pointer',
|
|
685
|
-
display: 'flex',
|
|
686
|
-
alignItems: 'center',
|
|
687
|
-
gap: 5,
|
|
688
|
-
fontStyle: 'italic',
|
|
689
|
-
opacity: scope.paths.includes(path) ? 0.6 : 1,
|
|
690
|
-
}}
|
|
691
|
-
>
|
|
692
|
-
(scope-level)
|
|
693
|
-
{scope.paths.includes(path) && (
|
|
694
|
-
<span style={{ marginLeft: 4, fontSize: 9 }}>✓</span>
|
|
695
|
-
)}
|
|
696
|
-
</button>
|
|
697
|
-
{scope.namespaces.map(ns => {
|
|
698
|
-
const claims = ns.paths.includes(path);
|
|
699
|
-
return (
|
|
700
|
-
<button
|
|
701
|
-
key={ns.name}
|
|
702
|
-
onClick={() => onPickExisting(scope.id, ns.name)}
|
|
703
|
-
title={claims ? 'Already claims this path' : 'Prefill inputs'}
|
|
704
|
-
style={{
|
|
705
|
-
fontSize: 11,
|
|
706
|
-
padding: '3px 7px',
|
|
707
|
-
background: claims ? '#0f172a' : '#1e293b',
|
|
708
|
-
color: claims ? '#475569' : '#e2e8f0',
|
|
709
|
-
border: '1px solid #334155',
|
|
710
|
-
borderRadius: 3,
|
|
711
|
-
cursor: 'pointer',
|
|
712
|
-
display: 'flex',
|
|
713
|
-
alignItems: 'center',
|
|
714
|
-
gap: 5,
|
|
715
|
-
opacity: claims ? 0.6 : 1,
|
|
716
|
-
}}
|
|
717
|
-
>
|
|
718
|
-
<span
|
|
719
|
-
style={{
|
|
720
|
-
width: 8,
|
|
721
|
-
height: 8,
|
|
722
|
-
borderRadius: 2,
|
|
723
|
-
background: ns.color,
|
|
724
|
-
flexShrink: 0,
|
|
725
|
-
}}
|
|
726
|
-
/>
|
|
727
|
-
{ns.name}
|
|
728
|
-
{claims && <span style={{ marginLeft: 4, fontSize: 9 }}>✓</span>}
|
|
729
|
-
</button>
|
|
730
|
-
);
|
|
731
|
-
})}
|
|
732
|
-
</div>
|
|
733
|
-
</div>
|
|
734
|
-
))}
|
|
735
|
-
</div>
|
|
736
|
-
</div>
|
|
737
|
-
</div>
|
|
738
|
-
</div>
|
|
739
|
-
);
|
|
740
|
-
};
|
|
741
|
-
|
|
742
|
-
// ---------------------------------------------------------------------------
|
|
743
|
-
// Single-scope explorer
|
|
744
|
-
// ---------------------------------------------------------------------------
|
|
745
|
-
|
|
746
|
-
type AuditMode = 'off' | 'uncovered' | 'covered';
|
|
747
|
-
|
|
748
|
-
const ALL_BUILDINGS = (electronAppCityData as CityData).buildings;
|
|
749
|
-
|
|
750
|
-
/**
|
|
751
|
-
* The electron-app city is rooted at `electron-app/` in the JSON data, but
|
|
752
|
-
* scope/namespace paths are authored relative to the package root (matching
|
|
753
|
-
* how principal-view canvases are stored). These helpers convert between the
|
|
754
|
-
* two representations.
|
|
755
|
-
*/
|
|
756
|
-
const PACKAGE_ROOT = 'electron-app/';
|
|
757
|
-
|
|
758
|
-
function toScopePath(cityPath: string): string {
|
|
759
|
-
let p = cityPath.endsWith('/') ? cityPath.slice(0, -1) : cityPath;
|
|
760
|
-
if (p.startsWith(PACKAGE_ROOT)) p = p.slice(PACKAGE_ROOT.length);
|
|
761
|
-
return p;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
function toCityPath(scopePath: string): string {
|
|
765
|
-
return scopePath.startsWith(PACKAGE_ROOT) ? scopePath : PACKAGE_ROOT + scopePath;
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
const NAMESPACE_PALETTE = [
|
|
769
|
-
'#22c55e',
|
|
770
|
-
'#3b82f6',
|
|
771
|
-
'#f59e0b',
|
|
772
|
-
'#ec4899',
|
|
773
|
-
'#8b5cf6',
|
|
774
|
-
'#06b6d4',
|
|
775
|
-
'#ef4444',
|
|
776
|
-
'#14b8a6',
|
|
777
|
-
'#a855f7',
|
|
778
|
-
'#eab308',
|
|
779
|
-
];
|
|
780
|
-
|
|
781
|
-
function pickNamespaceColor(scopes: readonly MockScope[]): string {
|
|
782
|
-
const used = new Set(scopes.flatMap(s => s.namespaces.map(n => n.color)));
|
|
783
|
-
return NAMESPACE_PALETTE.find(c => !used.has(c)) ?? NAMESPACE_PALETTE[scopes.length % NAMESPACE_PALETTE.length];
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
/**
|
|
787
|
-
* Build highlight layers for a scope: one fill layer per namespace plus a
|
|
788
|
-
* border-only layer for scope-level paths. Priority is path depth (longest-
|
|
789
|
-
* prefix wins) per the partition convention in docs/scope-namespace-overlay.md.
|
|
790
|
-
*/
|
|
791
|
-
function buildLayersForScope(scope: MockScope): HighlightLayer[] {
|
|
792
|
-
const layers: HighlightLayer[] = scope.namespaces.map(ns => {
|
|
793
|
-
const maxDepth = Math.max(1, ...ns.paths.map(p => p.split('/').length));
|
|
794
|
-
return {
|
|
795
|
-
id: `${scope.id}::${ns.name}`,
|
|
796
|
-
name: ns.name,
|
|
797
|
-
enabled: true,
|
|
798
|
-
color: ns.color,
|
|
799
|
-
opacity: 0.55,
|
|
800
|
-
priority: maxDepth,
|
|
801
|
-
items: ns.paths.map(p => ({
|
|
802
|
-
path: toCityPath(p),
|
|
803
|
-
type: 'directory' as const,
|
|
804
|
-
renderStrategy: 'fill' as const,
|
|
805
|
-
})),
|
|
806
|
-
};
|
|
807
|
-
});
|
|
808
|
-
if (scope.paths.length > 0) {
|
|
809
|
-
layers.push({
|
|
810
|
-
id: `${scope.id}::__scope__`,
|
|
811
|
-
name: `${scope.id} (scope-level)`,
|
|
812
|
-
enabled: true,
|
|
813
|
-
color: '#64748b',
|
|
814
|
-
opacity: 0.4,
|
|
815
|
-
priority: 0,
|
|
816
|
-
items: scope.paths.map(p => ({
|
|
817
|
-
path: toCityPath(p),
|
|
818
|
-
type: 'directory' as const,
|
|
819
|
-
renderStrategy: 'fill' as const,
|
|
820
|
-
})),
|
|
821
|
-
});
|
|
822
|
-
}
|
|
823
|
-
return layers;
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
const SingleScopeTemplate: React.FC = () => {
|
|
827
|
-
const [scopes, setScopes] = React.useState<MockScope[]>(loadScopesFromStorage);
|
|
828
|
-
|
|
829
|
-
React.useEffect(() => {
|
|
830
|
-
saveScopesToStorage(scopes);
|
|
831
|
-
}, [scopes]);
|
|
832
|
-
const [focusDirectory, setFocusDirectory] = React.useState<string | null>(null);
|
|
833
|
-
const [scopeSelection, setScopeSelection] = React.useState<ScopeTreeSelection | null>(null);
|
|
834
|
-
const [auditMode, setAuditMode] = React.useState<AuditMode>('off');
|
|
835
|
-
const [showAddModal, setShowAddModal] = React.useState(false);
|
|
836
|
-
const [modalScopeId, setModalScopeId] = React.useState('');
|
|
837
|
-
const [modalNamespaceName, setModalNamespaceName] = React.useState('');
|
|
838
|
-
const [activeTab, setActiveTab] = React.useState<'files' | 'scopes'>('files');
|
|
839
|
-
|
|
840
|
-
// Coverage derived from current scope state.
|
|
841
|
-
const claimedPaths = React.useMemo(
|
|
842
|
-
() =>
|
|
843
|
-
Array.from(
|
|
844
|
-
new Set(
|
|
845
|
-
scopes.flatMap(s => [...s.paths, ...s.namespaces.flatMap(ns => ns.paths)]),
|
|
846
|
-
),
|
|
847
|
-
),
|
|
848
|
-
[scopes],
|
|
849
|
-
);
|
|
850
|
-
const isPathCovered = React.useCallback(
|
|
851
|
-
(cityPath: string) => {
|
|
852
|
-
const candidate = toScopePath(cityPath);
|
|
853
|
-
for (const claimed of claimedPaths) {
|
|
854
|
-
if (candidate === claimed || candidate.startsWith(claimed + '/')) return true;
|
|
855
|
-
}
|
|
856
|
-
return false;
|
|
857
|
-
},
|
|
858
|
-
[claimedPaths],
|
|
859
|
-
);
|
|
860
|
-
const { uncoveredFiles, coveredFiles } = React.useMemo(() => {
|
|
861
|
-
const u: typeof ALL_BUILDINGS = [];
|
|
862
|
-
const c: typeof ALL_BUILDINGS = [];
|
|
863
|
-
for (const b of ALL_BUILDINGS) (isPathCovered(b.path) ? c : u).push(b);
|
|
864
|
-
return { uncoveredFiles: u, coveredFiles: c };
|
|
865
|
-
}, [isPathCovered]);
|
|
866
|
-
|
|
867
|
-
const auditHighlightLayers = React.useMemo(() => {
|
|
868
|
-
if (auditMode === 'uncovered') return createFileColorHighlightLayers(uncoveredFiles);
|
|
869
|
-
if (auditMode === 'covered') return createFileColorHighlightLayers(coveredFiles);
|
|
870
|
-
return undefined;
|
|
871
|
-
}, [auditMode, uncoveredFiles, coveredFiles]);
|
|
872
|
-
|
|
873
|
-
const totalFiles = ALL_BUILDINGS.length;
|
|
874
|
-
const uncoveredCount = uncoveredFiles.length;
|
|
875
|
-
const coveredCount = coveredFiles.length;
|
|
876
|
-
|
|
877
|
-
const { model: treeModel } = useFileTree({
|
|
878
|
-
paths: ELECTRON_PATHS,
|
|
879
|
-
search: true,
|
|
880
|
-
initialExpandedPaths: ['electron-app', 'electron-app/src', 'electron-app/src/renderer'],
|
|
881
|
-
onSelectionChange: paths => {
|
|
882
|
-
const selected = paths[0];
|
|
883
|
-
if (!selected) {
|
|
884
|
-
setFocusDirectory(null);
|
|
885
|
-
return;
|
|
886
|
-
}
|
|
887
|
-
// Selecting a directory focuses the city on it; selecting a file focuses
|
|
888
|
-
// the file's parent directory (closest ancestor that exists as a district).
|
|
889
|
-
if (ELECTRON_DIRECTORIES.has(selected)) {
|
|
890
|
-
setFocusDirectory(selected);
|
|
891
|
-
return;
|
|
892
|
-
}
|
|
893
|
-
const parts = selected.split('/');
|
|
894
|
-
while (parts.length > 1) {
|
|
895
|
-
parts.pop();
|
|
896
|
-
const candidate = parts.join('/');
|
|
897
|
-
if (ELECTRON_DIRECTORIES.has(candidate)) {
|
|
898
|
-
setFocusDirectory(candidate);
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
setFocusDirectory(null);
|
|
903
|
-
},
|
|
904
|
-
});
|
|
905
|
-
const selectedPaths = useFileTreeSelection(treeModel);
|
|
906
|
-
|
|
907
|
-
// Filter the file tree by audit mode so the tree mirrors what's highlighted.
|
|
908
|
-
const filteredFilePaths = React.useMemo(() => {
|
|
909
|
-
if (auditMode === 'uncovered') return uncoveredFiles.map(b => b.path).sort();
|
|
910
|
-
if (auditMode === 'covered') return coveredFiles.map(b => b.path).sort();
|
|
911
|
-
return ELECTRON_PATHS;
|
|
912
|
-
}, [auditMode, uncoveredFiles, coveredFiles]);
|
|
913
|
-
|
|
914
|
-
const isFirstFileTreeSync = React.useRef(true);
|
|
915
|
-
React.useEffect(() => {
|
|
916
|
-
if (isFirstFileTreeSync.current) {
|
|
917
|
-
isFirstFileTreeSync.current = false;
|
|
918
|
-
return;
|
|
919
|
-
}
|
|
920
|
-
treeModel.resetPaths(filteredFilePaths);
|
|
921
|
-
}, [treeModel, filteredFilePaths]);
|
|
922
|
-
|
|
923
|
-
const scopeTreePaths = React.useMemo(() => buildScopeTreePaths(scopes), [scopes]);
|
|
924
|
-
const initialScopeTreePaths = React.useRef(scopeTreePaths);
|
|
925
|
-
const initialExpandedScopeIds = React.useRef(scopes.map(s => s.id));
|
|
926
|
-
const { model: scopeTreeModel } = useFileTree({
|
|
927
|
-
paths: initialScopeTreePaths.current,
|
|
928
|
-
search: true,
|
|
929
|
-
initialExpandedPaths: initialExpandedScopeIds.current,
|
|
930
|
-
onSelectionChange: paths => {
|
|
931
|
-
const selected = paths[0];
|
|
932
|
-
if (!selected) {
|
|
933
|
-
setScopeSelection(null);
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
const parsed = parseScopeTreePath(selected);
|
|
937
|
-
setScopeSelection(parsed);
|
|
938
|
-
|
|
939
|
-
// Selecting a namespace or event also focuses the city on the namespace's
|
|
940
|
-
// first declared path; selecting a bare scope clears the focus.
|
|
941
|
-
if (parsed.namespaceName) {
|
|
942
|
-
const scope = scopes.find(s => s.id === parsed.scopeId);
|
|
943
|
-
const ns = scope?.namespaces.find(n => n.name === parsed.namespaceName);
|
|
944
|
-
if (ns?.paths[0]) setFocusDirectory(toCityPath(ns.paths[0]));
|
|
945
|
-
} else {
|
|
946
|
-
setFocusDirectory(null);
|
|
947
|
-
}
|
|
948
|
-
},
|
|
949
|
-
});
|
|
950
|
-
|
|
951
|
-
// Keep the scope tree's paths in sync as scopes mutate (the model is created
|
|
952
|
-
// once; later option changes need resetPaths per @pierre/trees docs).
|
|
953
|
-
const isFirstScopeTreeSync = React.useRef(true);
|
|
954
|
-
const pendingExpand = React.useRef<string[]>([]);
|
|
955
|
-
React.useEffect(() => {
|
|
956
|
-
if (isFirstScopeTreeSync.current) {
|
|
957
|
-
isFirstScopeTreeSync.current = false;
|
|
958
|
-
return;
|
|
959
|
-
}
|
|
960
|
-
scopeTreeModel.resetPaths(scopeTreePaths);
|
|
961
|
-
for (const dirPath of pendingExpand.current) {
|
|
962
|
-
const item = scopeTreeModel.getItem(dirPath);
|
|
963
|
-
if (item && item.isDirectory()) item.expand();
|
|
964
|
-
}
|
|
965
|
-
pendingExpand.current = [];
|
|
966
|
-
}, [scopeTreeModel, scopeTreePaths]);
|
|
967
|
-
|
|
968
|
-
// Track which scope/namespace nodes are expanded in the scope tree. The
|
|
969
|
-
// city panels mirror this: a collapsed scope shows one umbrella tile, an
|
|
970
|
-
// expanded scope shows per-namespace tiles, and an expanded namespace
|
|
971
|
-
// hides its tile so the buildings underneath are visible.
|
|
972
|
-
const treeExpansion = useFileTreeSelector(
|
|
973
|
-
scopeTreeModel,
|
|
974
|
-
React.useCallback(
|
|
975
|
-
(model: FileTree) => {
|
|
976
|
-
const expandedScopes = new Set<string>();
|
|
977
|
-
const expandedNamespaces = new Set<string>();
|
|
978
|
-
for (const scope of scopes) {
|
|
979
|
-
const scopeItem = model.getItem(scope.id);
|
|
980
|
-
if (scopeItem?.isDirectory() && scopeItem.isExpanded()) {
|
|
981
|
-
expandedScopes.add(scope.id);
|
|
982
|
-
for (const ns of scope.namespaces) {
|
|
983
|
-
const nsKey = `${scope.id}/${ns.name}`;
|
|
984
|
-
const nsItem = model.getItem(nsKey);
|
|
985
|
-
if (nsItem?.isDirectory() && nsItem.isExpanded()) {
|
|
986
|
-
expandedNamespaces.add(nsKey);
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
return { expandedScopes, expandedNamespaces };
|
|
992
|
-
},
|
|
993
|
-
[scopes],
|
|
994
|
-
),
|
|
995
|
-
React.useCallback((prev, next) => {
|
|
996
|
-
if (prev.expandedScopes.size !== next.expandedScopes.size) return false;
|
|
997
|
-
for (const k of prev.expandedScopes) if (!next.expandedScopes.has(k)) return false;
|
|
998
|
-
if (prev.expandedNamespaces.size !== next.expandedNamespaces.size) return false;
|
|
999
|
-
for (const k of prev.expandedNamespaces) if (!next.expandedNamespaces.has(k)) return false;
|
|
1000
|
-
return true;
|
|
1001
|
-
}, []),
|
|
1002
|
-
);
|
|
1003
|
-
|
|
1004
|
-
// Resolve the current scope tree selection into the underlying objects.
|
|
1005
|
-
const scopeInfo = React.useMemo(() => {
|
|
1006
|
-
if (!scopeSelection) return null;
|
|
1007
|
-
const scope = scopes.find(s => s.id === scopeSelection.scopeId);
|
|
1008
|
-
if (!scope) return null;
|
|
1009
|
-
const ns = scopeSelection.namespaceName
|
|
1010
|
-
? scope.namespaces.find(n => n.name === scopeSelection.namespaceName) ?? null
|
|
1011
|
-
: null;
|
|
1012
|
-
const ev =
|
|
1013
|
-
ns && scopeSelection.eventAction
|
|
1014
|
-
? ns.events.find(e => e.action === scopeSelection.eventAction) ?? null
|
|
1015
|
-
: null;
|
|
1016
|
-
return { scope, ns, ev };
|
|
1017
|
-
}, [scopeSelection, scopes]);
|
|
1018
|
-
|
|
1019
|
-
const selectedFilePath = selectedPaths[0] ?? null;
|
|
1020
|
-
|
|
1021
|
-
// City highlight layers derive from the active tab:
|
|
1022
|
-
// files tab → audit layers (uncovered / covered / off)
|
|
1023
|
-
// scopes tab → selected scope's namespace fills (+ scope-level borders)
|
|
1024
|
-
const cityHighlightLayers = React.useMemo(() => {
|
|
1025
|
-
if (activeTab === 'scopes') {
|
|
1026
|
-
return scopeInfo ? buildLayersForScope(scopeInfo.scope) : undefined;
|
|
1027
|
-
}
|
|
1028
|
-
return auditHighlightLayers;
|
|
1029
|
-
}, [activeTab, scopeInfo, auditHighlightLayers]);
|
|
1030
|
-
|
|
1031
|
-
// Elevated scope panels — driven by the scope tree's expansion state.
|
|
1032
|
-
// - Collapsed scope → one gray umbrella tile per scope path.
|
|
1033
|
-
// - Expanded scope, collapsed namespace → colored tile per namespace path.
|
|
1034
|
-
// - Expanded namespace → no tile (buildings show through).
|
|
1035
|
-
const cityElevatedPanels = React.useMemo<ElevatedScopePanel[] | undefined>(() => {
|
|
1036
|
-
if (activeTab !== 'scopes') return undefined;
|
|
1037
|
-
const panels: ElevatedScopePanel[] = [];
|
|
1038
|
-
|
|
1039
|
-
for (const scope of scopes) {
|
|
1040
|
-
const isScopeExpanded = treeExpansion.expandedScopes.has(scope.id);
|
|
1041
|
-
|
|
1042
|
-
if (!isScopeExpanded) {
|
|
1043
|
-
const onClick = () => {
|
|
1044
|
-
const item = scopeTreeModel.getItem(scope.id);
|
|
1045
|
-
if (item?.isDirectory()) item.toggle();
|
|
1046
|
-
};
|
|
1047
|
-
for (const sp of scope.paths) {
|
|
1048
|
-
const district = ELECTRON_DISTRICTS_BY_PATH.get(toCityPath(sp));
|
|
1049
|
-
if (!district) continue;
|
|
1050
|
-
panels.push({
|
|
1051
|
-
id: `${scope.id}::scope::${sp}`,
|
|
1052
|
-
color: '#64748b',
|
|
1053
|
-
height: 4,
|
|
1054
|
-
thickness: 2,
|
|
1055
|
-
bounds: district.worldBounds,
|
|
1056
|
-
label: scope.id,
|
|
1057
|
-
onClick,
|
|
1058
|
-
});
|
|
1059
|
-
}
|
|
1060
|
-
continue;
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
for (const ns of scope.namespaces) {
|
|
1064
|
-
const nsKey = `${scope.id}/${ns.name}`;
|
|
1065
|
-
if (treeExpansion.expandedNamespaces.has(nsKey)) continue;
|
|
1066
|
-
|
|
1067
|
-
const onClick = () => {
|
|
1068
|
-
const item = scopeTreeModel.getItem(nsKey);
|
|
1069
|
-
if (item?.isDirectory()) item.toggle();
|
|
1070
|
-
};
|
|
1071
|
-
for (const np of ns.paths) {
|
|
1072
|
-
const district = ELECTRON_DISTRICTS_BY_PATH.get(toCityPath(np));
|
|
1073
|
-
if (!district) continue;
|
|
1074
|
-
panels.push({
|
|
1075
|
-
id: `${scope.id}::${ns.name}::${np}`,
|
|
1076
|
-
color: ns.color,
|
|
1077
|
-
height: 4,
|
|
1078
|
-
thickness: 2,
|
|
1079
|
-
bounds: district.worldBounds,
|
|
1080
|
-
label: ns.name,
|
|
1081
|
-
onClick,
|
|
1082
|
-
});
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
return panels.length > 0 ? panels : undefined;
|
|
1088
|
-
}, [activeTab, scopes, scopeTreeModel, treeExpansion]);
|
|
1089
|
-
|
|
1090
|
-
// Track which folders are expanded in the file tree. The file-tree tab's
|
|
1091
|
-
// elevated panels mirror this: a collapsed folder shows one umbrella tile
|
|
1092
|
-
// covering every descendant district; expanding the folder reveals its
|
|
1093
|
-
// sub-folder tiles (or the buildings themselves at the leaves).
|
|
1094
|
-
const folderTreeExpansion = useFileTreeSelector(
|
|
1095
|
-
treeModel,
|
|
1096
|
-
React.useCallback((model: FileTree) => {
|
|
1097
|
-
const expanded = new Set<string>();
|
|
1098
|
-
for (const dir of ELECTRON_DIRECTORIES) {
|
|
1099
|
-
const item = model.getItem(dir);
|
|
1100
|
-
if (item?.isDirectory() && item.isExpanded()) expanded.add(dir);
|
|
1101
|
-
}
|
|
1102
|
-
return { expanded };
|
|
1103
|
-
}, []),
|
|
1104
|
-
React.useCallback((prev, next) => {
|
|
1105
|
-
if (prev.expanded.size !== next.expanded.size) return false;
|
|
1106
|
-
for (const k of prev.expanded) if (!next.expanded.has(k)) return false;
|
|
1107
|
-
return true;
|
|
1108
|
-
}, []),
|
|
1109
|
-
);
|
|
1110
|
-
|
|
1111
|
-
// Elevated folder panels — driven by the file tree's expansion state.
|
|
1112
|
-
const folderElevatedPanels = React.useMemo<ElevatedScopePanel[] | undefined>(() => {
|
|
1113
|
-
if (activeTab !== 'files') return undefined;
|
|
1114
|
-
const panels = buildFolderElevatedPanels({
|
|
1115
|
-
cityData: electronAppCityData as CityData,
|
|
1116
|
-
expandedFolders: folderTreeExpansion.expanded,
|
|
1117
|
-
onToggleFolder: (folderPath) => {
|
|
1118
|
-
const item = treeModel.getItem(folderPath);
|
|
1119
|
-
if (item?.isDirectory()) item.toggle();
|
|
1120
|
-
},
|
|
1121
|
-
index: ELECTRON_FOLDER_INDEX,
|
|
1122
|
-
});
|
|
1123
|
-
return panels.length > 0 ? panels : undefined;
|
|
1124
|
-
}, [activeTab, treeModel, folderTreeExpansion]);
|
|
1125
|
-
|
|
1126
|
-
const openAddModal = React.useCallback((prefillScopeId?: string) => {
|
|
1127
|
-
setModalScopeId(prefillScopeId ?? '');
|
|
1128
|
-
setModalNamespaceName('');
|
|
1129
|
-
setShowAddModal(true);
|
|
1130
|
-
}, []);
|
|
1131
|
-
|
|
1132
|
-
// Scopes that already cover the selected file-tree path (via scope-level
|
|
1133
|
-
// paths or any namespace path).
|
|
1134
|
-
const coveringScopes = React.useMemo(() => {
|
|
1135
|
-
if (!selectedFilePath) return [] as MockScope[];
|
|
1136
|
-
const sp = toScopePath(selectedFilePath);
|
|
1137
|
-
return scopes.filter(scope => {
|
|
1138
|
-
if (scope.paths.some(p => sp === p || sp.startsWith(p + '/'))) return true;
|
|
1139
|
-
return scope.namespaces.some(ns =>
|
|
1140
|
-
ns.paths.some(p => sp === p || sp.startsWith(p + '/')),
|
|
1141
|
-
);
|
|
1142
|
-
});
|
|
1143
|
-
}, [selectedFilePath, scopes]);
|
|
1144
|
-
|
|
1145
|
-
const submitAddToScope = React.useCallback(() => {
|
|
1146
|
-
if (!selectedFilePath) return;
|
|
1147
|
-
const path = toScopePath(selectedFilePath);
|
|
1148
|
-
const scopeId = modalScopeId.trim();
|
|
1149
|
-
const namespaceName = modalNamespaceName.trim();
|
|
1150
|
-
if (!scopeId) return;
|
|
1151
|
-
|
|
1152
|
-
// Queue branches to auto-expand once the tree re-resets.
|
|
1153
|
-
pendingExpand.current = namespaceName ? [scopeId, `${scopeId}/${namespaceName}`] : [scopeId];
|
|
1154
|
-
|
|
1155
|
-
// Invariant: a scope's `paths` must cover every path claimed by any of
|
|
1156
|
-
// its namespaces. If `path` isn't already covered by scope.paths, we add
|
|
1157
|
-
// it.
|
|
1158
|
-
const ensureScopePathCovers = (scope: MockScope): MockScope => {
|
|
1159
|
-
const covered = scope.paths.some(p => path === p || path.startsWith(p + '/'));
|
|
1160
|
-
if (covered) return scope;
|
|
1161
|
-
return { ...scope, paths: [...scope.paths, path] };
|
|
1162
|
-
};
|
|
1163
|
-
|
|
1164
|
-
setScopes(prev => {
|
|
1165
|
-
const scopeIdx = prev.findIndex(s => s.id === scopeId);
|
|
1166
|
-
|
|
1167
|
-
// Existing scope.
|
|
1168
|
-
if (scopeIdx >= 0) {
|
|
1169
|
-
const scope = prev[scopeIdx];
|
|
1170
|
-
|
|
1171
|
-
// No namespace given → add to scope-level paths.
|
|
1172
|
-
if (!namespaceName) {
|
|
1173
|
-
if (scope.paths.includes(path)) return prev;
|
|
1174
|
-
const next = [...prev];
|
|
1175
|
-
next[scopeIdx] = { ...scope, paths: [...scope.paths, path] };
|
|
1176
|
-
return next;
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
const nsIdx = scope.namespaces.findIndex(n => n.name === namespaceName);
|
|
1180
|
-
|
|
1181
|
-
// Existing namespace — push the path if not already there.
|
|
1182
|
-
if (nsIdx >= 0) {
|
|
1183
|
-
const ns = scope.namespaces[nsIdx];
|
|
1184
|
-
if (ns.paths.includes(path)) return prev;
|
|
1185
|
-
const newNs = { ...ns, paths: [...ns.paths, path] };
|
|
1186
|
-
const newNamespaces = [...scope.namespaces];
|
|
1187
|
-
newNamespaces[nsIdx] = newNs;
|
|
1188
|
-
const next = [...prev];
|
|
1189
|
-
next[scopeIdx] = ensureScopePathCovers({ ...scope, namespaces: newNamespaces });
|
|
1190
|
-
return next;
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
// New namespace under existing scope.
|
|
1194
|
-
const newNs: MockNamespace = {
|
|
1195
|
-
name: namespaceName,
|
|
1196
|
-
description: '(new namespace)',
|
|
1197
|
-
color: pickNamespaceColor(prev),
|
|
1198
|
-
paths: [path],
|
|
1199
|
-
events: [],
|
|
1200
|
-
};
|
|
1201
|
-
const next = [...prev];
|
|
1202
|
-
next[scopeIdx] = ensureScopePathCovers({
|
|
1203
|
-
...scope,
|
|
1204
|
-
namespaces: [...scope.namespaces, newNs],
|
|
1205
|
-
});
|
|
1206
|
-
return next;
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
// Brand-new scope. Scope paths are required, so the path always seeds
|
|
1210
|
-
// scope.paths even when a namespace is also being created.
|
|
1211
|
-
if (!namespaceName) {
|
|
1212
|
-
return [
|
|
1213
|
-
...prev,
|
|
1214
|
-
{
|
|
1215
|
-
id: scopeId,
|
|
1216
|
-
name: scopeId,
|
|
1217
|
-
description: '(new scope)',
|
|
1218
|
-
paths: [path],
|
|
1219
|
-
namespaces: [],
|
|
1220
|
-
},
|
|
1221
|
-
];
|
|
1222
|
-
}
|
|
1223
|
-
const newNs: MockNamespace = {
|
|
1224
|
-
name: namespaceName,
|
|
1225
|
-
description: '(new namespace)',
|
|
1226
|
-
color: pickNamespaceColor(prev),
|
|
1227
|
-
paths: [path],
|
|
1228
|
-
events: [],
|
|
1229
|
-
};
|
|
1230
|
-
return [
|
|
1231
|
-
...prev,
|
|
1232
|
-
{
|
|
1233
|
-
id: scopeId,
|
|
1234
|
-
name: scopeId,
|
|
1235
|
-
description: '(new scope)',
|
|
1236
|
-
paths: [path],
|
|
1237
|
-
namespaces: [newNs],
|
|
1238
|
-
},
|
|
1239
|
-
];
|
|
1240
|
-
});
|
|
1241
|
-
|
|
1242
|
-
setShowAddModal(false);
|
|
1243
|
-
}, [selectedFilePath, modalScopeId, modalNamespaceName]);
|
|
1244
|
-
|
|
1245
|
-
return (
|
|
1246
|
-
<div style={{ height: '100vh', display: 'flex', background: '#0f172a' }}>
|
|
1247
|
-
{/* Left pane — tabbed: file tree | scopes tree */}
|
|
1248
|
-
<div
|
|
1249
|
-
style={{
|
|
1250
|
-
width: 320,
|
|
1251
|
-
flexShrink: 0,
|
|
1252
|
-
display: 'flex',
|
|
1253
|
-
flexDirection: 'column',
|
|
1254
|
-
borderRight: '1px solid #1e293b',
|
|
1255
|
-
background: '#0b1220',
|
|
1256
|
-
color: '#e2e8f0',
|
|
1257
|
-
fontFamily: 'system-ui, sans-serif',
|
|
1258
|
-
}}
|
|
1259
|
-
>
|
|
1260
|
-
{/* Tab strip */}
|
|
1261
|
-
<div
|
|
1262
|
-
style={{
|
|
1263
|
-
display: 'flex',
|
|
1264
|
-
borderBottom: '1px solid #1e293b',
|
|
1265
|
-
background: '#0f172a',
|
|
1266
|
-
}}
|
|
1267
|
-
>
|
|
1268
|
-
{(
|
|
1269
|
-
[
|
|
1270
|
-
{ id: 'files' as const, label: 'File tree', accent: '#3b82f6' },
|
|
1271
|
-
{ id: 'scopes' as const, label: 'Scopes', accent: '#a855f7' },
|
|
1272
|
-
]
|
|
1273
|
-
).map(tab => {
|
|
1274
|
-
const active = activeTab === tab.id;
|
|
1275
|
-
return (
|
|
1276
|
-
<button
|
|
1277
|
-
key={tab.id}
|
|
1278
|
-
onClick={() => setActiveTab(tab.id)}
|
|
1279
|
-
style={{
|
|
1280
|
-
flex: 1,
|
|
1281
|
-
padding: '10px 12px',
|
|
1282
|
-
background: active ? '#0b1220' : 'transparent',
|
|
1283
|
-
color: active ? '#e2e8f0' : '#64748b',
|
|
1284
|
-
border: 'none',
|
|
1285
|
-
borderBottom: `2px solid ${active ? tab.accent : 'transparent'}`,
|
|
1286
|
-
cursor: 'pointer',
|
|
1287
|
-
fontSize: 12,
|
|
1288
|
-
fontWeight: active ? 600 : 400,
|
|
1289
|
-
fontFamily: 'system-ui, sans-serif',
|
|
1290
|
-
}}
|
|
1291
|
-
>
|
|
1292
|
-
{tab.label}
|
|
1293
|
-
</button>
|
|
1294
|
-
);
|
|
1295
|
-
})}
|
|
1296
|
-
</div>
|
|
1297
|
-
|
|
1298
|
-
{activeTab === 'files' ? (
|
|
1299
|
-
<>
|
|
1300
|
-
<div
|
|
1301
|
-
style={{
|
|
1302
|
-
padding: '12px 16px',
|
|
1303
|
-
borderBottom: '1px solid #1e293b',
|
|
1304
|
-
fontSize: 11,
|
|
1305
|
-
color: '#64748b',
|
|
1306
|
-
textTransform: 'uppercase',
|
|
1307
|
-
letterSpacing: 0.5,
|
|
1308
|
-
}}
|
|
1309
|
-
>
|
|
1310
|
-
Selection
|
|
1311
|
-
<div
|
|
1312
|
-
style={{
|
|
1313
|
-
marginTop: 6,
|
|
1314
|
-
fontFamily: 'monospace',
|
|
1315
|
-
fontSize: 11,
|
|
1316
|
-
color: '#94a3b8',
|
|
1317
|
-
textTransform: 'none',
|
|
1318
|
-
letterSpacing: 0,
|
|
1319
|
-
minHeight: 14,
|
|
1320
|
-
wordBreak: 'break-all',
|
|
1321
|
-
}}
|
|
1322
|
-
>
|
|
1323
|
-
{selectedFilePath ?? '(no selection)'}
|
|
1324
|
-
</div>
|
|
1325
|
-
{selectedFilePath && (
|
|
1326
|
-
<div
|
|
1327
|
-
style={{
|
|
1328
|
-
marginTop: 8,
|
|
1329
|
-
textTransform: 'none',
|
|
1330
|
-
letterSpacing: 0,
|
|
1331
|
-
fontFamily: 'system-ui, sans-serif',
|
|
1332
|
-
}}
|
|
1333
|
-
>
|
|
1334
|
-
{coveringScopes.length > 0 && (
|
|
1335
|
-
<div
|
|
1336
|
-
style={{
|
|
1337
|
-
fontSize: 11,
|
|
1338
|
-
color: '#94a3b8',
|
|
1339
|
-
marginBottom: 6,
|
|
1340
|
-
}}
|
|
1341
|
-
>
|
|
1342
|
-
In scope:{' '}
|
|
1343
|
-
{coveringScopes.map((s, i) => (
|
|
1344
|
-
<React.Fragment key={s.id}>
|
|
1345
|
-
{i > 0 && ', '}
|
|
1346
|
-
<code style={{ color: '#cbd5e1' }}>{s.id}</code>
|
|
1347
|
-
</React.Fragment>
|
|
1348
|
-
))}
|
|
1349
|
-
</div>
|
|
1350
|
-
)}
|
|
1351
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
1352
|
-
{coveringScopes.map(scope => (
|
|
1353
|
-
<button
|
|
1354
|
-
key={scope.id}
|
|
1355
|
-
onClick={() => openAddModal(scope.id)}
|
|
1356
|
-
style={{
|
|
1357
|
-
fontSize: 11,
|
|
1358
|
-
padding: '4px 10px',
|
|
1359
|
-
background: '#1e293b',
|
|
1360
|
-
color: '#e2e8f0',
|
|
1361
|
-
border: '1px solid #334155',
|
|
1362
|
-
borderRadius: 4,
|
|
1363
|
-
cursor: 'pointer',
|
|
1364
|
-
textAlign: 'left',
|
|
1365
|
-
}}
|
|
1366
|
-
>
|
|
1367
|
-
+ Add event namespace to <code>{scope.id}</code>
|
|
1368
|
-
</button>
|
|
1369
|
-
))}
|
|
1370
|
-
<button
|
|
1371
|
-
onClick={() => openAddModal()}
|
|
1372
|
-
style={{
|
|
1373
|
-
fontSize: 11,
|
|
1374
|
-
padding: '4px 10px',
|
|
1375
|
-
background: coveringScopes.length === 0 ? '#1e293b' : 'transparent',
|
|
1376
|
-
color: coveringScopes.length === 0 ? '#e2e8f0' : '#94a3b8',
|
|
1377
|
-
border: '1px solid #334155',
|
|
1378
|
-
borderRadius: 4,
|
|
1379
|
-
cursor: 'pointer',
|
|
1380
|
-
textAlign: 'left',
|
|
1381
|
-
}}
|
|
1382
|
-
>
|
|
1383
|
-
{coveringScopes.length === 0
|
|
1384
|
-
? '+ Add to scope'
|
|
1385
|
-
: '+ Add to a different scope'}
|
|
1386
|
-
</button>
|
|
1387
|
-
</div>
|
|
1388
|
-
</div>
|
|
1389
|
-
)}
|
|
1390
|
-
</div>
|
|
1391
|
-
<div
|
|
1392
|
-
style={{
|
|
1393
|
-
padding: '10px 12px',
|
|
1394
|
-
borderBottom: '1px solid #1e293b',
|
|
1395
|
-
display: 'flex',
|
|
1396
|
-
flexDirection: 'column',
|
|
1397
|
-
gap: 6,
|
|
1398
|
-
}}
|
|
1399
|
-
>
|
|
1400
|
-
<div style={sectionLabelStyle}>Audit filter</div>
|
|
1401
|
-
<div
|
|
1402
|
-
style={{
|
|
1403
|
-
display: 'flex',
|
|
1404
|
-
border: '1px solid #334155',
|
|
1405
|
-
borderRadius: 4,
|
|
1406
|
-
overflow: 'hidden',
|
|
1407
|
-
fontFamily: 'system-ui, sans-serif',
|
|
1408
|
-
fontSize: 12,
|
|
1409
|
-
}}
|
|
1410
|
-
>
|
|
1411
|
-
{(
|
|
1412
|
-
[
|
|
1413
|
-
{ mode: 'off' as const, label: 'Off', count: totalFiles, accent: '#475569' },
|
|
1414
|
-
{
|
|
1415
|
-
mode: 'uncovered' as const,
|
|
1416
|
-
label: 'Uncovered',
|
|
1417
|
-
count: uncoveredCount,
|
|
1418
|
-
accent: '#dc2626',
|
|
1419
|
-
},
|
|
1420
|
-
{
|
|
1421
|
-
mode: 'covered' as const,
|
|
1422
|
-
label: 'Covered',
|
|
1423
|
-
count: coveredCount,
|
|
1424
|
-
accent: '#16a34a',
|
|
1425
|
-
},
|
|
1426
|
-
]
|
|
1427
|
-
).map(({ mode, label, count, accent }, i) => {
|
|
1428
|
-
const active = auditMode === mode;
|
|
1429
|
-
return (
|
|
1430
|
-
<button
|
|
1431
|
-
key={mode}
|
|
1432
|
-
onClick={() => setAuditMode(mode)}
|
|
1433
|
-
style={{
|
|
1434
|
-
flex: 1,
|
|
1435
|
-
padding: '6px 4px',
|
|
1436
|
-
background: active ? accent : 'transparent',
|
|
1437
|
-
border: 'none',
|
|
1438
|
-
borderLeft: i === 0 ? 'none' : '1px solid #334155',
|
|
1439
|
-
color: active ? '#ffffff' : '#cbd5e1',
|
|
1440
|
-
fontWeight: active ? 500 : 400,
|
|
1441
|
-
cursor: 'pointer',
|
|
1442
|
-
display: 'flex',
|
|
1443
|
-
alignItems: 'center',
|
|
1444
|
-
justifyContent: 'center',
|
|
1445
|
-
gap: 6,
|
|
1446
|
-
minWidth: 0,
|
|
1447
|
-
}}
|
|
1448
|
-
>
|
|
1449
|
-
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
|
1450
|
-
<span
|
|
1451
|
-
style={{
|
|
1452
|
-
fontSize: 10,
|
|
1453
|
-
color: active ? '#fef3c7' : '#64748b',
|
|
1454
|
-
fontWeight: 400,
|
|
1455
|
-
}}
|
|
1456
|
-
>
|
|
1457
|
-
{count}
|
|
1458
|
-
</span>
|
|
1459
|
-
</button>
|
|
1460
|
-
);
|
|
1461
|
-
})}
|
|
1462
|
-
</div>
|
|
1463
|
-
</div>
|
|
1464
|
-
<FileTree
|
|
1465
|
-
model={treeModel}
|
|
1466
|
-
style={
|
|
1467
|
-
{
|
|
1468
|
-
flex: 1,
|
|
1469
|
-
minHeight: 0,
|
|
1470
|
-
'--trees-theme-list-active-selection-bg':
|
|
1471
|
-
'color-mix(in oklab, #3b82f6 28%, transparent)',
|
|
1472
|
-
'--trees-theme-list-hover-bg':
|
|
1473
|
-
'color-mix(in oklab, #3b82f6 14%, transparent)',
|
|
1474
|
-
} as React.CSSProperties
|
|
1475
|
-
}
|
|
1476
|
-
/>
|
|
1477
|
-
</>
|
|
1478
|
-
) : (
|
|
1479
|
-
<>
|
|
1480
|
-
<div
|
|
1481
|
-
style={{
|
|
1482
|
-
padding: '12px 16px',
|
|
1483
|
-
borderBottom: '1px solid #1e293b',
|
|
1484
|
-
fontSize: 11,
|
|
1485
|
-
color: '#64748b',
|
|
1486
|
-
textTransform: 'uppercase',
|
|
1487
|
-
letterSpacing: 0.5,
|
|
1488
|
-
}}
|
|
1489
|
-
>
|
|
1490
|
-
Scopes / namespaces / events
|
|
1491
|
-
<div
|
|
1492
|
-
style={{
|
|
1493
|
-
marginTop: 6,
|
|
1494
|
-
fontSize: 11,
|
|
1495
|
-
color: '#94a3b8',
|
|
1496
|
-
textTransform: 'none',
|
|
1497
|
-
letterSpacing: 0,
|
|
1498
|
-
lineHeight: 1.4,
|
|
1499
|
-
}}
|
|
1500
|
-
>
|
|
1501
|
-
Selecting a scope highlights its namespace coverage on the map.
|
|
1502
|
-
</div>
|
|
1503
|
-
</div>
|
|
1504
|
-
<FileTree
|
|
1505
|
-
model={scopeTreeModel}
|
|
1506
|
-
style={
|
|
1507
|
-
{
|
|
1508
|
-
flex: 1,
|
|
1509
|
-
minHeight: 0,
|
|
1510
|
-
'--trees-theme-list-active-selection-bg':
|
|
1511
|
-
'color-mix(in oklab, #a855f7 28%, transparent)',
|
|
1512
|
-
'--trees-theme-list-hover-bg':
|
|
1513
|
-
'color-mix(in oklab, #a855f7 14%, transparent)',
|
|
1514
|
-
} as React.CSSProperties
|
|
1515
|
-
}
|
|
1516
|
-
/>
|
|
1517
|
-
</>
|
|
1518
|
-
)}
|
|
1519
|
-
</div>
|
|
1520
|
-
|
|
1521
|
-
{/* Right pane — city + scope panel */}
|
|
1522
|
-
<div style={{ flex: 1, position: 'relative', minWidth: 0 }}>
|
|
1523
|
-
<FileCity3D
|
|
1524
|
-
cityData={electronAppCityData as CityData}
|
|
1525
|
-
height="100%"
|
|
1526
|
-
width="100%"
|
|
1527
|
-
heightScaling="linear"
|
|
1528
|
-
linearScale={0.5}
|
|
1529
|
-
focusDirectory={focusDirectory}
|
|
1530
|
-
highlightLayers={cityHighlightLayers}
|
|
1531
|
-
elevatedScopePanels={cityElevatedPanels ?? folderElevatedPanels}
|
|
1532
|
-
animation={{
|
|
1533
|
-
startFlat: true,
|
|
1534
|
-
autoStartDelay: null,
|
|
1535
|
-
staggerDelay: 5,
|
|
1536
|
-
tension: 150,
|
|
1537
|
-
friction: 16,
|
|
1538
|
-
}}
|
|
1539
|
-
showControls={true}
|
|
1540
|
-
/>
|
|
1541
|
-
|
|
1542
|
-
{/* Debug overlay — current focusDirectory */}
|
|
1543
|
-
<div
|
|
1544
|
-
style={{
|
|
1545
|
-
position: 'absolute',
|
|
1546
|
-
top: 16,
|
|
1547
|
-
left: 16,
|
|
1548
|
-
padding: '8px 12px',
|
|
1549
|
-
background: 'rgba(15, 23, 42, 0.92)',
|
|
1550
|
-
border: '1px solid #334155',
|
|
1551
|
-
borderRadius: 6,
|
|
1552
|
-
color: '#e2e8f0',
|
|
1553
|
-
fontFamily: 'system-ui, sans-serif',
|
|
1554
|
-
fontSize: 12,
|
|
1555
|
-
zIndex: 100,
|
|
1556
|
-
maxWidth: 480,
|
|
1557
|
-
}}
|
|
1558
|
-
>
|
|
1559
|
-
<div style={sectionLabelStyle}>focusDirectory</div>
|
|
1560
|
-
<div
|
|
1561
|
-
style={{
|
|
1562
|
-
marginTop: 4,
|
|
1563
|
-
fontFamily: 'monospace',
|
|
1564
|
-
fontSize: 12,
|
|
1565
|
-
color: focusDirectory ? '#fde68a' : '#64748b',
|
|
1566
|
-
wordBreak: 'break-all',
|
|
1567
|
-
}}
|
|
1568
|
-
>
|
|
1569
|
-
{focusDirectory ?? '(null)'}
|
|
1570
|
-
</div>
|
|
1571
|
-
</div>
|
|
1572
|
-
|
|
1573
|
-
{/* Info overlay — driven by scope tree selection */}
|
|
1574
|
-
{scopeInfo && <ScopeInfoOverlay info={scopeInfo} />}
|
|
1575
|
-
</div>
|
|
1576
|
-
|
|
1577
|
-
{/* Add-to-scope modal */}
|
|
1578
|
-
{showAddModal && selectedFilePath && (
|
|
1579
|
-
<AddToScopeModal
|
|
1580
|
-
path={toScopePath(selectedFilePath)}
|
|
1581
|
-
scopes={scopes}
|
|
1582
|
-
scopeId={modalScopeId}
|
|
1583
|
-
namespaceName={modalNamespaceName}
|
|
1584
|
-
onScopeIdChange={setModalScopeId}
|
|
1585
|
-
onNamespaceNameChange={setModalNamespaceName}
|
|
1586
|
-
onPickExisting={(s, n) => {
|
|
1587
|
-
setModalScopeId(s);
|
|
1588
|
-
setModalNamespaceName(n);
|
|
1589
|
-
}}
|
|
1590
|
-
onSubmit={submitAddToScope}
|
|
1591
|
-
onClose={() => setShowAddModal(false)}
|
|
1592
|
-
/>
|
|
1593
|
-
)}
|
|
1594
|
-
</div>
|
|
1595
|
-
);
|
|
1596
|
-
};
|
|
1597
|
-
|
|
1598
|
-
export const SingleScope: Story = {
|
|
1599
|
-
render: () => <SingleScopeTemplate />,
|
|
1600
|
-
parameters: {
|
|
1601
|
-
docs: {
|
|
1602
|
-
description: {
|
|
1603
|
-
story:
|
|
1604
|
-
'Story 1 from docs/scope-namespace-overlay.md — apply one scope at a time over the ' +
|
|
1605
|
-
'electron-app city. Toggle namespaces in the legend, switch between scopes, and change ' +
|
|
1606
|
-
'how uncovered (uninstrumented) files render.',
|
|
1607
|
-
},
|
|
1608
|
-
},
|
|
1609
|
-
},
|
|
1610
|
-
};
|