@tldiagram/core-ui 1.95.1 → 2.0.1
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/api/client.d.ts +184 -3
- package/dist/components/ConnectorPanel.d.ts +5 -1
- package/dist/components/CrossBranchControls.d.ts +4 -3
- package/dist/components/ElementNode.d.ts +5 -0
- package/dist/components/ElementPanel.d.ts +6 -1
- package/dist/components/LayoutSection.d.ts +2 -1
- package/dist/components/MergeDialog.d.ts +16 -0
- package/dist/components/NodeContainer.d.ts +2 -0
- package/dist/components/ProxyConnectorPanel.d.ts +4 -1
- package/dist/components/ViewExplorer/index.d.ts +1 -1
- package/dist/components/ViewFloatingMenu-vscode.d.ts +5 -0
- package/dist/components/ViewFloatingMenu.d.ts +8 -1
- package/dist/components/ViewGridNode.d.ts +3 -0
- package/dist/components/ViewPanel.d.ts +2 -1
- package/dist/components/WorkspacePanel.d.ts +2 -0
- package/dist/components/ZUI/ZUICanvas.d.ts +4 -0
- package/dist/components/ZUI/focus.d.ts +32 -0
- package/dist/components/ZUI/focus.test.d.ts +1 -0
- package/dist/components/ZUI/layout.d.ts +2 -2
- package/dist/components/ZUI/proxy.d.ts +20 -4
- package/dist/components/ZUI/renderer.d.ts +35 -1
- package/dist/components/ZUI/types.d.ts +6 -0
- package/dist/components/ZUI/useZUIInteraction.d.ts +1 -0
- package/dist/context/WorkspaceVersionContext.d.ts +49 -0
- package/dist/crossBranch/resolve.d.ts +39 -2
- package/dist/crossBranch/resolve.test.d.ts +1 -0
- package/dist/crossBranch/settings.d.ts +6 -1
- package/dist/crossBranch/types.d.ts +8 -0
- package/dist/hooks/useElementSearch.d.ts +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +16529 -14030
- package/dist/pages/InfiniteZoom.d.ts +1 -0
- package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +6 -1
- package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +2 -0
- package/dist/pages/ViewEditor/hooks/useViewData.d.ts +4 -2
- package/dist/pages/ViewEditor/hooks/useViewEditHistory.d.ts +13 -0
- package/dist/pages/viewsJumpSearch.d.ts +22 -0
- package/dist/pages/viewsJumpSearch.test.d.ts +1 -0
- package/dist/store/useStore.d.ts +3 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/utils/elementIcon.d.ts +2 -0
- package/dist/utils/elementIcon.test.d.ts +1 -0
- package/dist/utils/sourceEditor.d.ts +7 -0
- package/dist/utils/watchDiffSummary.d.ts +34 -0
- package/package.json +2 -2
- package/src/App.tsx +12 -8
- package/src/api/client.ts +488 -26
- package/src/components/CodePreviewPanel.tsx +90 -16
- package/src/components/ConnectorPanel.tsx +34 -3
- package/src/components/ContextNeighborElement.tsx +2 -5
- package/src/components/CrossBranchControls.tsx +46 -17
- package/src/components/ElementNode.tsx +98 -47
- package/src/components/ElementPanel.tsx +62 -25
- package/src/components/InlineElementAdder.tsx +8 -3
- package/src/components/LayoutSection.tsx +4 -1
- package/src/components/MergeDialog.tsx +269 -0
- package/src/components/NodeContainer.tsx +55 -17
- package/src/components/ProxyConnectorPanel.tsx +58 -16
- package/src/components/ViewBezierConnector.tsx +116 -21
- package/src/components/ViewExplorer/index.tsx +1 -1
- package/src/components/ViewFloatingMenu-vscode.tsx +5 -0
- package/src/components/ViewFloatingMenu.tsx +110 -1
- package/src/components/ViewGridNode.tsx +59 -8
- package/src/components/ViewPanel.tsx +3 -2
- package/src/components/WorkspacePanel.tsx +938 -0
- package/src/components/ZUI/ZUICanvas.tsx +216 -122
- package/src/components/ZUI/focus.test.ts +534 -0
- package/src/components/ZUI/focus.ts +293 -0
- package/src/components/ZUI/layout.ts +7 -11
- package/src/components/ZUI/proxy.ts +470 -114
- package/src/components/ZUI/renderer.ts +510 -134
- package/src/components/ZUI/types.ts +6 -0
- package/src/components/ZUI/useZUIInteraction.ts +66 -29
- package/src/context/WorkspaceVersionContext.tsx +126 -0
- package/src/crossBranch/resolve.test.ts +342 -0
- package/src/crossBranch/resolve.ts +368 -68
- package/src/crossBranch/settings.ts +49 -3
- package/src/crossBranch/types.ts +9 -0
- package/src/hooks/useElementSearch.ts +45 -0
- package/src/index.css +11 -0
- package/src/index.ts +7 -0
- package/src/pages/AppearanceSettings.tsx +24 -1
- package/src/pages/Dependencies.tsx +231 -65
- package/src/pages/InfiniteZoom.tsx +41 -19
- package/src/pages/Settings.tsx +1 -1
- package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +103 -24
- package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +102 -6
- package/src/pages/ViewEditor/hooks/useViewData.ts +42 -26
- package/src/pages/ViewEditor/hooks/useViewEditHistory.ts +62 -0
- package/src/pages/ViewEditor/index.tsx +549 -59
- package/src/pages/Views.tsx +112 -41
- package/src/pages/ViewsGrid.tsx +332 -113
- package/src/pages/viewsJumpSearch.test.ts +193 -0
- package/src/pages/viewsJumpSearch.ts +111 -0
- package/src/store/useStore.ts +58 -0
- package/src/types/index.ts +10 -0
- package/src/utils/elementIcon.test.ts +28 -0
- package/src/utils/elementIcon.ts +20 -0
- package/src/utils/sourceEditor.ts +46 -0
- package/src/utils/watchDiffSummary.ts +159 -0
|
@@ -41,10 +41,12 @@ import type {
|
|
|
41
41
|
LibraryElement as WorkspaceElement,
|
|
42
42
|
Connector,
|
|
43
43
|
ViewConnector,
|
|
44
|
+
VisibilityOverride,
|
|
44
45
|
Tag,
|
|
45
46
|
} from '../../types'
|
|
46
47
|
import ElementNode from '../../components/ElementNode'
|
|
47
48
|
import ElementPanel from '../../components/ElementPanel'
|
|
49
|
+
import MergeDialog from '../../components/MergeDialog'
|
|
48
50
|
import CodePreviewPanel from '../../components/CodePreviewPanel'
|
|
49
51
|
import ConnectorPanel from '../../components/ConnectorPanel'
|
|
50
52
|
import ElementLibrary from '../../components/ElementLibrary'
|
|
@@ -74,6 +76,7 @@ import { ViewEditorContext } from './context'
|
|
|
74
76
|
import { useViewData } from './hooks/useViewData'
|
|
75
77
|
import { useDrawingEngine } from './hooks/useDrawingEngine'
|
|
76
78
|
import { applyNodeChangesWithStructuralSharing, useCanvasInteractions } from './hooks/useCanvasInteractions'
|
|
79
|
+
import { useViewEditHistory } from './hooks/useViewEditHistory'
|
|
77
80
|
import { connectorToConnector, findClosestHandles, sanitizeExportFilename, triggerDownload } from './utils'
|
|
78
81
|
import { pickUnusedColor } from '../../components/ViewExplorer/utils'
|
|
79
82
|
|
|
@@ -85,7 +88,9 @@ import { useCrossBranchContextSettings } from '../../crossBranch/settings'
|
|
|
85
88
|
import { removeConnectorGraphSnapshot, upsertConnectorGraphSnapshot, useWorkspaceGraphSnapshot } from '../../crossBranch/store'
|
|
86
89
|
import type { ProxyConnectorDetails } from '../../crossBranch/types'
|
|
87
90
|
import { useDemoRevealViewport, type ViewEditorDemoOptions } from '../../demo/viewEditor'
|
|
88
|
-
import { buildElementLibraryItems, useStore } from '../../store/useStore'
|
|
91
|
+
import { buildElementLibraryItems, useStore, placedElementToLibraryElement } from '../../store/useStore'
|
|
92
|
+
import { useWorkspaceVersionPreview } from '../../context/WorkspaceVersionContext'
|
|
93
|
+
import { WATCH_REPRESENTATION_UPDATED_EVENT } from '../../components/WorkspacePanel'
|
|
89
94
|
|
|
90
95
|
const nodeTypes = {
|
|
91
96
|
elementNode: ElementNode,
|
|
@@ -101,6 +106,59 @@ const VIEW_EDITOR_PAN_MARGIN_MIN = 180
|
|
|
101
106
|
const VIEW_EDITOR_PAN_MARGIN_MAX = 720
|
|
102
107
|
const SNAP_GRID: [number, number] = [30, 30]
|
|
103
108
|
|
|
109
|
+
type ViewMetadataSnapshot = Pick<ViewTreeNode, 'id' | 'name' | 'level_label'>
|
|
110
|
+
|
|
111
|
+
function elementUpdatePayload(element: WorkspaceElement) {
|
|
112
|
+
return {
|
|
113
|
+
name: element.name,
|
|
114
|
+
description: element.description ?? '',
|
|
115
|
+
kind: element.kind ?? '',
|
|
116
|
+
technology: element.technology ?? '',
|
|
117
|
+
url: element.url ?? '',
|
|
118
|
+
logo_url: element.logo_url ?? '',
|
|
119
|
+
technology_connectors: element.technology_connectors ?? [],
|
|
120
|
+
tags: element.tags ?? [],
|
|
121
|
+
repo: element.repo,
|
|
122
|
+
branch: element.branch,
|
|
123
|
+
file_path: element.file_path,
|
|
124
|
+
language: element.language,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function connectorUpdatePayload(connector: Connector) {
|
|
129
|
+
return {
|
|
130
|
+
source_element_id: connector.source_element_id,
|
|
131
|
+
target_element_id: connector.target_element_id,
|
|
132
|
+
label: connector.label ?? '',
|
|
133
|
+
description: connector.description ?? '',
|
|
134
|
+
relationship: connector.relationship ?? '',
|
|
135
|
+
direction: connector.direction,
|
|
136
|
+
style: connector.style === 'default' ? 'bezier' : connector.style,
|
|
137
|
+
url: connector.url ?? '',
|
|
138
|
+
source_handle: connector.source_handle,
|
|
139
|
+
target_handle: connector.target_handle,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function connectorSnapshotsEqual(left: Connector, right: Connector) {
|
|
144
|
+
return JSON.stringify(connectorUpdatePayload(left)) === JSON.stringify(connectorUpdatePayload(right))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function elementSnapshotsEqual(left: WorkspaceElement, right: WorkspaceElement) {
|
|
148
|
+
return JSON.stringify(elementUpdatePayload(left)) === JSON.stringify(elementUpdatePayload(right))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function placementSnapshotsEqual(left: PlacedElement, right: PlacedElement) {
|
|
152
|
+
return left.view_id === right.view_id &&
|
|
153
|
+
left.element_id === right.element_id &&
|
|
154
|
+
Math.abs(left.position_x - right.position_x) < 0.5 &&
|
|
155
|
+
Math.abs(left.position_y - right.position_y) < 0.5
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function viewSnapshotsEqual(left: ViewMetadataSnapshot, right: ViewMetadataSnapshot) {
|
|
159
|
+
return left.id === right.id && left.name === right.name && (left.level_label ?? '') === (right.level_label ?? '')
|
|
160
|
+
}
|
|
161
|
+
|
|
104
162
|
function alphaColor(color: string, opacity: number): string {
|
|
105
163
|
if (opacity >= 1) return color
|
|
106
164
|
return `color-mix(in srgb, ${color} ${Math.round(opacity * 100)}%, transparent)`
|
|
@@ -127,6 +185,10 @@ function areTranslateExtentsEqual(
|
|
|
127
185
|
left[1][1] === right[1][1]
|
|
128
186
|
}
|
|
129
187
|
|
|
188
|
+
function canonicalNodePairKey(leftId: string, rightId: string) {
|
|
189
|
+
return leftId <= rightId ? `${leftId}::${rightId}` : `${rightId}::${leftId}`
|
|
190
|
+
}
|
|
191
|
+
|
|
130
192
|
|
|
131
193
|
|
|
132
194
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -154,12 +216,23 @@ function ViewEditorInner({
|
|
|
154
216
|
navigateRef.current = navigate
|
|
155
217
|
|
|
156
218
|
const toast = useToast()
|
|
219
|
+
const {
|
|
220
|
+
canUndo: canUndoViewEdit,
|
|
221
|
+
canRedo: canRedoViewEdit,
|
|
222
|
+
isApplyingHistory,
|
|
223
|
+
pushAction: pushEditAction,
|
|
224
|
+
clearHistory: clearEditHistory,
|
|
225
|
+
undo: undoViewEdit,
|
|
226
|
+
redo: redoViewEdit,
|
|
227
|
+
} = useViewEditHistory()
|
|
157
228
|
const canEdit = true
|
|
158
229
|
const isOwner = true
|
|
159
230
|
const isFreePlan = false
|
|
160
231
|
|
|
161
232
|
const setHeader = useSetHeader()
|
|
162
233
|
const isMobileLayout = useBreakpointValue({ base: true, md: false }) ?? false
|
|
234
|
+
const [densityLevel, setDensityLevel] = useState(0)
|
|
235
|
+
const [visibilityOverrides, setVisibilityOverrides] = useState<VisibilityOverride[]>([])
|
|
163
236
|
|
|
164
237
|
const elementPanel = useDisclosure()
|
|
165
238
|
const connectorPanel = useDisclosure()
|
|
@@ -168,6 +241,26 @@ function ViewEditorInner({
|
|
|
168
241
|
const exportModal = useDisclosure()
|
|
169
242
|
const importModal = useDisclosure()
|
|
170
243
|
const codePreview = useDisclosure()
|
|
244
|
+
const mergeDialog = useDisclosure()
|
|
245
|
+
const [mergeSourceElement, setMergeSourceElement] = useState<WorkspaceElement | null>(null)
|
|
246
|
+
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
if (viewId == null) {
|
|
249
|
+
setDensityLevel(0)
|
|
250
|
+
setVisibilityOverrides([])
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
let cancelled = false
|
|
254
|
+
void Promise.all([
|
|
255
|
+
api.workspace.views.density.get(viewId).catch(() => 0),
|
|
256
|
+
api.workspace.views.visibilityOverrides.list(viewId).catch(() => []),
|
|
257
|
+
]).then(([level, overrides]) => {
|
|
258
|
+
if (cancelled) return
|
|
259
|
+
setDensityLevel(level)
|
|
260
|
+
setVisibilityOverrides(overrides)
|
|
261
|
+
})
|
|
262
|
+
return () => { cancelled = true }
|
|
263
|
+
}, [viewId])
|
|
171
264
|
|
|
172
265
|
// ── Stable disclosure refs ──────────────────────────────────────────────
|
|
173
266
|
const openElementPanelRef = useRef(elementPanel.onOpen)
|
|
@@ -204,6 +297,14 @@ function ViewEditorInner({
|
|
|
204
297
|
const [selectedElement, setSelectedElement] = useState<WorkspaceElement | null>(null)
|
|
205
298
|
const [selectedEdge, setSelectedEdge] = useState<Connector | null>(null)
|
|
206
299
|
const [selectedProxyConnectorDetails, setSelectedProxyConnectorDetails] = useState<ProxyConnectorDetails | null>(null)
|
|
300
|
+
|
|
301
|
+
const [prevViewId, setPrevViewId] = useState(viewId)
|
|
302
|
+
if (viewId !== prevViewId) {
|
|
303
|
+
setPrevViewId(viewId)
|
|
304
|
+
setSelectedElement(null)
|
|
305
|
+
setSelectedEdge(null)
|
|
306
|
+
setSelectedProxyConnectorDetails(null)
|
|
307
|
+
}
|
|
207
308
|
const [previewElement, setPreviewElement] = useState<PlacedElement | null>(null)
|
|
208
309
|
const [libraryOpen, setLibraryOpen] = useState(() => {
|
|
209
310
|
if (typeof window === 'undefined') return false
|
|
@@ -226,7 +327,8 @@ function ViewEditorInner({
|
|
|
226
327
|
const setStoreSnapToGrid = useStore((state) => state.setSnapToGrid)
|
|
227
328
|
const upsertStoreConnector = useStore((state) => state.upsertConnector)
|
|
228
329
|
const removeStoreConnector = useStore((state) => state.removeConnector)
|
|
229
|
-
const
|
|
330
|
+
const mergeElementsInto = useStore((state) => state.mergeElementsInto)
|
|
331
|
+
const refreshElementsRef = useRef<() => Promise<void>>(async () => { })
|
|
230
332
|
const setSnapToGrid = useCallback((snap: boolean) => {
|
|
231
333
|
setStoreSnapToGrid(snap)
|
|
232
334
|
if (typeof window !== 'undefined') localStorage.setItem('diag:snapToGrid', String(snap))
|
|
@@ -257,6 +359,7 @@ function ViewEditorInner({
|
|
|
257
359
|
const [activeTags, setActiveTags] = useState<string[]>([])
|
|
258
360
|
const activeTagsRef = useRef<string[]>([])
|
|
259
361
|
activeTagsRef.current = activeTags
|
|
362
|
+
const { preview: versionPreview, followTarget: versionFollowTarget } = useWorkspaceVersionPreview()
|
|
260
363
|
const [tagColors, setTagColors] = useState<Record<string, Tag>>({})
|
|
261
364
|
|
|
262
365
|
useEffect(() => {
|
|
@@ -291,11 +394,12 @@ function ViewEditorInner({
|
|
|
291
394
|
if (viewId === null) return
|
|
292
395
|
try {
|
|
293
396
|
const layer = await api.workspace.views.layers.create(viewId, { name, tags, color })
|
|
397
|
+
clearEditHistory()
|
|
294
398
|
setLayers(prev => [...prev, layer])
|
|
295
399
|
} catch (e) {
|
|
296
400
|
toast({ status: 'error', title: 'Failed to create layer', description: String(e) })
|
|
297
401
|
}
|
|
298
|
-
}, [viewId, toast])
|
|
402
|
+
}, [clearEditHistory, viewId, toast])
|
|
299
403
|
|
|
300
404
|
const handleCreateTag = useCallback(async (tag: string, color?: string, description?: string) => {
|
|
301
405
|
const name = tag.trim()
|
|
@@ -304,28 +408,32 @@ function ViewEditorInner({
|
|
|
304
408
|
const nextColor = color ?? tagColors[name]?.color ?? pickUnusedColor(Object.values(tagColors).map(t => t.color))
|
|
305
409
|
const nextDescription = description ?? tagColors[name]?.description ?? null
|
|
306
410
|
|
|
411
|
+
await api.workspace.orgs.tagColors.update(name, nextColor, nextDescription)
|
|
412
|
+
clearEditHistory()
|
|
307
413
|
setTagColors((prev) => ({ ...prev, [name]: { name, color: nextColor, description: nextDescription } }))
|
|
308
|
-
}, [tagColors])
|
|
414
|
+
}, [clearEditHistory, tagColors])
|
|
309
415
|
|
|
310
416
|
const handleUpdateLayer = useCallback(async (layer: import('../../types').ViewLayer) => {
|
|
311
417
|
if (viewId === null) return
|
|
312
418
|
try {
|
|
313
419
|
const updated = await api.workspace.views.layers.update(viewId, layer.id, layer)
|
|
420
|
+
clearEditHistory()
|
|
314
421
|
setLayers(prev => prev.map(l => l.id === updated.id ? updated : l))
|
|
315
422
|
} catch (e) {
|
|
316
423
|
toast({ status: 'error', title: 'Failed to update layer', description: String(e) })
|
|
317
424
|
}
|
|
318
|
-
}, [viewId, toast])
|
|
425
|
+
}, [clearEditHistory, viewId, toast])
|
|
319
426
|
|
|
320
427
|
const handleDeleteLayer = useCallback(async (layerId: number) => {
|
|
321
428
|
if (viewId === null) return
|
|
322
429
|
try {
|
|
323
430
|
await api.workspace.views.layers.delete(viewId, layerId)
|
|
431
|
+
clearEditHistory()
|
|
324
432
|
setLayers(prev => prev.filter(l => l.id !== layerId))
|
|
325
433
|
} catch (e) {
|
|
326
434
|
toast({ status: 'error', title: 'Failed to delete layer', description: String(e) })
|
|
327
435
|
}
|
|
328
|
-
}, [viewId, toast])
|
|
436
|
+
}, [clearEditHistory, viewId, toast])
|
|
329
437
|
|
|
330
438
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
331
439
|
const drawingCanvasRef = useRef<DrawingCanvasHandle | null>(null)
|
|
@@ -384,6 +492,8 @@ function ViewEditorInner({
|
|
|
384
492
|
hoveredLayerTags,
|
|
385
493
|
hoveredLayerColor,
|
|
386
494
|
tagColors,
|
|
495
|
+
versionPreview,
|
|
496
|
+
versionFollowTarget,
|
|
387
497
|
stableOnZoomIn: useCallback(async (id: number) => { await stableOnZoomInRef.current(id) }, []),
|
|
388
498
|
stableOnZoomOut: useCallback(async (id: number) => { await stableOnZoomOutRef.current(id) }, []),
|
|
389
499
|
stableOnNavigateToView: useCallback((id: number) => { stableOnNavigateToViewRef.current(id) }, []),
|
|
@@ -435,11 +545,78 @@ function ViewEditorInner({
|
|
|
435
545
|
viewElementsRef, linksMapRef, parentLinksMapRef, incomingLinksRef,
|
|
436
546
|
treeDataRef, rfNodesRef, rfEdgesRef, viewIdRef,
|
|
437
547
|
refreshGrid, refreshElements,
|
|
438
|
-
handleElementDeleted, handleElementPermanentlyDeleted, handleElementSaved,
|
|
439
|
-
setAllElements: _setAllElements,
|
|
548
|
+
handleElementDeleted, handleElementPermanentlyDeleted, handleElementSaved: applyElementSaved,
|
|
440
549
|
} = data
|
|
441
550
|
refreshElementsRef.current = refreshElements
|
|
442
551
|
|
|
552
|
+
const overrideDeltaFor = useCallback((resourceType: VisibilityOverride['resource_type'], resourceId?: number | null) => {
|
|
553
|
+
if (resourceId == null) return 0
|
|
554
|
+
return visibilityOverrides.find((override) => override.resource_type === resourceType && override.resource_id === resourceId)?.level_delta ?? 0
|
|
555
|
+
}, [visibilityOverrides])
|
|
556
|
+
|
|
557
|
+
const reloadVisibilityOverrides = useCallback(async () => {
|
|
558
|
+
if (viewId == null) return
|
|
559
|
+
const overrides = await api.workspace.views.visibilityOverrides.list(viewId).catch(() => [])
|
|
560
|
+
setVisibilityOverrides(overrides)
|
|
561
|
+
}, [viewId])
|
|
562
|
+
|
|
563
|
+
const handleDensityLevelChange = useCallback(async (level: number) => {
|
|
564
|
+
if (viewId == null) return
|
|
565
|
+
setDensityLevel(level)
|
|
566
|
+
try {
|
|
567
|
+
await api.workspace.views.density.set(viewId, level)
|
|
568
|
+
clearEditHistory()
|
|
569
|
+
await refreshElements()
|
|
570
|
+
} catch {
|
|
571
|
+
toast({ status: 'error', title: 'Density was not saved' })
|
|
572
|
+
}
|
|
573
|
+
}, [clearEditHistory, refreshElements, toast, viewId])
|
|
574
|
+
|
|
575
|
+
const handleVisibilityOverride = useCallback(async (resourceType: VisibilityOverride['resource_type'], resourceId: number, action: 'promote' | 'demote' | 'reset') => {
|
|
576
|
+
if (viewId == null) return
|
|
577
|
+
try {
|
|
578
|
+
if (action === 'promote') await api.workspace.views.visibilityOverrides.promote(viewId, resourceType, resourceId)
|
|
579
|
+
else if (action === 'demote') await api.workspace.views.visibilityOverrides.demote(viewId, resourceType, resourceId)
|
|
580
|
+
else await api.workspace.views.visibilityOverrides.reset(viewId, resourceType, resourceId)
|
|
581
|
+
clearEditHistory()
|
|
582
|
+
await reloadVisibilityOverrides()
|
|
583
|
+
await refreshElements()
|
|
584
|
+
} catch {
|
|
585
|
+
toast({ status: 'error', title: 'Visibility override was not saved' })
|
|
586
|
+
}
|
|
587
|
+
}, [clearEditHistory, refreshElements, reloadVisibilityOverrides, toast, viewId])
|
|
588
|
+
|
|
589
|
+
const resolveWatchRepositoryId = useCallback(async () => {
|
|
590
|
+
const status = await api.watch.status().catch(() => null)
|
|
591
|
+
if (status?.repository?.id) return status.repository.id
|
|
592
|
+
const repositories = await api.watch.repositories().catch(() => [])
|
|
593
|
+
return repositories[0]?.id ?? null
|
|
594
|
+
}, [])
|
|
595
|
+
|
|
596
|
+
const applyWatchContextAction = useCallback(async (action: 'clean', resourceType: 'element' | 'view', resourceId: number) => {
|
|
597
|
+
const repositoryId = await resolveWatchRepositoryId()
|
|
598
|
+
if (!repositoryId) {
|
|
599
|
+
toast({ status: 'warning', title: 'No watch repository found' })
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
const result = await api.watch.cleanContext(repositoryId, { resource_type: resourceType, resource_id: resourceId })
|
|
604
|
+
clearEditHistory()
|
|
605
|
+
await refreshGrid()
|
|
606
|
+
await refreshElements()
|
|
607
|
+
window.dispatchEvent(new CustomEvent(WATCH_REPRESENTATION_UPDATED_EVENT, {
|
|
608
|
+
detail: { type: 'representation.updated', repository_id: repositoryId, at: new Date().toISOString(), data: result.summary },
|
|
609
|
+
}))
|
|
610
|
+
toast({
|
|
611
|
+
status: 'success',
|
|
612
|
+
title: 'Noise cleaned',
|
|
613
|
+
description: `${result.elements_removed + result.connectors_removed + result.views_removed} generated item${result.elements_removed + result.connectors_removed + result.views_removed === 1 ? '' : 's'} removed. Tier ${result.tier_after}/${result.max_tier}.`,
|
|
614
|
+
})
|
|
615
|
+
} catch (err) {
|
|
616
|
+
toast({ status: 'error', title: 'Failed to clean noise', description: String(err) })
|
|
617
|
+
}
|
|
618
|
+
}, [clearEditHistory, refreshElements, refreshGrid, resolveWatchRepositoryId, toast])
|
|
619
|
+
|
|
443
620
|
const tagCounts = useMemo(() => {
|
|
444
621
|
const counts: Record<string, number> = {}
|
|
445
622
|
viewElements.forEach(p => {
|
|
@@ -510,10 +687,11 @@ function ViewEditorInner({
|
|
|
510
687
|
|
|
511
688
|
const availableTags = useMemo(() => {
|
|
512
689
|
const tags = new Set<string>()
|
|
690
|
+
viewElements.forEach((o) => o.tags?.forEach((t: string) => tags.add(t)))
|
|
513
691
|
allElements.forEach((o) => o.tags?.forEach((t: string) => tags.add(t)))
|
|
514
692
|
Object.keys(tagColors).forEach((t) => tags.add(t))
|
|
515
693
|
return Array.from(tags).sort((a, b) => a.localeCompare(b))
|
|
516
|
-
}, [allElements, tagColors])
|
|
694
|
+
}, [allElements, tagColors, viewElements])
|
|
517
695
|
|
|
518
696
|
const effectiveWorkspaceSnapshot = useMemo(() => {
|
|
519
697
|
if (viewId == null) return workspaceGraphSnapshot
|
|
@@ -568,33 +746,213 @@ function ViewEditorInner({
|
|
|
568
746
|
|
|
569
747
|
previewViewElementsRef.current = viewElements
|
|
570
748
|
|
|
749
|
+
const handleUnsupportedMutation = useCallback(() => {
|
|
750
|
+
clearEditHistory()
|
|
751
|
+
}, [clearEditHistory])
|
|
752
|
+
|
|
753
|
+
const pushViewEditAction = useCallback((before: ViewMetadataSnapshot, after: ViewMetadataSnapshot) => {
|
|
754
|
+
if (viewSnapshotsEqual(before, after)) return
|
|
755
|
+
pushEditAction({
|
|
756
|
+
undo: async () => {
|
|
757
|
+
const updated = await api.workspace.views.update(before.id, { name: before.name, label: before.level_label ?? '' })
|
|
758
|
+
if (view && view.id === updated.id) setView({ ...view, name: updated.name, level_label: updated.label })
|
|
759
|
+
await refreshGrid()
|
|
760
|
+
},
|
|
761
|
+
redo: async () => {
|
|
762
|
+
const updated = await api.workspace.views.update(after.id, { name: after.name, label: after.level_label ?? '' })
|
|
763
|
+
if (view && view.id === updated.id) setView({ ...view, name: updated.name, level_label: updated.label })
|
|
764
|
+
await refreshGrid()
|
|
765
|
+
},
|
|
766
|
+
})
|
|
767
|
+
}, [pushEditAction, refreshGrid, setView, view])
|
|
768
|
+
|
|
769
|
+
const pushElementEditAction = useCallback((before: WorkspaceElement, after: WorkspaceElement) => {
|
|
770
|
+
if (elementSnapshotsEqual(before, after)) return
|
|
771
|
+
pushEditAction({
|
|
772
|
+
undo: async () => {
|
|
773
|
+
const saved = await api.elements.update(before.id, elementUpdatePayload(before))
|
|
774
|
+
applyElementSaved(saved)
|
|
775
|
+
setSelectedElement((current) => current?.id === saved.id ? saved : current)
|
|
776
|
+
await refreshElements()
|
|
777
|
+
},
|
|
778
|
+
redo: async () => {
|
|
779
|
+
const saved = await api.elements.update(after.id, elementUpdatePayload(after))
|
|
780
|
+
applyElementSaved(saved)
|
|
781
|
+
setSelectedElement((current) => current?.id === saved.id ? saved : current)
|
|
782
|
+
await refreshElements()
|
|
783
|
+
},
|
|
784
|
+
})
|
|
785
|
+
}, [applyElementSaved, pushEditAction, refreshElements])
|
|
786
|
+
|
|
571
787
|
const handleUpdateTags = useCallback(async (elementId: number, tags: string[]) => {
|
|
572
788
|
if (!canEdit) return
|
|
573
789
|
const obj = selectedElement?.id === elementId ? selectedElement : allElements.find(o => o.id === elementId)
|
|
574
790
|
if (!obj) return
|
|
575
791
|
try {
|
|
576
792
|
const saved = await api.elements.update(elementId, {
|
|
577
|
-
|
|
578
|
-
description: obj.description ?? '',
|
|
579
|
-
kind: obj.kind ?? '',
|
|
580
|
-
technology: obj.technology ?? '',
|
|
581
|
-
url: obj.url ?? '',
|
|
582
|
-
logo_url: obj.logo_url ?? '',
|
|
583
|
-
technology_connectors: obj.technology_connectors ?? [],
|
|
793
|
+
...elementUpdatePayload(obj),
|
|
584
794
|
tags,
|
|
585
|
-
repo: obj.repo,
|
|
586
|
-
branch: obj.branch,
|
|
587
|
-
file_path: obj.file_path,
|
|
588
|
-
language: obj.language,
|
|
589
795
|
})
|
|
590
|
-
|
|
796
|
+
applyElementSaved(saved)
|
|
797
|
+
pushElementEditAction(obj, saved)
|
|
591
798
|
if (selectedElement?.id === elementId) {
|
|
592
799
|
setSelectedElement(saved)
|
|
593
800
|
}
|
|
594
801
|
} catch (err) {
|
|
595
802
|
console.error('Failed to update tags:', err)
|
|
596
803
|
}
|
|
597
|
-
}, [canEdit, selectedElement, allElements,
|
|
804
|
+
}, [canEdit, selectedElement, allElements, applyElementSaved, pushElementEditAction, setSelectedElement])
|
|
805
|
+
|
|
806
|
+
const pushPlacementMoveAction = useCallback((before: PlacedElement, after: PlacedElement) => {
|
|
807
|
+
if (placementSnapshotsEqual(before, after)) return
|
|
808
|
+
pushEditAction({
|
|
809
|
+
undo: async () => {
|
|
810
|
+
await api.workspace.views.placements.updatePosition(before.view_id, before.element_id, before.position_x, before.position_y)
|
|
811
|
+
await refreshElements()
|
|
812
|
+
},
|
|
813
|
+
redo: async () => {
|
|
814
|
+
await api.workspace.views.placements.updatePosition(after.view_id, after.element_id, after.position_x, after.position_y)
|
|
815
|
+
await refreshElements()
|
|
816
|
+
},
|
|
817
|
+
})
|
|
818
|
+
}, [pushEditAction, refreshElements])
|
|
819
|
+
|
|
820
|
+
const pushPlacementRemoveAction = useCallback((placement: PlacedElement) => {
|
|
821
|
+
pushEditAction({
|
|
822
|
+
undo: async () => {
|
|
823
|
+
await api.workspace.views.placements.add(placement.view_id, placement.element_id, placement.position_x, placement.position_y)
|
|
824
|
+
await refreshElements()
|
|
825
|
+
},
|
|
826
|
+
redo: async () => {
|
|
827
|
+
await api.workspace.views.placements.remove(placement.view_id, placement.element_id)
|
|
828
|
+
await refreshElements()
|
|
829
|
+
},
|
|
830
|
+
})
|
|
831
|
+
}, [pushEditAction, refreshElements])
|
|
832
|
+
|
|
833
|
+
const pushConnectorEditAction = useCallback((before: Connector, after: Connector) => {
|
|
834
|
+
if (connectorSnapshotsEqual(before, after)) return
|
|
835
|
+
pushEditAction({
|
|
836
|
+
undo: async () => {
|
|
837
|
+
const updated = await api.workspace.connectors.update(before.view_id, before.id, connectorUpdatePayload(before))
|
|
838
|
+
const connector = connectorToConnector(updated)
|
|
839
|
+
upsertConnectorGraphSnapshot(connector)
|
|
840
|
+
upsertStoreConnector(connector)
|
|
841
|
+
setSelectedEdge((current) => current?.id === connector.id ? connector : current)
|
|
842
|
+
await refreshElements()
|
|
843
|
+
},
|
|
844
|
+
redo: async () => {
|
|
845
|
+
const updated = await api.workspace.connectors.update(after.view_id, after.id, connectorUpdatePayload(after))
|
|
846
|
+
const connector = connectorToConnector(updated)
|
|
847
|
+
upsertConnectorGraphSnapshot(connector)
|
|
848
|
+
upsertStoreConnector(connector)
|
|
849
|
+
setSelectedEdge((current) => current?.id === connector.id ? connector : current)
|
|
850
|
+
await refreshElements()
|
|
851
|
+
},
|
|
852
|
+
})
|
|
853
|
+
}, [pushEditAction, refreshElements, upsertStoreConnector])
|
|
854
|
+
|
|
855
|
+
const pushConnectorDeleteAction = useCallback((deleted: Connector) => {
|
|
856
|
+
let activeConnector = deleted
|
|
857
|
+
pushEditAction({
|
|
858
|
+
undo: async () => {
|
|
859
|
+
const created = await api.workspace.connectors.create(deleted.view_id, connectorUpdatePayload(deleted))
|
|
860
|
+
activeConnector = connectorToConnector(created)
|
|
861
|
+
upsertConnectorGraphSnapshot(activeConnector)
|
|
862
|
+
upsertStoreConnector(activeConnector)
|
|
863
|
+
await refreshElements()
|
|
864
|
+
},
|
|
865
|
+
redo: async () => {
|
|
866
|
+
await api.workspace.connectors.delete('', activeConnector.id)
|
|
867
|
+
removeConnectorGraphSnapshot(activeConnector.view_id, activeConnector.id)
|
|
868
|
+
removeStoreConnector(activeConnector.id)
|
|
869
|
+
setSelectedEdge((current) => current?.id === activeConnector.id ? null : current)
|
|
870
|
+
await refreshElements()
|
|
871
|
+
},
|
|
872
|
+
})
|
|
873
|
+
}, [pushEditAction, refreshElements, removeStoreConnector, upsertStoreConnector])
|
|
874
|
+
|
|
875
|
+
const elementEditSessionRef = useRef<{ before: WorkspaceElement; after: WorkspaceElement | null } | null>(null)
|
|
876
|
+
const finalizeElementEditSession = useCallback(() => {
|
|
877
|
+
const session = elementEditSessionRef.current
|
|
878
|
+
elementEditSessionRef.current = null
|
|
879
|
+
if (session?.after) pushElementEditAction(session.before, session.after)
|
|
880
|
+
}, [pushElementEditAction])
|
|
881
|
+
|
|
882
|
+
useEffect(() => {
|
|
883
|
+
if (!elementPanel.isOpen || !selectedElement) return
|
|
884
|
+
const session = elementEditSessionRef.current
|
|
885
|
+
if (!session || session.before.id !== selectedElement.id) {
|
|
886
|
+
if (session?.after) pushElementEditAction(session.before, session.after)
|
|
887
|
+
elementEditSessionRef.current = { before: selectedElement, after: null }
|
|
888
|
+
}
|
|
889
|
+
}, [elementPanel.isOpen, pushElementEditAction, selectedElement])
|
|
890
|
+
|
|
891
|
+
const handleElementPanelSave = useCallback((saved: WorkspaceElement) => {
|
|
892
|
+
const session = elementEditSessionRef.current
|
|
893
|
+
if (!session || session.before.id !== saved.id) {
|
|
894
|
+
elementEditSessionRef.current = { before: selectedElement?.id === saved.id ? selectedElement : saved, after: saved }
|
|
895
|
+
} else {
|
|
896
|
+
session.after = saved
|
|
897
|
+
}
|
|
898
|
+
applyElementSaved(saved)
|
|
899
|
+
setSelectedElement(saved)
|
|
900
|
+
}, [applyElementSaved, selectedElement])
|
|
901
|
+
|
|
902
|
+
const handleElementPanelClose = useCallback(() => {
|
|
903
|
+
finalizeElementEditSession()
|
|
904
|
+
elementPanel.onClose()
|
|
905
|
+
}, [elementPanel, finalizeElementEditSession])
|
|
906
|
+
|
|
907
|
+
const connectorEditSessionRef = useRef<{ before: Connector; after: Connector | null } | null>(null)
|
|
908
|
+
const finalizeConnectorEditSession = useCallback(() => {
|
|
909
|
+
const session = connectorEditSessionRef.current
|
|
910
|
+
connectorEditSessionRef.current = null
|
|
911
|
+
if (session?.after) pushConnectorEditAction(session.before, session.after)
|
|
912
|
+
}, [pushConnectorEditAction])
|
|
913
|
+
|
|
914
|
+
useEffect(() => {
|
|
915
|
+
if (!connectorPanel.isOpen || !selectedEdge) return
|
|
916
|
+
const session = connectorEditSessionRef.current
|
|
917
|
+
if (!session || session.before.id !== selectedEdge.id) {
|
|
918
|
+
if (session?.after) pushConnectorEditAction(session.before, session.after)
|
|
919
|
+
connectorEditSessionRef.current = { before: selectedEdge, after: null }
|
|
920
|
+
}
|
|
921
|
+
}, [connectorPanel.isOpen, pushConnectorEditAction, selectedEdge])
|
|
922
|
+
|
|
923
|
+
const handleConnectorPanelSave = useCallback((updated: Connector) => {
|
|
924
|
+
const connector = connectorToConnector(updated)
|
|
925
|
+
const session = connectorEditSessionRef.current
|
|
926
|
+
if (!session || session.before.id !== connector.id) {
|
|
927
|
+
connectorEditSessionRef.current = { before: selectedEdge?.id === connector.id ? selectedEdge : connector, after: connector }
|
|
928
|
+
} else {
|
|
929
|
+
session.after = connector
|
|
930
|
+
}
|
|
931
|
+
upsertConnectorGraphSnapshot(connector)
|
|
932
|
+
upsertStoreConnector(connector)
|
|
933
|
+
setSelectedEdge(connector)
|
|
934
|
+
}, [selectedEdge, upsertStoreConnector])
|
|
935
|
+
|
|
936
|
+
const handleConnectorPanelClose = useCallback(() => {
|
|
937
|
+
finalizeConnectorEditSession()
|
|
938
|
+
connectorPanel.onClose()
|
|
939
|
+
}, [connectorPanel, finalizeConnectorEditSession])
|
|
940
|
+
|
|
941
|
+
const handleUndoViewEdit = useCallback(async () => {
|
|
942
|
+
try {
|
|
943
|
+
await undoViewEdit()
|
|
944
|
+
} catch (err) {
|
|
945
|
+
toast({ status: 'error', title: 'Undo failed', description: err instanceof Error ? err.message : String(err) })
|
|
946
|
+
}
|
|
947
|
+
}, [undoViewEdit, toast])
|
|
948
|
+
|
|
949
|
+
const handleRedoViewEdit = useCallback(async () => {
|
|
950
|
+
try {
|
|
951
|
+
await redoViewEdit()
|
|
952
|
+
} catch (err) {
|
|
953
|
+
toast({ status: 'error', title: 'Redo failed', description: err instanceof Error ? err.message : String(err) })
|
|
954
|
+
}
|
|
955
|
+
}, [redoViewEdit, toast])
|
|
598
956
|
|
|
599
957
|
// ── Canvas interactions ────────────────────────────────────────────────────
|
|
600
958
|
const canvas = useCanvasInteractions({
|
|
@@ -633,6 +991,7 @@ function ViewEditorInner({
|
|
|
633
991
|
const connector = connectorToConnector(newConnector)
|
|
634
992
|
upsertConnectorGraphSnapshot(connector)
|
|
635
993
|
upsertStoreConnector(connector)
|
|
994
|
+
handleUnsupportedMutation()
|
|
636
995
|
} catch { /* intentionally empty */ }
|
|
637
996
|
},
|
|
638
997
|
existingElementIds, linksMapRef, parentLinksMapRef,
|
|
@@ -648,11 +1007,17 @@ function ViewEditorInner({
|
|
|
648
1007
|
openProxyConnectorPanel: useCallback(() => openProxyConnectorPanelRef.current(), []),
|
|
649
1008
|
closeProxyConnectorPanel: useCallback(() => closeProxyConnectorPanelRef.current(), []),
|
|
650
1009
|
handleElementDeleted, handleElementPermanentlyDeleted,
|
|
651
|
-
handleConnectorDeleted: useCallback((edgeId: number) => {
|
|
652
|
-
|
|
1010
|
+
handleConnectorDeleted: useCallback((edgeId: number, ownerViewId?: number) => {
|
|
1011
|
+
const vid = ownerViewId ?? viewId
|
|
1012
|
+
if (vid != null) removeConnectorGraphSnapshot(vid, edgeId)
|
|
653
1013
|
removeStoreConnector(edgeId)
|
|
654
1014
|
void refreshElementsRef.current()
|
|
655
1015
|
}, [removeStoreConnector, viewId]),
|
|
1016
|
+
onPlacementMoved: pushPlacementMoveAction,
|
|
1017
|
+
onPlacementRemoved: pushPlacementRemoveAction,
|
|
1018
|
+
onConnectorUpdated: pushConnectorEditAction,
|
|
1019
|
+
onConnectorDeleted: pushConnectorDeleteAction,
|
|
1020
|
+
onUnsupportedMutation: handleUnsupportedMutation,
|
|
656
1021
|
handleUpdateTags,
|
|
657
1022
|
drawingCanvasRef,
|
|
658
1023
|
snapToGrid,
|
|
@@ -680,7 +1045,7 @@ function ViewEditorInner({
|
|
|
680
1045
|
})
|
|
681
1046
|
}, [])
|
|
682
1047
|
|
|
683
|
-
const { contextNodes, contextConnectors } = useViewContextNeighbours({
|
|
1048
|
+
const { contextNodes, contextConnectors, hiddenProxyCountsByPair, hiddenProxyDetailsByPair } = useViewContextNeighbours({
|
|
684
1049
|
snapshot: effectiveWorkspaceSnapshot,
|
|
685
1050
|
settings: crossBranchSettings,
|
|
686
1051
|
viewId,
|
|
@@ -699,6 +1064,39 @@ function ViewEditorInner({
|
|
|
699
1064
|
onToggleAncestorGroup: stableOnToggleAncestorGroup,
|
|
700
1065
|
})
|
|
701
1066
|
|
|
1067
|
+
const rfEdgesWithProxyBadges = useMemo(() => {
|
|
1068
|
+
if (Object.keys(hiddenProxyCountsByPair).length === 0) return rfEdges
|
|
1069
|
+
|
|
1070
|
+
let changed = false
|
|
1071
|
+
const next = rfEdges.map((edge) => {
|
|
1072
|
+
const pairKey = canonicalNodePairKey(edge.source, edge.target)
|
|
1073
|
+
const proxyBadgeCount = hiddenProxyCountsByPair[pairKey] ?? 0
|
|
1074
|
+
const currentBadgeCount = (edge.data as { proxyBadgeCount?: number } | undefined)?.proxyBadgeCount ?? 0
|
|
1075
|
+
const proxyBadgeDetails = hiddenProxyDetailsByPair[pairKey] ?? null
|
|
1076
|
+
const currentBadgeDetails = (edge.data as { proxyBadgeDetails?: ProxyConnectorDetails | null } | undefined)?.proxyBadgeDetails ?? null
|
|
1077
|
+
if (proxyBadgeCount === currentBadgeCount && proxyBadgeDetails === currentBadgeDetails) return edge
|
|
1078
|
+
changed = true
|
|
1079
|
+
return {
|
|
1080
|
+
...edge,
|
|
1081
|
+
data: {
|
|
1082
|
+
...(edge.data ?? {}),
|
|
1083
|
+
proxyBadgeCount: proxyBadgeCount > 0 ? proxyBadgeCount : undefined,
|
|
1084
|
+
proxyBadgeDetails,
|
|
1085
|
+
onOpenProxyBadge: (details: ProxyConnectorDetails) => {
|
|
1086
|
+
setSelectedElement(null)
|
|
1087
|
+
setSelectedEdge(null)
|
|
1088
|
+
closeConnectorPanelRef.current()
|
|
1089
|
+
closeElementPanelRef.current()
|
|
1090
|
+
setSelectedProxyConnectorDetails(details)
|
|
1091
|
+
openProxyConnectorPanelRef.current()
|
|
1092
|
+
},
|
|
1093
|
+
},
|
|
1094
|
+
}
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
return changed ? next : rfEdges
|
|
1098
|
+
}, [hiddenProxyCountsByPair, hiddenProxyDetailsByPair, rfEdges])
|
|
1099
|
+
|
|
702
1100
|
// Keep context nodes in state so React Flow can store measured dimensions.
|
|
703
1101
|
// When computed positions change (e.g. main node drag), preserve the previously
|
|
704
1102
|
// measured width/height so nodes don't flash hidden while being re-measured.
|
|
@@ -735,10 +1133,10 @@ function ViewEditorInner({
|
|
|
735
1133
|
}
|
|
736
1134
|
|
|
737
1135
|
const allEdges = contextConnectors.length === 0
|
|
738
|
-
?
|
|
739
|
-
:
|
|
1136
|
+
? rfEdgesWithProxyBadges
|
|
1137
|
+
: rfEdgesWithProxyBadges.length === 0
|
|
740
1138
|
? contextConnectors
|
|
741
|
-
: [...contextConnectors, ...
|
|
1139
|
+
: [...contextConnectors, ...rfEdgesWithProxyBadges]
|
|
742
1140
|
|
|
743
1141
|
const selectedEdgeEndPoints = new Set<string>()
|
|
744
1142
|
let hasEdgeSel = false
|
|
@@ -773,14 +1171,14 @@ function ViewEditorInner({
|
|
|
773
1171
|
cache.set(n, faded)
|
|
774
1172
|
return faded
|
|
775
1173
|
})
|
|
776
|
-
}, [liveContextNodes, rfNodes, contextConnectors,
|
|
1174
|
+
}, [liveContextNodes, rfNodes, contextConnectors, rfEdgesWithProxyBadges])
|
|
777
1175
|
|
|
778
1176
|
const flowEdges = useMemo(() => {
|
|
779
1177
|
const allEdges = contextConnectors.length === 0
|
|
780
|
-
?
|
|
781
|
-
:
|
|
1178
|
+
? rfEdgesWithProxyBadges
|
|
1179
|
+
: rfEdgesWithProxyBadges.length === 0
|
|
782
1180
|
? contextConnectors
|
|
783
|
-
: [...contextConnectors, ...
|
|
1181
|
+
: [...contextConnectors, ...rfEdgesWithProxyBadges]
|
|
784
1182
|
const allNodes = liveContextNodes.length === 0
|
|
785
1183
|
? rfNodes
|
|
786
1184
|
: rfNodes.length === 0
|
|
@@ -815,7 +1213,7 @@ function ViewEditorInner({
|
|
|
815
1213
|
cache.set(e, faded)
|
|
816
1214
|
return faded
|
|
817
1215
|
})
|
|
818
|
-
}, [contextConnectors,
|
|
1216
|
+
}, [contextConnectors, rfEdgesWithProxyBadges, liveContextNodes, rfNodes])
|
|
819
1217
|
|
|
820
1218
|
// Route onNodesChange: context node changes (dimensions, selection) go to
|
|
821
1219
|
// liveContextNodes state; main node changes go to the canvas handler.
|
|
@@ -885,7 +1283,7 @@ function ViewEditorInner({
|
|
|
885
1283
|
return
|
|
886
1284
|
}
|
|
887
1285
|
|
|
888
|
-
const ok = safeFitView({ duration: 0 })
|
|
1286
|
+
const ok = safeFitView({ duration: 0, padding: 400 })
|
|
889
1287
|
if (ok) needsFitView.current = false
|
|
890
1288
|
else setTimeout(() => { if (needsFitView.current) maybeFitView() }, 50)
|
|
891
1289
|
}, [applyDemoRevealViewport, clampedRevealProgress, safeFitView, rfNodesRef])
|
|
@@ -902,7 +1300,18 @@ function ViewEditorInner({
|
|
|
902
1300
|
return () => observer.disconnect()
|
|
903
1301
|
}, [maybeFitView])
|
|
904
1302
|
|
|
905
|
-
useEffect(() => {
|
|
1303
|
+
useEffect(() => {
|
|
1304
|
+
setSelectedElement(null)
|
|
1305
|
+
setSelectedEdge(null)
|
|
1306
|
+
setSelectedProxyConnectorDetails(null)
|
|
1307
|
+
elementEditSessionRef.current = null
|
|
1308
|
+
connectorEditSessionRef.current = null
|
|
1309
|
+
clearEditHistory()
|
|
1310
|
+
closeElementPanelRef.current()
|
|
1311
|
+
closeConnectorPanelRef.current()
|
|
1312
|
+
closeProxyConnectorPanelRef.current()
|
|
1313
|
+
needsFitView.current = true
|
|
1314
|
+
}, [clearEditHistory, viewId])
|
|
906
1315
|
|
|
907
1316
|
// ── Dynamic viewport bounds ────────────────────────────────────────────────
|
|
908
1317
|
useEffect(() => {
|
|
@@ -995,31 +1404,71 @@ function ViewEditorInner({
|
|
|
995
1404
|
useEffect(() => () => setHeader(null), [setHeader])
|
|
996
1405
|
|
|
997
1406
|
// ── Share ──────────────────────────────────────────────────────────────────
|
|
998
|
-
const onShare = useCallback(() => {}, [])
|
|
1407
|
+
const onShare = useCallback(() => { }, [])
|
|
999
1408
|
|
|
1000
1409
|
const handleExplorerHoverZoom = useCallback((elementId: number | null, type: 'in' | 'out' | null) => {
|
|
1001
1410
|
setHoveredZoom(type && elementId ? { elementId, type } : null)
|
|
1002
1411
|
}, [])
|
|
1003
1412
|
const handleToggleExplorer = useCallback(() => setIsExplorerOpen((v) => !v), [])
|
|
1004
1413
|
const handleCloseLibrary = useCallback(() => setLibraryOpen(false), [])
|
|
1005
|
-
const handleCreateNewLibraryRef = useRef<() => void>(() => {})
|
|
1414
|
+
const handleCreateNewLibraryRef = useRef<() => void>(() => { })
|
|
1006
1415
|
const handleCreateNewLibrary = useCallback(() => handleCreateNewLibraryRef.current(), [])
|
|
1007
1416
|
const handleFocusModeChange = useCallback((v: boolean) => setCrossBranchEnabled(!v), [setCrossBranchEnabled])
|
|
1008
1417
|
const handleOpenExport = useCallback(() => exportModal.onOpen(), [exportModal])
|
|
1009
|
-
const
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
}, [upsertStoreConnector])
|
|
1013
|
-
const handleConnectorDeleted = useCallback((edgeId: number) => {
|
|
1014
|
-
if (viewId != null) removeConnectorGraphSnapshot(viewId, edgeId)
|
|
1418
|
+
const handleConnectorDeleted = useCallback((edgeId: number, ownerViewId?: number) => {
|
|
1419
|
+
const vid = ownerViewId ?? viewId
|
|
1420
|
+
if (vid != null) removeConnectorGraphSnapshot(vid, edgeId)
|
|
1015
1421
|
removeStoreConnector(edgeId)
|
|
1016
1422
|
void refreshElements()
|
|
1017
1423
|
}, [refreshElements, removeStoreConnector, viewId])
|
|
1018
|
-
|
|
1019
|
-
|
|
1424
|
+
|
|
1425
|
+
const handleOpenMerge = useCallback((elementId: number) => {
|
|
1426
|
+
const el = allElements.find((e) => e.id === elementId)
|
|
1427
|
+
?? (() => {
|
|
1428
|
+
const placed = viewElements.find((e) => e.element_id === elementId)
|
|
1429
|
+
return placed ? placedElementToLibraryElement(placed) : null
|
|
1430
|
+
})()
|
|
1431
|
+
if (el) {
|
|
1432
|
+
setMergeSourceElement(el)
|
|
1433
|
+
mergeDialog.onOpen()
|
|
1434
|
+
}
|
|
1435
|
+
}, [allElements, viewElements, mergeDialog])
|
|
1436
|
+
|
|
1437
|
+
const handleMerge = useCallback(async (survivorId: number, resolved: {
|
|
1438
|
+
kind: string | null
|
|
1439
|
+
description: string | null
|
|
1440
|
+
repo: string | null
|
|
1441
|
+
branch: string | null
|
|
1442
|
+
file_path: string | null
|
|
1443
|
+
language: string | null
|
|
1444
|
+
}) => {
|
|
1445
|
+
if (!mergeSourceElement) return
|
|
1446
|
+
const result = await api.elements.merge(mergeSourceElement.id, survivorId, resolved)
|
|
1447
|
+
mergeElementsInto(mergeSourceElement.id, result.survivor)
|
|
1448
|
+
await refreshElements()
|
|
1449
|
+
mergeDialog.onClose()
|
|
1450
|
+
setMergeSourceElement(null)
|
|
1451
|
+
if (selectedElement?.id === mergeSourceElement.id) {
|
|
1452
|
+
setSelectedElement(result.survivor)
|
|
1453
|
+
}
|
|
1454
|
+
}, [mergeSourceElement, mergeElementsInto, mergeDialog, selectedElement, refreshElements])
|
|
1455
|
+
|
|
1456
|
+
const handleConnectorDeleteInPanel = useCallback((edgeId: number, ownerViewId?: number) => {
|
|
1457
|
+
const deleted = selectedEdge?.id === edgeId ? selectedEdge : connectors.find((connector) => connector.id === edgeId) ?? null
|
|
1458
|
+
connectorEditSessionRef.current = null
|
|
1459
|
+
if (deleted) pushConnectorDeleteAction(deleted)
|
|
1460
|
+
handleConnectorDeleted(edgeId, ownerViewId)
|
|
1020
1461
|
setSelectedEdge(null)
|
|
1021
|
-
}, [handleConnectorDeleted, setSelectedEdge])
|
|
1022
|
-
const handleViewSave = useCallback((updated: ViewTreeNode) =>
|
|
1462
|
+
}, [connectors, handleConnectorDeleted, pushConnectorDeleteAction, selectedEdge, setSelectedEdge])
|
|
1463
|
+
const handleViewSave = useCallback((updated: ViewTreeNode) => {
|
|
1464
|
+
if (view) {
|
|
1465
|
+
pushViewEditAction(
|
|
1466
|
+
{ id: view.id, name: view.name, level_label: view.level_label },
|
|
1467
|
+
{ id: updated.id, name: updated.name, level_label: updated.level_label },
|
|
1468
|
+
)
|
|
1469
|
+
}
|
|
1470
|
+
setView(updated)
|
|
1471
|
+
}, [pushViewEditAction, setView, view])
|
|
1023
1472
|
|
|
1024
1473
|
// ── Library helpers ────────────────────────────────────────────────────────
|
|
1025
1474
|
// Assigned below; referenced by memoized callbacks (e.g. ElementLibrary onCreateNew).
|
|
@@ -1101,6 +1550,7 @@ function ViewEditorInner({
|
|
|
1101
1550
|
setIsImporting(true)
|
|
1102
1551
|
try {
|
|
1103
1552
|
const res = await api.import.resources('', { elements: parsed.elements, connectors: parsed.connectors })
|
|
1553
|
+
clearEditHistory()
|
|
1104
1554
|
closeImportModalRef.current()
|
|
1105
1555
|
toast({ status: 'success', title: 'Import complete', description: `Created ${parsed.elements.length} elements and ${parsed.connectors.length} connectors.`, duration: 5000, isClosable: true })
|
|
1106
1556
|
if (res.view_id && res.view_id !== currentViewId) navigate(`/views/${res.view_id}`)
|
|
@@ -1108,16 +1558,16 @@ function ViewEditorInner({
|
|
|
1108
1558
|
} catch (e) {
|
|
1109
1559
|
toast({ status: 'error', title: 'Import failed', description: e instanceof Error ? e.message : 'Unknown error' })
|
|
1110
1560
|
} finally { setIsImporting(false) }
|
|
1111
|
-
}, [navigate, toast, viewIdRef])
|
|
1561
|
+
}, [clearEditHistory, navigate, toast, viewIdRef])
|
|
1112
1562
|
|
|
1113
1563
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1114
1564
|
// Render states
|
|
1115
1565
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1116
1566
|
if (view === undefined) {
|
|
1117
|
-
return <Flex h=
|
|
1567
|
+
return <Flex h="100%" align="center" justify="center"><Spinner size="xl" /></Flex>
|
|
1118
1568
|
}
|
|
1119
1569
|
if (view === null) {
|
|
1120
|
-
return <Flex h=
|
|
1570
|
+
return <Flex h="100%" align="center" justify="center"><Text>View not found.</Text></Flex>
|
|
1121
1571
|
}
|
|
1122
1572
|
|
|
1123
1573
|
return (
|
|
@@ -1125,7 +1575,7 @@ function ViewEditorInner({
|
|
|
1125
1575
|
viewId, canEdit, isOwner, isFreePlan, snapToGrid, setSnapToGrid,
|
|
1126
1576
|
selectedElement, selectedConnector: selectedEdge
|
|
1127
1577
|
}}>
|
|
1128
|
-
<Box h=
|
|
1578
|
+
<Box h="100%" display="flex" flexDir="column">
|
|
1129
1579
|
<Flex flex={1} overflow="hidden">
|
|
1130
1580
|
<Box
|
|
1131
1581
|
ref={containerRef}
|
|
@@ -1363,6 +1813,13 @@ function ViewEditorInner({
|
|
|
1363
1813
|
extrasOpen={extrasOpen} setExtrasOpen={setExtrasOpen}
|
|
1364
1814
|
focusMode={!crossBranchSettings.enabled}
|
|
1365
1815
|
onFocusModeChange={handleFocusModeChange}
|
|
1816
|
+
densityLevel={densityLevel}
|
|
1817
|
+
onDensityLevelChange={handleDensityLevelChange}
|
|
1818
|
+
canUndo={canUndoViewEdit}
|
|
1819
|
+
canRedo={canRedoViewEdit}
|
|
1820
|
+
undoRedoDisabled={isApplyingHistory}
|
|
1821
|
+
onUndo={handleUndoViewEdit}
|
|
1822
|
+
onRedo={handleRedoViewEdit}
|
|
1366
1823
|
disableImportExport={disableImportExport}
|
|
1367
1824
|
onImport={importModal.onOpen} onExport={handleOpenExport} onShare={onShare}
|
|
1368
1825
|
allTags={availableTags}
|
|
@@ -1394,9 +1851,19 @@ function ViewEditorInner({
|
|
|
1394
1851
|
/>
|
|
1395
1852
|
|
|
1396
1853
|
<ElementPanel
|
|
1397
|
-
isOpen={elementPanel.isOpen} onClose={
|
|
1398
|
-
onSave={
|
|
1399
|
-
|
|
1854
|
+
isOpen={elementPanel.isOpen} onClose={handleElementPanelClose} element={selectedElement}
|
|
1855
|
+
onSave={handleElementPanelSave} autoSave
|
|
1856
|
+
onMerge={handleOpenMerge}
|
|
1857
|
+
onDelete={(elementId) => {
|
|
1858
|
+
elementEditSessionRef.current = null
|
|
1859
|
+
const placement = viewElements.find((item) => item.element_id === elementId)
|
|
1860
|
+
if (placement) pushPlacementRemoveAction(placement)
|
|
1861
|
+
handleElementDeleted(elementId)
|
|
1862
|
+
}} onPermanentDelete={handleElementPermanentlyDeleted}
|
|
1863
|
+
visibilityOverrideDelta={overrideDeltaFor('element', selectedElement?.id)}
|
|
1864
|
+
onPromoteVisibility={(id) => handleVisibilityOverride('element', id, 'promote')}
|
|
1865
|
+
onDemoteVisibility={(id) => handleVisibilityOverride('element', id, 'demote')}
|
|
1866
|
+
onResetVisibility={(id) => handleVisibilityOverride('element', id, 'reset')}
|
|
1400
1867
|
orgId={''}
|
|
1401
1868
|
links={selectedElement ? (linksMap[selectedElement.id] || EMPTY_LINKS) : EMPTY_LINKS}
|
|
1402
1869
|
parentLinks={selectedElement ? (parentLinksMap[selectedElement.id] || EMPTY_LINKS) : EMPTY_LINKS}
|
|
@@ -1408,24 +1875,41 @@ function ViewEditorInner({
|
|
|
1408
1875
|
<CodePreviewPanel isOpen={codePreview.isOpen} onClose={codePreview.onClose} element={previewElement} hasBackdrop={isMobileLayout} />
|
|
1409
1876
|
|
|
1410
1877
|
<ConnectorPanel
|
|
1411
|
-
isOpen={connectorPanel.isOpen} onClose={
|
|
1878
|
+
isOpen={connectorPanel.isOpen} onClose={handleConnectorPanelClose} connector={selectedEdge}
|
|
1412
1879
|
orgId={''}
|
|
1413
|
-
onSave={
|
|
1880
|
+
onSave={handleConnectorPanelSave} autoSave
|
|
1414
1881
|
onDelete={handleConnectorDeleteInPanel}
|
|
1882
|
+
visibilityOverrideDelta={overrideDeltaFor('connector', selectedEdge?.id)}
|
|
1883
|
+
onPromoteVisibility={(id) => handleVisibilityOverride('connector', id, 'promote')}
|
|
1884
|
+
onDemoteVisibility={(id) => handleVisibilityOverride('connector', id, 'demote')}
|
|
1885
|
+
onResetVisibility={(id) => handleVisibilityOverride('connector', id, 'reset')}
|
|
1415
1886
|
hasBackdrop={isMobileLayout}
|
|
1416
|
-
|
|
1417
|
-
|
|
1887
|
+
connectorPanelAfterContentSlot={connectorPanelAfterContentSlot}
|
|
1888
|
+
/>
|
|
1418
1889
|
<ProxyConnectorPanel
|
|
1419
1890
|
isOpen={proxyConnectorPanel.isOpen}
|
|
1420
1891
|
onClose={proxyConnectorPanel.onClose}
|
|
1421
1892
|
details={selectedProxyConnectorDetails}
|
|
1422
1893
|
hasBackdrop={isMobileLayout}
|
|
1894
|
+
onEdit={(connector) => {
|
|
1895
|
+
setSelectedEdge(connector)
|
|
1896
|
+
connectorPanel.onOpen()
|
|
1897
|
+
}}
|
|
1898
|
+
onDelete={(edgeId, ownerViewId) => {
|
|
1899
|
+
const deleted = selectedProxyConnectorDetails?.connectors.find((leaf) => leaf.connector.id === edgeId)?.connector
|
|
1900
|
+
?? connectors.find((connector) => connector.id === edgeId)
|
|
1901
|
+
?? null
|
|
1902
|
+
void api.workspace.connectors.delete('', edgeId).then(() => {
|
|
1903
|
+
if (deleted) pushConnectorDeleteAction(deleted)
|
|
1904
|
+
handleConnectorDeleted(edgeId, ownerViewId)
|
|
1905
|
+
}).catch(() => { /* intentionally empty */ })
|
|
1906
|
+
}}
|
|
1423
1907
|
/>
|
|
1424
1908
|
|
|
1425
1909
|
<ViewPanel
|
|
1426
1910
|
isOpen={viewDetails.isOpen} onClose={viewDetails.onClose}
|
|
1427
1911
|
view={view as ViewTreeNode}
|
|
1428
|
-
onSave={handleViewSave} hasBackdrop={isMobileLayout}
|
|
1912
|
+
onSave={handleViewSave} onUnsupportedMutation={handleUnsupportedMutation} hasBackdrop={isMobileLayout}
|
|
1429
1913
|
/>
|
|
1430
1914
|
|
|
1431
1915
|
<ExportModal
|
|
@@ -1437,6 +1921,12 @@ function ViewEditorInner({
|
|
|
1437
1921
|
isOpen={importModal.isOpen} onClose={importModal.onClose}
|
|
1438
1922
|
onImport={handleImportView} isImporting={isImporting}
|
|
1439
1923
|
/>
|
|
1924
|
+
<MergeDialog
|
|
1925
|
+
isOpen={mergeDialog.isOpen}
|
|
1926
|
+
onClose={() => { mergeDialog.onClose(); setMergeSourceElement(null) }}
|
|
1927
|
+
source={mergeSourceElement}
|
|
1928
|
+
onMerge={handleMerge}
|
|
1929
|
+
/>
|
|
1440
1930
|
{!demoOptions?.disableOnboarding && <ViewEditorOnboarding hasElements={rfNodes.length > 0} />}
|
|
1441
1931
|
</Box>
|
|
1442
1932
|
</ViewEditorContext.Provider>
|