@tldiagram/core-ui 1.95.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/api/client.d.ts +184 -3
  2. package/dist/components/ConnectorPanel.d.ts +5 -1
  3. package/dist/components/CrossBranchControls.d.ts +4 -3
  4. package/dist/components/ElementNode.d.ts +5 -0
  5. package/dist/components/ElementPanel.d.ts +6 -1
  6. package/dist/components/LayoutSection.d.ts +2 -1
  7. package/dist/components/MergeDialog.d.ts +16 -0
  8. package/dist/components/NodeContainer.d.ts +2 -0
  9. package/dist/components/ProxyConnectorPanel.d.ts +4 -1
  10. package/dist/components/ViewExplorer/index.d.ts +1 -1
  11. package/dist/components/ViewFloatingMenu-vscode.d.ts +5 -0
  12. package/dist/components/ViewFloatingMenu.d.ts +8 -1
  13. package/dist/components/ViewGridNode.d.ts +3 -0
  14. package/dist/components/ViewPanel.d.ts +2 -1
  15. package/dist/components/WorkspacePanel.d.ts +2 -0
  16. package/dist/components/ZUI/ZUICanvas.d.ts +4 -0
  17. package/dist/components/ZUI/focus.d.ts +32 -0
  18. package/dist/components/ZUI/focus.test.d.ts +1 -0
  19. package/dist/components/ZUI/layout.d.ts +2 -2
  20. package/dist/components/ZUI/proxy.d.ts +20 -4
  21. package/dist/components/ZUI/renderer.d.ts +35 -1
  22. package/dist/components/ZUI/types.d.ts +6 -0
  23. package/dist/components/ZUI/useZUIInteraction.d.ts +1 -0
  24. package/dist/context/WorkspaceVersionContext.d.ts +49 -0
  25. package/dist/crossBranch/resolve.d.ts +39 -2
  26. package/dist/crossBranch/resolve.test.d.ts +1 -0
  27. package/dist/crossBranch/settings.d.ts +6 -1
  28. package/dist/crossBranch/types.d.ts +8 -0
  29. package/dist/hooks/useElementSearch.d.ts +8 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +16529 -14030
  32. package/dist/pages/InfiniteZoom.d.ts +1 -0
  33. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +6 -1
  34. package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +2 -0
  35. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +4 -2
  36. package/dist/pages/ViewEditor/hooks/useViewEditHistory.d.ts +13 -0
  37. package/dist/pages/viewsJumpSearch.d.ts +22 -0
  38. package/dist/pages/viewsJumpSearch.test.d.ts +1 -0
  39. package/dist/store/useStore.d.ts +3 -0
  40. package/dist/types/index.d.ts +9 -0
  41. package/dist/utils/elementIcon.d.ts +2 -0
  42. package/dist/utils/elementIcon.test.d.ts +1 -0
  43. package/dist/utils/sourceEditor.d.ts +7 -0
  44. package/dist/utils/watchDiffSummary.d.ts +34 -0
  45. package/package.json +2 -2
  46. package/src/App.tsx +12 -8
  47. package/src/api/client.ts +488 -26
  48. package/src/components/CodePreviewPanel.tsx +90 -16
  49. package/src/components/ConnectorPanel.tsx +34 -3
  50. package/src/components/ContextNeighborElement.tsx +2 -5
  51. package/src/components/CrossBranchControls.tsx +46 -17
  52. package/src/components/ElementNode.tsx +98 -47
  53. package/src/components/ElementPanel.tsx +62 -25
  54. package/src/components/InlineElementAdder.tsx +8 -3
  55. package/src/components/LayoutSection.tsx +4 -1
  56. package/src/components/MergeDialog.tsx +269 -0
  57. package/src/components/NodeContainer.tsx +55 -17
  58. package/src/components/ProxyConnectorPanel.tsx +58 -16
  59. package/src/components/ViewBezierConnector.tsx +116 -21
  60. package/src/components/ViewExplorer/index.tsx +1 -1
  61. package/src/components/ViewFloatingMenu-vscode.tsx +5 -0
  62. package/src/components/ViewFloatingMenu.tsx +110 -1
  63. package/src/components/ViewGridNode.tsx +59 -8
  64. package/src/components/ViewPanel.tsx +3 -2
  65. package/src/components/WorkspacePanel.tsx +938 -0
  66. package/src/components/ZUI/ZUICanvas.tsx +216 -122
  67. package/src/components/ZUI/focus.test.ts +534 -0
  68. package/src/components/ZUI/focus.ts +293 -0
  69. package/src/components/ZUI/layout.ts +7 -11
  70. package/src/components/ZUI/proxy.ts +470 -114
  71. package/src/components/ZUI/renderer.ts +510 -134
  72. package/src/components/ZUI/types.ts +6 -0
  73. package/src/components/ZUI/useZUIInteraction.ts +66 -29
  74. package/src/context/WorkspaceVersionContext.tsx +126 -0
  75. package/src/crossBranch/resolve.test.ts +342 -0
  76. package/src/crossBranch/resolve.ts +368 -68
  77. package/src/crossBranch/settings.ts +49 -3
  78. package/src/crossBranch/types.ts +9 -0
  79. package/src/hooks/useElementSearch.ts +45 -0
  80. package/src/index.css +11 -0
  81. package/src/index.ts +7 -0
  82. package/src/pages/AppearanceSettings.tsx +24 -1
  83. package/src/pages/Dependencies.tsx +231 -65
  84. package/src/pages/InfiniteZoom.tsx +41 -19
  85. package/src/pages/Settings.tsx +1 -1
  86. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +103 -24
  87. package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +102 -6
  88. package/src/pages/ViewEditor/hooks/useViewData.ts +42 -26
  89. package/src/pages/ViewEditor/hooks/useViewEditHistory.ts +62 -0
  90. package/src/pages/ViewEditor/index.tsx +549 -59
  91. package/src/pages/Views.tsx +112 -41
  92. package/src/pages/ViewsGrid.tsx +332 -113
  93. package/src/pages/viewsJumpSearch.test.ts +193 -0
  94. package/src/pages/viewsJumpSearch.ts +111 -0
  95. package/src/store/useStore.ts +58 -0
  96. package/src/types/index.ts +10 -0
  97. package/src/utils/elementIcon.test.ts +28 -0
  98. package/src/utils/elementIcon.ts +20 -0
  99. package/src/utils/sourceEditor.ts +46 -0
  100. package/src/utils/watchDiffSummary.ts +159 -0
@@ -0,0 +1,193 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildJumpSearchResults,
4
+ flattenTree,
5
+ jumpResultActionLabel,
6
+ jumpResultSubtitle,
7
+ } from './viewsJumpSearch'
8
+ import type { ExploreData, PlacedElement, ViewTreeNode } from '../types'
9
+
10
+ function viewNode(id: number, name: string, children: ViewTreeNode[] = [], level = 1): ViewTreeNode {
11
+ return {
12
+ id,
13
+ owner_element_id: null,
14
+ name,
15
+ description: null,
16
+ level_label: level === 1 ? 'System' : null,
17
+ level,
18
+ depth: level - 1,
19
+ created_at: '2024-01-01',
20
+ updated_at: '2024-01-01',
21
+ parent_view_id: null,
22
+ children,
23
+ }
24
+ }
25
+
26
+ function placed(viewId: number, elementId: number, name: string, overrides: Partial<PlacedElement> = {}): PlacedElement {
27
+ return {
28
+ id: viewId * 1000 + elementId,
29
+ view_id: viewId,
30
+ element_id: elementId,
31
+ position_x: 0,
32
+ position_y: 0,
33
+ name,
34
+ description: null,
35
+ kind: 'service',
36
+ technology: null,
37
+ url: null,
38
+ logo_url: null,
39
+ technology_connectors: [],
40
+ tags: [],
41
+ has_view: false,
42
+ view_label: null,
43
+ ...overrides,
44
+ }
45
+ }
46
+
47
+ function exploreData(tree: ViewTreeNode[], placementsByView: Record<string, PlacedElement[]>): ExploreData {
48
+ return {
49
+ tree,
50
+ navigations: [],
51
+ views: Object.fromEntries(
52
+ Object.entries(placementsByView).map(([viewId, placements]) => [
53
+ viewId,
54
+ { placements, connectors: [] },
55
+ ]),
56
+ ),
57
+ }
58
+ }
59
+
60
+ describe('views jump search', () => {
61
+ const tree = [
62
+ viewNode(1, 'Workspace', [
63
+ viewNode(2, 'Payments', [], 2),
64
+ viewNode(3, 'Platform Payments', [], 2),
65
+ viewNode(4, 'Identity', [], 2),
66
+ ]),
67
+ ]
68
+ const flatTree = flattenTree(tree)
69
+
70
+ it('flattens the view tree in navigation order', () => {
71
+ expect(flatTree.map((node) => node.name)).toEqual([
72
+ 'Workspace',
73
+ 'Payments',
74
+ 'Platform Payments',
75
+ 'Identity',
76
+ ])
77
+ })
78
+
79
+ it('returns view results without requiring workspace placement data', () => {
80
+ const results = buildJumpSearchResults('payments', flatTree, null)
81
+
82
+ expect(results).toEqual([
83
+ {
84
+ type: 'view',
85
+ key: 'view:2',
86
+ name: 'Payments',
87
+ viewId: 2,
88
+ level: 2,
89
+ levelLabel: null,
90
+ },
91
+ {
92
+ type: 'view',
93
+ key: 'view:3',
94
+ name: 'Platform Payments',
95
+ viewId: 3,
96
+ level: 2,
97
+ levelLabel: null,
98
+ },
99
+ ])
100
+ })
101
+
102
+ it('builds element navigation results from names and metadata fields', () => {
103
+ const data = exploreData(tree, {
104
+ 2: [
105
+ placed(2, 201, 'Checkout API', {
106
+ kind: 'api',
107
+ technology: 'Node',
108
+ file_path: 'services/checkout/index.ts',
109
+ tags: ['critical-path'],
110
+ }),
111
+ ],
112
+ 4: [
113
+ placed(4, 401, 'Session Store', {
114
+ kind: 'database',
115
+ technology: 'Redis',
116
+ tags: ['auth'],
117
+ }),
118
+ ],
119
+ })
120
+
121
+ const byName = buildJumpSearchResults('checkout', flatTree, data)
122
+ expect(byName).toContainEqual({
123
+ type: 'element',
124
+ key: 'element:2:201',
125
+ name: 'Checkout API',
126
+ viewId: 2,
127
+ viewName: 'Payments',
128
+ elementId: 201,
129
+ kind: 'api',
130
+ })
131
+
132
+ const byTag = buildJumpSearchResults('critical', flatTree, data)
133
+ expect(byTag.map((result) => result.key)).toEqual(['element:2:201'])
134
+
135
+ const byTechnology = buildJumpSearchResults('redis', flatTree, data)
136
+ expect(byTechnology.map((result) => result.key)).toEqual(['element:4:401'])
137
+
138
+ const byPath = buildJumpSearchResults('services/checkout', flatTree, data)
139
+ expect(byPath.map((result) => result.key)).toEqual(['element:2:201'])
140
+ })
141
+
142
+ it('keeps same-element placements in different views as distinct navigation targets', () => {
143
+ const data = exploreData(tree, {
144
+ 2: [placed(2, 501, 'Shared Logger')],
145
+ 3: [placed(3, 501, 'Shared Logger')],
146
+ })
147
+
148
+ const results = buildJumpSearchResults('shared logger', flatTree, data)
149
+
150
+ expect(results).toEqual([
151
+ expect.objectContaining({ type: 'element', key: 'element:2:501', viewId: 2, elementId: 501 }),
152
+ expect.objectContaining({ type: 'element', key: 'element:3:501', viewId: 3, elementId: 501 }),
153
+ ])
154
+ })
155
+
156
+ it('caps result groups while preserving view-first ordering', () => {
157
+ const manyViews = [
158
+ viewNode(10, 'Alpha Root', [
159
+ viewNode(11, 'Alpha Billing', [], 2),
160
+ viewNode(12, 'Alpha Catalog', [], 2),
161
+ viewNode(13, 'Alpha Checkout', [], 2),
162
+ viewNode(14, 'Alpha Delivery', [], 2),
163
+ viewNode(15, 'Alpha Events', [], 2),
164
+ ]),
165
+ ]
166
+ const manyFlatTree = flattenTree(manyViews)
167
+ const data = exploreData(manyViews, {
168
+ 11: Array.from({ length: 8 }, (_, index) => placed(11, 700 + index, `Alpha Element ${index}`)),
169
+ })
170
+
171
+ const results = buildJumpSearchResults('alpha', manyFlatTree, data)
172
+
173
+ expect(results).toHaveLength(8)
174
+ expect(results.filter((result) => result.type === 'view')).toHaveLength(4)
175
+ expect(results.filter((result) => result.type === 'element')).toHaveLength(4)
176
+ expect(results.slice(0, 4).every((result) => result.type === 'view')).toBe(true)
177
+ })
178
+
179
+ it('formats result hints used by the toolbar', () => {
180
+ expect(buildJumpSearchResults('id', flatTree, null)).toEqual([])
181
+ expect(jumpResultActionLabel('explore')).toBe('ZOOM')
182
+ expect(jumpResultActionLabel('hierarchy')).toBe('OPEN')
183
+
184
+ const [viewResult] = buildJumpSearchResults('payments', flatTree, null)
185
+ expect(jumpResultSubtitle(viewResult)).toBe('Level 2 • Diagram')
186
+
187
+ const data = exploreData(tree, {
188
+ 4: [placed(4, 401, 'Session Store', { kind: null })],
189
+ })
190
+ const [elementResult] = buildJumpSearchResults('session', flatTree, data)
191
+ expect(jumpResultSubtitle(elementResult)).toBe('Element • Identity')
192
+ })
193
+ })
@@ -0,0 +1,111 @@
1
+ import type { ExploreData, PlacedElement, ViewTreeNode } from '../types'
2
+
3
+ export type JumpViewMode = 'explore' | 'hierarchy'
4
+
5
+ export type JumpSearchResult =
6
+ | {
7
+ type: 'view'
8
+ key: string
9
+ name: string
10
+ viewId: number
11
+ level: number
12
+ levelLabel: string | null
13
+ }
14
+ | {
15
+ type: 'element'
16
+ key: string
17
+ name: string
18
+ viewId: number
19
+ viewName: string
20
+ elementId: number
21
+ kind: string | null
22
+ }
23
+
24
+ export function flattenTree(roots: ViewTreeNode[]): ViewTreeNode[] {
25
+ const result: ViewTreeNode[] = []
26
+ const traverse = (node: ViewTreeNode) => {
27
+ result.push(node)
28
+ node.children.forEach(traverse)
29
+ }
30
+ roots.forEach(traverse)
31
+ return result
32
+ }
33
+
34
+ export function jumpResultSubtitle(result: JumpSearchResult): string {
35
+ if (result.type === 'view') {
36
+ return `Level ${result.level} • ${result.levelLabel || 'Diagram'}`
37
+ }
38
+ return `${result.kind || 'Element'} • ${result.viewName}`
39
+ }
40
+
41
+ export function jumpResultActionLabel(view: JumpViewMode): string {
42
+ if (view === 'explore') return 'ZOOM'
43
+ return 'OPEN'
44
+ }
45
+
46
+ function searchScore(value: string, normalizedTerm: string): number {
47
+ const normalizedValue = value.toLowerCase()
48
+ if (normalizedValue === normalizedTerm) return 0
49
+ if (normalizedValue.startsWith(normalizedTerm)) return 1
50
+ return normalizedValue.includes(normalizedTerm) ? 2 : 3
51
+ }
52
+
53
+ function placementMatches(placement: PlacedElement, normalizedTerm: string): boolean {
54
+ return [
55
+ placement.name,
56
+ placement.kind,
57
+ placement.technology,
58
+ placement.file_path,
59
+ ...(placement.tags ?? []),
60
+ ]
61
+ .filter(Boolean)
62
+ .some((value) => String(value).toLowerCase().includes(normalizedTerm))
63
+ }
64
+
65
+ export function buildJumpSearchResults(term: string, flatTree: ViewTreeNode[], exploreData: ExploreData | null): JumpSearchResult[] {
66
+ const normalized = term.trim().toLowerCase()
67
+ if (normalized.length < 3) return []
68
+
69
+ const viewById = new Map(flatTree.map((node) => [node.id, node]))
70
+ const viewResults: JumpSearchResult[] = flatTree
71
+ .filter((node) => node.name.toLowerCase().includes(normalized))
72
+ .sort((a, b) => searchScore(a.name, normalized) - searchScore(b.name, normalized) || a.name.localeCompare(b.name))
73
+ .slice(0, 4)
74
+ .map((node) => ({
75
+ type: 'view',
76
+ key: `view:${node.id}`,
77
+ name: node.name,
78
+ viewId: node.id,
79
+ level: node.level,
80
+ levelLabel: node.level_label,
81
+ }))
82
+
83
+ const elementResults: JumpSearchResult[] = []
84
+ if (exploreData) {
85
+ Object.entries(exploreData.views ?? {}).forEach(([viewIdText, content]) => {
86
+ const viewId = Number(viewIdText)
87
+ if (!Number.isFinite(viewId)) return
88
+ const viewName = viewById.get(viewId)?.name ?? `View ${viewId}`
89
+ ;(content.placements ?? []).forEach((placement) => {
90
+ if (!placementMatches(placement, normalized)) return
91
+ elementResults.push({
92
+ type: 'element',
93
+ key: `element:${viewId}:${placement.element_id}`,
94
+ name: placement.name || `Element ${placement.element_id}`,
95
+ viewId,
96
+ viewName,
97
+ elementId: placement.element_id,
98
+ kind: placement.kind,
99
+ })
100
+ })
101
+ })
102
+ }
103
+
104
+ const dedupedElements = Array.from(
105
+ new Map(elementResults.map((result) => [result.key, result])).values(),
106
+ )
107
+ .sort((a, b) => searchScore(a.name, normalized) - searchScore(b.name, normalized) || a.name.localeCompare(b.name))
108
+ .slice(0, 6)
109
+
110
+ return [...viewResults, ...dedupedElements].slice(0, 8)
111
+ }
@@ -66,6 +66,7 @@ export type CanvasStoreState = ViewEditorUiState & {
66
66
  removeElementPlacement: (elementId: number) => void
67
67
  removeElementEverywhere: (elementId: number) => void
68
68
  mergeSavedElement: (saved: LibraryElement) => void
69
+ mergeElementsInto: (sourceId: number, survivor: LibraryElement) => void
69
70
  upsertConnector: (connector: Connector) => void
70
71
  replaceConnector: (connector: Connector) => void
71
72
  removeConnector: (connectorId: number) => void
@@ -254,6 +255,56 @@ export function removeConnectorFromList(connectors: Connector[], connectorId: nu
254
255
  return connectors.filter((connector) => connector.id !== connectorId)
255
256
  }
256
257
 
258
+ export function reassignConnectorsToElement(connectors: Connector[], fromId: number, toId: number): Connector[] {
259
+ return connectors.map((c) => {
260
+ if (c.source_element_id === fromId && c.target_element_id === fromId) {
261
+ return { ...c, source_element_id: toId, target_element_id: toId }
262
+ }
263
+ if (c.source_element_id === fromId) {
264
+ return { ...c, source_element_id: toId }
265
+ }
266
+ if (c.target_element_id === fromId) {
267
+ return { ...c, target_element_id: toId }
268
+ }
269
+ return c
270
+ })
271
+ }
272
+
273
+ export function mergeElementReplacements(elements: PlacedElement[], sourceId: number, survivor: LibraryElement): PlacedElement[] {
274
+ const survivorId = survivor.id
275
+ const hasSurvivor = elements.some((el) => el.element_id === survivorId)
276
+ const sourcePlacement = elements.find((el) => el.element_id === sourceId)
277
+ if (hasSurvivor) {
278
+ return elements.filter((el) => el.element_id !== sourceId)
279
+ }
280
+ if (sourcePlacement) {
281
+ return elements.map((el) => {
282
+ if (el.element_id === sourceId) {
283
+ return {
284
+ ...el,
285
+ element_id: survivorId,
286
+ name: survivor.name,
287
+ kind: survivor.kind,
288
+ description: survivor.description,
289
+ technology: survivor.technology,
290
+ url: survivor.url,
291
+ logo_url: survivor.logo_url,
292
+ technology_connectors: survivor.technology_connectors,
293
+ tags: survivor.tags,
294
+ repo: survivor.repo,
295
+ branch: survivor.branch,
296
+ file_path: survivor.file_path,
297
+ language: survivor.language,
298
+ has_view: survivor.has_view,
299
+ view_label: survivor.view_label,
300
+ }
301
+ }
302
+ return el
303
+ })
304
+ }
305
+ return elements
306
+ }
307
+
257
308
  export const useStore = create<CanvasStoreState>((set) => ({
258
309
  ...emptyViewEditorUiState,
259
310
  view: undefined,
@@ -305,6 +356,13 @@ export const useStore = create<CanvasStoreState>((set) => ({
305
356
  viewElements: mergeSavedElementIntoPlacements(state.viewElements, saved),
306
357
  allElements: state.allElements.map((el) => (el.id === saved.id ? saved : el)),
307
358
  })),
359
+ mergeElementsInto: (sourceId, survivor) => set((state) => ({
360
+ viewElements: mergeElementReplacements(state.viewElements, sourceId, survivor),
361
+ connectors: reassignConnectorsToElement(state.connectors, sourceId, survivor.id),
362
+ allElements: state.allElements
363
+ .filter((el) => el.id !== sourceId)
364
+ .map((el) => (el.id === survivor.id ? survivor : el)),
365
+ })),
308
366
  upsertConnector: (connector) => set((state) => ({
309
367
  connectors: upsertConnectorInList(state.connectors, connector),
310
368
  })),
@@ -3,6 +3,7 @@ export interface TechnologyConnector {
3
3
  slug?: string
4
4
  label: string
5
5
  is_primary_icon?: boolean
6
+ isPrimaryIcon?: boolean
6
7
  }
7
8
 
8
9
  export interface TechnologyCatalogItem {
@@ -82,6 +83,15 @@ export interface PlacedElement {
82
83
  view_label: string | null
83
84
  }
84
85
 
86
+ export interface VisibilityOverride {
87
+ view_id: number
88
+ resource_type: 'element' | 'connector'
89
+ resource_id: number
90
+ level_delta: number
91
+ created_at?: string
92
+ updated_at?: string
93
+ }
94
+
85
95
  export interface NavigationConnector {
86
96
  id: number
87
97
  element_id: number | null
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { resolveElementIconUrl } from './elementIcon'
3
+
4
+ describe('resolveElementIconUrl', () => {
5
+ it('uses an explicit logo url before derived technology icons', () => {
6
+ expect(resolveElementIconUrl('/custom.svg', [
7
+ { type: 'catalog', slug: 'golang', label: 'Go', is_primary_icon: true },
8
+ ])).toBe('/custom.svg')
9
+ })
10
+
11
+ it('derives the selected catalog technology icon when logo_url is missing', () => {
12
+ expect(resolveElementIconUrl(null, [
13
+ { type: 'catalog', slug: 'golang', label: 'Go', is_primary_icon: true },
14
+ ])).toBe('/icons/golang.png')
15
+ })
16
+
17
+ it('falls back to the first catalog link when the API omits primary icon metadata', () => {
18
+ expect(resolveElementIconUrl(null, [
19
+ { type: 'catalog', slug: 'javascript', label: 'JavaScript' },
20
+ ])).toBe('/icons/javascript.png')
21
+ })
22
+
23
+ it('preserves explicit no-icon clears instead of falling back to technology', () => {
24
+ expect(resolveElementIconUrl('', [
25
+ { type: 'catalog', slug: 'golang', label: 'Go', is_primary_icon: true },
26
+ ])).toBeNull()
27
+ })
28
+ })
@@ -0,0 +1,20 @@
1
+ import type { TechnologyConnector } from '../types'
2
+ import { resolveIconPath } from './url'
3
+
4
+ export function resolveElementIconUrl(
5
+ logoUrl: string | null | undefined,
6
+ technologyConnectors: TechnologyConnector[] | null | undefined,
7
+ ): string | null {
8
+ if (logoUrl != null) {
9
+ return logoUrl === '' ? null : resolveIconPath(logoUrl)
10
+ }
11
+
12
+ const catalogLinks = technologyConnectors?.filter((link) => link.type === 'catalog' && !!link.slug) ?? []
13
+ const selected = catalogLinks.find((link) => (
14
+ link.type === 'catalog' &&
15
+ !!(link.is_primary_icon ?? link.isPrimaryIcon) &&
16
+ !!link.slug
17
+ )) ?? catalogLinks[0]
18
+ if (!selected?.slug) return null
19
+ return resolveIconPath(`/icons/${selected.slug}.png`)
20
+ }
@@ -0,0 +1,46 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { SourceEditor } from '../api/client'
3
+
4
+ const SOURCE_EDITOR_KEY = 'diag:source-editor'
5
+ const DEFAULT_SOURCE_EDITOR: SourceEditor = 'zed'
6
+
7
+ function readSourceEditor(): SourceEditor {
8
+ if (typeof window === 'undefined') return DEFAULT_SOURCE_EDITOR
9
+ const stored = window.localStorage.getItem(SOURCE_EDITOR_KEY)
10
+ return stored === 'vscode' || stored === 'zed' ? stored : DEFAULT_SOURCE_EDITOR
11
+ }
12
+
13
+ export function getSourceEditor(): SourceEditor {
14
+ return readSourceEditor()
15
+ }
16
+
17
+ export function setSourceEditor(value: SourceEditor) {
18
+ window.localStorage.setItem(SOURCE_EDITOR_KEY, value)
19
+ window.dispatchEvent(new CustomEvent('diag:source-editor-change', { detail: value }))
20
+ }
21
+
22
+ export function useSourceEditor() {
23
+ const [editor, setEditorState] = useState<SourceEditor>(() => readSourceEditor())
24
+
25
+ useEffect(() => {
26
+ const handleStorage = (event: StorageEvent) => {
27
+ if (event.key === SOURCE_EDITOR_KEY) {
28
+ setEditorState(readSourceEditor())
29
+ }
30
+ }
31
+ const handleChange = () => setEditorState(readSourceEditor())
32
+ window.addEventListener('storage', handleStorage)
33
+ window.addEventListener('diag:source-editor-change', handleChange)
34
+ return () => {
35
+ window.removeEventListener('storage', handleStorage)
36
+ window.removeEventListener('diag:source-editor-change', handleChange)
37
+ }
38
+ }, [])
39
+
40
+ const setEditor = (value: SourceEditor) => {
41
+ setSourceEditor(value)
42
+ setEditorState(value)
43
+ }
44
+
45
+ return { editor, setEditor }
46
+ }
@@ -0,0 +1,159 @@
1
+ import type { Connector, ExploreData, ViewTreeNode } from '../types'
2
+ import type { WatchDiff } from '../api/client'
3
+
4
+ export type WatchChangeType = 'added' | 'updated' | 'deleted' | 'initialized'
5
+
6
+ export interface WatchResourceStat {
7
+ added: number
8
+ updated: number
9
+ deleted: number
10
+ initialized: number
11
+ addedLines: number
12
+ removedLines: number
13
+ }
14
+
15
+ export interface WatchDiffLocation {
16
+ key: string
17
+ label: string
18
+ resourceType: string
19
+ resourceId?: number
20
+ changeType: WatchChangeType
21
+ summary?: string
22
+ addedLines: number
23
+ removedLines: number
24
+ viewId: number
25
+ viewName: string
26
+ }
27
+
28
+ export interface WatchDiffSummary {
29
+ files: WatchResourceStat
30
+ elements: WatchResourceStat
31
+ connectors: WatchResourceStat
32
+ }
33
+
34
+ export function normalizeWatchChangeType(value: string): WatchChangeType {
35
+ if (value === 'added' || value === 'updated' || value === 'deleted' || value === 'initialized') return value
36
+ return 'updated'
37
+ }
38
+
39
+ export function emptyWatchResourceStat(): WatchResourceStat {
40
+ return { added: 0, updated: 0, deleted: 0, initialized: 0, addedLines: 0, removedLines: 0 }
41
+ }
42
+
43
+ export function summarizeWatchDiffs(diffs: WatchDiff[] | null | undefined): WatchDiffSummary {
44
+ const summary = {
45
+ files: emptyWatchResourceStat(),
46
+ elements: emptyWatchResourceStat(),
47
+ connectors: emptyWatchResourceStat(),
48
+ }
49
+ ;(Array.isArray(diffs) ? diffs : []).forEach((diff) => {
50
+ const bucket =
51
+ diff.resource_type === 'file' || diff.owner_type === 'file'
52
+ ? summary.files
53
+ : diff.resource_type === 'element'
54
+ ? summary.elements
55
+ : diff.resource_type === 'connector'
56
+ ? summary.connectors
57
+ : null
58
+ if (!bucket) return
59
+ bucket[normalizeWatchChangeType(diff.change_type)] += 1
60
+ bucket.addedLines += Math.max(0, diff.added_lines ?? 0)
61
+ bucket.removedLines += Math.max(0, diff.removed_lines ?? 0)
62
+ })
63
+ return summary
64
+ }
65
+
66
+ export function formatStatLine(label: string, stat: WatchResourceStat): string {
67
+ const total = stat.added + stat.updated + stat.deleted + stat.initialized
68
+ const parts = [`${total} ${label}${total === 1 ? '' : 's'} changed`]
69
+ if (stat.addedLines > 0) parts.push(`+${stat.addedLines}`)
70
+ if (stat.removedLines > 0) parts.push(`-${stat.removedLines}`)
71
+ return parts.join(', ')
72
+ }
73
+
74
+ export function formatTldStatLine(summary: WatchDiffSummary): string {
75
+ return [
76
+ formatStatLine('element', summary.elements),
77
+ formatStatLine('connector', summary.connectors),
78
+ ].join(' · ')
79
+ }
80
+
81
+ function flattenViews(nodes: ViewTreeNode[], out = new Map<number, ViewTreeNode>()): Map<number, ViewTreeNode> {
82
+ nodes.forEach((node) => {
83
+ out.set(node.id, node)
84
+ flattenViews(node.children ?? [], out)
85
+ })
86
+ return out
87
+ }
88
+
89
+ function connectorName(connector: Connector): string {
90
+ return connector.label || connector.relationship || `connector ${connector.id}`
91
+ }
92
+
93
+ export function buildWatchDiffLocations(data: ExploreData, diffs: WatchDiff[] | null | undefined): WatchDiffLocation[] {
94
+ const views = flattenViews(data.tree ?? [])
95
+ const elementViews = new Map<number, WatchDiffLocation[]>()
96
+ const connectorViews = new Map<number, WatchDiffLocation>()
97
+
98
+ Object.entries(data.views ?? {}).forEach(([viewIdText, content]) => {
99
+ const viewId = Number(viewIdText)
100
+ if (!Number.isFinite(viewId)) return
101
+ const viewName = views.get(viewId)?.name ?? `View ${viewId}`
102
+ ;(content.placements ?? []).forEach((placement) => {
103
+ const list = elementViews.get(placement.element_id) ?? []
104
+ list.push({
105
+ key: `element:${placement.element_id}:${viewId}`,
106
+ label: placement.name || `element ${placement.element_id}`,
107
+ resourceType: 'element',
108
+ resourceId: placement.element_id,
109
+ changeType: 'updated',
110
+ addedLines: 0,
111
+ removedLines: 0,
112
+ viewId,
113
+ viewName,
114
+ })
115
+ elementViews.set(placement.element_id, list)
116
+ })
117
+ ;(content.connectors ?? []).forEach((connector) => {
118
+ connectorViews.set(connector.id, {
119
+ key: `connector:${connector.id}:${viewId}`,
120
+ label: connectorName(connector),
121
+ resourceType: 'connector',
122
+ resourceId: connector.id,
123
+ changeType: 'updated',
124
+ addedLines: 0,
125
+ removedLines: 0,
126
+ viewId,
127
+ viewName,
128
+ })
129
+ })
130
+ })
131
+
132
+ const locations: WatchDiffLocation[] = []
133
+ ;(Array.isArray(diffs) ? diffs : []).forEach((diff) => {
134
+ if (!diff.resource_id) return
135
+ const base = {
136
+ changeType: normalizeWatchChangeType(diff.change_type),
137
+ summary: diff.summary,
138
+ addedLines: Math.max(0, diff.added_lines ?? 0),
139
+ removedLines: Math.max(0, diff.removed_lines ?? 0),
140
+ }
141
+ if (diff.resource_type === 'element') {
142
+ ;(elementViews.get(diff.resource_id) ?? []).forEach((location) => {
143
+ locations.push({ ...location, ...base })
144
+ })
145
+ }
146
+ if (diff.resource_type === 'connector') {
147
+ const location = connectorViews.get(diff.resource_id)
148
+ if (location) locations.push({ ...location, ...base })
149
+ }
150
+ })
151
+
152
+ const seen = new Set<string>()
153
+ return locations.filter((location) => {
154
+ const key = `${location.resourceType}:${location.resourceId}:${location.viewId}`
155
+ if (seen.has(key)) return false
156
+ seen.add(key)
157
+ return true
158
+ })
159
+ }