-
-
Notifications
You must be signed in to change notification settings - Fork 438
feat: add package download button #1586
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
5d3bea5
6a28f59
cf35100
91e2933
7a6e29e
14912af
e4b803a
16e9dd7
6ecfc4d
60aac4a
f393057
e98977e
70f9d59
c8e7774
36461d7
401e44f
989dfa3
cef21bb
ba7281d
1c7a680
b8c7570
cf724bd
b0de596
b1ee806
a91db2b
8f9ac17
eaa2de2
19eb93c
eb8c0da
c76c15c
5e0882f
f48b384
992b9ea
1b7487a
b56aa2e
52a9dcb
02d6bb1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,253 @@ | ||
| <script setup lang="ts"> | ||
| import type { SlimPackumentVersion, InstallSizeResult } from '#shared/types' | ||
| import { onClickOutside, useEventListener } from '@vueuse/core' | ||
|
|
||
| const props = withDefaults( | ||
| defineProps<{ | ||
| packageName: string | ||
| version: SlimPackumentVersion | ||
| installSize: InstallSizeResult | null | ||
| size?: 'small' | 'medium' | ||
| }>(), | ||
| { | ||
| size: 'medium', | ||
| }, | ||
| ) | ||
|
|
||
| const triggerRef = useTemplateRef('triggerRef') | ||
| const listRef = useTemplateRef('listRef') | ||
| const isOpen = shallowRef(false) | ||
| const highlightedIndex = shallowRef(-1) | ||
| const dropdownPosition = shallowRef<{ top: number; right: number } | null>(null) | ||
|
|
||
| const { t } = useI18n() | ||
| const menuId = useId() | ||
| const menuItems = computed(() => { | ||
| const items = [{ id: 'package', label: t('package.download.package'), icon: 'i-lucide:package' }] | ||
| if (props.installSize) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Since this doesn't have anything to do with "install size" per se, it feels like this is misusing the field a bit?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch! Fixed here |
||
| items.push({ | ||
| id: 'dependencies', | ||
| label: t('package.download.dependencies'), | ||
| icon: 'i-lucide:list-tree', | ||
| }) | ||
| } | ||
| return items | ||
| }) | ||
|
|
||
| function getDropdownStyle(): Record<string, string> { | ||
| if (!dropdownPosition.value) return {} | ||
| return { | ||
| top: `${dropdownPosition.value.top}px`, | ||
| right: `${document.documentElement.clientWidth - dropdownPosition.value.right}px`, | ||
| } | ||
| } | ||
|
|
||
| function toggle() { | ||
| if (isOpen.value) { | ||
| close() | ||
| } else { | ||
| const rect = triggerRef.value?.$el?.getBoundingClientRect() | ||
| if (rect) { | ||
| dropdownPosition.value = { | ||
| top: rect.bottom + 4, | ||
| right: rect.right, | ||
| } | ||
| } | ||
| isOpen.value = true | ||
| highlightedIndex.value = 0 | ||
| } | ||
| } | ||
|
|
||
| function close() { | ||
| isOpen.value = false | ||
| highlightedIndex.value = -1 | ||
| } | ||
|
|
||
| onClickOutside(listRef, close, { ignore: [triggerRef] }) | ||
|
|
||
| function handleKeydown(event: KeyboardEvent) { | ||
| if (!isOpen.value) { | ||
| if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { | ||
| event.preventDefault() | ||
| toggle() | ||
| } | ||
| return | ||
| } | ||
|
|
||
| switch (event.key) { | ||
| case 'ArrowDown': | ||
| event.preventDefault() | ||
| highlightedIndex.value = (highlightedIndex.value + 1) % menuItems.value.length | ||
| break | ||
| case 'ArrowUp': | ||
| event.preventDefault() | ||
| highlightedIndex.value = | ||
| highlightedIndex.value <= 0 ? menuItems.value.length - 1 : highlightedIndex.value - 1 | ||
| break | ||
| case 'Enter': | ||
| case ' ': | ||
| event.preventDefault() | ||
| handleAction(menuItems.value[highlightedIndex.value]?.id) | ||
| break | ||
| case 'Escape': | ||
| event.preventDefault() | ||
| close() | ||
| triggerRef.value?.$el?.focus() | ||
| break | ||
| case 'Tab': | ||
| close() | ||
| break | ||
| } | ||
| } | ||
|
|
||
| function handleAction(id: string | undefined) { | ||
| if (id === 'package') { | ||
| downloadPackage() | ||
| } else if (id === 'dependencies') { | ||
| downloadDependenciesScript() | ||
| } | ||
| close() | ||
| } | ||
|
|
||
| async function downloadPackage() { | ||
| const tarballUrl = props.version.dist.tarball | ||
| if (!tarballUrl) return | ||
|
|
||
| try { | ||
| const response = await fetch(tarballUrl) | ||
| if (!response.ok) { | ||
| throw new Error(`Failed to fetch tarball (${response.status})`) | ||
| } | ||
| const blob = await response.blob() | ||
| const url = URL.createObjectURL(blob) | ||
| const link = document.createElement('a') | ||
| link.href = url | ||
| link.download = `${props.packageName.replace(/\//g, '__')}-${props.version.version}.tgz` | ||
| document.body.appendChild(link) | ||
| link.click() | ||
| document.body.removeChild(link) | ||
| URL.revokeObjectURL(url) | ||
| } catch { | ||
| // Fallback to direct link for non-CORS or other issues, though download attribute may be ignored | ||
| const link = document.createElement('a') | ||
| link.href = tarballUrl | ||
| link.download = `${props.packageName.replace(/\//g, '__')}-${props.version.version}.tgz` | ||
| document.body.appendChild(link) | ||
| link.click() | ||
| document.body.removeChild(link) | ||
| } | ||
| } | ||
|
|
||
| function downloadDependenciesScript() { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm having a hard time imagining the value of this feature. What use cases are you thinking of? My suggestion would be to punt on this part for this PR and revisit this in a follow-up. Does that seem ok?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That’s fair, I’m happy to revisit later. The main use case I had in mind is allowing users to download dependency tarballs directly so they can inspect or audit them before installing. It’s not something everyone would use, but it can be helpful in cases where people want more visibility into what’s being pulled in. It aligns with the original author of the feature as well #1528 (comment) But we can talk about it if we want to implement it, I'm happy to work on that and make it cross-platform as well 👍 |
||
| if (!props.installSize) return | ||
|
|
||
| const lines = [ | ||
| '#!/bin/bash', | ||
| `# Download dependencies for ${props.packageName}@${props.version.version}`, | ||
| 'mkdir -p node_modules', | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This script will only work on Linux and macOS machines, so we'd need to either generate a Windows version conditionally or warn that it isn't Windows compatible. But see my comment above – I'm not sure this feature is worthwhile 🤔. |
||
| '', | ||
| ] | ||
|
|
||
| // Add root package | ||
| const rootTarball = props.version.dist.tarball | ||
| if (rootTarball) { | ||
| lines.push(`# ${props.packageName}@${props.version.version}`) | ||
| lines.push( | ||
| `curl -L "${rootTarball}" -o "${props.packageName.replace(/\//g, '__')}-${props.version.version}.tgz"`, | ||
| ) | ||
| } | ||
|
|
||
| // Add dependencies | ||
| props.installSize.dependencies.forEach(dep => { | ||
| if (!dep.tarballUrl) return | ||
| lines.push(`# ${dep.name}@${dep.version}`) | ||
| lines.push( | ||
| `curl -L "${dep.tarballUrl}" -o "${dep.name.replace(/\//g, '__')}-${dep.version}.tgz"`, | ||
| ) | ||
| }) | ||
|
|
||
| const blob = new Blob([lines.join('\n')], { type: 'text/x-shellscript' }) | ||
| const url = URL.createObjectURL(blob) | ||
| const link = document.createElement('a') | ||
| link.href = url | ||
| link.download = `download-${props.packageName.replace(/\//g, '__')}-deps.sh` | ||
| document.body.appendChild(link) | ||
| link.click() | ||
| document.body.removeChild(link) | ||
| URL.revokeObjectURL(url) | ||
| } | ||
|
|
||
| const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)') | ||
|
|
||
| useEventListener('scroll', () => isOpen.value && close(), { passive: true }) | ||
|
|
||
| defineOptions({ | ||
| inheritAttrs: false, | ||
| }) | ||
| </script> | ||
|
|
||
| <template> | ||
| <ButtonBase | ||
| ref="triggerRef" | ||
| v-bind="$attrs" | ||
| type="button" | ||
| :variant="size === 'small' ? 'subtle' : 'secondary'" | ||
| :size | ||
| classicon="i-lucide:download" | ||
| :aria-expanded="isOpen" | ||
| aria-haspopup="menu" | ||
| :aria-controls="menuId" | ||
| @click="toggle" | ||
| @keydown="handleKeydown" | ||
| > | ||
| {{ $t('package.download.button') }} | ||
| <span | ||
| class="i-lucide:chevron-down ms-1" | ||
| :class="[ | ||
| size === 'small' ? 'w-3 h-3' : 'w-3.5 h-3.5', | ||
| { 'rotate-180': isOpen }, | ||
| prefersReducedMotion ? '' : 'transition-transform duration-200', | ||
| ]" | ||
| aria-hidden="true" | ||
| /> | ||
| </ButtonBase> | ||
|
|
||
| <Teleport to="body"> | ||
| <Transition | ||
| :enter-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-150'" | ||
| :enter-from-class="prefersReducedMotion ? '' : 'opacity-0'" | ||
| enter-to-class="opacity-100" | ||
| :leave-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-100'" | ||
| leave-from-class="opacity-100" | ||
| :leave-to-class="prefersReducedMotion ? '' : 'opacity-0'" | ||
| > | ||
| <div | ||
| v-if="isOpen" | ||
| :id="menuId" | ||
| ref="listRef" | ||
| role="menu" | ||
| :style="getDropdownStyle()" | ||
| class="fixed bg-bg-subtle border border-border rounded-md shadow-lg z-50 py-1 w-64 overscroll-contain" | ||
| @keydown="handleKeydown" | ||
| > | ||
| <button | ||
| v-for="(item, index) in menuItems" | ||
| :key="item.id" | ||
| role="menuitem" | ||
| type="button" | ||
| class="w-full flex items-center gap-2 px-3 py-2 text-sm text-fg-muted transition-colors duration-150" | ||
| :class="[ | ||
| highlightedIndex === index | ||
| ? 'bg-bg-elevated text-fg' | ||
| : 'hover:bg-bg-elevated hover:text-fg', | ||
| ]" | ||
| @click="handleAction(item.id)" | ||
| @mouseenter="highlightedIndex = index" | ||
| > | ||
| <span :class="item.icon" class="w-4 h-4" aria-hidden="true" /> | ||
| {{ item.label }} | ||
| </button> | ||
| </div> | ||
| </Transition> | ||
| </Teleport> | ||
| </template> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -595,6 +595,11 @@ | |
| "b": "{size} B", | ||
| "kb": "{size} kB", | ||
| "mb": "{size} MB" | ||
| }, | ||
| "download": { | ||
| "button": "Download", | ||
| "package": "Download Package (.tgz)", | ||
| "dependencies": "Download Dependencies (.sh)" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this says
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch! I changed the implementation a bit to run a script instead of downloading an sh file to make it adaptable to any platform. |
||
| } | ||
| }, | ||
| "connector": { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.