@likec4/diagram 1.17.1 → 1.18.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 (49) hide show
  1. package/package.json +17 -15
  2. package/src/LikeC4Diagram.props.ts +21 -12
  3. package/src/LikeC4Diagram.tsx +5 -7
  4. package/src/LikeC4Search.tsx +54 -51
  5. package/src/hooks/useDiagramState.ts +8 -3
  6. package/src/hooks/useXYFlow.ts +2 -1
  7. package/src/likec4model/useLikeC4ElementsTree.ts +6 -13
  8. package/src/likec4model/useLikeC4Model.ts +21 -11
  9. package/src/overlays/Overlays.tsx +96 -63
  10. package/src/overlays/edge-details/EdgeDetailsXYFlow.tsx +3 -1
  11. package/src/overlays/edge-details/_types.ts +7 -3
  12. package/src/overlays/edge-details/use-layouted-edge-details.ts +21 -14
  13. package/src/overlays/edge-details/xyflow/ElementNode.tsx +4 -2
  14. package/src/overlays/edge-details/xyflow/RelationshipEdge.tsx +4 -4
  15. package/src/overlays/element-details/ElementDetailsCard.tsx +91 -78
  16. package/src/overlays/element-details/TabPanelDeployments.css.ts +69 -0
  17. package/src/overlays/element-details/TabPanelDeployments.tsx +217 -0
  18. package/src/overlays/element-details/TabPanelRelationships.tsx +8 -9
  19. package/src/overlays/element-details/TabPanelStructure.tsx +10 -10
  20. package/src/overlays/relationships-of/RelationshipsOverlay.tsx +1 -1
  21. package/src/overlays/relationships-of/RelationshipsXYFlow.tsx +3 -11
  22. package/src/overlays/relationships-of/SelectElement.tsx +2 -2
  23. package/src/overlays/relationships-of/_types.ts +2 -2
  24. package/src/overlays/relationships-of/use-layouted-relationships.ts +18 -19
  25. package/src/overlays/relationships-of/xyflow/CompoundNode.tsx +1 -3
  26. package/src/overlays/relationships-of/xyflow/ElementNode.tsx +12 -6
  27. package/src/overlays/relationships-of/xyflow/RelationshipEdge.tsx +0 -1
  28. package/src/state/diagramStore.ts +28 -11
  29. package/src/ui/DiagramTitlePanel.tsx +2 -29
  30. package/src/ui/Link.tsx +40 -0
  31. package/src/ui/notation/NotationPanel.tsx +4 -2
  32. package/src/ui/top-left/ChangeAutoLayoutButton.tsx +2 -2
  33. package/src/ui/top-left/TopLeftPanel.tsx +5 -5
  34. package/src/xyflow/edges/EdgeLabel.tsx +4 -4
  35. package/src/xyflow/edges/RelationshipsDropdownMenu.tsx +20 -7
  36. package/src/xyflow/nodes/compound/CompoundNode.css.ts +28 -13
  37. package/src/xyflow/nodes/compound/CompoundNode.tsx +51 -19
  38. package/src/xyflow/nodes/element/ElementNode.tsx +147 -124
  39. package/src/xyflow/nodes/element/element.css.ts +52 -19
  40. package/src/xyflow/nodes/shared/ElementIcon.tsx +1 -1
  41. package/src/xyflow/nodes/shared/Toolbar.tsx +24 -8
  42. package/src/xyflow/useLayoutConstraints.ts +1 -1
  43. package/src/xyflow/useXYFlowEvents.ts +18 -5
  44. package/src/ui/OptionsPanel.css.ts +0 -9
  45. package/src/ui/OptionsPanel.tsx +0 -70
  46. package/src/ui/options/EdgeOptions.tsx +0 -142
  47. package/src/ui/options/NodeOptions.tsx +0 -362
  48. package/src/xyflow/nodes/element/ElementDetails.css.ts +0 -101
  49. package/src/xyflow/nodes/element/ElementDetails.tsx +0 -393
@@ -1,13 +1,15 @@
1
1
  import {
2
- type ComputedView,
3
- type DiagramNode,
2
+ ComputedNode,
3
+ ComputedView,
4
4
  type DiagramView,
5
- type EdgeId,
6
5
  type Element,
7
6
  type Fqn,
8
7
  invariant,
8
+ isDeploymentView,
9
+ isScopedElementView,
10
+ type LikeC4View,
9
11
  type Link,
10
- type ViewID
12
+ type ViewId
11
13
  } from '@likec4/core'
12
14
  import {
13
15
  ActionIcon,
@@ -37,20 +39,20 @@ import {
37
39
  Tooltip as MantineTooltip,
38
40
  UnstyledButton
39
41
  } from '@mantine/core'
40
- import { useViewportSize } from '@mantine/hooks'
41
- import { IconCheck, IconCopy, IconExternalLink, IconFileSymlink, IconZoomScan } from '@tabler/icons-react'
42
- import { useInternalNode } from '@xyflow/react'
42
+ import { useSessionStorage, useViewportSize } from '@mantine/hooks'
43
+ import { IconCheck, IconCopy, IconExternalLink, IconFileSymlink, IconStack2, IconZoomScan } from '@tabler/icons-react'
43
44
  import clsx from 'clsx'
44
45
  import { m, type PanInfo, useDragControls, useMotionValue } from 'framer-motion'
45
- import { type PropsWithChildren, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
46
- import { clamp, find, isNullish, map, only, partition, pick, pipe, unique } from 'remeda'
47
- import { useDiagramState, useDiagramStoreApi, useXYFlow } from '../../hooks'
46
+ import { memo, type PropsWithChildren, useCallback, useEffect, useRef } from 'react'
47
+ import { clamp, find, isNullish, map, only, partition, pick, pipe } from 'remeda'
48
+ import { useDiagramState, useDiagramStoreApi, useXYFlow, useXYInternalNode } from '../../hooks'
48
49
  import type { ElementIconRenderer, OnNavigateTo } from '../../LikeC4Diagram.props'
49
- import { useLikeC4Model } from '../../likec4model'
50
+ import { useLikeC4CurrentViewModel } from '../../likec4model'
50
51
  import { useOverlayDialog } from '../OverlayContext'
51
52
  import * as css from './ElementDetailsCard.css'
53
+ import { TabPanelDeployments } from './TabPanelDeployments'
52
54
  import { TabPanelRelationships } from './TabPanelRelationships'
53
- import { TabElementStructure } from './TabPanelStructure'
55
+ import { TabPanelStructure } from './TabPanelStructure'
54
56
 
55
57
  const Divider = MantineDivider.withProps({
56
58
  mb: 8,
@@ -88,62 +90,68 @@ type ElementDetailsCardProps = {
88
90
 
89
91
  const MIN_PADDING = 24
90
92
 
91
- export function ElementDetailsCard({ fqn }: ElementDetailsCardProps) {
93
+ const TABS = ['Properties', 'Relationships', 'Views', 'Structure', 'Deployments'] as const
94
+ type TabName = typeof TABS[number]
95
+
96
+ export const ElementDetailsCard = memo(({ fqn }: ElementDetailsCardProps) => {
92
97
  const windowSize = useViewportSize()
93
98
  const windowWidth = windowSize.width || window.innerWidth || 1200,
94
99
  windowHeight = windowSize.height || window.innerHeight || 800
95
100
  const xyflow = useXYFlow()
96
- const xynode = useInternalNode(fqn)
101
+ const xynode = useXYInternalNode(fqn)
97
102
  invariant(xynode, `XYNode with id ${fqn} not found`)
98
103
 
99
104
  const overlay = useOverlayDialog()
100
- const [activeTab, setActiveTab] = useState<'Properties' | 'Relationships' | 'Views'>('Properties')
105
+ const [activeTab, setActiveTab] = useSessionStorage<TabName>({
106
+ key: `likec4:element-details:active-tab`,
107
+ defaultValue: 'Properties'
108
+ })
101
109
  const diagramApi = useDiagramStoreApi()
110
+ const viewModel = useLikeC4CurrentViewModel()
102
111
  const {
103
112
  view: currentView,
104
113
  renderIcon,
105
- onNavigateTo,
106
- onOpenSourceElement: onOpenSource
107
- } = useDiagramState(pick(['view', 'renderIcon', 'onNavigateTo', 'onOpenSourceElement']))
108
- const element = currentView.nodes.find(n => n.id === fqn)
109
- invariant(element, `DiagramNode with fqn ${fqn} not found`)
110
-
111
- const likec4Model = useLikeC4Model(true)
112
- const elementModel = likec4Model.element(fqn)
113
- const viewId = currentView.id
114
+ onOpenSource
115
+ } = useDiagramState(pick(['view', 'renderIcon', 'onNavigateTo', 'onOpenSource']))
116
+ const diagramNode = currentView.nodes.find(n => n.id === fqn)
117
+ invariant(diagramNode, `DiagramNode with fqn ${fqn} not found`)
118
+ const nodeModel = viewModel.findNode(fqn)
119
+ invariant(nodeModel && nodeModel.hasElement(), `NodeModel with fqn ${fqn} not found`)
120
+ const elementModel = nodeModel.element
121
+ const viewId = viewModel.id
114
122
 
115
123
  const elementIcon = ElementIcon({
116
- element,
124
+ element: nodeModel.$node,
117
125
  viewId,
118
126
  renderIcon
119
127
  })
120
128
 
121
- const incoming = elementModel.incoming().map(r => r.id)
122
- const outgoing = elementModel.outgoing().map(r => r.id)
129
+ // const incoming = elementModel.incoming().map(r => r.id).toArray()
130
+ // const outgoing = elementModel.outgoing().map(r => r.id).toArray()
123
131
 
124
- const findRelationIds = (edgeId: EdgeId) => currentView.edges.find((edge) => edge.id === edgeId)?.relations ?? []
132
+ // const incomingInView = unique(nodeModel.incoming().flatMap(e => e.$edge.relations).toArray())
133
+ // const outgoingInView = unique(nodeModel.outgoing().flatMap(e => e.$edge.relations).toArray())
125
134
 
126
- const incomingInView = unique(element.inEdges.flatMap(findRelationIds))
127
- const outgoingInView = unique(element.outEdges.flatMap(findRelationIds))
128
-
129
- const notIncludedRelations = [
130
- ...incoming,
131
- ...outgoing
132
- ].filter(r => !incomingInView.includes(r) && !outgoingInView.includes(r)).length
135
+ // const notIncludedRelations = [
136
+ // ...incoming,
137
+ // ...outgoing
138
+ // ].filter(r => !incomingInView.includes(r) && !outgoingInView.includes(r)).length
133
139
 
134
140
  const [viewsOf, otherViews] = pipe(
135
- elementModel.views(),
136
- map(v => v.view as ComputedView),
137
- partition(v => v.__ !== 'dynamic' && v.viewOf === fqn)
141
+ [...elementModel.views()],
142
+ map(v => v.$view),
143
+ partition(view => {
144
+ const v = view as LikeC4View
145
+ return isScopedElementView(v) && v.viewOf === fqn
146
+ })
138
147
  )
139
148
 
140
- const defaultView = element.navigateTo
141
- ? likec4Model.view(element.navigateTo).view
142
- : (find(viewsOf, v => v.id !== currentView.id) ?? null)
149
+ const defaultView = nodeModel.navigateTo?.$view
150
+ ?? find(viewsOf, v => v.id !== viewModel.id) ?? null
143
151
 
144
- const defaultLink = element.links && only(element.links)
152
+ const defaultLink = only(nodeModel.links)
145
153
 
146
- const onNavigateToCb = useCallback((toView: ViewID, e?: React.MouseEvent): void => {
154
+ const onNavigateToCb = useCallback((toView: ViewId, e?: React.MouseEvent): void => {
147
155
  e?.stopPropagation()
148
156
  const { onNavigateTo } = diagramApi.getState()
149
157
  if (!onNavigateTo) {
@@ -152,21 +160,21 @@ export function ElementDetailsCard({ fqn }: ElementDetailsCardProps) {
152
160
  overlay.close(() => {
153
161
  diagramApi.setState({
154
162
  lastOnNavigate: {
155
- fromView: currentView.id,
163
+ fromView: viewModel.id,
156
164
  toView,
157
165
  fromNode: fqn
158
166
  }
159
167
  })
160
168
  onNavigateTo(toView)
161
169
  })
162
- }, [fqn, currentView.id])
170
+ }, [fqn, viewModel.id])
163
171
  const controls = useDragControls()
164
172
 
165
- const isCompound = element.children.length > 0
173
+ const isCompound = nodeModel.children.length > 0
166
174
 
167
175
  const fromPositon = xyflow.flowToScreenPosition({
168
- x: xynode.internals.positionAbsolute.x + element.width / 2,
169
- y: xynode.internals.positionAbsolute.y + (isCompound ? 0 : element.height / 2)
176
+ x: xynode.internals.positionAbsolute.x + diagramNode.width / 2,
177
+ y: xynode.internals.positionAbsolute.y + (isCompound ? 0 : diagramNode.height / 2)
170
178
  })
171
179
 
172
180
  const _width = Math.min(700, windowWidth - MIN_PADDING * 2)
@@ -272,7 +280,7 @@ export function ElementDetailsCard({ fqn }: ElementDetailsCardProps) {
272
280
  overlay.close()
273
281
  }
274
282
  }}
275
- data-likec4-color={element.color}>
283
+ data-likec4-color={nodeModel.color}>
276
284
  <FocusTrap>
277
285
  <FocusTrapInitialFocus />
278
286
  <Box
@@ -289,9 +297,9 @@ export function ElementDetailsCard({ fqn }: ElementDetailsCardProps) {
289
297
  className={css.title}>
290
298
  {elementModel.title}
291
299
  </Text>
292
- {element.notation && (
300
+ {nodeModel.$node.notation && (
293
301
  <Text component="div" c={'dimmed'} fz={'sm'} fw={500} lh={1.3} lineClamp={1}>
294
- {element.notation}
302
+ {nodeModel.$node.notation}
295
303
  </Text>
296
304
  )}
297
305
  </Box>
@@ -307,15 +315,15 @@ export function ElementDetailsCard({ fqn }: ElementDetailsCardProps) {
307
315
  <Group align="baseline" gap={'sm'} wrap="nowrap">
308
316
  <Box>
309
317
  <SmallLabel>kind</SmallLabel>
310
- <Badge radius={'sm'} size="sm" fw={600} color="gray">{element.kind}</Badge>
318
+ <Badge radius={'sm'} size="sm" fw={600} color="gray">{elementModel.kind}</Badge>
311
319
  </Box>
312
320
  <Box flex={1}>
313
321
  <SmallLabel>tags</SmallLabel>
314
322
  <Flex gap={4} flex={1} mt={6}>
315
- {element.tags?.map((tag) => (
323
+ {elementModel.tags.map((tag) => (
316
324
  <Badge key={tag} radius={'sm'} size="sm" fw={600} variant="gradient">#{tag}</Badge>
317
325
  ))}
318
- {!element.tags && <Badge radius={'sm'} size="sm" fw={600} color="gray">—</Badge>}
326
+ {elementModel.tags.length === 0 && <Badge radius={'sm'} size="sm" fw={600} color="gray">—</Badge>}
319
327
  </Flex>
320
328
  </Box>
321
329
  <ActionIconGroup
@@ -342,7 +350,9 @@ export function ElementDetailsCard({ fqn }: ElementDetailsCardProps) {
342
350
  radius="sm"
343
351
  onClick={e => {
344
352
  e.stopPropagation()
345
- diagramApi.getState().onOpenSourceElement?.(fqn)
353
+ diagramApi.getState().onOpenSource?.({
354
+ element: elementModel.id
355
+ })
346
356
  }}
347
357
  >
348
358
  <IconFileSymlink stroke={1.8} style={{ width: '62%' }} />
@@ -377,40 +387,33 @@ export function ElementDetailsCard({ fqn }: ElementDetailsCardProps) {
377
387
  panel: css.tabsPanel
378
388
  }}>
379
389
  <TabsList>
380
- <TabsTab value="Properties">
381
- Properties
382
- </TabsTab>
383
- <TabsTab value="Relationships">
384
- Relationships
385
- </TabsTab>
386
- <TabsTab value="Views">
387
- Views
388
- </TabsTab>
389
- <TabsTab value="Structure">
390
- Structure
391
- </TabsTab>
390
+ {TABS.map(tab => (
391
+ <TabsTab key={tab} value={tab}>
392
+ {tab}
393
+ </TabsTab>
394
+ ))}
392
395
  </TabsList>
393
396
 
394
397
  <TabsPanel value="Properties">
395
398
  <ScrollArea scrollbars="y" type="auto">
396
399
  <Box className={css.propertiesGrid} pt={'xs'}>
397
400
  <ElementProperty title="description" emptyValue="no description">
398
- {element.description}
401
+ {elementModel.description}
399
402
  </ElementProperty>
400
- {element.technology && (
403
+ {elementModel.technology && (
401
404
  <ElementProperty title="technology">
402
- {element.technology}
405
+ {elementModel.technology}
403
406
  </ElementProperty>
404
407
  )}
405
- {element.links && (
408
+ {elementModel.links.length > 0 && (
406
409
  <>
407
410
  <PropertyLabel>links</PropertyLabel>
408
411
  <Stack gap={'xs'} align="flex-start">
409
- {element.links.map((link, i) => <ElementLink key={i} value={link} />)}
412
+ {elementModel.links.map((link, i) => <ElementLink key={i} value={link} />)}
410
413
  </Stack>
411
414
  </>
412
415
  )}
413
- {elementModel.element.metadata && <ElementMetata value={elementModel.element.metadata} />}
416
+ {elementModel.$element.metadata && <ElementMetata value={elementModel.$element.metadata} />}
414
417
  </Box>
415
418
  </ScrollArea>
416
419
  </TabsPanel>
@@ -419,6 +422,7 @@ export function ElementDetailsCard({ fqn }: ElementDetailsCardProps) {
419
422
  {activeTab === 'Relationships' && (
420
423
  <TabPanelRelationships
421
424
  element={elementModel}
425
+ node={nodeModel}
422
426
  currentView={currentView} />
423
427
  )}
424
428
  </TabsPanel>
@@ -460,7 +464,13 @@ export function ElementDetailsCard({ fqn }: ElementDetailsCardProps) {
460
464
 
461
465
  <TabsPanel value="Structure">
462
466
  <ScrollArea scrollbars="y" type="auto">
463
- <TabElementStructure element={elementModel} />
467
+ <TabPanelStructure element={elementModel} />
468
+ </ScrollArea>
469
+ </TabsPanel>
470
+
471
+ <TabsPanel value="Deployments">
472
+ <ScrollArea scrollbars="y" type="auto">
473
+ <TabPanelDeployments elementFqn={elementModel.id} />
464
474
  </ScrollArea>
465
475
  </TabsPanel>
466
476
  </Tabs>
@@ -477,16 +487,17 @@ export function ElementDetailsCard({ fqn }: ElementDetailsCardProps) {
477
487
  </RemoveScroll>
478
488
  </m.dialog>
479
489
  )
480
- }
490
+ })
491
+ ElementDetailsCard.displayName = 'ElementDetailsCard'
481
492
 
482
493
  const ElementIcon = (
483
494
  { element, viewId, renderIcon: RenderIcon }: {
484
- element: DiagramNode
495
+ element: ComputedNode
485
496
  viewId: string
486
497
  renderIcon: ElementIconRenderer | null
487
498
  }
488
499
  ) => {
489
- if (!element.icon) {
500
+ if (!element.icon || element.icon === 'none') {
490
501
  return null
491
502
  }
492
503
  let icon = null as React.ReactNode
@@ -525,7 +536,9 @@ const ViewButton = ({
525
536
  <UnstyledButton className={css.viewButton} onClick={e => onNavigateTo(view.id, e)}>
526
537
  <Group gap={6} align="start" wrap="nowrap">
527
538
  <ThemeIcon size={'sm'} variant="transparent">
528
- <IconZoomScan stroke={1.8} />
539
+ {isDeploymentView(view)
540
+ ? <IconStack2 stroke={1.8} />
541
+ : <IconZoomScan stroke={1.8} />}
529
542
  </ThemeIcon>
530
543
  <Box>
531
544
  <Text component="div" className={css.viewButtonTitle} lineClamp={1}>
@@ -0,0 +1,69 @@
1
+ import { rem, Text, ThemeIcon } from '@mantine/core'
2
+ import { globalStyle, style } from '@vanilla-extract/css'
3
+ import { easings, mantine, transitions, whereDark } from '../../theme-vars'
4
+
5
+ export const treeNode = style({})
6
+ globalStyle(`${treeNode}[data-level='1']`, {
7
+ marginBottom: rem(16)
8
+ })
9
+
10
+ export const treeNodeLabel = style({
11
+ cursor: 'default',
12
+ marginTop: 0,
13
+ marginBottom: 0
14
+ })
15
+
16
+ const label = style({
17
+ transition: transitions.fast,
18
+ color: mantine.colors.gray[7],
19
+ ':hover': {
20
+ transitionTimingFunction: easings.out
21
+ },
22
+ selectors: {
23
+ [`${whereDark} &`]: {
24
+ color: mantine.colors.dark[1]
25
+ }
26
+ }
27
+ })
28
+ globalStyle(`${label} > *`, {
29
+ transition: transitions.fast
30
+ })
31
+ globalStyle(`${label}:hover > :not([data-no-transform])`, {
32
+ transitionTimingFunction: easings.out,
33
+ transform: 'translateX(1px)'
34
+ })
35
+ globalStyle(`.mantine-Button-root:hover ${label} > :not([data-no-transform])`, {
36
+ transitionTimingFunction: easings.out,
37
+ transform: 'translateX(1px)'
38
+ })
39
+
40
+ export const nodeLabel = style([label, {}])
41
+
42
+ export const instanceLabel = style([label, {
43
+ cursor: 'pointer',
44
+ width: '100%',
45
+ justifyContent: 'stretch',
46
+ flexWrap: 'nowrap',
47
+ height: rem(36),
48
+ paddingInlineStart: rem(16),
49
+ paddingInlineEnd: rem(10),
50
+ borderRadius: mantine.radius.sm,
51
+ alignItems: 'center',
52
+ color: mantine.colors.gray[7],
53
+ selectors: {
54
+ [`${whereDark} &`]: {
55
+ color: mantine.colors.gray.lightColor
56
+ }
57
+ },
58
+ ':hover': {
59
+ background: mantine.colors.gray.lightHover
60
+ }
61
+ }])
62
+ globalStyle(`${instanceLabel} .tabler-icon`, {
63
+ transition: transitions.fast,
64
+ width: '90%',
65
+ opacity: 0.65
66
+ })
67
+ // globalStyle(`.mantine-Button-root:hover ${instanceLabel} .tabler-icon`, {
68
+ // opacity: 0.8
69
+ // })
@@ -0,0 +1,217 @@
1
+ import type { Fqn, LikeC4Model } from '@likec4/core'
2
+ import { Alert, Box, Button, Group, Menu, rem, Text, ThemeIcon, Tree, useTree } from '@mantine/core'
3
+ import { IconInfoCircle, IconTarget, IconZoomScan } from '@tabler/icons-react'
4
+ import { memo, type ReactNode, useEffect, useMemo } from 'react'
5
+ import { useCurrentViewId, useDiagramStoreApi } from '../../hooks'
6
+ import { useLikeC4ElementModel } from '../../likec4model/useLikeC4Model'
7
+ import { stopPropagation } from '../../xyflow/utils'
8
+ import * as css from './TabPanelDeployments.css'
9
+
10
+ interface TreeNodeData {
11
+ label: ReactNode
12
+ value: string
13
+ type: 'node' | 'instance'
14
+ children: TreeNodeData[]
15
+ }
16
+
17
+ type TabPanelDeploymentsProps = {
18
+ elementFqn: Fqn
19
+ }
20
+
21
+ const DeploymentNodeRenderer = ({
22
+ node
23
+ }: {
24
+ node: LikeC4Model.DeploymentNode
25
+ }) => (
26
+ <Group className={css.nodeLabel} gap={6} align="baseline" wrap="nowrap">
27
+ <Text component="div" fz={11} c="dimmed">{node.kind}:</Text>
28
+ <Text component="div" fz={'sm'} fw={'500'}>{node.title}</Text>
29
+ </Group>
30
+ )
31
+
32
+ const DeployedInstanceRenderer = (
33
+ {
34
+ instance
35
+ }: {
36
+ instance: LikeC4Model.DeployedInstance
37
+ }
38
+ ) => {
39
+ const currentViewId = useCurrentViewId()
40
+ const diagramApi = useDiagramStoreApi()
41
+ const views = [...instance.views()]
42
+ return (
43
+ (
44
+ <Group className={css.instanceLabel} gap={4}>
45
+ <ThemeIcon color="gray" variant="transparent" size={'xs'} flex={0}>
46
+ <IconTarget stroke={1.2} />
47
+ </ThemeIcon>
48
+ <Text component="div" fz={'sm'} fw={'500'} flex={'1 1 100%'}>{instance.title}</Text>
49
+ <Box onClick={stopPropagation} pos={'relative'} data-no-transform flex={0}>
50
+ {views.length === 0 && (
51
+ <Button size="compact-xs" variant="transparent" color="gray" disabled>
52
+ no views
53
+ </Button>
54
+ )}
55
+ {views.length > 0 && (
56
+ <Menu
57
+ shadow="md"
58
+ withinPortal={false}
59
+ position="bottom-start"
60
+ offset={0}
61
+ // trigger={'click-hover'}
62
+ // openDelay={100}
63
+ // closeDelay={200}
64
+ closeOnClickOutside
65
+ clickOutsideEvents={['pointerdown', 'mousedown', 'click']}
66
+ closeOnEscape
67
+ trapFocus
68
+ >
69
+ <Menu.Target>
70
+ <Button size="compact-xs" variant="subtle" color="gray">
71
+ {views.length} view{views.length > 1 ? 's' : ''}
72
+ </Button>
73
+ </Menu.Target>
74
+
75
+ <Menu.Dropdown>
76
+ {views.map(view => (
77
+ <Menu.Item
78
+ key={view.id}
79
+ px={'xs'}
80
+ py={4}
81
+ disabled={view.id === currentViewId}
82
+ leftSection={
83
+ <ThemeIcon size={'sm'} variant="transparent" color="gray">
84
+ <IconZoomScan stroke={1.8} opacity={0.65} />
85
+ </ThemeIcon>
86
+ }
87
+ styles={{
88
+ itemSection: {
89
+ marginInlineEnd: rem(8)
90
+ }
91
+ }}
92
+ onClick={e => {
93
+ e.stopPropagation()
94
+ diagramApi.getState().onNavigateTo?.(view.id)
95
+ }}>
96
+ {view.title}
97
+ </Menu.Item>
98
+ ))}
99
+ </Menu.Dropdown>
100
+ </Menu>
101
+ )}
102
+ </Box>
103
+ </Group>
104
+ )
105
+ )
106
+ }
107
+
108
+ export const TabPanelDeployments = memo<TabPanelDeploymentsProps>(({ elementFqn }) => {
109
+ const element = useLikeC4ElementModel(elementFqn)
110
+ const deployments = [...element.deployments()]
111
+
112
+ const tree = useTree({
113
+ multiple: false
114
+ })
115
+
116
+ const data = useMemo(() => {
117
+ let roots = [] as TreeNodeData[]
118
+ let treeItems = new Map<string, TreeNodeData>()
119
+
120
+ for (const instance of element.deployments()) {
121
+ let instanceNode: TreeNodeData = {
122
+ label: <DeployedInstanceRenderer instance={instance} />,
123
+ value: instance.id,
124
+ type: 'instance' as const,
125
+ children: []
126
+ }
127
+ treeItems.set(instance.id, instanceNode)
128
+
129
+ let ancestor = instance.parent as LikeC4Model.DeploymentNode | null
130
+ while (ancestor) {
131
+ let ancestorNode = treeItems.get(ancestor.id)
132
+ if (ancestorNode) {
133
+ ancestorNode.children.push(instanceNode)
134
+ break
135
+ }
136
+ ancestorNode = {
137
+ label: <DeploymentNodeRenderer node={ancestor} />,
138
+ value: ancestor.id,
139
+ type: 'node' as const,
140
+ children: [instanceNode]
141
+ }
142
+ treeItems.set(ancestor.id, ancestorNode)
143
+ instanceNode = ancestorNode
144
+ ancestor = ancestor.parent
145
+ }
146
+
147
+ if (!ancestor && !roots.includes(instanceNode)) {
148
+ roots.push(instanceNode)
149
+ }
150
+ }
151
+ return roots
152
+ }, [element])
153
+
154
+ useEffect(() => {
155
+ tree.expandAllNodes()
156
+ }, [data])
157
+
158
+ if (deployments.length === 0) {
159
+ return (
160
+ <Alert variant="light" color="gray" icon={<IconInfoCircle />}>
161
+ This element does not have any deployments
162
+ </Alert>
163
+ )
164
+ }
165
+
166
+ return (
167
+ // <Box></Box>
168
+ <Tree
169
+ levelOffset={'sm'}
170
+ allowRangeSelection={false}
171
+ classNames={{
172
+ node: css.treeNode,
173
+ label: css.treeNodeLabel
174
+ }}
175
+ styles={{
176
+ root: {
177
+ position: 'relative',
178
+
179
+ width: 'min-content',
180
+ minWidth: 300
181
+ }
182
+ }}
183
+ data={data}
184
+ tree={tree}
185
+ renderNode={({ node, selected, elementProps, hasChildren }) => (
186
+ <Box
187
+ {...elementProps}
188
+ style={{
189
+ ...(!hasChildren && {
190
+ marginBottom: rem(4)
191
+ })
192
+ }}
193
+ >
194
+ {hasChildren
195
+ ? (
196
+ <Button
197
+ fullWidth
198
+ color={'gray'}
199
+ variant={selected ? 'transparent' : 'subtle'}
200
+ size="xs"
201
+ justify="flex-start"
202
+ styles={{
203
+ root: {
204
+ position: 'unset',
205
+ paddingInlineStart: rem(16)
206
+ }
207
+ }}
208
+ >
209
+ {node.label}
210
+ </Button>
211
+ )
212
+ : node.label}
213
+ </Box>
214
+ )}
215
+ />
216
+ )
217
+ })
@@ -1,4 +1,4 @@
1
- import { type DiagramView, type EdgeId, type LikeC4Model, nameFromFqn, nonNullable } from '@likec4/core'
1
+ import { type DiagramView, type LikeC4Model, nameFromFqn } from '@likec4/core'
2
2
  import { Box, Button, Group, Paper, SegmentedControl, Stack, Text, ThemeIcon } from '@mantine/core'
3
3
  import { useId } from '@mantine/hooks'
4
4
  import { IconArrowRight, IconExternalLink, IconInfoCircle } from '@tabler/icons-react'
@@ -14,26 +14,25 @@ import * as css from './TabPanelRelationships.css'
14
14
 
15
15
  type RelationshipsTabPanelProps = {
16
16
  currentView: DiagramView
17
- element: LikeC4Model.ElementModel
17
+ node: LikeC4Model.Node
18
+ element: LikeC4Model.Element
18
19
  }
19
20
 
20
21
  export function TabPanelRelationships({
21
22
  currentView,
23
+ node,
22
24
  element
23
25
  }: RelationshipsTabPanelProps) {
24
26
  const layoutId = useId()
25
27
  const enableRelationshipBrowser = useDiagramState(s => s.enableRelationshipBrowser)
26
28
  const overlay = useOverlayDialog()
27
29
  const [scope, setScope] = useState<'global' | 'view'>('view')
28
- const node = nonNullable(currentView.nodes.find((n) => n.id === element.id))
29
30
 
30
- const incoming = element.incoming().map(r => r.id)
31
- const outgoing = element.outgoing().map(r => r.id)
31
+ const incoming = element.incoming().map(r => r.id).toArray()
32
+ const outgoing = element.outgoing().map(r => r.id).toArray()
32
33
 
33
- const findRelationIds = (edgeId: EdgeId) => currentView.edges.find((edge) => edge.id === edgeId)?.relations ?? []
34
-
35
- const incomingInView = unique(node.inEdges.flatMap(findRelationIds))
36
- const outgoingInView = unique(node.outEdges.flatMap(findRelationIds))
34
+ const incomingInView = unique(node.incoming().flatMap(e => e.$edge.relations).toArray())
35
+ const outgoingInView = unique(node.outgoing().flatMap(e => e.$edge.relations).toArray())
37
36
 
38
37
  const notIncludedRelations = [
39
38
  ...incoming,