Skip to content
Open
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
39 changes: 28 additions & 11 deletions app/components/Package/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ const { copied: copiedPkgName, copy: copyPkgName } = useClipboard({
copiedDuring: 2000,
})

const { copied: copiedPkgVersion, copy: copyPkgVersion } = useClipboard({
source: () => props.resolvedVersion ?? '',
copiedDuring: 2000,
})

function hasProvenance(version: PackumentVersion | null): boolean {
if (!version?.dist) return false
return !!(version.dist as { attestations?: unknown }).attestations
Expand Down Expand Up @@ -206,16 +211,15 @@ useShortcuts({
<header class="bg-bg pt-5 pb-1 w-full container">
<!-- Package name and version -->
<div class="flex items-baseline justify-between gap-x-2 gap-y-1 flex-wrap min-w-0">
<CopyToClipboardButton
:copied="copiedPkgName"
:copy-text="$t('package.copy_name')"
class="flex flex-col items-start min-w-0"
@click="copyPkgName()"
<h1
class="flex flex-row items-start min-w-0 font-mono text-lg sm:text-3xl font-medium break-words"
:title="pkg?.name"
dir="ltr"
>
<h1
class="font-mono text-lg sm:text-3xl font-medium min-w-0 break-words"
:title="pkg?.name"
dir="ltr"
<CopyToClipboardButton
:copied="copiedPkgName"
:copy-text="$t('package.copy_name')"
@click="copyPkgName()"
>
<LinkBase v-if="orgName" :to="{ name: 'org', params: { org: orgName } }">
@{{ orgName }}
Expand All @@ -224,8 +228,21 @@ useShortcuts({
<span :class="{ 'text-fg-muted': orgName }">
{{ orgName ? pkg?.name.replace(`@${orgName}/`, '') : pkg?.name }}
</span>
</h1>
</CopyToClipboardButton>
</CopyToClipboardButton>
<template v-if="requestedVersion && resolvedVersion">
<span class="text-fg-muted">@</span>
<CopyToClipboardButton
:copied="copiedPkgVersion"
:copy-text="$t('package.copy_version')"
:title="resolvedVersion"
@click="copyPkgVersion()"
>
<LinkBase :to="packageRoute(packageName, resolvedVersion)" class="text-fg-muted">
{{ resolvedVersion }}
</LinkBase>
</CopyToClipboardButton>
</template>
</h1>
<!-- Package metrics -->
<div class="flex gap-2 flex-wrap items-stretch">
<LinkBase
Expand Down
28 changes: 21 additions & 7 deletions app/components/Terminal/Install.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ const props = defineProps<{
createPackageInfo?: { packageName: string } | null
}>()

const { selectedPM, showTypesInInstall, copied, copyInstallCommand } = useInstallCommand(
() => props.packageName,
() => props.requestedVersion ?? null,
() => props.jsrInfo ?? null,
() => props.typesPackageName ?? null,
() => props.installVersionOverride ?? null,
)
const { selectedPM, showTypesInInstall, copied, copyInstallCommand, copyPkgVersion } =
useInstallCommand(
() => props.packageName,
() => props.requestedVersion ?? null,
() => props.jsrInfo ?? null,
() => props.typesPackageName ?? null,
() => props.installVersionOverride ?? null,
)

// Generate install command parts for a specific package manager
function getInstallPartsForPM(pmId: PackageManagerId) {
Expand Down Expand Up @@ -139,6 +140,19 @@ useCommandPaletteContextCommands(
},
},
]
if (props.requestedVersion) {
commands.push({
id: 'package-copy-version',
group: 'package',
label: $t('package.copy_version'),
keywords: [props.packageName],
iconClass: 'i-lucide:copy',
action: () => {
copyPkgVersion()
announce($t('command_palette.announcements.copied_to_clipboard'))
},
})
}

if (devDependencySuggestion.value.recommended) {
commands.push({
Expand Down
14 changes: 13 additions & 1 deletion app/composables/useInstallCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,24 @@ export function useInstallCommand(
return `${installCommand.value}; ${pm.label} ${pm.action} ${devFlag.value} ${pkgSpec}`
})

// Copy state
// Copy state — separate clipboard instances so copying the version does not
// flip the install command's copied indicator (and vice versa).
const { copied, copy } = useClipboard({ copiedDuring: 2000 })
const { copied: copiedPkgVersion, copy: copyPkgVersionToClipboard } = useClipboard({
copiedDuring: 2000,
})

async function copyInstallCommand() {
if (!fullInstallCommand.value) return
await copy(fullInstallCommand.value)
}

async function copyPkgVersion() {
const requestedVersionValue = toValue(requestedVersion)
if (!requestedVersionValue) return
await copyPkgVersionToClipboard(requestedVersionValue)
}

return {
selectedPM,
installCommandParts,
Expand All @@ -94,6 +104,8 @@ export function useInstallCommand(
fullInstallCommand,
showTypesInInstall,
copied,
copiedPkgVersion,
copyInstallCommand,
copyPkgVersion,
}
}
1 change: 1 addition & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@
"verified_provenance": "Verified provenance",
"navigation": "Package",
"copy_name": "Copy package name",
"copy_version": "Copy package version",
"deprecation": {
"package": "This package has been deprecated.",
"version": "This version has been deprecated.",
Expand Down
3 changes: 3 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,9 @@
"copy_name": {
"type": "string"
},
"copy_version": {
"type": "string"
},
"deprecation": {
"type": "object",
"properties": {
Expand Down
6 changes: 3 additions & 3 deletions test/e2e/interactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,14 @@ test.describe('Package Page', () => {
const packageHeading = page.locator('h1').first()
await expect(packageHeading).toBeVisible({ timeout: 10000 })

// Hover the parent of the heading to trigger the button's visibility
await packageHeading.locator('..').hover()

const copyButton = page
.locator('button[aria-label="copy"]')
.filter({ hasText: /copy/i })
.first()

// Hover the button's group container (its parent) to trigger its visibility
await copyButton.locator('..').hover()

await expect(copyButton).toBeVisible({ timeout: 10000 })
await copyButton.hover()

Expand Down
106 changes: 106 additions & 0 deletions test/nuxt/components/Package/Header.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { VueWrapper } from '@vue/test-utils'
import PackageHeader from '~/components/Package/Header.vue'

const { mockUsePackageRoute } = vi.hoisted(() => ({
mockUsePackageRoute: vi.fn(),
}))

mockNuxtImport('usePackageRoute', () => mockUsePackageRoute)

function setRoute({
requestedVersion = null as string | null,
orgName = null as string | null,
} = {}) {
mockUsePackageRoute.mockReturnValue({
packageName: computed(() => 'vue'),
requestedVersion: computed(() => requestedVersion),
orgName: computed(() => orgName),
})
}

const baseProps = {
pkg: {
'name': 'vue',
'dist-tags': {},
'versions': {},
},
resolvedVersion: '3.5.0',
displayVersion: {
_id: '1234567890',
_npmVersion: '3.5.0',
name: 'vue',
version: '3.5.0',
dist: {
shasum: '1234567890',
signatures: [],
tarball: 'https://npmx.dev/package/vue/tarball',
},
},
latestVersion: { version: '3.5.0', tags: [] },
provenanceData: null,
provenanceStatus: 'idle',
page: 'docs' as const,
versionUrlPattern: '/package/vue/v/{version}',
}

function mountHeader() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mountSuspended(PackageHeader, { props: baseProps as any })
}

describe('PackageHeader version display', () => {
let wrapper: VueWrapper

beforeEach(() => {
mockUsePackageRoute.mockReset()
})

afterEach(() => {
wrapper?.unmount()
})

it('hides the resolved version in the title when the URL has no explicit version', async () => {
setRoute({ requestedVersion: null })

wrapper = await mountHeader()

// The <h1> title should show only the package name, not "@3.5.0"
expect(wrapper.get('h1').text()).not.toContain('3.5.0')
// No copy-version affordance should be rendered
expect(wrapper.text()).not.toContain('Copy package version')
})

it('shows the resolved version in the title when the URL has an explicit version', async () => {
setRoute({ requestedVersion: '3.5.0' })

wrapper = await mountHeader()

expect(wrapper.get('h1').text()).toContain('3.5.0')
expect(wrapper.text()).toContain('Copy package version')
})

it('links the version to its explicit version route', async () => {
setRoute({ requestedVersion: '3.5.0' })

wrapper = await mountHeader()

const versionLink = wrapper
.get('h1')
.findAll('a')
.find(a => a.text().includes('3.5.0'))

expect(versionLink).toBeTruthy()
expect(versionLink!.attributes('href')).toBe('/package/vue/v/3.5.0')
})

it('shows the resolved version for a dist-tag request (e.g. /v/latest)', async () => {
// requestedVersion is the raw URL segment ("latest"); resolvedVersion is the concrete number
setRoute({ requestedVersion: 'latest' })

wrapper = await mountHeader()

expect(wrapper.get('h1').text()).toContain('3.5.0')
})
})
38 changes: 38 additions & 0 deletions test/nuxt/composables/use-install-command.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,4 +317,42 @@ describe('useInstallCommand', () => {
expect(copied.value).toBe(false)
})
})

describe('copyPkgVersion', () => {
it('should copy the requested version and set its own copied state', async () => {
vi.useFakeTimers()

const { copyPkgVersion, copiedPkgVersion, copied } = useInstallCommand(
'vue',
'3.5.0',
null,
null,
)

expect(copiedPkgVersion.value).toBe(false)

await copyPkgVersion()

// useClipboard sets copiedPkgVersion to true after a successful copy
expect(copiedPkgVersion.value).toBe(true)
// ...without flipping the install command's copied indicator
expect(copied.value).toBe(false)

// Advance timers to reset copiedPkgVersion (copiedDuring: 2000)
await vi.advanceTimersByTimeAsync(2100)
expect(copiedPkgVersion.value).toBe(false)

vi.useRealTimers()
})

it('should not copy when there is no requested version', async () => {
const { copyPkgVersion, copiedPkgVersion } = useInstallCommand('vue', null, null, null)

expect(copiedPkgVersion.value).toBe(false)
await copyPkgVersion()

// Should remain false since there was no version to copy
expect(copiedPkgVersion.value).toBe(false)
})
})
})
Loading