@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.
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
@@ -73,8 +73,8 @@ function dedupeTechnologyLinks(links: TechnologyConnector[]): TechnologyConnecto
73
73
 
74
74
  // Sort links to process primary ones first, ensuring they are preserved during deduping
75
75
  const sortedLinks = [...links].sort((a, b) => {
76
- const aPrimary = !!(a.is_primary_icon ?? (a as any).isPrimaryIcon)
77
- const bPrimary = !!(b.is_primary_icon ?? (b as any).isPrimaryIcon)
76
+ const aPrimary = !!(a.is_primary_icon ?? a.isPrimaryIcon)
77
+ const bPrimary = !!(b.is_primary_icon ?? b.isPrimaryIcon)
78
78
  if (aPrimary && !bPrimary) return -1
79
79
  if (!aPrimary && bPrimary) return 1
80
80
  return 0
@@ -84,7 +84,7 @@ function dedupeTechnologyLinks(links: TechnologyConnector[]): TechnologyConnecto
84
84
  const label = link.label.trim()
85
85
  if (!label) continue
86
86
 
87
- const isPrimary = !!(link.is_primary_icon ?? (link as any).isPrimaryIcon)
87
+ const isPrimary = !!(link.is_primary_icon ?? link.isPrimaryIcon)
88
88
 
89
89
  if (link.type === 'catalog' && link.slug) {
90
90
  const slug = link.slug.trim()
@@ -141,7 +141,7 @@ async function normalizeInitialTechnologyLinks(element: LibraryElement): Promise
141
141
  type: 'catalog',
142
142
  slug: link.slug,
143
143
  label: match?.name ?? link.label,
144
- is_primary_icon: !!(link.is_primary_icon ?? (link as any).isPrimaryIcon),
144
+ is_primary_icon: !!(link.is_primary_icon ?? link.isPrimaryIcon),
145
145
  })
146
146
  } else {
147
147
  const parts = splitTechnologyLabel(link.label)
@@ -158,10 +158,11 @@ async function normalizeInitialTechnologyLinks(element: LibraryElement): Promise
158
158
  }
159
159
  }
160
160
 
161
- // If no catalog item is primary, try to match against element.logo_url or fallback to first catalog item
161
+ // If no catalog item is primary, try to match against element.logo_url. An
162
+ // explicit empty logo_url means the user deselected technology icons.
162
163
  const deduped = dedupeTechnologyLinks(normalized)
163
164
  const hasPrimary = deduped.some(l => l.type === 'catalog' && l.is_primary_icon)
164
- if (!hasPrimary) {
165
+ if (!hasPrimary && element.logo_url !== '') {
165
166
  let bestMatchIndex = -1
166
167
  if (element.logo_url) {
167
168
  bestMatchIndex = deduped.findIndex(l => l.type === 'catalog' && l.slug && element.logo_url?.toLowerCase().includes(l.slug.toLowerCase()))
@@ -169,11 +170,6 @@ async function normalizeInitialTechnologyLinks(element: LibraryElement): Promise
169
170
 
170
171
  if (bestMatchIndex !== -1) {
171
172
  deduped[bestMatchIndex].is_primary_icon = true
172
- } else {
173
- const firstCatalog = deduped.find(l => l.type === 'catalog')
174
- if (firstCatalog) {
175
- firstCatalog.is_primary_icon = true
176
- }
177
173
  }
178
174
  }
179
175
 
@@ -218,6 +214,11 @@ export interface ElementPanelProps extends ElementPanelSlots {
218
214
  autoSave?: boolean
219
215
  onDelete?: (id: number) => void
220
216
  onPermanentDelete?: (id: number) => void
217
+ onMerge?: (id: number) => void
218
+ visibilityOverrideDelta?: number
219
+ onPromoteVisibility?: (id: number) => Promise<void> | void
220
+ onDemoteVisibility?: (id: number) => Promise<void> | void
221
+ onResetVisibility?: (id: number) => Promise<void> | void
221
222
  orgId?: string
222
223
  links?: ViewConnector[]
223
224
  parentLinks?: ViewConnector[]
@@ -231,7 +232,7 @@ export interface ElementPanelProps extends ElementPanelSlots {
231
232
  * Location: Right side of the screen on desktop. Overlays screen on mobile.
232
233
  * Aliases: Element Properties, Element Details.
233
234
  */
234
- function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDelete, onPermanentDelete, orgId, links = [], parentLinks = [], hasBackdrop = true, availableTags = [], elementPanelAfterContentSlot }: ElementPanelProps) {
235
+ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDelete, onPermanentDelete, onMerge, visibilityOverrideDelta = 0, onPromoteVisibility, onDemoteVisibility, onResetVisibility, orgId, links = [], parentLinks = [], hasBackdrop = true, availableTags = [], elementPanelAfterContentSlot }: ElementPanelProps) {
235
236
  const { canEdit, viewId } = useViewEditorContext()
236
237
  const isEdit = !!element
237
238
  const isReadOnly = !canEdit
@@ -276,11 +277,11 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
276
277
  setTypeResults([])
277
278
  setUrl(element.url ?? '')
278
279
  setTags(element.tags ?? [])
279
- setExplicitLogoClear(false)
280
+ setExplicitLogoClear(element.logo_url === '')
280
281
 
281
282
  const linksFromElement = (element.technology_connectors ?? []).map(tl => ({
282
283
  ...tl,
283
- is_primary_icon: !!(tl.is_primary_icon ?? (tl as any).isPrimaryIcon),
284
+ is_primary_icon: !!(tl.is_primary_icon ?? tl.isPrimaryIcon),
284
285
  }))
285
286
  const fallbackLinks: TechnologyConnector[] = linksFromElement.length > 0
286
287
  ? linksFromElement
@@ -333,14 +334,14 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
333
334
  }, [element, isOpen])
334
335
 
335
336
  const buildPayloadAndFingerprint = useCallback(async () => {
336
- const primaryLink = technologyLinks.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && link.slug)
337
+ const primaryLink = technologyLinks.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? link.isPrimaryIcon) && link.slug)
337
338
  const primarySlug = primaryLink?.slug
338
339
 
339
340
  const normalizedLinks = technologyLinks.map((link) => ({
340
341
  type: link.type,
341
342
  slug: link.type === 'catalog' ? link.slug : undefined,
342
343
  label: link.label,
343
- is_primary_icon: !!(link.is_primary_icon ?? (link as any).isPrimaryIcon),
344
+ is_primary_icon: !!(link.is_primary_icon ?? link.isPrimaryIcon),
344
345
  }))
345
346
 
346
347
  const normalizedType = type.trim().toLowerCase()
@@ -420,9 +421,9 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
420
421
  })
421
422
  }
422
423
 
423
- const handleClose = useCallback(() => {
424
+ const handleClose = useCallback(async () => {
424
425
  if (autoSaveEdit) {
425
- void saveIfDirtyRef.current?.()
426
+ await saveIfDirtyRef.current?.()
426
427
  }
427
428
  onClose()
428
429
  }, [autoSaveEdit, onClose])
@@ -526,13 +527,12 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
526
527
  type: 'catalog',
527
528
  slug: item.defaultSlug,
528
529
  label: item.name,
529
- is_primary_icon: !hasPrimaryCatalog,
530
+ is_primary_icon: !explicitLogoClear && !hasPrimaryCatalog,
530
531
  },
531
532
  ]))
532
533
  setTechnologyQuery('')
533
534
  setTechnologyResults([])
534
535
  setTechnologyMeta((prev) => ({ ...prev, [item.defaultSlug]: item }))
535
- setExplicitLogoClear(false)
536
536
  scheduleAutoSave()
537
537
  }
538
538
 
@@ -572,7 +572,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
572
572
  scheduleAutoSave()
573
573
  }
574
574
 
575
- const selectedPrimarySlug = technologyLinks.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && !!link.slug)?.slug ?? ''
575
+ const selectedPrimarySlug = technologyLinks.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? link.isPrimaryIcon) && !!link.slug)?.slug ?? ''
576
576
 
577
577
  const commitTypeFromQuery = () => {
578
578
  if (isReadOnly) return
@@ -595,7 +595,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
595
595
  if (isReadOnly || !name.trim()) return
596
596
  setLoading(true)
597
597
  try {
598
- const primaryLink = technologyLinks.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && link.slug)
598
+ const primaryLink = technologyLinks.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? link.isPrimaryIcon) && link.slug)
599
599
  const primaryMetadata = primaryLink?.slug
600
600
  ? (technologyMeta[primaryLink.slug] ?? await getTechnologyCatalogItemBySlug(primaryLink.slug))
601
601
  : null
@@ -604,7 +604,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
604
604
  type: link.type,
605
605
  slug: link.type === 'catalog' ? link.slug : undefined,
606
606
  label: link.label,
607
- is_primary_icon: !!(link.is_primary_icon ?? (link as any).isPrimaryIcon),
607
+ is_primary_icon: !!(link.is_primary_icon ?? link.isPrimaryIcon),
608
608
  }))
609
609
 
610
610
  const normalizedType = type.trim().toLowerCase()
@@ -862,7 +862,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
862
862
  const meta = link.slug ? technologyMeta[link.slug] : undefined
863
863
  const sourceUrl = meta?.websiteUrl || meta?.docsUrl
864
864
  const isSelectable = link.type === 'catalog' && !!link.slug && !isReadOnly
865
- const isPrimaryIcon = link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && !!link.slug
865
+ const isPrimaryIcon = link.type === 'catalog' && !!(link.is_primary_icon ?? link.isPrimaryIcon) && !!link.slug
866
866
  return (
867
867
  <WrapItem key={`${link.type}:${link.slug ?? link.label}`}>
868
868
  <Popover trigger={isMobile ? 'click' : 'hover'} placement="top" closeOnBlur>
@@ -1034,6 +1034,43 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
1034
1034
 
1035
1035
  {elementPanelAfterContentSlot}
1036
1036
 
1037
+ {element && (onPromoteVisibility || onDemoteVisibility || onResetVisibility) && (
1038
+ <Box borderTop="1px solid" borderColor="whiteAlpha.100" pt={2}>
1039
+ <HStack justify="space-between" mb={2}>
1040
+ <FormLabel fontSize="xs" fontWeight="bold" color="gray.400" mb={0}>DENSITY</FormLabel>
1041
+ {visibilityOverrideDelta !== 0 && (
1042
+ <Badge colorScheme={visibilityOverrideDelta > 0 ? 'teal' : 'orange'} variant="subtle">
1043
+ {visibilityOverrideDelta > 0 ? `+${visibilityOverrideDelta}` : visibilityOverrideDelta}
1044
+ </Badge>
1045
+ )}
1046
+ </HStack>
1047
+ <HStack spacing={2}>
1048
+ <Button variant="subtle" size="sm" color="teal.200" _hover={{ bg: 'teal.900', color: 'teal.100' }} onClick={() => onPromoteVisibility?.(element.id)} flex={1} isDisabled={isReadOnly}>
1049
+ Promote
1050
+ </Button>
1051
+ <Button variant="subtle" size="sm" color="orange.200" _hover={{ bg: 'orange.900', color: 'orange.100' }} onClick={() => onDemoteVisibility?.(element.id)} flex={1} isDisabled={isReadOnly}>
1052
+ Demote
1053
+ </Button>
1054
+ {visibilityOverrideDelta !== 0 && (
1055
+ <Button variant="ghost" size="sm" onClick={() => onResetVisibility?.(element.id)} isDisabled={isReadOnly}>
1056
+ Reset
1057
+ </Button>
1058
+ )}
1059
+ </HStack>
1060
+ </Box>
1061
+ )}
1062
+
1063
+
1064
+
1065
+ {isEdit && canEdit && onMerge && (
1066
+ <Box borderTop="1px solid" borderColor="whiteAlpha.100" pt={2}>
1067
+ <Button variant="subtle" size="sm" color="teal.200" _hover={{ bg: 'teal.900', color: 'teal.100' }}
1068
+ onClick={() => onMerge(element.id)} w="full">
1069
+ Merge
1070
+ </Button>
1071
+ </Box>
1072
+ )}
1073
+
1037
1074
  {isEdit && canEdit && (
1038
1075
  <HStack borderTop="1px solid" borderColor="whiteAlpha.100" pt={2} spacing={2}>
1039
1076
  <Button variant="subtle" size="sm" color="white" _hover={{ bg: 'whiteAlpha.100' }} onClick={handleDelete} flex={1}>
@@ -1072,7 +1109,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
1072
1109
  onClose={confirmPermanentDelete.onClose}
1073
1110
  onConfirm={handlePermanentDelete}
1074
1111
  title="Delete Element"
1075
- body="Permanently delete this element? It will be removed from all views and cannot be recovered."
1112
+ body="This permanently deletes the element from the library and cannot be reverted."
1076
1113
  confirmLabel="Delete Permanently"
1077
1114
  />
1078
1115
  </>
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
2
2
  import { Box, Badge, HStack, Input, Text, VStack } from '@chakra-ui/react'
3
3
  import type { LibraryElement } from '../types'
4
4
  import { TYPE_COLORS } from '../types'
5
+ import { useElementSearch } from '../hooks/useElementSearch'
5
6
 
6
7
  interface Props {
7
8
  x: number
@@ -32,7 +33,7 @@ export default function InlineElementAdder({
32
33
  title,
33
34
  getSecondaryLabel,
34
35
  }: Props) {
35
- const [query, setQuery] = useState('')
36
+ const { query, setQuery, remoteElements } = useElementSearch()
36
37
  const [activeIndex, setActiveIndex] = useState(0)
37
38
  const [busy, setBusy] = useState(false)
38
39
  const inputRef = useRef<HTMLInputElement>(null)
@@ -44,12 +45,16 @@ export default function InlineElementAdder({
44
45
 
45
46
  const filtered = (() => {
46
47
  if (!query.trim()) return []
48
+ const byID = new Map<number, LibraryElement>()
49
+ remoteElements.forEach((element) => byID.set(element.id, element))
50
+ allElements.forEach((element) => byID.set(element.id, element))
51
+ const candidates = Array.from(byID.values())
47
52
  try {
48
53
  const re = new RegExp(query, 'i')
49
- return allElements.filter((o) => re.test(o.name)).slice(0, 8)
54
+ return candidates.filter((o) => re.test(o.name)).slice(0, 8)
50
55
  } catch {
51
56
  const q = query.toLowerCase()
52
- return allElements.filter((o) => o.name.toLowerCase().includes(q)).slice(0, 8)
57
+ return candidates.filter((o) => o.name.toLowerCase().includes(q)).slice(0, 8)
53
58
  }
54
59
  })()
55
60
 
@@ -49,9 +49,10 @@ const ALGO_META: Record<Algorithm, { label: string }> = {
49
49
  interface Props {
50
50
  view: ViewTreeNode | null
51
51
  canEdit: boolean
52
+ onUnsupportedMutation?: () => void
52
53
  }
53
54
 
54
- export default function LayoutSection({ view, canEdit }: Props) {
55
+ export default function LayoutSection({ view, canEdit, onUnsupportedMutation }: Props) {
55
56
  const [open, setOpen] = useState(false)
56
57
  const [algo, setAlgo] = useState<Algorithm>('dagre')
57
58
  const [running, setRunning] = useState(false)
@@ -73,6 +74,7 @@ export default function LayoutSection({ view, canEdit }: Props) {
73
74
 
74
75
  const handleCollisionRemoval = async () => {
75
76
  if (!canEdit || !view) return
77
+ onUnsupportedMutation?.()
76
78
  setCollisionRunning(true)
77
79
  try {
78
80
  const [objs, edgeList] = await Promise.all([
@@ -192,6 +194,7 @@ export default function LayoutSection({ view, canEdit }: Props) {
192
194
 
193
195
  const applyLayout = async () => {
194
196
  if (!view || !canEdit) return
197
+ onUnsupportedMutation?.()
195
198
  setRunning(true)
196
199
  try {
197
200
  const [objs, edgeList] = await Promise.all([
@@ -0,0 +1,269 @@
1
+ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
2
+ import {
3
+ Box,
4
+ Button,
5
+ CloseButton,
6
+ HStack,
7
+ Input,
8
+ InputGroup,
9
+ InputLeftElement,
10
+ Radio,
11
+ RadioGroup,
12
+ Spinner,
13
+ Text,
14
+ VStack,
15
+ Badge,
16
+ } from '@chakra-ui/react'
17
+ import type { LibraryElement } from '../types'
18
+ import { useElementSearch } from '../hooks/useElementSearch'
19
+
20
+ interface MergeDialogProps {
21
+ isOpen: boolean
22
+ onClose: () => void
23
+ source: LibraryElement | null
24
+ onMerge: (survivorId: number, resolved: {
25
+ kind: string | null
26
+ description: string | null
27
+ repo: string | null
28
+ branch: string | null
29
+ file_path: string | null
30
+ language: string | null
31
+ }) => Promise<void>
32
+ }
33
+
34
+ type Step = 'select' | 'resolve'
35
+
36
+ function fieldsConflict(a: LibraryElement, b: LibraryElement): boolean {
37
+ if ((a.kind || null) !== (b.kind || null)) return true
38
+ if ((a.description || null) !== (b.description || null)) return true
39
+ if ((a.repo || null) !== (b.repo || null)) return true
40
+ if ((a.branch || null) !== (b.branch || null)) return true
41
+ if ((a.file_path || null) !== (b.file_path || null)) return true
42
+ if ((a.language || null) !== (a.language || null)) return true
43
+ return false
44
+ }
45
+
46
+ export default function MergeDialog({ isOpen, onClose, source, onMerge }: MergeDialogProps) {
47
+ const [step, setStep] = useState<Step>('select')
48
+ const [survivor, setSurvivor] = useState<LibraryElement | null>(null)
49
+ const [resolved, setResolved] = useState<Record<string, string>>({})
50
+ const [loading, setLoading] = useState(false)
51
+ const [error, setError] = useState<string | null>(null)
52
+ const { query: search, setQuery: setSearch, remoteElements, fetching } = useElementSearch()
53
+ const searchInputRef = useRef<HTMLInputElement>(null)
54
+
55
+ useEffect(() => {
56
+ if (!isOpen) return
57
+ const t = setTimeout(() => searchInputRef.current?.focus(), 30)
58
+ return () => clearTimeout(t)
59
+ }, [isOpen])
60
+
61
+ const candidates = useMemo(() => {
62
+ if (!source) return []
63
+ const results = remoteElements.filter((el) => el.id !== source.id)
64
+ return results
65
+ }, [source, remoteElements])
66
+
67
+ const conflicts = useMemo(() => {
68
+ if (!source || !survivor || !fieldsConflict(source, survivor)) return null
69
+ const items: { key: string; label: string; source: string | null; survivor: string | null }[] = []
70
+ if ((source.kind || null) !== (survivor.kind || null)) {
71
+ items.push({ key: 'kind', label: 'Type/Kind', source: source.kind || null, survivor: survivor.kind || null })
72
+ }
73
+ if ((source.description || null) !== (survivor.description || null)) {
74
+ items.push({ key: 'description', label: 'Description', source: source.description || null, survivor: survivor.description || null })
75
+ }
76
+ const srcGit = [source.repo, source.branch, source.file_path, source.language].filter(Boolean).join(' / ')
77
+ const surGit = [survivor.repo, survivor.branch, survivor.file_path, survivor.language].filter(Boolean).join(' / ')
78
+ if (srcGit !== surGit) {
79
+ items.push({ key: 'gitsource', label: 'Git Source', source: srcGit || null, survivor: surGit || null })
80
+ }
81
+ return items.length > 0 ? items : null
82
+ }, [source, survivor])
83
+
84
+ const handleSelect = useCallback((el: LibraryElement) => {
85
+ setSurvivor(el)
86
+ if (source && fieldsConflict(source, el)) {
87
+ setStep('resolve')
88
+ } else {
89
+ handleMerge(el.id)
90
+ }
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, [source])
93
+
94
+ const handleResolveChange = useCallback((key: string, value: string) => {
95
+ setResolved((prev) => ({ ...prev, [key]: value }))
96
+ }, [])
97
+
98
+ const handleMerge = useCallback(async (survivorId: number) => {
99
+ setLoading(true)
100
+ setError(null)
101
+ try {
102
+ const finalResolved: {
103
+ kind: string | null
104
+ description: string | null
105
+ repo: string | null
106
+ branch: string | null
107
+ file_path: string | null
108
+ language: string | null
109
+ } = { kind: null, description: null, repo: null, branch: null, file_path: null, language: null }
110
+
111
+ if (resolved.kind) finalResolved.kind = resolved.kind
112
+ if (resolved.description) finalResolved.description = resolved.description
113
+ if (resolved.gitsource) {
114
+ const parts = resolved.gitsource.split(' / ')
115
+ if (parts.length === 4) {
116
+ finalResolved.repo = parts[0] || null
117
+ finalResolved.branch = parts[1] || null
118
+ finalResolved.file_path = parts[2] || null
119
+ finalResolved.language = parts[3] || null
120
+ }
121
+ }
122
+ await onMerge(survivorId, finalResolved)
123
+ } catch (e) {
124
+ setError(e instanceof Error ? e.message : 'Merge failed')
125
+ } finally {
126
+ setLoading(false)
127
+ }
128
+ }, [resolved, onMerge])
129
+
130
+ const handleResolve = useCallback(async () => {
131
+ if (!survivor) return
132
+ await handleMerge(survivor.id)
133
+ }, [survivor, handleMerge])
134
+
135
+ const handleClose = useCallback(() => {
136
+ setStep('select')
137
+ setSearch('')
138
+ setSurvivor(null)
139
+ setResolved({})
140
+ setError(null)
141
+ onClose()
142
+ }, [onClose, setSearch])
143
+
144
+ if (!isOpen || !source) return null
145
+
146
+ return (
147
+ <Box position="fixed" inset={0} zIndex={1500} display="flex" alignItems="center" justifyContent="center">
148
+ <Box position="absolute" inset={0} bg="blackAlpha.800" onClick={handleClose} />
149
+ <Box position="relative" bg="var(--bg-panel)" border="1px solid" borderColor="whiteAlpha.100" rounded="xl"
150
+ boxShadow="0 8px 32px rgba(0,0,0,0.5)" backdropFilter="blur(20px)" mx={4} w="100%" maxW="440px" maxH="80vh" overflow="hidden">
151
+ <Box px={4} py={3} borderBottom="1px solid" borderColor="whiteAlpha.100">
152
+ <HStack justify="space-between">
153
+ <Text fontWeight="semibold" color="white">
154
+ {step === 'select' ? 'Merge Element Into...' : 'Resolve Conflicts'}
155
+ </Text>
156
+ <CloseButton size="sm" onClick={handleClose} />
157
+ </HStack>
158
+ </Box>
159
+
160
+ {step === 'select' && (
161
+ <>
162
+ <Box px={4} pt={3} pb={2}>
163
+ <Text fontSize="xs" color="gray.400">
164
+ Merging <Badge variant="subtle" colorScheme="blue" fontSize="xs" mx={0.5}>{source.name}</Badge> into another element. All connectors will be reassigned.
165
+ </Text>
166
+ </Box>
167
+ <Box px={4} pb={2}>
168
+ <InputGroup size="sm">
169
+ <InputLeftElement pointerEvents="none" color="gray.400" fontSize="xs">
170
+ <Text>&#x1F50D;</Text>
171
+ </InputLeftElement>
172
+ <Input ref={searchInputRef} placeholder="Search elements..." value={search} onChange={(e) => setSearch(e.target.value)}
173
+ autoFocus bg="whiteAlpha.50" borderColor="whiteAlpha.100" _hover={{ borderColor: 'whiteAlpha.200' }}
174
+ _focus={{ borderColor: 'var(--accent)' }} />
175
+ </InputGroup>
176
+ </Box>
177
+ <Box maxH="320px" overflowY="auto" px={1}>
178
+ {fetching ? (
179
+ <Box px={4} py={6} textAlign="center">
180
+ <Spinner size="sm" color="var(--accent)" />
181
+ </Box>
182
+ ) : !search.trim() ? (
183
+ <Box px={4} py={6} textAlign="center">
184
+ <Text fontSize="sm" color="gray.500">Type to search elements...</Text>
185
+ </Box>
186
+ ) : candidates.length === 0 ? (
187
+ <Box px={4} py={6} textAlign="center">
188
+ <Text fontSize="sm" color="gray.500">No matching elements</Text>
189
+ </Box>
190
+ ) : (
191
+ <VStack spacing={0} align="stretch" px={1} pb={2}>
192
+ {candidates.map((el) => (
193
+ <Button key={el.id} variant="ghost" size="sm" h="auto" py={2.5} px={3} justifyContent="flex-start"
194
+ color="clay.text" _hover={{ bg: 'whiteAlpha.100' }} onClick={() => handleSelect(el)}>
195
+ <VStack spacing={0.5} align="start" w="full">
196
+ <Text fontWeight="medium" fontSize="sm">{el.name}</Text>
197
+ {el.kind && <Badge variant="subtle" fontSize="2xs" colorScheme="gray">{el.kind}</Badge>}
198
+ </VStack>
199
+ </Button>
200
+ ))}
201
+ </VStack>
202
+ )}
203
+ </Box>
204
+ </>
205
+ )}
206
+
207
+ {step === 'resolve' && survivor && conflicts && (
208
+ <>
209
+ <Box px={4} py={2}>
210
+ <Text fontSize="xs" color="gray.400" mb={1}>
211
+ Some fields differ. Choose which value to keep for each conflict.
212
+ </Text>
213
+ <HStack spacing={2} mb={4}>
214
+ <Badge variant="subtle" colorScheme="red" fontSize="2xs">{source.name} (will be deleted)</Badge>
215
+ <Badge variant="subtle" colorScheme="green" fontSize="2xs">{survivor.name} (survivor)</Badge>
216
+ </HStack>
217
+ </Box>
218
+ <Box px={4} pb={2} maxH="280px" overflowY="auto">
219
+ <VStack spacing={4} align="stretch">
220
+ {conflicts.map((conflict) => (
221
+ <Box key={conflict.key} p={3} bg="whiteAlpha.50" rounded="md" border="1px solid" borderColor="whiteAlpha.100">
222
+ <Text fontSize="xs" fontWeight="medium" color="gray.300" mb={2}>{conflict.label}</Text>
223
+ <RadioGroup value={resolved[conflict.key] || ''} onChange={(val) => handleResolveChange(conflict.key, val)}>
224
+ <VStack spacing={1.5} align="stretch">
225
+ {conflict.source && conflict.source !== 'null' && conflict.source !== '' && (
226
+ <Radio value="source" size="sm">
227
+ <HStack spacing={1.5}>
228
+ <Text fontSize="xs" color="gray.300">{conflict.source}</Text>
229
+ <Badge variant="subtle" colorScheme="red" fontSize="2xs">source</Badge>
230
+ </HStack>
231
+ </Radio>
232
+ )}
233
+ {conflict.survivor && conflict.survivor !== 'null' && conflict.survivor !== '' && (
234
+ <Radio value="survivor" size="sm">
235
+ <HStack spacing={1.5}>
236
+ <Text fontSize="xs" color="gray.300">{conflict.survivor}</Text>
237
+ <Badge variant="subtle" colorScheme="green" fontSize="2xs">survivor</Badge>
238
+ </HStack>
239
+ </Radio>
240
+ )}
241
+ <Radio value="none" size="sm">
242
+ <Text fontSize="xs" color="gray.400">Leave empty</Text>
243
+ </Radio>
244
+ </VStack>
245
+ </RadioGroup>
246
+ </Box>
247
+ ))}
248
+ </VStack>
249
+ </Box>
250
+ <Box px={4} pt={2} pb={4} borderTop="1px solid" borderColor="whiteAlpha.100">
251
+ <HStack justify="space-between">
252
+ <Button variant="ghost" size="sm" onClick={() => setStep('select')}>Back</Button>
253
+ <Button size="sm" colorScheme="blue" onClick={handleResolve} isLoading={loading}>
254
+ Merge
255
+ </Button>
256
+ </HStack>
257
+ </Box>
258
+ </>
259
+ )}
260
+
261
+ {error && (
262
+ <Box px={4} pb={3}>
263
+ <Text fontSize="xs" color="red.300">{error}</Text>
264
+ </Box>
265
+ )}
266
+ </Box>
267
+ </Box>
268
+ )
269
+ }
@@ -8,6 +8,8 @@ export interface ElementContainerProps extends BoxProps {
8
8
  isSource?: boolean
9
9
  isTarget?: boolean
10
10
  isConnectorHighlighted?: boolean
11
+ hasStack?: boolean
12
+ kind?: string | null
11
13
  }
12
14
 
13
15
  export const ElementContainer = memo(forwardRef<ElementContainerProps, 'div'>(({
@@ -15,19 +17,24 @@ export const ElementContainer = memo(forwardRef<ElementContainerProps, 'div'>(({
15
17
  isSource,
16
18
  isTarget,
17
19
  isConnectorHighlighted,
20
+ hasStack,
21
+ kind: _kind,
18
22
  children,
19
23
  ...props
20
24
  }, ref) => {
21
25
  const { accent } = useAccentColor()
22
26
 
27
+ const brandedBorder = hexToRgba('#a0aec0', 0.5)
28
+
23
29
  const borderColor = isSource
24
30
  ? accent
25
31
  : isTarget
26
32
  ? 'teal.300'
27
33
  : isSelected || isConnectorHighlighted
28
34
  ? accent
29
- : 'gray.600'
35
+ : brandedBorder
30
36
 
37
+ // Shadows matching ZUICanvas / high-fidelity look
31
38
  const selectionShadow = `0 0 0 3px ${hexToRgba(accent, 0.35)}, 0 10px 36px rgba(0,0,0,0.55), 0 3px 10px rgba(0,0,0,0.4)`
32
39
  const sourceShadow = `0 0 0 3px ${hexToRgba(accent, 0.55)}, 0 0 24px ${hexToRgba(accent, 0.25)}`
33
40
  const edgeHighlightShadow = `0 0 0 2px ${hexToRgba(accent, 0.2)}, 0 8px 32px rgba(0,0,0,0.55), 0 2px 8px rgba(0,0,0,0.35)`
@@ -36,23 +43,54 @@ export const ElementContainer = memo(forwardRef<ElementContainerProps, 'div'>(({
36
43
 
37
44
  const boxShadow = isSource ? sourceShadow : isSelected ? selectionShadow : isConnectorHighlighted ? edgeHighlightShadow : restingShadow
38
45
 
46
+ const finalBorderColor = borderColor
47
+
39
48
  return (
40
- <Box
41
- ref={ref}
42
- bg="var(--bg-element)"
43
- borderColor={borderColor}
44
- borderWidth="1px"
45
- rounded="lg"
46
- boxShadow={boxShadow}
47
- transition="all var(--chakra-transitions-duration-fast) var(--chakra-transitions-easing-pop)"
48
- position="relative"
49
- _hover={{
50
- borderColor: isSource ? accent : isTarget ? 'teal.200' : accent,
51
- boxShadow: hoverShadow,
52
- }}
53
- {...props}
54
- >
55
- {children}
49
+ <Box position="relative" zIndex={1}>
50
+ {hasStack && (
51
+ <>
52
+ {/* Stack effect matching ZUI renderer.ts (offset 4px and 8px) */}
53
+ <Box
54
+ position="absolute"
55
+ inset={0}
56
+ transform="translate(8px, 8px)"
57
+ bg="var(--bg-element)"
58
+ borderColor={finalBorderColor}
59
+ borderWidth="1px"
60
+ rounded="lg"
61
+ opacity={0.4}
62
+ zIndex={-2}
63
+ />
64
+ <Box
65
+ position="absolute"
66
+ inset={0}
67
+ transform="translate(4px, 4px)"
68
+ bg="var(--bg-element)"
69
+ borderColor={finalBorderColor}
70
+ borderWidth="1px"
71
+ rounded="lg"
72
+ opacity={0.7}
73
+ zIndex={-1}
74
+ />
75
+ </>
76
+ )}
77
+ <Box
78
+ ref={ref}
79
+ bg="var(--bg-element)"
80
+ borderColor={finalBorderColor}
81
+ borderWidth="1px"
82
+ rounded="lg"
83
+ boxShadow={boxShadow}
84
+ transition="all var(--chakra-transitions-duration-fast) var(--chakra-transitions-easing-pop)"
85
+ position="relative"
86
+ _hover={{
87
+ borderColor: isSource ? accent : isTarget ? 'teal.200' : accent,
88
+ boxShadow: hoverShadow,
89
+ }}
90
+ {...props}
91
+ >
92
+ {children}
93
+ </Box>
56
94
  </Box>
57
95
  )
58
96
  }))