Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions app/components/DependencyPathPopup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<script setup lang="ts">
defineProps<{
/** Dependency path from root to vulnerable package (readonly from VulnerabilityTreeResult) */
path: readonly string[]
}>()

const { t } = useI18n()

const isOpen = shallowRef(false)
const popupEl = ref<HTMLElement | null>(null)
const popupPosition = shallowRef<{ top: number; left: number } | null>(null)

// Function ref - captures the element when popup mounts
function setPopupRef(el: unknown) {
popupEl.value = (el as HTMLElement) || null
}

function closePopup() {
isOpen.value = false
}

// Close popup on click outside
onClickOutside(popupEl, () => {
if (isOpen.value) closePopup()
})

// Close popup on ESC or scroll
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closePopup()
}

onMounted(() => {
document.addEventListener('keydown', handleKeydown)
window.addEventListener('scroll', closePopup, true)
})

onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
window.removeEventListener('scroll', closePopup, true)
})

function togglePopup(event: MouseEvent) {
if (isOpen.value) {
closePopup()
} else {
const button = event.currentTarget as HTMLElement
const rect = button.getBoundingClientRect()
popupPosition.value = {
top: rect.bottom + 4,
left: rect.left,
}
isOpen.value = true
}
}

function getPopupStyle(): Record<string, string> {
if (!popupPosition.value) return {}
return {
top: `${popupPosition.value.top}px`,
left: `${popupPosition.value.left}px`,
}
}

// Parse package string "name@version" into { name, version }
function parsePackageString(pkg: string): { name: string; version: string } {
const atIndex = pkg.lastIndexOf('@')
if (atIndex > 0) {
return { name: pkg.slice(0, atIndex), version: pkg.slice(atIndex + 1) }
}
return { name: pkg, version: '' }
}
</script>

<template>
<div class="relative">
<!-- Path badge button -->
<button
type="button"
class="path-badge font-mono text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-400 cursor-pointer transition-all duration-200 ease-out whitespace-nowrap flex items-center gap-1 hover:bg-amber-500/20 hover:border-amber-500/50"
:aria-expanded="isOpen"
@click.stop="togglePopup"
>
<span class="i-carbon-tree-view w-3 h-3" aria-hidden="true" />
<span>{{ t('package.vulnerabilities.path') }}</span>
</button>

<!-- Tree popup -->
<div
v-if="isOpen"
:ref="setPopupRef"
class="fixed z-[100] bg-bg-elevated border border-border rounded-lg shadow-xl p-3 min-w-64 max-w-sm"
:style="getPopupStyle()"
>
<ul class="list-none m-0 p-0 space-y-0.5">
<li
v-for="(pathItem, idx) in path"
:key="idx"
class="font-mono text-xs"
:style="{ paddingLeft: `${idx * 12}px` }"
>
<span v-if="idx > 0" class="text-fg-subtle mr-1">└─</span>
<NuxtLink
:to="{
name: 'package',
params: {
package: [
...parsePackageString(pathItem).name.split('/'),
'v',
parsePackageString(pathItem).version,
],
},
}"
class="hover:underline"
:class="idx === path.length - 1 ? 'text-fg font-medium' : 'text-fg-muted'"
@click="closePopup"
>
{{ pathItem }}
</NuxtLink>
<span v-if="idx === path.length - 1" class="ml-1 text-amber-500">⚠</span>
</li>
</ul>
</div>
</div>
</template>
32 changes: 32 additions & 0 deletions app/components/PackageDependencies.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<script setup lang="ts">
import { useVulnerabilityTree } from '~/composables/useVulnerabilityTree'
import { SEVERITY_TEXT_COLORS, getHighestSeverity } from '#shared/utils/severity'

const props = defineProps<{
packageName: string
version: string
dependencies?: Record<string, string>
peerDependencies?: Record<string, string>
peerDependenciesMeta?: Record<string, { optional?: boolean }>
Expand All @@ -10,6 +14,18 @@ const props = defineProps<{
// Fetch outdated info for dependencies
const outdatedDeps = useOutdatedDependencies(() => props.dependencies)

// Get vulnerability info from shared cache (already fetched by PackageVulnerabilityTree)
const { data: vulnTree } = useVulnerabilityTree(
() => props.packageName,
() => props.version,
)

// Check if a dependency has vulnerabilities (only direct deps)
function getVulnerableDepInfo(depName: string) {
if (!vulnTree.value) return null
return vulnTree.value.vulnerablePackages.find(p => p.name === depName && p.depth === 'direct')
}

// Expanded state for each section
const depsExpanded = shallowRef(false)
const peerDepsExpanded = shallowRef(false)
Expand Down Expand Up @@ -91,6 +107,19 @@ const sortedOptionalDependencies = computed(() => {
>
<span class="i-carbon-warning-alt w-3 h-3 block" />
</span>
<NuxtLink
v-if="getVulnerableDepInfo(dep)"
:to="{
name: 'package',
params: { package: [...dep.split('/'), 'v', getVulnerableDepInfo(dep)!.version] },
}"
class="shrink-0"
:class="SEVERITY_TEXT_COLORS[getHighestSeverity(getVulnerableDepInfo(dep)!.counts)]"
:title="`${getVulnerableDepInfo(dep)!.counts.total} vulnerabilities`"
>
<span class="i-carbon-security w-3 h-3 block" aria-hidden="true" />
<span class="sr-only">View vulnerabilities</span>
</NuxtLink>
<NuxtLink
:to="{ name: 'package', params: { package: [...dep.split('/'), 'v', version] } }"
class="font-mono text-xs text-right truncate"
Expand All @@ -102,6 +131,9 @@ const sortedOptionalDependencies = computed(() => {
<span v-if="outdatedDeps[dep]" class="sr-only">
({{ getOutdatedTooltip(outdatedDeps[dep]) }})
</span>
<span v-if="getVulnerableDepInfo(dep)" class="sr-only">
({{ getVulnerableDepInfo(dep)!.counts.total }} vulnerabilities)
</span>
</span>
</li>
</ul>
Expand Down
Loading