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