Skip to content

Commit 62ec3e1

Browse files
committed
feat: asset relationship
1 parent 60c1a57 commit 62ec3e1

File tree

4 files changed

+298
-22
lines changed

4 files changed

+298
-22
lines changed

packages/devtools/src/app/components/data/AssetDetails.vue

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const props = defineProps<{
1010
session: SessionContext
1111
asset: RolldownAssetInfo
1212
importers: AssetInfo[]
13+
imports: AssetInfo[]
1314
}>()
1415
1516
const rpc = useRpc()
@@ -42,6 +43,7 @@ function openInEditor() {
4243
</button>
4344
</div>
4445
</div>
46+
4547
<template v-if="showSource">
4648
<div flex="~ gap-2 items-center">
4749
<div op50>
@@ -65,27 +67,31 @@ function openInEditor() {
6567
/>
6668
</div>
6769
</template>
68-
<div op50>
69-
Chunks
70-
</div>
71-
<div v-for="chunk of assetChunks" :key="chunk.chunk_id" border="~ base rounded-lg" px2 py1>
72-
<DataChunkDetails
73-
:chunk="chunk"
74-
:session="session"
75-
:show-modules="false"
76-
/>
77-
</div>
78-
<template v-if="importers.length">
79-
<div op50>
80-
Importers
81-
</div>
70+
71+
<div flex="~ col gap-4" pl2>
8272
<div flex="~ col gap-2">
83-
<AssetsListItem
84-
v-for="asset in importers"
85-
:key="asset.filename"
86-
:asset="asset"
87-
/>
73+
<div op50>
74+
Chunks
75+
</div>
76+
<div v-for="chunk of assetChunks" :key="chunk.chunk_id" border="~ base rounded-lg" px2 py1>
77+
<DataChunkDetails
78+
:chunk="chunk"
79+
:session="session"
80+
:show-modules="false"
81+
/>
82+
</div>
8883
</div>
89-
</template>
84+
<template v-if="importers.length || imports.length">
85+
<div flex="~ col gap-2">
86+
<div op50>
87+
Asset Relationships
88+
</div>
89+
<DataAssetRelationships
90+
:importers="importers"
91+
:imports="imports"
92+
/>
93+
</div>
94+
</template>
95+
</div>
9096
</div>
9197
</template>

packages/devtools/src/app/components/data/AssetDetailsLoader.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ const { state } = useAsyncState(
2424
asset: { ...res?.asset, type: 'asset' },
2525
chunks: [{ ...res?.chunk, type: 'chunk' }],
2626
importers: res?.importers,
27+
imports: res?.imports,
2728
} satisfies {
2829
asset: RolldownAssetInfo
2930
chunks: RolldownChunkInfo[]
3031
importers: AssetInfo[]
32+
imports: AssetInfo[]
3133
}
3234
},
3335
null,
@@ -40,6 +42,6 @@ const { state } = useAsyncState(
4042
absolute right-2 top-1.5
4143
@click="emit('close')"
4244
/>
43-
<DataAssetDetails :asset="state.asset" :session="session" :chunks="state?.chunks" :importers="state?.importers" />
45+
<DataAssetDetails :asset="state.asset" :session="session" :chunks="state?.chunks" :importers="state?.importers" :imports="state?.imports" />
4446
</div>
4547
</template>
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
<script setup lang="ts">
2+
import type { Asset as AssetInfo } from '@rolldown/debug'
3+
import type { HierarchyLink, HierarchyNode } from 'd3-hierarchy'
4+
import { linkHorizontal, linkVertical } from 'd3-shape'
5+
import { computed, onMounted, shallowRef, useTemplateRef, watch } from 'vue'
6+
7+
const props = defineProps<{
8+
importers?: AssetInfo[]
9+
imports?: AssetInfo[]
10+
}>()
11+
12+
interface Node {
13+
asset: AssetInfo
14+
}
15+
16+
type Link = HierarchyLink<Node> & {
17+
id: string
18+
}
19+
20+
type LinkPoint = 'importer-start' | 'importer-end' | 'import-start' | 'import-end'
21+
22+
const MAX_LINKS = 20
23+
const SPACING = {
24+
width: 400,
25+
height: 50,
26+
padding: 4,
27+
marginX: 8,
28+
border: 1,
29+
margin: 8,
30+
dot: 16,
31+
dotOffset: 80,
32+
}
33+
34+
const container = useTemplateRef<HTMLDivElement>('container')
35+
const links = shallowRef<Link[]>([])
36+
37+
const normalizedMaxLinks = computed(() => {
38+
return Math.min(Math.max(props.importers?.length || 0, props.imports?.length || 0), MAX_LINKS)
39+
})
40+
41+
const importersMaxLength = computed(() => Math.min(props.importers?.length || 0, MAX_LINKS))
42+
const importsMaxLength = computed(() => Math.min(props.imports?.length || 0, MAX_LINKS))
43+
const nodesHeight = computed(() => SPACING.height * normalizedMaxLinks.value + SPACING.padding * (normalizedMaxLinks.value + 1) + SPACING.border * 2)
44+
45+
const importersVerticalOffset = computed(() => {
46+
const diff = Math.max(0, importsMaxLength.value - importersMaxLength.value)
47+
const offset = (diff * (SPACING.height + SPACING.padding)) / 2
48+
return Math.min(offset, nodesHeight.value / 2)
49+
})
50+
51+
const importsVerticalOffset = computed(() => {
52+
const diff = Math.max(0, importersMaxLength.value - importsMaxLength.value)
53+
const offset = (diff * (SPACING.height + SPACING.padding)) / 2
54+
return Math.min(offset, nodesHeight.value / 2)
55+
})
56+
57+
const createLinkHorizontal = linkHorizontal()
58+
.x(d => d[0])
59+
.y(d => d[1])
60+
61+
const createLinkVertical = linkVertical()
62+
.x(d => d[0])
63+
.y(d => d[1])
64+
65+
function generateLink(link: Link) {
66+
if (link.target.x! <= link.source.x!) {
67+
return createLinkVertical({
68+
source: [link.source.x!, link.source.y!],
69+
target: [link.target.x!, link.target.y!],
70+
})
71+
}
72+
return createLinkHorizontal({
73+
source: [link.source.x!, link.source.y!],
74+
target: [link.target.x!, link.target.y!],
75+
})
76+
}
77+
78+
function getLinkColor(_link: Link) {
79+
return 'stroke-#8882'
80+
}
81+
82+
const dotNodeMargin = computed(() => `${nodesHeight.value / 2 - SPACING.dot / 2}px ${SPACING.dotOffset}px 0 ${props.importers?.length ? SPACING.dotOffset : 0}px`)
83+
const linkStartX = computed(() => props.importers?.length ? SPACING.width + SPACING.marginX : SPACING.marginX)
84+
const dotStartX = computed(() => props.importers?.length ? linkStartX.value + SPACING.dotOffset : linkStartX.value)
85+
const dotStartY = computed(() => (SPACING.height * normalizedMaxLinks.value + ((normalizedMaxLinks.value + 1) * SPACING.padding)) / 2)
86+
87+
function calculateLinkX(type: LinkPoint) {
88+
switch (type) {
89+
case 'importer-start':
90+
return linkStartX.value
91+
case 'importer-end':
92+
return dotStartX.value
93+
case 'import-start':
94+
return dotStartX.value + SPACING.dot
95+
case 'import-end':
96+
return props.importers?.length ? linkStartX.value + SPACING.dotOffset * 2 + SPACING.dot : linkStartX.value + SPACING.dotOffset + SPACING.dot
97+
}
98+
}
99+
100+
function calculateLinkY(type: LinkPoint, i?: number) {
101+
switch (type) {
102+
case 'importer-start':
103+
return ((SPACING.height + SPACING.padding) * i!) + (SPACING.height / 2 + SPACING.padding) + importersVerticalOffset.value
104+
case 'import-end':
105+
return ((SPACING.height + SPACING.padding) * i!) + (SPACING.height / 2 + SPACING.padding) + importsVerticalOffset.value
106+
case 'importer-end':
107+
case 'import-start':
108+
return dotStartY.value
109+
}
110+
}
111+
112+
function generateLinks() {
113+
links.value = []
114+
115+
// importers (left -> current asset)
116+
if (props.importers?.length) {
117+
const _importersLinks = Array.from({ length: importersMaxLength.value }, (_, i) => {
118+
return {
119+
id: `importer-${i}`,
120+
source: {
121+
x: calculateLinkX('importer-start'),
122+
y: calculateLinkY('importer-start', i),
123+
} as HierarchyNode<Node>,
124+
target: {
125+
x: calculateLinkX('importer-end'),
126+
y: calculateLinkY('importer-end'),
127+
} as HierarchyNode<Node>,
128+
}
129+
})
130+
links.value.push(..._importersLinks)
131+
}
132+
133+
// imports (current asset -> right)
134+
if (props.imports?.length) {
135+
const _importsLinks = Array.from({ length: importsMaxLength.value }, (_, i) => {
136+
return {
137+
id: `import-${i}`,
138+
source: {
139+
x: calculateLinkX('import-start'),
140+
y: calculateLinkY('import-start'),
141+
} as HierarchyNode<Node>,
142+
target: {
143+
x: calculateLinkX('import-end'),
144+
y: calculateLinkY('import-end', i),
145+
} as HierarchyNode<Node>,
146+
}
147+
})
148+
links.value.push(..._importsLinks)
149+
}
150+
}
151+
152+
onMounted(() => {
153+
watch(
154+
() => [props.importers, props.imports],
155+
generateLinks,
156+
{ immediate: true },
157+
)
158+
})
159+
</script>
160+
161+
<template>
162+
<div
163+
v-if="importers?.length || imports?.length"
164+
ref="container"
165+
w-full relative select-none
166+
>
167+
<!-- nodes -->
168+
<div flex px2>
169+
<!-- importers -->
170+
<div
171+
v-if="importers?.length"
172+
py1
173+
:style="{
174+
width: `${SPACING.width}px`,
175+
marginTop: `${importersVerticalOffset}px`,
176+
}"
177+
>
178+
<template v-for="(importer, i) of importers" :key="importer.filename">
179+
<NuxtLink
180+
:to="{ query: { asset: importer.filename } }"
181+
hover="bg-active" block px2 p1 bg-base
182+
z-graph-node
183+
border="~ base rounded"
184+
font-mono text-sm
185+
:style="{
186+
width: `${SPACING.width}px`,
187+
height: `${SPACING.height}px`,
188+
overflow: 'hidden',
189+
marginBottom: `${i === importers!.length - 1 ? 0 : SPACING.padding}px`,
190+
display: 'flex',
191+
alignItems: 'center',
192+
gap: '0.25rem',
193+
}"
194+
>
195+
<DisplayFileIcon :filename="importer.filename" />
196+
<span overflow-hidden text-ellipsis>
197+
{{ importer.filename }}
198+
</span>
199+
</NuxtLink>
200+
</template>
201+
</div>
202+
203+
<!-- dot: current asset -->
204+
<div
205+
bg-base rounded-full border-3 font-mono border-active :style="{
206+
margin: dotNodeMargin,
207+
width: `${SPACING.dot}px`,
208+
height: `${SPACING.dot}px`,
209+
}"
210+
/>
211+
212+
<!-- imports -->
213+
<div
214+
v-if="imports?.length"
215+
py1
216+
:style="{
217+
width: `${SPACING.width}px`,
218+
marginTop: `${importsVerticalOffset}px`,
219+
}"
220+
>
221+
<template v-for="(_import, i) of imports" :key="_import.filename">
222+
<NuxtLink
223+
:to="{ query: { asset: _import.filename } }"
224+
hover="bg-active" block px2 p1 bg-base
225+
z-graph-node
226+
border="~ base rounded"
227+
font-mono text-sm
228+
:style="{
229+
width: `${SPACING.width}px`,
230+
height: `${SPACING.height}px`,
231+
overflow: 'hidden',
232+
marginBottom: `${i === imports!.length - 1 ? 0 : SPACING.padding}px`,
233+
display: 'flex',
234+
alignItems: 'center',
235+
gap: '0.25rem',
236+
}"
237+
>
238+
<DisplayFileIcon :filename="_import.filename" />
239+
<span overflow-hidden text-ellipsis>
240+
{{ _import.filename }}
241+
</span>
242+
</NuxtLink>
243+
</template>
244+
</div>
245+
</div>
246+
247+
<!-- links -->
248+
<svg
249+
pointer-events-none absolute left-0 top-0 z-graph-link w-full
250+
:style="{
251+
height: `${nodesHeight}px`,
252+
}"
253+
>
254+
<g>
255+
<path
256+
v-for="link of links"
257+
:key="link.id"
258+
:d="generateLink(link)!"
259+
:class="getLinkColor(link)"
260+
fill="none"
261+
/>
262+
</g>
263+
</svg>
264+
</div>
265+
</template>

packages/devtools/src/node/rpc/functions/rolldown-get-asset-details.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ export const rolldownGetAssetDetails = defineRpcFunction({
1111
const chunks = await reader.manager.chunks
1212
const asset = assets.get(id)!
1313
const assetList = Array.from(assets.values())
14+
const chunkList = Array.from(chunks.values())
1415
const assetChunkId = asset.chunk_id!
1516
const chunk = chunks.get(assetChunkId)!
16-
const importers = Array.from(chunks.values()).filter(mod => mod.imports.some(i => i.chunk_id === assetChunkId)).map(c => assetList.find(a => a.chunk_id === c.chunk_id)!)
17+
const importers = chunkList.filter(mod => mod.imports.some(i => i.chunk_id === assetChunkId)).map(c => assetList.find(a => a.chunk_id === c.chunk_id)!)
18+
const imports = chunk.imports.map(c => assetList.find(a => a.chunk_id === c.chunk_id)!)
1719
return {
1820
asset,
1921
chunk,
2022
importers,
23+
imports,
2124
}
2225
},
2326
}

0 commit comments

Comments
 (0)