Skip to content

Commit 0a5980c

Browse files
tjirabclaude
andauthored
Feat: VSCode lineage "Only Direct Neighbors" filter (#5815)
Signed-off-by: Bart Schuijt <schuijt.bart@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3deb1f6 commit 0a5980c

5 files changed

Lines changed: 183 additions & 30 deletions

File tree

Lines changed: 84 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { test, expect } from './fixtures'
2+
import type { FrameLocator, Page } from '@playwright/test'
23
import fs from 'fs-extra'
34
import {
45
openLineageView,
@@ -8,6 +9,31 @@ import {
89
} from './utils'
910
import { createPythonInterpreterSettingsSpecifier } from './utils_code_server'
1011

12+
/**
13+
* Find the iframe that hosts the lineage UI (the one containing the
14+
* Settings cog button). Returns null if it can't be located.
15+
*/
16+
async function findLineageFrame(page: Page): Promise<FrameLocator | null> {
17+
const iframes = page.locator('iframe')
18+
const iframeCount = await iframes.count()
19+
20+
for (let i = 0; i < iframeCount; i++) {
21+
const contentFrame = iframes.nth(i).contentFrame()
22+
if (!contentFrame) continue
23+
const activeFrame = contentFrame.locator('#active-frame').contentFrame()
24+
if (!activeFrame) continue
25+
try {
26+
await activeFrame
27+
.getByRole('button', { name: 'Settings' })
28+
.waitFor({ timeout: 1000 })
29+
return activeFrame
30+
} catch {
31+
continue
32+
}
33+
}
34+
return null
35+
}
36+
1137
test('Settings button is visible in the lineage view', async ({
1238
page,
1339
sharedCodeServer,
@@ -35,30 +61,64 @@ test('Settings button is visible in the lineage view', async ({
3561
// Open lineage
3662
await openLineageView(page)
3763

38-
const iframes = page.locator('iframe')
39-
const iframeCount = await iframes.count()
40-
let settingsCount = 0
64+
const lineageFrame = await findLineageFrame(page)
65+
expect(lineageFrame).not.toBeNull()
66+
})
4167

42-
for (let i = 0; i < iframeCount; i++) {
43-
const iframe = iframes.nth(i)
44-
const contentFrame = iframe.contentFrame()
45-
if (contentFrame) {
46-
const activeFrame = contentFrame.locator('#active-frame').contentFrame()
47-
if (activeFrame) {
48-
try {
49-
await activeFrame
50-
.getByRole('button', {
51-
name: 'Settings',
52-
})
53-
.waitFor({ timeout: 1000 })
54-
settingsCount++
55-
} catch {
56-
// Continue to next iframe if this one doesn't have the error
57-
continue
58-
}
59-
}
60-
}
61-
}
68+
test('Only Direct Neighbors toggle filters the lineage graph', async ({
69+
page,
70+
sharedCodeServer,
71+
tempDir,
72+
}) => {
73+
await fs.copy(SUSHI_SOURCE_PATH, tempDir)
74+
await createPythonInterpreterSettingsSpecifier(tempDir)
6275

63-
expect(settingsCount).toBeGreaterThan(0)
76+
await openServerPage(page, tempDir, sharedCodeServer)
77+
await page.waitForSelector('text=models')
78+
79+
await page
80+
.getByRole('treeitem', { name: 'models', exact: true })
81+
.locator('a')
82+
.click()
83+
await page
84+
.getByRole('treeitem', { name: 'waiters.py', exact: true })
85+
.locator('a')
86+
.click()
87+
await waitForLoadedSQLMesh(page)
88+
89+
await openLineageView(page)
90+
91+
const lineageFrame = await findLineageFrame(page)
92+
expect(lineageFrame).not.toBeNull()
93+
if (!lineageFrame) return
94+
95+
// Wait for the graph to render at least one node
96+
await lineageFrame.locator('.react-flow__node').first().waitFor()
97+
const nodesBefore = await lineageFrame.locator('.react-flow__node').count()
98+
expect(nodesBefore).toBeGreaterThan(0)
99+
100+
// Open the settings menu and toggle "Only Direct Neighbors"
101+
await lineageFrame.getByRole('button', { name: 'Settings' }).click()
102+
const toggle = lineageFrame.getByRole('button', {
103+
name: 'Only Direct Neighbors',
104+
})
105+
await toggle.waitFor()
106+
await toggle.click()
107+
108+
// After enabling, the visible node set must be a subset of the original.
109+
// We assert a strict drop only when the original graph had room to shrink
110+
// (i.e. more than the worst-case direct-neighbor count of 1 + parents + children).
111+
await page.waitForTimeout(250) // let React Flow re-layout
112+
const nodesAfter = await lineageFrame.locator('.react-flow__node').count()
113+
expect(nodesAfter).toBeLessThanOrEqual(nodesBefore)
114+
expect(nodesAfter).toBeGreaterThan(0) // main node is always shown
115+
116+
// Toggle off → graph returns to the full size
117+
await lineageFrame.getByRole('button', { name: 'Settings' }).click()
118+
await lineageFrame
119+
.getByRole('button', { name: 'Only Direct Neighbors' })
120+
.click()
121+
await page.waitForTimeout(250)
122+
const nodesRestored = await lineageFrame.locator('.react-flow__node').count()
123+
expect(nodesRestored).toBe(nodesBefore)
64124
})

vscode/react/src/components/graph/ModelLineage.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ function ModelColumnLineage(): JSX.Element {
209209
withConnected,
210210
withImpacted,
211211
withSecondary,
212+
withOnlyDirect,
213+
directNeighbors,
212214
hasBackground,
213215
activeEdges,
214216
connectedNodes,
@@ -217,6 +219,7 @@ function ModelColumnLineage(): JSX.Element {
217219
handleError,
218220
setActiveNodes,
219221
setWithColumns,
222+
setWithOnlyDirect,
220223
} = useLineageFlow()
221224

222225
const { setCenter } = useReactFlow()
@@ -252,6 +255,8 @@ function ModelColumnLineage(): JSX.Element {
252255
withConnected,
253256
withImpacted,
254257
withSecondary,
258+
withOnlyDirect,
259+
directNeighbors,
255260
)
256261
const newEdges = getUpdatedEdges(
257262
allEdges,
@@ -264,6 +269,8 @@ function ModelColumnLineage(): JSX.Element {
264269
withConnected,
265270
withImpacted,
266271
withSecondary,
272+
withOnlyDirect,
273+
directNeighbors,
267274
)
268275
const createLayout = createGraphLayout({
269276
nodesMap,
@@ -324,6 +331,8 @@ function ModelColumnLineage(): JSX.Element {
324331
withConnected,
325332
withImpacted,
326333
withSecondary,
334+
withOnlyDirect,
335+
directNeighbors,
327336
)
328337

329338
const newEdges = getUpdatedEdges(
@@ -337,6 +346,8 @@ function ModelColumnLineage(): JSX.Element {
337346
withConnected,
338347
withImpacted,
339348
withSecondary,
349+
withOnlyDirect,
350+
directNeighbors,
340351
)
341352

342353
setEdges(newEdges)
@@ -353,6 +364,8 @@ function ModelColumnLineage(): JSX.Element {
353364
withConnected,
354365
withImpacted,
355366
withSecondary,
367+
withOnlyDirect,
368+
directNeighbors,
356369
withColumns,
357370
mainNode,
358371
])
@@ -395,6 +408,8 @@ function ModelColumnLineage(): JSX.Element {
395408
<SettingsControl
396409
showColumns={withColumns}
397410
onWithColumnsChange={setWithColumns}
411+
withOnlyDirect={withOnlyDirect}
412+
onWithOnlyDirectChange={setWithOnlyDirect}
398413
/>
399414
</Controls>
400415
<Background

vscode/react/src/components/graph/SettingsControl.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,21 @@ import clsx from 'clsx'
66
interface SettingsControlProps {
77
showColumns: boolean
88
onWithColumnsChange: (value: boolean) => void
9+
withOnlyDirect: boolean
10+
onWithOnlyDirectChange: (value: boolean) => void
911
}
1012

13+
const itemClass = clsx(
14+
'group flex w-full items-center px-2 py-1 text-sm',
15+
'text-[var(--vscode-button-foreground)]',
16+
'hover:bg-[var(--vscode-button-background)] bg-[var(--vscode-button-hoverBackground)]',
17+
)
18+
1119
export function SettingsControl({
1220
showColumns,
1321
onWithColumnsChange,
22+
withOnlyDirect,
23+
onWithOnlyDirectChange,
1424
}: SettingsControlProps): JSX.Element {
1525
return (
1626
<Menu
@@ -29,11 +39,7 @@ export function SettingsControl({
2939
<MenuItems className="absolute bottom-0 left-full ml-2 w-56 origin-bottom-left divide-y bg-theme shadow-lg focus:outline-none z-50">
3040
<MenuItem
3141
as="button"
32-
className={clsx(
33-
'group flex w-full items-center px-2 py-1 text-sm',
34-
'text-[var(--vscode-button-foreground)]',
35-
'hover:bg-[var(--vscode-button-background)] bg-[var(--vscode-button-hoverBackground)]',
36-
)}
42+
className={itemClass}
3743
onClick={() => onWithColumnsChange(!showColumns)}
3844
>
3945
<span className="flex-1 text-left">Show Columns</span>
@@ -44,6 +50,19 @@ export function SettingsControl({
4450
/>
4551
)}
4652
</MenuItem>
53+
<MenuItem
54+
as="button"
55+
className={itemClass}
56+
onClick={() => onWithOnlyDirectChange(!withOnlyDirect)}
57+
>
58+
<span className="flex-1 text-left">Only Direct Neighbors</span>
59+
{withOnlyDirect && (
60+
<CheckIcon
61+
className="h-4 w-4 text-primary-500"
62+
aria-hidden="true"
63+
/>
64+
)}
65+
</MenuItem>
4766
</MenuItems>
4867
</Menu>
4968
)

vscode/react/src/components/graph/context.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from 'react'
88
import { getNodeMap, hasActiveEdge, hasActiveEdgeConnector } from './help'
99
import { type Node } from 'reactflow'
10+
import { isNil } from '@/utils/index'
1011
import type { Lineage } from '@/domain/lineage'
1112
import type { ModelSQLMeshModel } from '@/domain/sqlmesh-model'
1213
import type { Column } from '@/domain/column'
@@ -43,6 +44,8 @@ interface LineageFlow {
4344
hasBackground: boolean
4445
withImpacted: boolean
4546
withSecondary: boolean
47+
withOnlyDirect: boolean
48+
directNeighbors: Set<ModelEncodedFQN>
4649
manuallySelectedColumn?: [ModelSQLMeshModel, Column]
4750
highlightedNodes: HighlightedNodes
4851
nodesMap: Record<ModelEncodedFQN, Node>
@@ -55,6 +58,7 @@ interface LineageFlow {
5558
setHasBackground: React.Dispatch<React.SetStateAction<boolean>>
5659
setWithImpacted: React.Dispatch<React.SetStateAction<boolean>>
5760
setWithSecondary: React.Dispatch<React.SetStateAction<boolean>>
61+
setWithOnlyDirect: React.Dispatch<React.SetStateAction<boolean>>
5862
setConnections: React.Dispatch<React.SetStateAction<Map<string, Connections>>>
5963
hasActiveEdge: (edge: [string | undefined, string | undefined]) => boolean
6064
addActiveEdges: (edges: Array<[string, string]>) => void
@@ -85,6 +89,8 @@ export const LineageFlowContext = createContext<LineageFlow>({
8589
withConnected: false,
8690
withImpacted: true,
8791
withSecondary: false,
92+
withOnlyDirect: false,
93+
directNeighbors: new Set(),
8894
hasBackground: true,
8995
mainNode: undefined,
9096
activeEdges: new Map(),
@@ -103,6 +109,7 @@ export const LineageFlowContext = createContext<LineageFlow>({
103109
setWithImpacted: () => false,
104110
setWithSecondary: () => false,
105111
setWithConnected: () => false,
112+
setWithOnlyDirect: () => false,
106113
hasActiveEdge: () => false,
107114
addActiveEdges: () => {},
108115
removeActiveEdges: () => {},
@@ -161,6 +168,7 @@ export default function LineageFlowProvider({
161168
const [hasBackground, setHasBackground] = useState(true)
162169
const [withImpacted, setWithImpacted] = useState(true)
163170
const [withSecondary, setWithSecondary] = useState(false)
171+
const [withOnlyDirect, setWithOnlyDirect] = useState(false)
164172

165173
const nodesMap = useMemo(
166174
() =>
@@ -264,6 +272,39 @@ export default function LineageFlowProvider({
264272
[nodesConnections],
265273
)
266274

275+
// Reverse adjacency index: parent -> children. Built once per `lineage`
276+
// change so per-model `directNeighbors` lookups stay O(parents + children)
277+
// instead of scanning the whole graph on every mainNode switch.
278+
const childrenByParent = useMemo(() => {
279+
const map = new Map<ModelEncodedFQN, ModelEncodedFQN[]>()
280+
for (const [child, info] of Object.entries(lineage) as Array<
281+
[ModelEncodedFQN, Lineage]
282+
>) {
283+
for (const parent of info?.models ?? []) {
284+
const existing = map.get(parent)
285+
if (existing) {
286+
existing.push(child)
287+
} else {
288+
map.set(parent, [child])
289+
}
290+
}
291+
}
292+
return map
293+
}, [lineage])
294+
295+
const directNeighbors = useMemo(() => {
296+
const set = new Set<ModelEncodedFQN>()
297+
if (isNil(mainNode)) return set
298+
set.add(mainNode)
299+
for (const parent of lineage[mainNode]?.models ?? []) {
300+
set.add(parent)
301+
}
302+
for (const child of childrenByParent.get(mainNode) ?? []) {
303+
set.add(child)
304+
}
305+
return set
306+
}, [mainNode, lineage, childrenByParent])
307+
267308
const selectedEdges = useMemo(
268309
() =>
269310
Array.from(selectedNodes)
@@ -292,6 +333,8 @@ export default function LineageFlowProvider({
292333
withConnected,
293334
withImpacted,
294335
withSecondary,
336+
withOnlyDirect,
337+
directNeighbors,
295338
showControls,
296339
hasBackground,
297340
nodesMap,
@@ -304,6 +347,7 @@ export default function LineageFlowProvider({
304347
setWithConnected,
305348
setWithImpacted,
306349
setWithSecondary,
350+
setWithOnlyDirect,
307351
setHasBackground,
308352
setSelectedNodes,
309353
setMainNode,

0 commit comments

Comments
 (0)