From 39a1a0ce04690d680c65e70ba68deb2f5962d247 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Mon, 16 Mar 2026 11:51:41 +1100 Subject: [PATCH] Update skill modal ZIP download (#1015) Make the skill modal download button reuse the existing skill ZIP behavior so it downloads the full skill bundle instead of only the current file. Extract the ZIP creation into a shared utility and reuse it from the skills and hooks pages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- website/src/scripts/modal.ts | 30 +++++++++++++ website/src/scripts/pages/hooks.ts | 40 +---------------- website/src/scripts/pages/skills.ts | 40 +---------------- website/src/scripts/utils.ts | 70 +++++++++++++++++++++++++---- 4 files changed, 95 insertions(+), 85 deletions(-) diff --git a/website/src/scripts/modal.ts b/website/src/scripts/modal.ts index 2c51fe99..c61e6a88 100644 --- a/website/src/scripts/modal.ts +++ b/website/src/scripts/modal.ts @@ -10,6 +10,7 @@ import { copyToClipboard, showToast, downloadFile, + downloadZipBundle, shareFile, getResourceType, escapeHtml, @@ -46,6 +47,7 @@ interface SkillFile { } interface SkillItem extends ResourceItem { + id: string; skillFile: string; files: SkillFile[]; } @@ -56,6 +58,10 @@ interface SkillsData { let skillsCache: SkillsData | null | undefined; +function getSkillDownloadName(skill: SkillItem): string { + return skill.id || skill.path.split("/").pop() || "skill"; +} + const RESOURCE_TYPE_TO_JSON: Record = { agent: "agents.json", instruction: "instructions.json", @@ -524,6 +530,24 @@ export function setupModal(): void { downloadBtn?.addEventListener("click", async () => { if (currentFilePath) { + if (currentFileType === "skill") { + const skill = await getSkillItemByFilePath(currentFilePath); + if (!skill || skill.files.length === 0) { + showToast("No files found for this skill.", "error"); + return; + } + + try { + await downloadZipBundle(getSkillDownloadName(skill), skill.files); + showToast("Download started!", "success"); + } catch (error) { + const message = + error instanceof Error ? error.message : "Download failed"; + showToast(message, "error"); + } + return; + } + const success = await downloadFile(currentFilePath); showToast( success ? "Download started!" : "Download failed", @@ -868,6 +892,12 @@ export async function openFileModal( // Show copy/download buttons for regular files if (copyBtn) copyBtn.style.display = "inline-flex"; if (downloadBtn) downloadBtn.style.display = "inline-flex"; + if (downloadBtn) { + downloadBtn.setAttribute( + "aria-label", + type === "skill" ? "Download skill as ZIP" : "Download file" + ); + } renderPlainText("Loading..."); hideSkillFileSwitcher(); updateViewButtons(); diff --git a/website/src/scripts/pages/hooks.ts b/website/src/scripts/pages/hooks.ts index 50a3c02e..a958fb97 100644 --- a/website/src/scripts/pages/hooks.ts +++ b/website/src/scripts/pages/hooks.ts @@ -6,9 +6,8 @@ import { FuzzySearch, type SearchItem } from "../search"; import { fetchData, debounce, - getRawGitHubUrl, showToast, - loadJSZip, + downloadZipBundle, } from "../utils"; import { setupModal, openFileModal } from "../modal"; import { @@ -157,42 +156,7 @@ async function downloadHook( ' Preparing...'; try { - const JSZip = await loadJSZip(); - const zip = new JSZip(); - const folder = zip.folder(hook.id); - - const fetchPromises = files.map(async (file) => { - const url = getRawGitHubUrl(file.path); - try { - const response = await fetch(url); - if (!response.ok) return null; - const content = await response.text(); - return { name: file.name, content }; - } catch { - return null; - } - }); - - const results = await Promise.all(fetchPromises); - let addedFiles = 0; - for (const result of results) { - if (result && folder) { - folder.file(result.name, result.content); - addedFiles++; - } - } - - if (addedFiles === 0) throw new Error("Failed to fetch any files"); - - const blob = await zip.generateAsync({ type: "blob" }); - const downloadUrl = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = downloadUrl; - link.download = `${hook.id}.zip`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(downloadUrl); + await downloadZipBundle(hook.id, files); btn.innerHTML = ' Downloaded!'; diff --git a/website/src/scripts/pages/skills.ts b/website/src/scripts/pages/skills.ts index a4effdff..ac88e14a 100644 --- a/website/src/scripts/pages/skills.ts +++ b/website/src/scripts/pages/skills.ts @@ -6,9 +6,8 @@ import { FuzzySearch, type SearchItem } from "../search"; import { fetchData, debounce, - getRawGitHubUrl, showToast, - loadJSZip, + downloadZipBundle, } from "../utils"; import { setupModal, openFileModal } from "../modal"; import { @@ -137,42 +136,7 @@ async function downloadSkill( ' Preparing...'; try { - const JSZip = await loadJSZip(); - const zip = new JSZip(); - const folder = zip.folder(skill.id); - - const fetchPromises = skill.files.map(async (file) => { - const url = getRawGitHubUrl(file.path); - try { - const response = await fetch(url); - if (!response.ok) return null; - const content = await response.text(); - return { name: file.name, content }; - } catch { - return null; - } - }); - - const results = await Promise.all(fetchPromises); - let addedFiles = 0; - for (const result of results) { - if (result && folder) { - folder.file(result.name, result.content); - addedFiles++; - } - } - - if (addedFiles === 0) throw new Error("Failed to fetch any files"); - - const blob = await zip.generateAsync({ type: "blob" }); - const downloadUrl = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = downloadUrl; - link.download = `${skill.id}.zip`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(downloadUrl); + await downloadZipBundle(skill.id, skill.files); btn.innerHTML = ' Downloaded!'; diff --git a/website/src/scripts/utils.ts b/website/src/scripts/utils.ts index 3d0d9511..071fde26 100644 --- a/website/src/scripts/utils.ts +++ b/website/src/scripts/utils.ts @@ -70,6 +70,66 @@ export async function loadJSZip() { return JSZip; } +export interface ZipDownloadFile { + name: string; + path: string; +} + +function triggerBlobDownload(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +export async function downloadZipBundle( + bundleName: string, + files: ZipDownloadFile[] +): Promise { + if (files.length === 0) { + throw new Error("No files found for this download."); + } + + const JSZip = await loadJSZip(); + const zip = new JSZip(); + const folder = zip.folder(bundleName); + + const fetchPromises = files.map(async (file) => { + try { + const response = await fetch(getRawGitHubUrl(file.path)); + if (!response.ok) return null; + + return { + name: file.name, + content: await response.text(), + }; + } catch { + return null; + } + }); + + const results = await Promise.all(fetchPromises); + let addedFiles = 0; + + for (const result of results) { + if (result && folder) { + folder.file(result.name, result.content); + addedFiles++; + } + } + + if (addedFiles === 0) { + throw new Error("Failed to fetch any files"); + } + + const blob = await zip.generateAsync({ type: "blob" }); + triggerBlobDownload(blob, `${bundleName}.zip`); +} + /** * Fetch raw file content from GitHub */ @@ -156,15 +216,7 @@ export async function downloadFile(filePath: string): Promise { const filename = filePath.split("/").pop() || "file.md"; const blob = new Blob([content], { type: "text/markdown" }); - const url = URL.createObjectURL(blob); - - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + triggerBlobDownload(blob, filename); return true; } catch (error) {