Skip to content

Commit b6fe2fe

Browse files
feat(ui): add "copy package version" to command palette and header
1 parent 42862ec commit b6fe2fe

5 files changed

Lines changed: 150 additions & 14 deletions

File tree

app/components/Package/Header.vue

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ const { copied: copiedPkgName, copy: copyPkgName } = useClipboard({
7575
copiedDuring: 2000,
7676
})
7777
78+
const { copied: copiedPkgVersion, copy: copyPkgVersion } = useClipboard({
79+
source: () => props.resolvedVersion ?? '',
80+
copiedDuring: 2000,
81+
})
82+
7883
function hasProvenance(version: PackumentVersion | null): boolean {
7984
if (!version?.dist) return false
8085
return !!(version.dist as { attestations?: unknown }).attestations
@@ -98,6 +103,17 @@ useCommandPaletteContextCommands(
98103
announce($t('command_palette.announcements.copied_to_clipboard'))
99104
},
100105
},
106+
{
107+
id: 'package-copy-version',
108+
group: 'package',
109+
label: $t('package.copy_version'),
110+
keywords: [packageName.value],
111+
iconClass: 'i-lucide:copy',
112+
action: () => {
113+
copyPkgVersion()
114+
announce($t('command_palette.announcements.copied_to_clipboard'))
115+
},
116+
},
101117
]
102118
103119
if (fundingUrl.value) {
@@ -206,16 +222,15 @@ useShortcuts({
206222
<header class="bg-bg pt-5 pb-1 w-full container">
207223
<!-- Package name and version -->
208224
<div class="flex items-baseline justify-between gap-x-2 gap-y-1 flex-wrap min-w-0">
209-
<CopyToClipboardButton
210-
:copied="copiedPkgName"
211-
:copy-text="$t('package.copy_name')"
212-
class="flex flex-col items-start min-w-0"
213-
@click="copyPkgName()"
225+
<h1
226+
class="flex flex-row items-start min-w-0 font-mono text-lg sm:text-3xl font-medium break-words"
227+
:title="pkg?.name"
228+
dir="ltr"
214229
>
215-
<h1
216-
class="font-mono text-lg sm:text-3xl font-medium min-w-0 break-words"
217-
:title="pkg?.name"
218-
dir="ltr"
230+
<CopyToClipboardButton
231+
:copied="copiedPkgName"
232+
:copy-text="$t('package.copy_name')"
233+
@click="copyPkgName()"
219234
>
220235
<LinkBase v-if="orgName" :to="{ name: 'org', params: { org: orgName } }">
221236
@{{ orgName }}
@@ -224,8 +239,19 @@ useShortcuts({
224239
<span :class="{ 'text-fg-muted': orgName }">
225240
{{ orgName ? pkg?.name.replace(`@${orgName}/`, '') : pkg?.name }}
226241
</span>
227-
</h1>
228-
</CopyToClipboardButton>
242+
</CopyToClipboardButton>
243+
<template v-if="requestedVersion && resolvedVersion">
244+
<span class="text-fg-muted">@</span>
245+
<CopyToClipboardButton
246+
:copied="copiedPkgVersion"
247+
:copy-text="$t('package.copy_version')"
248+
:title="resolvedVersion"
249+
@click="copyPkgVersion()"
250+
>
251+
{{ resolvedVersion }}
252+
</CopyToClipboardButton>
253+
</template>
254+
</h1>
229255
<!-- Package metrics -->
230256
<div class="flex gap-2 flex-wrap items-stretch">
231257
<LinkBase

i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@
411411
"verified_provenance": "Verified provenance",
412412
"navigation": "Package",
413413
"copy_name": "Copy package name",
414+
"copy_version": "Copy package version",
414415
"deprecation": {
415416
"package": "This package has been deprecated.",
416417
"version": "This version has been deprecated.",

i18n/schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,6 +1237,9 @@
12371237
"copy_name": {
12381238
"type": "string"
12391239
},
1240+
"copy_version": {
1241+
"type": "string"
1242+
},
12401243
"deprecation": {
12411244
"type": "object",
12421245
"properties": {

test/e2e/interactions.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,14 @@ test.describe('Package Page', () => {
9898
const packageHeading = page.locator('h1').first()
9999
await expect(packageHeading).toBeVisible({ timeout: 10000 })
100100

101-
// Hover the parent of the heading to trigger the button's visibility
102-
await packageHeading.locator('..').hover()
103-
104101
const copyButton = page
105102
.locator('button[aria-label="copy"]')
106103
.filter({ hasText: /copy/i })
107104
.first()
108105

106+
// Hover the button's group container (its parent) to trigger its visibility
107+
await copyButton.locator('..').hover()
108+
109109
await expect(copyButton).toBeVisible({ timeout: 10000 })
110110
await copyButton.hover()
111111

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import type { VueWrapper } from '@vue/test-utils'
4+
import PackageHeader from '~/components/Package/Header.vue'
5+
6+
const { mockUsePackageRoute } = vi.hoisted(() => ({
7+
mockUsePackageRoute: vi.fn(),
8+
}))
9+
10+
mockNuxtImport('usePackageRoute', () => mockUsePackageRoute)
11+
12+
function setRoute({
13+
requestedVersion = null as string | null,
14+
orgName = null as string | null,
15+
} = {}) {
16+
mockUsePackageRoute.mockReturnValue({
17+
packageName: computed(() => 'vue'),
18+
requestedVersion: computed(() => requestedVersion),
19+
orgName: computed(() => orgName),
20+
})
21+
}
22+
23+
const baseProps = {
24+
pkg: {
25+
'name': 'vue',
26+
'dist-tags': {},
27+
'versions': {},
28+
},
29+
resolvedVersion: '3.5.0',
30+
displayVersion: {
31+
_id: '1234567890',
32+
_npmVersion: '3.5.0',
33+
name: 'vue',
34+
version: '3.5.0',
35+
dist: {
36+
shasum: '1234567890',
37+
signatures: [],
38+
tarball: 'https://npmx.dev/package/vue/tarball',
39+
},
40+
},
41+
latestVersion: { version: '3.5.0', tags: [] },
42+
provenanceData: null,
43+
provenanceStatus: 'idle',
44+
page: 'docs' as const,
45+
versionUrlPattern: '/package/vue/v/{version}',
46+
}
47+
48+
function mountHeader() {
49+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
50+
return mountSuspended(PackageHeader, { props: baseProps as any })
51+
}
52+
53+
describe('PackageHeader version display', () => {
54+
let wrapper: VueWrapper
55+
56+
beforeEach(() => {
57+
mockUsePackageRoute.mockReset()
58+
})
59+
60+
afterEach(() => {
61+
wrapper?.unmount()
62+
})
63+
64+
it('hides the resolved version in the title when the URL has no explicit version', async () => {
65+
setRoute({ requestedVersion: null })
66+
67+
wrapper = await mountHeader()
68+
69+
// The <h1> title should show only the package name, not "@3.5.0"
70+
expect(wrapper.get('h1').text()).not.toContain('3.5.0')
71+
// The version copy button should not be rendered
72+
expect(wrapper.text()).not.toContain('Copy package version')
73+
})
74+
75+
it('shows the resolved version in the title when the URL has an explicit version', async () => {
76+
setRoute({ requestedVersion: '3.5.0' })
77+
78+
wrapper = await mountHeader()
79+
80+
expect(wrapper.get('h1').text()).toContain('3.5.0')
81+
expect(wrapper.text()).toContain('Copy package version')
82+
})
83+
84+
it('renders separate copy buttons for the package name and the version', async () => {
85+
setRoute({ requestedVersion: '3.5.0' })
86+
87+
wrapper = await mountHeader()
88+
89+
const copyButtonLabels = wrapper
90+
.findAll('button')
91+
.map(b => b.text())
92+
.filter(text => text.includes('Copy package'))
93+
94+
expect(copyButtonLabels.some(text => text.includes('Copy package name'))).toBe(true)
95+
expect(copyButtonLabels.some(text => text.includes('Copy package version'))).toBe(true)
96+
})
97+
98+
it('shows the resolved version for a dist-tag request (e.g. /v/latest)', async () => {
99+
// requestedVersion is the raw URL segment ("latest"); resolvedVersion is the concrete number
100+
setRoute({ requestedVersion: 'latest' })
101+
102+
wrapper = await mountHeader()
103+
104+
expect(wrapper.get('h1').text()).toContain('3.5.0')
105+
})
106+
})

0 commit comments

Comments
 (0)