diff --git a/playground/package-lock.json b/playground/package-lock.json index f312611741a2b..561e74e7e75cc 100644 --- a/playground/package-lock.json +++ b/playground/package-lock.json @@ -5466,6 +5466,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8889,6 +8895,7 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "classnames": "^2.3.2", + "fflate": "^0.8.2", "react": "^19.0.0", "react-aria-components": "^1.16.0", "react-resizable-panels": "^4.0.0" diff --git a/playground/ruff/src/Editor/Chrome.tsx b/playground/ruff/src/Editor/Chrome.tsx index 4aa1c0a00ee77..10d84007b64b2 100644 --- a/playground/ruff/src/Editor/Chrome.tsx +++ b/playground/ruff/src/Editor/Chrome.tsx @@ -1,6 +1,6 @@ import ruffSchema from "../../../../ruff.schema.json"; import { useCallback, useMemo, useRef, useState } from "react"; -import { Header, useTheme, setupMonaco } from "shared"; +import { Header, useTheme, setupMonaco, downloadZip } from "shared"; import { copyAsMarkdown, copyAsMarkdownLink, @@ -44,6 +44,24 @@ export default function Chrome() { await copyAsMarkdown(settings, pythonSource); }, [pythonSource, settings]); + const handleDownload = useCallback(async () => { + if (settings == null || pythonSource == null) { + return; + } + + const toml = await import("smol-toml"); + + const files: { [name: string]: string } = { "main.py": pythonSource }; + + try { + files["ruff.toml"] = toml.stringify(JSON.parse(settings)); + } catch { + files["ruff.json"] = settings; + } + + await downloadZip(files, "ruff-playground"); + }, [pythonSource, settings]); + if (initPromise.current == null) { initPromise.current = startPlayground() .then(({ sourceCode, settings, ruffVersion }) => { @@ -110,6 +128,7 @@ export default function Chrome() { onShare={handleShare} onCopyMarkdownLink={handleCopyMarkdownLink} onCopyMarkdown={handleCopyMarkdown} + onDownload={handleDownload} onReset={handleResetClicked} /> diff --git a/playground/shared/package.json b/playground/shared/package.json index ca0caf3561945..b99d61dae6286 100644 --- a/playground/shared/package.json +++ b/playground/shared/package.json @@ -6,6 +6,7 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "classnames": "^2.3.2", + "fflate": "^0.8.2", "react-aria-components": "^1.16.0", "react": "^19.0.0", "react-resizable-panels": "^4.0.0" diff --git a/playground/shared/src/Header.tsx b/playground/shared/src/Header.tsx index f0ada18d5e73b..ba5facf106906 100644 --- a/playground/shared/src/Header.tsx +++ b/playground/shared/src/Header.tsx @@ -16,6 +16,7 @@ export default function Header({ onShare, onCopyMarkdownLink, onCopyMarkdown, + onDownload, }: { theme: Theme; tool: "ruff" | "ty"; @@ -26,6 +27,7 @@ export default function Header({ onShare: () => Promise; onCopyMarkdownLink: () => Promise; onCopyMarkdown: () => Promise; + onDownload(): void; }) { return (
diff --git a/playground/shared/src/ShareButton.tsx b/playground/shared/src/ShareButton.tsx index 6c543cf70dd53..6d72b68423739 100644 --- a/playground/shared/src/ShareButton.tsx +++ b/playground/shared/src/ShareButton.tsx @@ -7,6 +7,7 @@ import { MenuTrigger, Popover, Pressable, + Separator, } from "react-aria-components"; import AstralButton from "./AstralButton"; @@ -17,10 +18,12 @@ export default function ShareButton({ onShare, onCopyMarkdownLink, onCopyMarkdown, + onDownload, }: { onShare: () => Promise; onCopyMarkdownLink: () => Promise; onCopyMarkdown: () => Promise; + onDownload(): void; }) { const [status, dispatch, isPending] = useActionState( async (_previousStatus: ShareStatus, action: ShareAction) => { @@ -101,6 +104,8 @@ export default function ShareButton({ > Markdown + + Download ZIP diff --git a/playground/shared/src/downloadZip.ts b/playground/shared/src/downloadZip.ts new file mode 100644 index 0000000000000..15f4e230e8d77 --- /dev/null +++ b/playground/shared/src/downloadZip.ts @@ -0,0 +1,41 @@ +import { strToU8, zipSync } from "fflate"; + +/** + * Creates a ZIP archive from the given files and triggers a browser download. + * The filename includes a short content hash for uniqueness. + */ +export async function downloadZip( + files: { [name: string]: string }, + prefix = "playground", +): Promise { + const data: { [name: string]: Uint8Array } = {}; + + for (const [name, content] of Object.entries(files)) { + data[name] = strToU8(content); + } + + const zipped = zipSync(data); + + const hash = await contentHash(JSON.stringify(files)); + + const blob = new Blob([zipped.buffer as ArrayBuffer], { + type: "application/zip", + }); + const url = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.href = url; + a.download = `${prefix}-${hash}.zip`; + a.click(); + + URL.revokeObjectURL(url); +} + +async function contentHash(content: string): Promise { + const encoded = new TextEncoder().encode(content); + const digest = await crypto.subtle.digest("SHA-256", encoded); + const bytes = new Uint8Array(digest); + return Array.from(bytes.slice(0, 4)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/playground/shared/src/index.ts b/playground/shared/src/index.ts index 5cf7533191c24..21d75c1fddffe 100644 --- a/playground/shared/src/index.ts +++ b/playground/shared/src/index.ts @@ -6,6 +6,7 @@ export * as Icons from "./Icons"; export { type Theme, useTheme } from "./theme"; export { HorizontalResizeHandle, VerticalResizeHandle } from "./ResizeHandle"; export { setupMonaco } from "./setupMonaco"; +export { downloadZip } from "./downloadZip"; export { default as SideBar, SideBarEntry, diff --git a/playground/ty/src/Playground.tsx b/playground/ty/src/Playground.tsx index 4ebabe3504f27..d89990c1da4e1 100644 --- a/playground/ty/src/Playground.tsx +++ b/playground/ty/src/Playground.tsx @@ -9,7 +9,13 @@ import { useRef, useState, } from "react"; -import { ErrorMessage, Header, setupMonaco, useTheme } from "shared"; +import { + ErrorMessage, + Header, + setupMonaco, + useTheme, + downloadZip, +} from "shared"; import { FileHandle, PositionEncoding, Workspace } from "ty_wasm"; import { copyAsMarkdown, @@ -80,6 +86,28 @@ export default function Playground() { } }, [files]); + const handleDownload = useCallback(async () => { + const serialized = serializeFiles(files); + + if (serialized != null) { + const downloadFiles = { ...serialized.files }; + + if (SETTINGS_FILE_NAME in downloadFiles) { + try { + const toml = await import("smol-toml"); + const tomlContent = toml.stringify( + JSON.parse(downloadFiles[SETTINGS_FILE_NAME]), + ); + delete downloadFiles[SETTINGS_FILE_NAME]; + downloadFiles["ty.toml"] = tomlContent; + } catch { + // Keep the original JSON file if conversion fails. + } + } + + await downloadZip(downloadFiles, "ty-playground"); + } + }, [files]); const handleFileAdded = useCallback((workspace: Workspace, name: string) => { let handle = null; @@ -205,6 +233,7 @@ export default function Playground() { onShare={handleShare} onCopyMarkdownLink={handleCopyMarkdownLink} onCopyMarkdown={handleCopyMarkdown} + onDownload={handleDownload} onReset={workspace == null ? undefined : handleReset} />