@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.
- 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
|
@@ -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
|
+
}
|
package/src/store/useStore.ts
CHANGED
|
@@ -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
|
})),
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
+
}
|