@tldiagram/core-ui 2.0.6 → 2.0.7

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.
@@ -3,6 +3,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe
3
3
  import { useLocation, useNavigate, useParams } from 'react-router-dom'
4
4
  import {
5
5
  Box,
6
+ Badge,
6
7
  Button,
7
8
  Center,
8
9
  HStack,
@@ -30,6 +31,14 @@ import CrossBranchControls from '../components/CrossBranchControls'
30
31
  import { primeWorkspaceGraphSnapshot } from '../crossBranch/store'
31
32
  import { WATCH_REPRESENTATION_UPDATED_EVENT } from '../components/WorkspacePanel'
32
33
  import { useWorkspaceVersionPreview } from '../context/WorkspaceVersionContext'
34
+ import {
35
+ buildExploreDiffLens,
36
+ type ExploreDiffDetail,
37
+ type ExploreDiffLens,
38
+ type ExploreDiffTarget,
39
+ } from '../utils/exploreDiffLens'
40
+ import { getSourceEditor } from '../utils/sourceEditor'
41
+ import { toast } from '../utils/toast'
33
42
 
34
43
  // ── Types ──────────────────────────────────────────────────────────
35
44
  interface Props {
@@ -71,8 +80,16 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
71
80
  } = useCrossBranchContextSettings(crossBranchSurface)
72
81
  const { preview: versionPreview, followTarget: versionFollowTarget } = useWorkspaceVersionPreview()
73
82
 
83
+ const diffVersionId = useMemo(() => {
84
+ if (sharedToken) return 0
85
+ const value = Number(new URLSearchParams(location.search).get('diffVersion') ?? 0)
86
+ return Number.isFinite(value) && value > 0 ? value : 0
87
+ }, [location.search, sharedToken])
74
88
  const cameraProfile = useMemo(() => new URLSearchParams(location.search).get('profile'), [location.search])
75
89
  const isDetailToOverviewProfile = sharedToken && cameraProfile === 'detail-to-overview'
90
+ const [diffLens, setDiffLens] = useState<ExploreDiffLens | null>(null)
91
+ const [diffLoading, setDiffLoading] = useState(false)
92
+ const [activeDiffTargetIndex, setActiveDiffTargetIndex] = useState(0)
76
93
 
77
94
  const initialCameraFrame = useMemo<ZUICameraFrame | undefined>(() => {
78
95
  return isDetailToOverviewProfile
@@ -248,6 +265,85 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
248
265
  return () => window.removeEventListener('message', handleMessage)
249
266
  }, [sharedToken])
250
267
 
268
+ useEffect(() => {
269
+ if (!data || !diffVersionId) {
270
+ setDiffLens(null)
271
+ setDiffLoading(false)
272
+ setActiveDiffTargetIndex(0)
273
+ return
274
+ }
275
+ let cancelled = false
276
+ setDiffLoading(true)
277
+ api.watch.diffs(diffVersionId)
278
+ .then((diffs) => {
279
+ if (cancelled) return
280
+ setDiffLens(buildExploreDiffLens(data, diffs, diffVersionId))
281
+ setActiveDiffTargetIndex(0)
282
+ })
283
+ .catch((error: unknown) => {
284
+ if (cancelled) return
285
+ setDiffLens(null)
286
+ toast({
287
+ title: 'Could not load diff map',
288
+ description: error instanceof Error ? error.message : 'The selected watch diff could not be loaded.',
289
+ status: 'error',
290
+ })
291
+ })
292
+ .finally(() => {
293
+ if (!cancelled) setDiffLoading(false)
294
+ })
295
+ return () => { cancelled = true }
296
+ }, [data, diffVersionId])
297
+
298
+ const activeDiffTarget = diffLens?.orderedTargets[activeDiffTargetIndex] ?? null
299
+
300
+ const focusDiffTarget = useCallback((target: ExploreDiffTarget | null | undefined) => {
301
+ if (!target?.viewId) return false
302
+ if (target.resourceType === 'element' && target.resourceId) {
303
+ return zuiRef.current?.focusElement(target.viewId, target.resourceId) ?? false
304
+ }
305
+ return zuiRef.current?.focusDiagram(target.viewId) ?? false
306
+ }, [])
307
+
308
+ useEffect(() => {
309
+ if (!canvasReady || !activeDiffTarget) return
310
+ const timer = window.setTimeout(() => {
311
+ focusDiffTarget(activeDiffTarget)
312
+ }, 80)
313
+ return () => window.clearTimeout(timer)
314
+ }, [activeDiffTarget, canvasReady, focusDiffTarget])
315
+
316
+ const navigateDiffTarget = useCallback((offset: number) => {
317
+ const count = diffLens?.orderedTargets.length ?? 0
318
+ if (count === 0) return
319
+ setActiveDiffTargetIndex((index) => (index + offset + count) % count)
320
+ }, [diffLens])
321
+
322
+ const exitDiffMode = useCallback(() => {
323
+ const params = new URLSearchParams(location.search)
324
+ params.set('view', 'explore')
325
+ params.delete('diffVersion')
326
+ params.delete('focus')
327
+ params.delete('element')
328
+ const suffix = params.toString()
329
+ navigate(`${location.pathname}${suffix ? `?${suffix}` : ''}`, { replace: true })
330
+ }, [location.pathname, location.search, navigate])
331
+
332
+ const openDiffSource = useCallback((detail: ExploreDiffDetail) => {
333
+ if (!detail.sourcePath) return
334
+ api.editor.open({
335
+ editor: getSourceEditor(),
336
+ file_path: detail.sourcePath,
337
+ line: detail.line ?? null,
338
+ }).catch((error: unknown) => {
339
+ toast({
340
+ title: 'Could not open source',
341
+ description: error instanceof Error ? error.message : 'The source editor command failed.',
342
+ status: 'error',
343
+ })
344
+ })
345
+ }, [])
346
+
251
347
  if (!loading && (!data || (data.tree ?? []).length === 0 || !hasPlacements)) {
252
348
  const noDiagrams = !data || (data.tree ?? []).length === 0
253
349
  return (
@@ -304,6 +400,7 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
304
400
  hiddenTags={hiddenTags}
305
401
  versionPreview={versionPreview}
306
402
  versionFollowTarget={versionFollowTarget}
403
+ diffLens={diffLens}
307
404
  crossBranchSettings={crossBranchSettings}
308
405
  hoverLocked={isTagsOpen}
309
406
  />
@@ -312,6 +409,119 @@ function InfiniteZoomInner({ sharedToken, shareSlot }: Props, ref?: React.Ref<In
312
409
  {data && !sharedToken && <ExploreOnboarding hasLinkedNodes={!!(data.navigations?.length > 0)} />}
313
410
  <MiniZoomOnboarding isVisible={showMiniOnboarding} onClose={dismissMiniOnboarding} />
314
411
 
412
+ {diffVersionId > 0 && (
413
+ <Box
414
+ position="absolute"
415
+ top={4}
416
+ right={4}
417
+ zIndex={14}
418
+ className="glass"
419
+ borderRadius="lg"
420
+ px={3}
421
+ py={2.5}
422
+ w={{ base: 'calc(100vw - 32px)', md: '340px' }}
423
+ maxW="calc(100vw - 32px)"
424
+ pointerEvents="auto"
425
+ opacity={showContent ? 1 : 0}
426
+ transition="opacity 0.3s"
427
+ >
428
+ <VStack align="stretch" spacing={2}>
429
+ <HStack justify="space-between" spacing={3}>
430
+ <HStack spacing={2} minW={0}>
431
+ <Badge colorScheme="blue" variant="subtle">Diff map</Badge>
432
+ <Text fontSize="xs" color="gray.400" fontFamily="mono" flexShrink={0}>
433
+ +{diffLens?.totalAddedLines ?? 0} -{diffLens?.totalRemovedLines ?? 0}
434
+ </Text>
435
+ </HStack>
436
+ <Button size="xs" variant="ghost" color="gray.300" onClick={exitDiffMode}>
437
+ Exit
438
+ </Button>
439
+ </HStack>
440
+ <Text fontSize="xs" color="gray.200" noOfLines={1} minH="18px">
441
+ {diffLoading
442
+ ? 'Loading changed resources...'
443
+ : activeDiffTarget
444
+ ? `${activeDiffTargetIndex + 1} of ${diffLens?.orderedTargets.length ?? 0}: ${activeDiffTarget.label}`
445
+ : 'No placed changed resources'}
446
+ </Text>
447
+ <HStack spacing={2}>
448
+ <Button
449
+ size="xs"
450
+ variant="solid"
451
+ bg="whiteAlpha.200"
452
+ _hover={{ bg: 'whiteAlpha.300' }}
453
+ flex={1}
454
+ isDisabled={!diffLens?.orderedTargets.length}
455
+ onClick={() => navigateDiffTarget(-1)}
456
+ >
457
+ Previous
458
+ </Button>
459
+ <Button
460
+ size="xs"
461
+ variant="solid"
462
+ bg="whiteAlpha.200"
463
+ _hover={{ bg: 'whiteAlpha.300' }}
464
+ flex={1}
465
+ isDisabled={!diffLens?.orderedTargets.length}
466
+ onClick={() => navigateDiffTarget(1)}
467
+ >
468
+ Next
469
+ </Button>
470
+ </HStack>
471
+ </VStack>
472
+ </Box>
473
+ )}
474
+
475
+ {diffLens && diffLens.unplacedTargets.length > 0 && (
476
+ <Box
477
+ position="absolute"
478
+ top={{ base: '150px', md: '132px' }}
479
+ right={4}
480
+ zIndex={13}
481
+ className="glass"
482
+ borderRadius="lg"
483
+ px={3}
484
+ py={3}
485
+ w={{ base: 'calc(100vw - 32px)', md: '340px' }}
486
+ maxH="260px"
487
+ overflowY="auto"
488
+ pointerEvents="auto"
489
+ data-zui-native-wheel="true"
490
+ sx={{ overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch', touchAction: 'pan-y' }}
491
+ >
492
+ <VStack align="stretch" spacing={2}>
493
+ <Text fontSize="11px" color="gray.400" fontWeight="700" textTransform="uppercase">
494
+ Deleted or unplaced
495
+ </Text>
496
+ {diffLens.unplacedTargets.slice(0, 8).map((target) => (
497
+ <Box key={target.key} borderTop="1px solid" borderColor="whiteAlpha.100" pt={2}>
498
+ <HStack spacing={2} align="start">
499
+ <Badge colorScheme={target.changeType === 'deleted' ? 'red' : 'yellow'} variant="subtle" fontSize="9px">
500
+ {target.changeType}
501
+ </Badge>
502
+ <Box minW={0} flex={1}>
503
+ <Text fontSize="xs" color="gray.100" noOfLines={1}>{target.label}</Text>
504
+ {target.sourcePath && (
505
+ <Text fontSize="10px" color="gray.500" fontFamily="mono" noOfLines={1}>{target.sourcePath}</Text>
506
+ )}
507
+ </Box>
508
+ {target.sourcePath && (
509
+ <Button size="xs" variant="ghost" color="var(--accent)" onClick={() => openDiffSource(target)}>
510
+ Open
511
+ </Button>
512
+ )}
513
+ </HStack>
514
+ </Box>
515
+ ))}
516
+ {diffLens.unplacedTargets.length > 8 && (
517
+ <Text fontSize="xs" color="gray.500">
518
+ +{diffLens.unplacedTargets.length - 8} more
519
+ </Text>
520
+ )}
521
+ </VStack>
522
+ </Box>
523
+ )}
524
+
315
525
  {/* Bottom toolbar */}
316
526
  <Box
317
527
  position="absolute"
@@ -0,0 +1,185 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { WatchDiff } from '../api/client'
3
+ import type { Connector, ExploreData, PlacedElement, ViewTreeNode } from '../types'
4
+ import { buildExploreDiffLens, sourcePathFromDiff } from './exploreDiffLens'
5
+
6
+ function viewNode(id: number, name: string, parentViewId: number | null = null, ownerElementId: number | null = null, children: ViewTreeNode[] = []): ViewTreeNode {
7
+ return {
8
+ id,
9
+ owner_element_id: ownerElementId,
10
+ name,
11
+ description: null,
12
+ level_label: null,
13
+ level: parentViewId ? 2 : 1,
14
+ depth: parentViewId ? 1 : 0,
15
+ created_at: '2024-01-01',
16
+ updated_at: '2024-01-01',
17
+ parent_view_id: parentViewId,
18
+ children,
19
+ }
20
+ }
21
+
22
+ function placed(viewId: number, elementId: number, name: string): PlacedElement {
23
+ return {
24
+ id: viewId * 1000 + elementId,
25
+ view_id: viewId,
26
+ element_id: elementId,
27
+ position_x: 0,
28
+ position_y: 0,
29
+ name,
30
+ description: null,
31
+ kind: 'service',
32
+ technology: null,
33
+ url: null,
34
+ logo_url: null,
35
+ technology_connectors: [],
36
+ tags: [],
37
+ has_view: false,
38
+ view_label: null,
39
+ }
40
+ }
41
+
42
+ function connector(id: number, viewId: number, source: number, target: number): Connector {
43
+ return {
44
+ id,
45
+ view_id: viewId,
46
+ source_element_id: source,
47
+ target_element_id: target,
48
+ label: 'calls',
49
+ description: null,
50
+ relationship: null,
51
+ direction: 'forward',
52
+ style: 'bezier',
53
+ url: null,
54
+ source_handle: null,
55
+ target_handle: null,
56
+ created_at: '2024-01-01',
57
+ updated_at: '2024-01-01',
58
+ }
59
+ }
60
+
61
+ function diff(overrides: Partial<WatchDiff>): WatchDiff {
62
+ return {
63
+ id: 1,
64
+ version_id: 9,
65
+ owner_type: 'symbol',
66
+ owner_key: 'go:services/api/main.go:function:Serve',
67
+ change_type: 'updated',
68
+ resource_type: 'element',
69
+ resource_id: 301,
70
+ summary: 'Serve',
71
+ added_lines: 3,
72
+ removed_lines: 1,
73
+ ...overrides,
74
+ }
75
+ }
76
+
77
+ describe('explore diff lens', () => {
78
+ const tree = [
79
+ viewNode(1, 'Root', null, null, [
80
+ viewNode(2, 'Payments', 1, 101),
81
+ ]),
82
+ ]
83
+ const data: ExploreData = {
84
+ tree,
85
+ navigations: [],
86
+ views: {
87
+ 1: {
88
+ placements: [placed(1, 101, 'Payments'), placed(1, 102, 'Identity')],
89
+ connectors: [connector(501, 1, 101, 102)],
90
+ },
91
+ 2: {
92
+ placements: [placed(2, 301, 'Checkout API'), placed(2, 302, 'Ledger')],
93
+ connectors: [connector(601, 2, 301, 302)],
94
+ },
95
+ },
96
+ }
97
+
98
+ it('keeps changed nodes, ancestor path, and sibling context', () => {
99
+ const lens = buildExploreDiffLens(data, [diff({})], 9)
100
+
101
+ expect(lens.changedElementIds.has(301)).toBe(true)
102
+ expect(lens.ancestorElementIds.has(101)).toBe(true)
103
+ expect(lens.siblingElementIds.has(302)).toBe(true)
104
+ expect(lens.siblingElementIds.has(102)).toBe(true)
105
+ expect(lens.contextElementIds.has(101)).toBe(true)
106
+ expect(lens.contextElementIds.has(302)).toBe(true)
107
+ expect(lens.contextElementIds.has(301)).toBe(false)
108
+ })
109
+
110
+ it('indexes connector changes and adjacent context connectors', () => {
111
+ const lens = buildExploreDiffLens(data, [diff({ resource_type: 'connector', resource_id: 601, summary: 'calls' })], 9)
112
+
113
+ expect(lens.connectorChanges.get(601)).toBe('updated')
114
+ expect(lens.contextElementIds.has(301)).toBe(true)
115
+ expect(lens.contextElementIds.has(302)).toBe(true)
116
+ expect(lens.contextConnectorIds.has(501)).toBe(true)
117
+ })
118
+
119
+ it('places missing resources into the unplaced tray', () => {
120
+ const lens = buildExploreDiffLens(data, [diff({ resource_id: 999, summary: 'Removed service', change_type: 'deleted' })], 9)
121
+
122
+ expect(lens.orderedTargets).toHaveLength(0)
123
+ expect(lens.unplacedTargets).toEqual([
124
+ expect.objectContaining({
125
+ resourceId: 999,
126
+ changeType: 'deleted',
127
+ label: 'Removed service',
128
+ unplaced: true,
129
+ }),
130
+ ])
131
+ })
132
+
133
+ it('keeps file-only changes available in the unplaced tray', () => {
134
+ const lens = buildExploreDiffLens(data, [
135
+ diff({
136
+ owner_type: 'file',
137
+ owner_key: 'services/api/main.go',
138
+ resource_type: 'file',
139
+ resource_id: undefined,
140
+ summary: 'services/api/main.go',
141
+ }),
142
+ ], 9)
143
+
144
+ expect(lens.unplacedTargets).toEqual([
145
+ expect.objectContaining({
146
+ resourceType: 'file',
147
+ label: 'services/api/main.go',
148
+ sourcePath: 'services/api/main.go',
149
+ }),
150
+ ])
151
+ })
152
+
153
+ it('preserves summaries, line deltas, and source paths', () => {
154
+ const lens = buildExploreDiffLens(data, [diff({})], 9)
155
+
156
+ expect(lens.elementLineDeltas.get(301)).toEqual({ added: 3, removed: 1 })
157
+ expect(lens.diffDetailsByResource.get('element:301')).toEqual(expect.objectContaining({
158
+ summary: 'Serve',
159
+ addedLines: 3,
160
+ removedLines: 1,
161
+ sourcePath: 'services/api/main.go',
162
+ }))
163
+ expect(lens.totalAddedLines).toBe(3)
164
+ expect(lens.totalRemovedLines).toBe(1)
165
+ })
166
+
167
+ it('ignores initialized resources as diff targets', () => {
168
+ const lens = buildExploreDiffLens(data, [
169
+ diff({ change_type: 'initialized', resource_type: 'element', resource_id: 301 }),
170
+ diff({ change_type: 'initialized', resource_type: 'connector', resource_id: 601 }),
171
+ ], 9)
172
+
173
+ expect(lens.orderedTargets).toHaveLength(0)
174
+ expect(lens.unplacedTargets).toHaveLength(0)
175
+ expect(lens.changedElementIds.size).toBe(0)
176
+ expect(lens.changedConnectorIds.size).toBe(0)
177
+ expect(lens.diffDetailsByResource.size).toBe(0)
178
+ })
179
+
180
+ it('extracts source paths from common owner key shapes', () => {
181
+ expect(sourcePathFromDiff({ owner_type: 'file', owner_key: 'cmd/root.go' })).toBe('cmd/root.go')
182
+ expect(sourcePathFromDiff({ owner_type: 'symbol', owner_key: 'go:internal/app/app.go:function:Run' })).toBe('internal/app/app.go')
183
+ expect(sourcePathFromDiff({ owner_type: 'reference', owner_key: 'symbol:go:a.go:function:A:go:b.go:function:B:call' })).toBe('a.go')
184
+ })
185
+ })
@@ -0,0 +1,262 @@
1
+ import type { WatchDiff } from '../api/client'
2
+ import type { Connector, ExploreData, PlacedElement, ViewTreeNode } from '../types'
3
+ import { isWatchDiffChange, normalizeWatchChangeType, type WatchChangeType } from './watchDiffSummary'
4
+
5
+ export interface ExploreDiffLineDelta {
6
+ added: number
7
+ removed: number
8
+ }
9
+
10
+ export interface ExploreDiffDetail {
11
+ key: string
12
+ resourceType: string
13
+ resourceId?: number
14
+ changeType: WatchChangeType
15
+ summary?: string
16
+ ownerType: string
17
+ ownerKey: string
18
+ language?: string
19
+ addedLines: number
20
+ removedLines: number
21
+ sourcePath?: string
22
+ line?: number
23
+ }
24
+
25
+ export interface ExploreDiffTarget extends ExploreDiffDetail {
26
+ label: string
27
+ viewId?: number
28
+ viewName?: string
29
+ unplaced: boolean
30
+ }
31
+
32
+ export interface ExploreDiffLens {
33
+ versionId: number
34
+ elementChanges: Map<number, WatchChangeType>
35
+ connectorChanges: Map<number, WatchChangeType>
36
+ elementLineDeltas: Map<number, ExploreDiffLineDelta>
37
+ diffDetailsByResource: Map<string, ExploreDiffDetail>
38
+ orderedTargets: ExploreDiffTarget[]
39
+ unplacedTargets: ExploreDiffTarget[]
40
+ changedElementIds: Set<number>
41
+ changedConnectorIds: Set<number>
42
+ ancestorElementIds: Set<number>
43
+ siblingElementIds: Set<number>
44
+ contextElementIds: Set<number>
45
+ contextConnectorIds: Set<number>
46
+ totalAddedLines: number
47
+ totalRemovedLines: number
48
+ }
49
+
50
+ export function diffResourceKey(resourceType: string | null | undefined, resourceId: number | null | undefined): string {
51
+ return `${resourceType || 'resource'}:${resourceId ?? 'unknown'}`
52
+ }
53
+
54
+ function flattenViews(nodes: ViewTreeNode[], out = new Map<number, ViewTreeNode>()): Map<number, ViewTreeNode> {
55
+ nodes.forEach((node) => {
56
+ out.set(node.id, node)
57
+ flattenViews(node.children ?? [], out)
58
+ })
59
+ return out
60
+ }
61
+
62
+ function firstPathLikePart(parts: string[], startIndex: number): string | undefined {
63
+ for (let i = startIndex; i < parts.length; i += 1) {
64
+ const part = parts[i]?.trim()
65
+ if (!part) continue
66
+ if (part.includes('/') || part.includes('\\') || part.includes('.')) return part.replace(/\\/g, '/')
67
+ }
68
+ return undefined
69
+ }
70
+
71
+ export function sourcePathFromDiff(diff: Pick<WatchDiff, 'owner_type' | 'owner_key'>): string | undefined {
72
+ const ownerType = diff.owner_type?.trim()
73
+ const ownerKey = diff.owner_key?.trim()
74
+ if (!ownerKey) return undefined
75
+ if (ownerType === 'file') return ownerKey.replace(/^file:/, '').replace(/\\/g, '/')
76
+ if (ownerKey.startsWith('file:')) return ownerKey.slice('file:'.length).replace(/\\/g, '/')
77
+
78
+ const parts = ownerKey.split(':')
79
+ if (ownerType === 'symbol' && parts.length >= 4) {
80
+ return parts[1]?.replace(/\\/g, '/')
81
+ }
82
+ if (ownerKey.startsWith('symbol:') && parts.length >= 5) {
83
+ return parts[2]?.replace(/\\/g, '/')
84
+ }
85
+ return firstPathLikePart(parts, 1)
86
+ }
87
+
88
+ function connectorName(connector: Connector): string {
89
+ return connector.label || connector.relationship || `connector ${connector.id}`
90
+ }
91
+
92
+ function targetBase(diff: WatchDiff): ExploreDiffDetail {
93
+ const addedLines = Math.max(0, diff.added_lines ?? 0)
94
+ const removedLines = Math.max(0, diff.removed_lines ?? 0)
95
+ const detail: ExploreDiffDetail = {
96
+ key: diffResourceKey(diff.resource_type, diff.resource_id),
97
+ resourceType: diff.resource_type || 'resource',
98
+ resourceId: diff.resource_id,
99
+ changeType: normalizeWatchChangeType(diff.change_type),
100
+ summary: diff.summary,
101
+ ownerType: diff.owner_type,
102
+ ownerKey: diff.owner_key,
103
+ language: diff.language,
104
+ addedLines,
105
+ removedLines,
106
+ sourcePath: sourcePathFromDiff(diff),
107
+ }
108
+ return detail
109
+ }
110
+
111
+ export function buildExploreDiffLens(data: ExploreData, diffs: WatchDiff[] | null | undefined, versionId: number): ExploreDiffLens {
112
+ const views = flattenViews(data.tree ?? [])
113
+ const viewByOwnerElement = new Map<number, ViewTreeNode>()
114
+ views.forEach((view) => {
115
+ if (view.owner_element_id != null) viewByOwnerElement.set(view.owner_element_id, view)
116
+ })
117
+
118
+ const placementsByElement = new Map<number, PlacedElement[]>()
119
+ const connectorsById = new Map<number, Connector>()
120
+ Object.values(data.views ?? {}).forEach((content) => {
121
+ ;(content.placements ?? []).forEach((placement) => {
122
+ const list = placementsByElement.get(placement.element_id) ?? []
123
+ list.push(placement)
124
+ placementsByElement.set(placement.element_id, list)
125
+ })
126
+ ;(content.connectors ?? []).forEach((connector) => {
127
+ connectorsById.set(connector.id, connector)
128
+ })
129
+ })
130
+
131
+ const elementChanges = new Map<number, WatchChangeType>()
132
+ const connectorChanges = new Map<number, WatchChangeType>()
133
+ const elementLineDeltas = new Map<number, ExploreDiffLineDelta>()
134
+ const diffDetailsByResource = new Map<string, ExploreDiffDetail>()
135
+ const orderedTargets: ExploreDiffTarget[] = []
136
+ const unplacedTargets: ExploreDiffTarget[] = []
137
+ const changedElementIds = new Set<number>()
138
+ const changedConnectorIds = new Set<number>()
139
+ const ancestorElementIds = new Set<number>()
140
+ const siblingElementIds = new Set<number>()
141
+ const contextElementIds = new Set<number>()
142
+ const contextConnectorIds = new Set<number>()
143
+ let totalAddedLines = 0
144
+ let totalRemovedLines = 0
145
+
146
+ const addContextForViewPath = (viewId: number) => {
147
+ let current = views.get(viewId)
148
+ while (current) {
149
+ const content = data.views[String(current.id)]
150
+ ;(content?.placements ?? []).forEach((placement) => {
151
+ siblingElementIds.add(placement.element_id)
152
+ contextElementIds.add(placement.element_id)
153
+ })
154
+ if (current.owner_element_id != null) {
155
+ ancestorElementIds.add(current.owner_element_id)
156
+ contextElementIds.add(current.owner_element_id)
157
+ }
158
+ current = current.parent_view_id != null ? views.get(current.parent_view_id) : undefined
159
+ }
160
+ }
161
+
162
+ ;(Array.isArray(diffs) ? diffs : []).forEach((diff) => {
163
+ if (!isWatchDiffChange(diff.change_type)) return
164
+ const detail = targetBase(diff)
165
+ totalAddedLines += detail.addedLines
166
+ totalRemovedLines += detail.removedLines
167
+ diffDetailsByResource.set(detail.key, detail)
168
+
169
+ if (diff.resource_type === 'element' && diff.resource_id) {
170
+ elementChanges.set(diff.resource_id, detail.changeType)
171
+ changedElementIds.add(diff.resource_id)
172
+ if (detail.addedLines > 0 || detail.removedLines > 0) {
173
+ elementLineDeltas.set(diff.resource_id, { added: detail.addedLines, removed: detail.removedLines })
174
+ }
175
+
176
+ const placements = placementsByElement.get(diff.resource_id) ?? []
177
+ if (placements.length === 0) {
178
+ const ownedView = viewByOwnerElement.get(diff.resource_id)
179
+ unplacedTargets.push({
180
+ ...detail,
181
+ label: detail.summary || `element ${diff.resource_id}`,
182
+ viewId: ownedView?.id,
183
+ viewName: ownedView?.name,
184
+ unplaced: true,
185
+ })
186
+ } else {
187
+ placements.forEach((placement) => {
188
+ const view = views.get(placement.view_id)
189
+ addContextForViewPath(placement.view_id)
190
+ orderedTargets.push({
191
+ ...detail,
192
+ key: `${detail.key}:view:${placement.view_id}`,
193
+ label: placement.name || detail.summary || `element ${diff.resource_id}`,
194
+ viewId: placement.view_id,
195
+ viewName: view?.name ?? `View ${placement.view_id}`,
196
+ unplaced: false,
197
+ })
198
+ })
199
+ }
200
+ } else if (diff.resource_type === 'connector' && diff.resource_id) {
201
+ connectorChanges.set(diff.resource_id, detail.changeType)
202
+ changedConnectorIds.add(diff.resource_id)
203
+ const connector = connectorsById.get(diff.resource_id)
204
+ if (connector) {
205
+ addContextForViewPath(connector.view_id)
206
+ contextElementIds.add(connector.source_element_id)
207
+ contextElementIds.add(connector.target_element_id)
208
+ orderedTargets.push({
209
+ ...detail,
210
+ label: connectorName(connector),
211
+ viewId: connector.view_id,
212
+ viewName: views.get(connector.view_id)?.name ?? `View ${connector.view_id}`,
213
+ unplaced: false,
214
+ })
215
+ } else {
216
+ unplacedTargets.push({
217
+ ...detail,
218
+ label: detail.summary || `connector ${diff.resource_id}`,
219
+ unplaced: true,
220
+ })
221
+ }
222
+ } else if (detail.sourcePath || diff.resource_type === 'file' || diff.owner_type === 'file') {
223
+ unplacedTargets.push({
224
+ ...detail,
225
+ label: detail.summary || detail.sourcePath || detail.ownerKey,
226
+ unplaced: true,
227
+ })
228
+ }
229
+ })
230
+
231
+ Object.values(data.views ?? {}).forEach((content) => {
232
+ ;(content.connectors ?? []).forEach((connector) => {
233
+ if (changedConnectorIds.has(connector.id)) return
234
+ if (contextElementIds.has(connector.source_element_id) || contextElementIds.has(connector.target_element_id)) {
235
+ contextConnectorIds.add(connector.id)
236
+ }
237
+ })
238
+ })
239
+
240
+ changedElementIds.forEach((id) => {
241
+ contextElementIds.delete(id)
242
+ siblingElementIds.delete(id)
243
+ })
244
+
245
+ return {
246
+ versionId,
247
+ elementChanges,
248
+ connectorChanges,
249
+ elementLineDeltas,
250
+ diffDetailsByResource,
251
+ orderedTargets,
252
+ unplacedTargets,
253
+ changedElementIds,
254
+ changedConnectorIds,
255
+ ancestorElementIds,
256
+ siblingElementIds,
257
+ contextElementIds,
258
+ contextConnectorIds,
259
+ totalAddedLines,
260
+ totalRemovedLines,
261
+ }
262
+ }