diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index d0306d3706..219657d8f3 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -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 @@ -206,16 +211,15 @@ useShortcuts({
- -

@{{ orgName }} @@ -224,8 +228,21 @@ useShortcuts({ {{ orgName ? pkg?.name.replace(`@${orgName}/`, '') : pkg?.name }} -

-
+ + +
() -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) { @@ -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({ diff --git a/app/composables/useInstallCommand.ts b/app/composables/useInstallCommand.ts index 827ac89f46..fcd6300c8d 100644 --- a/app/composables/useInstallCommand.ts +++ b/app/composables/useInstallCommand.ts @@ -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, @@ -94,6 +104,8 @@ export function useInstallCommand( fullInstallCommand, showTypesInInstall, copied, + copiedPkgVersion, copyInstallCommand, + copyPkgVersion, } } diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 672047fc30..a2524fb994 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -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.", diff --git a/i18n/schema.json b/i18n/schema.json index 63dccc9991..63d95b8257 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1237,6 +1237,9 @@ "copy_name": { "type": "string" }, + "copy_version": { + "type": "string" + }, "deprecation": { "type": "object", "properties": { diff --git a/test/e2e/interactions.spec.ts b/test/e2e/interactions.spec.ts index 2955eef2ed..2ac88c05fc 100644 --- a/test/e2e/interactions.spec.ts +++ b/test/e2e/interactions.spec.ts @@ -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() diff --git a/test/nuxt/components/Package/Header.spec.ts b/test/nuxt/components/Package/Header.spec.ts new file mode 100644 index 0000000000..b5e1bf743f --- /dev/null +++ b/test/nuxt/components/Package/Header.spec.ts @@ -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

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') + }) +}) diff --git a/test/nuxt/composables/use-install-command.spec.ts b/test/nuxt/composables/use-install-command.spec.ts index 5799427945..e15fb5045a 100644 --- a/test/nuxt/composables/use-install-command.spec.ts +++ b/test/nuxt/composables/use-install-command.spec.ts @@ -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) + }) + }) })