From fbb9375089b1394c098a8c4d3814482afe13f2c8 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Mon, 16 Mar 2026 11:29:39 +1100 Subject: [PATCH] Update skill modal ZIP download 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 2c51fe993..c61e6a88e 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 50a3c02e4..a958fb97e 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 a4effdff6..ac88e14a4 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 3d0d95110..071fde268 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) {