@tldiagram/core-ui 2.0.6 → 2.0.8

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"
@@ -8,6 +8,7 @@ declare module "*.svg" {
8
8
  declare global {
9
9
  interface Window {
10
10
  __TLD_VSCODE__?: boolean
11
+ __TLD_SERVER_URL__?: string
11
12
  }
12
13
  }
13
14
 
@@ -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
+ })