Skip to content

Commit f6aa690

Browse files
committed
feat: total size split-bar
1 parent 2106cb4 commit f6aa690

File tree

2 files changed

+243
-18
lines changed

2 files changed

+243
-18
lines changed

app/components/Package/Dependencies.vue

Lines changed: 240 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script setup lang="ts">
2+
import type { NuxtError } from '#app'
23
import { SEVERITY_TEXT_COLORS, getHighestSeverity } from '#shared/utils/severity'
34
import { getOutdatedTooltip, getVersionClass } from '~/utils/npm/outdated-dependencies'
45
@@ -12,6 +13,7 @@ const props = defineProps<{
1213
peerDependencies?: Record<string, string>
1314
peerDependenciesMeta?: Record<string, { optional?: boolean }>
1415
optionalDependencies?: Record<string, string>
16+
bundledDependencies?: boolean | string[]
1517
}>()
1618
1719
// Fetch outdated info for dependencies
@@ -38,11 +40,6 @@ function getDeprecatedDepInfo(depName: string) {
3840
return vulnTree.value.deprecatedPackages.find(p => p.name === depName && p.depth === 'direct')
3941
}
4042
41-
// Get dependency size (only direct deps)
42-
function getSizeDepInfo(depName: string) {
43-
return props.packageSize?.dependencies?.find(p => p.name === depName)?.size ?? null
44-
}
45-
4643
// Sort dependencies alphabetically
4744
const sortedDependencies = computed(() => {
4845
if (!props.dependencies) return []
@@ -72,6 +69,198 @@ const sortedOptionalDependencies = computed(() => {
7269
return Object.entries(props.optionalDependencies).sort(([a], [b]) => a.localeCompare(b))
7370
})
7471
72+
// Fetch size information for dependencies that require it
73+
const { data: serverSizes, pending: sizesLoading } = await useAsyncData(
74+
`sizes:${props.packageName}:${props.version}`,
75+
async (_app, { signal }) => {
76+
const entries = sortedDependencies.value
77+
78+
const results = await Promise.all(
79+
entries.map<
80+
Promise<
81+
{ kind: 'success'; packageSize: InstallSizeResult } | { kind: 'error'; error: NuxtError }
82+
>
83+
>(async ([name, version]) => {
84+
try {
85+
const { data: resolvedVersion, error } = await useResolvedVersion(name, version)
86+
87+
if (error.value || !resolvedVersion.value) return { kind: 'error', error: error.value! }
88+
89+
return {
90+
kind: 'success',
91+
packageSize: await $fetch<InstallSizeResult>(
92+
`/api/registry/install-size/${name}/v/${encodeURIComponent(resolvedVersion.value)}`,
93+
{ signal },
94+
),
95+
}
96+
} catch (err) {
97+
return { kind: 'error', error: (err as Ref<NuxtError>)?.value }
98+
}
99+
}),
100+
)
101+
102+
return results.reduce(
103+
(acc, curr) => {
104+
if (curr.kind === 'error') return acc
105+
acc[curr.packageSize.package] = curr
106+
return acc
107+
},
108+
{} as Record<
109+
string,
110+
{ kind: 'success'; packageSize: InstallSizeResult } | { kind: 'error'; error: NuxtError }
111+
>,
112+
)
113+
},
114+
{
115+
watch: [sortedDependencies],
116+
},
117+
)
118+
119+
// Minimum percentage to be shown as an individual slice
120+
const THRESHOLD_PERCENT = 10
121+
122+
type Sizereq = {
123+
info: InstallSizeResult
124+
bundled: boolean
125+
percent: number
126+
error: NuxtError | null
127+
}
128+
129+
// Process dependencies for size visualization
130+
const sortedSizereqDependecies = computed(() => {
131+
if (!props.packageSize?.totalSize || !props.packageSize.dependencies) {
132+
return { visible: [], others: [], totalOthersSize: 0, othersPercentage: 0 }
133+
}
134+
135+
const total = props.packageSize.totalSize
136+
137+
// 1. Map everything first, preserving the 'bundled' flag from the source
138+
const allMapped = props.packageSize.dependencies
139+
.map(depSize => {
140+
const bundled = !sortedDependencies.value.some(([name]) => name === depSize.name)
141+
const percent = props.packageSize ? (depSize.size / props.packageSize.totalSize) * 100 : 0
142+
const serverData = serverSizes.value?.[depSize.name]
143+
const error = serverData?.kind === 'error' ? serverData.error : null
144+
return {
145+
info:
146+
serverData?.kind === 'success'
147+
? {
148+
package: depSize.name,
149+
totalSize: serverData.packageSize?.totalSize ?? depSize.size,
150+
selfSize: serverData.packageSize?.selfSize ?? depSize.size,
151+
}
152+
: {
153+
package: depSize.name,
154+
totalSize: depSize.size,
155+
selfSize: depSize.size,
156+
},
157+
error,
158+
bundled,
159+
percent,
160+
} as Sizereq
161+
})
162+
.sort((a, b) => {
163+
// Bundled first
164+
if (a.bundled !== b.bundled) return a.bundled ? -1 : 1
165+
return b.info.totalSize - a.info.totalSize
166+
})
167+
168+
const visible: Sizereq[] = []
169+
const others: Sizereq[] = []
170+
171+
for (const dep of allMapped) {
172+
const percentage = (dep.info.totalSize / total) * 100
173+
if (percentage >= THRESHOLD_PERCENT) {
174+
visible.push({ ...dep, percent: percentage })
175+
} else {
176+
others.push(dep)
177+
}
178+
}
179+
180+
const totalOthersSize = others.reduce((acc, d) => acc + d.info.totalSize, 0)
181+
const othersPercentage = (totalOthersSize / total) * 100
182+
183+
if (othersPercentage < THRESHOLD_PERCENT || others.length === 1) {
184+
visible.push(others[0]!)
185+
others.length = 0
186+
visible.sort((a, b) => b.info.totalSize - a.info.totalSize)
187+
}
188+
189+
return { visible, others, totalOthersSize, othersPercentage }
190+
})
191+
192+
const othersTooltip = computed(() => {
193+
const others = sortedSizereqDependecies.value.others
194+
if (others.length === 0) return ''
195+
196+
const MAX_VISIBLE_IN_TOOLTIP = 0
197+
const visiblePart = others.slice(0, MAX_VISIBLE_IN_TOOLTIP)
198+
const remainingCount = others.length - MAX_VISIBLE_IN_TOOLTIP
199+
200+
const lines = [
201+
bytesFormatter.format(sortedSizereqDependecies.value.totalOthersSize),
202+
numberFormatter.value.format(sortedSizereqDependecies.value.othersPercentage),
203+
'',
204+
...visiblePart.flatMap(size => [size.info.package, getDepSizeTooltipText(size), '']),
205+
]
206+
207+
if (remainingCount > 0) {
208+
lines.push(t('package.size_increase.deps', { count: remainingCount }))
209+
}
210+
211+
return lines.join('\n')
212+
})
213+
214+
const selfSizeWidth = computed(() => {
215+
if (!props.packageSize?.selfSize || !props.packageSize?.totalSize) return 0
216+
return (props.packageSize.selfSize / props.packageSize.totalSize) * 100
217+
})
218+
219+
const remainingWidth = computed(() => {
220+
const total = props.packageSize?.totalSize
221+
if (!total) return 100
222+
223+
// Sum up everything we actually HAVE data for
224+
const self = props.packageSize.selfSize || 0
225+
const depsSum = [
226+
...sortedSizereqDependecies.value.visible,
227+
...sortedSizereqDependecies.value.others,
228+
].reduce((acc, d) => acc + d.info.totalSize, 0)
229+
230+
const width = ((total - (self + depsSum)) / total) * 100
231+
return Math.max(0, width)
232+
})
233+
234+
// Get dependency size tooltip
235+
function getDepSizeTooltip(dep: string): string | undefined {
236+
const size = [
237+
...sortedSizereqDependecies.value.visible,
238+
...sortedSizereqDependecies.value.others,
239+
].find(d => d.info.package === dep)
240+
return size && getDepSizeTooltipText(size)
241+
}
242+
243+
function getDepSizeTooltipText(size: Sizereq): string {
244+
const packageSize = size?.error ? undefined : size?.info
245+
const percent = size?.percent
246+
return [
247+
size?.error?.message,
248+
percent && numberFormatter.value.format(percent),
249+
packageSize &&
250+
packageSize?.totalSize !== packageSize?.selfSize &&
251+
t('package.stats.size_tooltip.unpacked', {
252+
size: bytesFormatter.format(packageSize.selfSize!),
253+
}),
254+
packageSize?.totalSize &&
255+
t('package.stats.size_tooltip.total', {
256+
count: packageSize.dependencyCount,
257+
size: bytesFormatter.format(packageSize.totalSize),
258+
}),
259+
]
260+
.filter(Boolean)
261+
.join('\n')
262+
}
263+
75264
// Get version tooltip
76265
function getDepVersionTooltip(dep: string, version: string) {
77266
const outdated = outdatedDeps.value[dep]
@@ -128,6 +317,41 @@ const bytesFormatter = useBytesFormatter()
128317
)
129318
"
130319
>
320+
<div class="gap-0.5 flex flex-row h-6 w-full bg-fg-muted/10 overflow-hidden rounded-md">
321+
<TooltipApp
322+
v-if="selfSizeWidth > 0"
323+
:text="
324+
t('package.stats.size_tooltip.unpacked', {
325+
size: bytesFormatter.format(props.packageSize?.selfSize || 0),
326+
})
327+
"
328+
class="h-full bg-blue-500"
329+
:style="{ width: selfSizeWidth + '%' }"
330+
/>
331+
332+
<template v-for="dep in sortedSizereqDependecies.visible" :key="dep.info.package">
333+
<TooltipApp
334+
:text="`${dep.info.package}\n${getDepSizeTooltip(dep.info.package)}`"
335+
class="h-full"
336+
:class="dep.bundled ? 'bg-blue-500' : 'bg-fg'"
337+
:style="{ width: dep.percent + '%' }"
338+
/>
339+
</template>
340+
341+
<TooltipApp
342+
v-if="sortedSizereqDependecies.others.length > 0"
343+
:text="othersTooltip"
344+
class="h-full bg-fg flex items-center justify-center"
345+
:style="{ width: sortedSizereqDependecies.othersPercentage + '%' }"
346+
>
347+
<span class="i-lucide:layers-2 w-3 h-3 text-bg" aria-hidden="true" />
348+
</TooltipApp>
349+
350+
<div
351+
v-if="remainingWidth > 0"
352+
class="h-full bg-bg-elevated animate-skeleton-pulse flex-1"
353+
/>
354+
</div>
131355
<ul class="space-y-1 list-none m-0" :aria-label="$t('package.dependencies.list_label')">
132356
<li
133357
v-for="[dep, version] in visibleDeps"
@@ -197,25 +421,23 @@ const bytesFormatter = useBytesFormatter()
197421
{{ version }}
198422
</LinkBase>
199423
<TooltipApp
200-
v-if="getSizeDepInfo(dep)"
424+
v-if="getDepSizeTooltip(dep)"
201425
class="shrink-0"
202426
:class="getVersionClass(undefined)"
203-
:text="
204-
$t('package.stats.size_tooltip.unpacked', {
205-
size: bytesFormatter.format(getSizeDepInfo(dep)!),
206-
})
207-
"
427+
:text="getDepSizeTooltip(dep)"
208428
>
209429
<button
210430
type="button"
211-
class="inline-flex items-center justify-center p-2 -m-2"
212-
:aria-label="
213-
$t('package.stats.size_tooltip.unpacked', {
214-
size: bytesFormatter.format(getSizeDepInfo(dep)!),
215-
})
216-
"
431+
class="inline-flex items-center justify-center p-2 -m-2 outline-none"
432+
:aria-label="getDepSizeTooltip(dep)"
217433
>
218-
<span class="i-lucide:info w-3 h-3" aria-hidden="true" />
434+
<span
435+
class="i-lucide:info w-3 h-3 opacity-50 transition-opacity hover:opacity-100"
436+
:class="{
437+
'i-svg-spinners:ring-resize': sizesLoading && !serverSizes?.[dep],
438+
}"
439+
aria-hidden="true"
440+
/>
219441
</button>
220442
</TooltipApp>
221443
<span v-if="outdatedDeps[dep]" class="sr-only">

app/pages/package/[[org]]/[name].vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,9 @@ const showSkeleton = shallowRef(false)
988988
:peer-dependencies="displayVersion.peerDependencies"
989989
:peer-dependencies-meta="displayVersion.peerDependenciesMeta"
990990
:optional-dependencies="displayVersion.optionalDependencies"
991+
:bundled-dependencies="
992+
displayVersion.bundleDependencies || displayVersion.bundledDependencies
993+
"
991994
/>
992995

993996
<!-- Keywords -->

0 commit comments

Comments
 (0)