@tldiagram/core-ui 1.95.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/api/client.d.ts +184 -3
  2. package/dist/components/ConnectorPanel.d.ts +5 -1
  3. package/dist/components/CrossBranchControls.d.ts +4 -3
  4. package/dist/components/ElementNode.d.ts +5 -0
  5. package/dist/components/ElementPanel.d.ts +6 -1
  6. package/dist/components/LayoutSection.d.ts +2 -1
  7. package/dist/components/MergeDialog.d.ts +16 -0
  8. package/dist/components/NodeContainer.d.ts +2 -0
  9. package/dist/components/ProxyConnectorPanel.d.ts +4 -1
  10. package/dist/components/ViewExplorer/index.d.ts +1 -1
  11. package/dist/components/ViewFloatingMenu-vscode.d.ts +5 -0
  12. package/dist/components/ViewFloatingMenu.d.ts +8 -1
  13. package/dist/components/ViewGridNode.d.ts +3 -0
  14. package/dist/components/ViewPanel.d.ts +2 -1
  15. package/dist/components/WorkspacePanel.d.ts +2 -0
  16. package/dist/components/ZUI/ZUICanvas.d.ts +4 -0
  17. package/dist/components/ZUI/focus.d.ts +32 -0
  18. package/dist/components/ZUI/focus.test.d.ts +1 -0
  19. package/dist/components/ZUI/layout.d.ts +2 -2
  20. package/dist/components/ZUI/proxy.d.ts +20 -4
  21. package/dist/components/ZUI/renderer.d.ts +35 -1
  22. package/dist/components/ZUI/types.d.ts +6 -0
  23. package/dist/components/ZUI/useZUIInteraction.d.ts +1 -0
  24. package/dist/context/WorkspaceVersionContext.d.ts +49 -0
  25. package/dist/crossBranch/resolve.d.ts +39 -2
  26. package/dist/crossBranch/resolve.test.d.ts +1 -0
  27. package/dist/crossBranch/settings.d.ts +6 -1
  28. package/dist/crossBranch/types.d.ts +8 -0
  29. package/dist/hooks/useElementSearch.d.ts +8 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +16529 -14030
  32. package/dist/pages/InfiniteZoom.d.ts +1 -0
  33. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +6 -1
  34. package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +2 -0
  35. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +4 -2
  36. package/dist/pages/ViewEditor/hooks/useViewEditHistory.d.ts +13 -0
  37. package/dist/pages/viewsJumpSearch.d.ts +22 -0
  38. package/dist/pages/viewsJumpSearch.test.d.ts +1 -0
  39. package/dist/store/useStore.d.ts +3 -0
  40. package/dist/types/index.d.ts +9 -0
  41. package/dist/utils/elementIcon.d.ts +2 -0
  42. package/dist/utils/elementIcon.test.d.ts +1 -0
  43. package/dist/utils/sourceEditor.d.ts +7 -0
  44. package/dist/utils/watchDiffSummary.d.ts +34 -0
  45. package/package.json +2 -2
  46. package/src/App.tsx +12 -8
  47. package/src/api/client.ts +488 -26
  48. package/src/components/CodePreviewPanel.tsx +90 -16
  49. package/src/components/ConnectorPanel.tsx +34 -3
  50. package/src/components/ContextNeighborElement.tsx +2 -5
  51. package/src/components/CrossBranchControls.tsx +46 -17
  52. package/src/components/ElementNode.tsx +98 -47
  53. package/src/components/ElementPanel.tsx +62 -25
  54. package/src/components/InlineElementAdder.tsx +8 -3
  55. package/src/components/LayoutSection.tsx +4 -1
  56. package/src/components/MergeDialog.tsx +269 -0
  57. package/src/components/NodeContainer.tsx +55 -17
  58. package/src/components/ProxyConnectorPanel.tsx +58 -16
  59. package/src/components/ViewBezierConnector.tsx +116 -21
  60. package/src/components/ViewExplorer/index.tsx +1 -1
  61. package/src/components/ViewFloatingMenu-vscode.tsx +5 -0
  62. package/src/components/ViewFloatingMenu.tsx +110 -1
  63. package/src/components/ViewGridNode.tsx +59 -8
  64. package/src/components/ViewPanel.tsx +3 -2
  65. package/src/components/WorkspacePanel.tsx +938 -0
  66. package/src/components/ZUI/ZUICanvas.tsx +216 -122
  67. package/src/components/ZUI/focus.test.ts +534 -0
  68. package/src/components/ZUI/focus.ts +293 -0
  69. package/src/components/ZUI/layout.ts +7 -11
  70. package/src/components/ZUI/proxy.ts +470 -114
  71. package/src/components/ZUI/renderer.ts +510 -134
  72. package/src/components/ZUI/types.ts +6 -0
  73. package/src/components/ZUI/useZUIInteraction.ts +66 -29
  74. package/src/context/WorkspaceVersionContext.tsx +126 -0
  75. package/src/crossBranch/resolve.test.ts +342 -0
  76. package/src/crossBranch/resolve.ts +368 -68
  77. package/src/crossBranch/settings.ts +49 -3
  78. package/src/crossBranch/types.ts +9 -0
  79. package/src/hooks/useElementSearch.ts +45 -0
  80. package/src/index.css +11 -0
  81. package/src/index.ts +7 -0
  82. package/src/pages/AppearanceSettings.tsx +24 -1
  83. package/src/pages/Dependencies.tsx +231 -65
  84. package/src/pages/InfiniteZoom.tsx +41 -19
  85. package/src/pages/Settings.tsx +1 -1
  86. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +103 -24
  87. package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +102 -6
  88. package/src/pages/ViewEditor/hooks/useViewData.ts +42 -26
  89. package/src/pages/ViewEditor/hooks/useViewEditHistory.ts +62 -0
  90. package/src/pages/ViewEditor/index.tsx +549 -59
  91. package/src/pages/Views.tsx +112 -41
  92. package/src/pages/ViewsGrid.tsx +332 -113
  93. package/src/pages/viewsJumpSearch.test.ts +193 -0
  94. package/src/pages/viewsJumpSearch.ts +111 -0
  95. package/src/store/useStore.ts +58 -0
  96. package/src/types/index.ts +10 -0
  97. package/src/utils/elementIcon.test.ts +28 -0
  98. package/src/utils/elementIcon.ts +20 -0
  99. package/src/utils/sourceEditor.ts +46 -0
  100. 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 refreshElementsRef = useRef<() => Promise<void>>(async () => {})
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
- name: obj.name,
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
- handleElementSaved(saved)
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, handleElementSaved, setSelectedElement])
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
- if (viewId != null) removeConnectorGraphSnapshot(viewId, edgeId)
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
- ? rfEdges
739
- : rfEdges.length === 0
1136
+ ? rfEdgesWithProxyBadges
1137
+ : rfEdgesWithProxyBadges.length === 0
740
1138
  ? contextConnectors
741
- : [...contextConnectors, ...rfEdges]
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, rfEdges])
1174
+ }, [liveContextNodes, rfNodes, contextConnectors, rfEdgesWithProxyBadges])
777
1175
 
778
1176
  const flowEdges = useMemo(() => {
779
1177
  const allEdges = contextConnectors.length === 0
780
- ? rfEdges
781
- : rfEdges.length === 0
1178
+ ? rfEdgesWithProxyBadges
1179
+ : rfEdgesWithProxyBadges.length === 0
782
1180
  ? contextConnectors
783
- : [...contextConnectors, ...rfEdges]
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, rfEdges, liveContextNodes, rfNodes])
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(() => { needsFitView.current = true }, [viewId])
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 handleConnectorSave = useCallback((updated: Connector) => {
1010
- upsertConnectorGraphSnapshot(updated)
1011
- upsertStoreConnector(updated)
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
- const handleConnectorDeleteInPanel = useCallback((edgeId: number) => {
1019
- handleConnectorDeleted(edgeId)
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) => setView(updated), [setView])
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={{ base: '100vh', sm: 'calc(100vh - var(--editor-top-offset, 48px))' }} align="center" justify="center"><Spinner size="xl" /></Flex>
1567
+ return <Flex h="100%" align="center" justify="center"><Spinner size="xl" /></Flex>
1118
1568
  }
1119
1569
  if (view === null) {
1120
- return <Flex h={{ base: '100vh', sm: 'calc(100vh - var(--editor-top-offset, 48px))' }} align="center" justify="center"><Text>View not found.</Text></Flex>
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={{ base: '100vh', sm: 'calc(100vh - var(--editor-top-offset, 48px))' }} display="flex" flexDir="column">
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={elementPanel.onClose} element={selectedElement}
1398
- onSave={handleElementSaved} autoSave
1399
- onDelete={handleElementDeleted} onPermanentDelete={handleElementPermanentlyDeleted}
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={connectorPanel.onClose} connector={selectedEdge}
1878
+ isOpen={connectorPanel.isOpen} onClose={handleConnectorPanelClose} connector={selectedEdge}
1412
1879
  orgId={''}
1413
- onSave={handleConnectorSave} autoSave
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
- connectorPanelAfterContentSlot={connectorPanelAfterContentSlot}
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>