@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.
- 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
|
@@ -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 ??
|
|
77
|
-
const bPrimary = !!(b.is_primary_icon ??
|
|
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 ??
|
|
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 ??
|
|
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
|
|
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(
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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
|
-
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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="
|
|
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
|
|
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
|
|
54
|
+
return candidates.filter((o) => re.test(o.name)).slice(0, 8)
|
|
50
55
|
} catch {
|
|
51
56
|
const q = query.toLowerCase()
|
|
52
|
-
return
|
|
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>🔍</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
|
-
:
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
}))
|