Skip to content

Commit 4b7b71c

Browse files
committed
fix(web): make workspace downloads direct
1 parent bb4dbc4 commit 4b7b71c

File tree

7 files changed

+92
-65
lines changed

7 files changed

+92
-65
lines changed

runtime/src/channels/web/handlers/workspace.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ export function handleWorkspaceDelete(req: Request): Response {
105105
*/
106106
export function handleWorkspaceRaw(req: Request): Response {
107107
const url = new URL(req.url);
108-
const result = workspaceService.getRaw(url.searchParams.get("path"));
108+
const download = url.searchParams.get("download") === "1" || url.searchParams.get("download") === "true";
109+
const result = workspaceService.getRaw(url.searchParams.get("path"), download);
109110
if (result.status !== 200) {
110111
return new Response(result.body as string, {
111112
status: result.status,
@@ -120,11 +121,13 @@ export function handleWorkspaceRaw(req: Request): Response {
120121
const file = result.body as ReturnType<typeof Bun.file>;
121122
const filePath = result.filePath || null;
122123
const fileSize = typeof result.size === "number" ? result.size : (typeof file?.size === "number" ? file.size : 0);
124+
const downloadFilename = String(result.filename || "download").replace(/["\\]/g, "_");
123125
const baseHeaders: Record<string, string> = {
124126
"Content-Type": contentType,
125127
"Accept-Ranges": "bytes",
126128
"X-Frame-Options": "SAMEORIGIN",
127129
"Content-Security-Policy": "default-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'",
130+
...(result.download ? { "Content-Disposition": `attachment; filename="${downloadFilename}"` } : {}),
128131
};
129132

130133
const readRangeChunk = (start: number, chunkSize: number): Uint8Array | null => {

runtime/src/channels/web/workspace/file-service.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ export class WorkspaceFileService {
127127
}
128128
}
129129

130-
getRaw(pathParam: string | null): { status: number; body: string | Blob; contentType?: string; filePath?: string; size?: number } {
130+
getRaw(
131+
pathParam: string | null,
132+
download = false,
133+
): { status: number; body: string | Blob; contentType?: string; filePath?: string; size?: number; filename?: string; download?: boolean } {
131134
const targetPath = resolveWorkspacePath(pathParam);
132135
if (!targetPath) return { status: 400, body: "Invalid path" };
133136

@@ -136,7 +139,15 @@ export class WorkspaceFileService {
136139
if (stats.isDirectory()) return { status: 400, body: "Path is a directory" };
137140
const contentType = contentTypeForPath(targetPath);
138141
const file = Bun.file(targetPath);
139-
return { status: 200, body: file, contentType, filePath: targetPath, size: stats.size };
142+
return {
143+
status: 200,
144+
body: file,
145+
contentType,
146+
filePath: targetPath,
147+
size: stats.size,
148+
filename: path.basename(targetPath),
149+
download,
150+
};
140151
} catch {
141152
return { status: 404, body: "Not found" };
142153
}

runtime/src/channels/web/workspace/service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ export class WorkspaceService {
2525
return this.fileService.getFile(pathParam, maxParam, mode);
2626
}
2727

28-
getRaw(pathParam: string | null) {
29-
return this.fileService.getRaw(pathParam);
28+
getRaw(pathParam: string | null, download = false) {
29+
return this.fileService.getRaw(pathParam, download);
3030
}
3131

3232
getGitBranch(pathParam: string | null) {

runtime/test/channels/web/workspace-file-service.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ test("updateFile/deleteFile and getRaw handle success and errors", () => {
7575
const raw = service.getRaw(`${prefix}/edit.txt`);
7676
expect(raw.status).toBe(200);
7777
expect(raw.contentType).toBe("text/plain");
78+
expect(raw.download).toBe(false);
79+
80+
const rawDownload = service.getRaw(`${prefix}/edit.txt`, true);
81+
expect(rawDownload.status).toBe(200);
82+
expect(rawDownload.download).toBe(true);
83+
expect(rawDownload.filename).toBe("edit.txt");
7884

7985
expect(service.updateFile(`${prefix}/edit.txt`, undefined as any).status).toBe(400);
8086
expect(service.updateFile(`${prefix}/missing.txt`, "x").status).toBe(404);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { expect, test } from "bun:test";
2+
import "../../helpers.js";
3+
import { mkdirSync, rmSync, writeFileSync } from "fs";
4+
import { join } from "path";
5+
6+
import { handleWorkspaceRaw } from "../../../src/channels/web/handlers/workspace.js";
7+
import { WORKSPACE_DIR } from "../../../src/core/config.js";
8+
9+
function setupWorkspaceFile() {
10+
const prefix = `workspace-raw-handler-${Date.now()}-${Math.random().toString(36).slice(2)}`;
11+
const base = join(WORKSPACE_DIR, prefix);
12+
mkdirSync(base, { recursive: true });
13+
const filePath = join(base, "download me.txt");
14+
writeFileSync(filePath, "hello\n", "utf8");
15+
return {
16+
prefix,
17+
cleanup: () => rmSync(base, { recursive: true, force: true }),
18+
};
19+
}
20+
21+
test("handleWorkspaceRaw adds attachment headers when download=1", async () => {
22+
const { prefix, cleanup } = setupWorkspaceFile();
23+
try {
24+
const response = handleWorkspaceRaw(new Request(
25+
`https://example.com/workspace/raw?path=${encodeURIComponent(`${prefix}/download me.txt`)}&download=1`,
26+
{ method: "GET" },
27+
));
28+
29+
expect(response.status).toBe(200);
30+
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="download me.txt"');
31+
expect(await response.text()).toBe("hello\n");
32+
} finally {
33+
cleanup();
34+
}
35+
});

runtime/web/src/api.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -655,8 +655,17 @@ export async function setWorkspaceVisibility(visible, showHidden = false) {
655655
/**
656656
* Get raw workspace file URL (images/SVG)
657657
*/
658-
export function getWorkspaceRawUrl(path) {
659-
return `${API_BASE}/workspace/raw?path=${encodeURIComponent(path)}`;
658+
export function getWorkspaceRawUrl(path, options = {}) {
659+
const query = new URLSearchParams({ path: String(path || '') });
660+
if (options.download) query.set('download', '1');
661+
return `${API_BASE}/workspace/raw?${query.toString()}`;
662+
}
663+
664+
/**
665+
* Get workspace file download URL.
666+
*/
667+
export function getWorkspaceFileDownloadUrl(path) {
668+
return getWorkspaceRawUrl(path, { download: true });
660669
}
661670

662671
/**

runtime/web/src/components/workspace-explorer.ts

Lines changed: 21 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,11 @@
22
import { html, useCallback, useEffect, useMemo, useRef, useState } from '../vendor/preact-htm.js';
33
import { getLocalStorageBoolean, getLocalStorageItem, getLocalStorageNumber, setLocalStorageItem } from '../utils/storage.js';
44
import {
5-
attachWorkspaceFile,
65
createWorkspaceFile,
76
deleteWorkspaceFile,
8-
getMediaInfo,
9-
getMediaUrl,
107
getWorkspaceDownloadUrl,
118
getWorkspaceFile,
9+
getWorkspaceFileDownloadUrl,
1210
getWorkspaceTree,
1311
moveWorkspaceEntry,
1412
renameWorkspaceFile,
@@ -524,40 +522,16 @@ function FolderStarburstChart({ payload }) {
524522
`;
525523
}
526524

527-
// ── FileAttachmentCard ────────────────────────────────────────────────────────
528-
529-
function FileAttachmentCard({ mediaId }) {
530-
const [info, setInfo] = useState(null);
531-
useEffect(() => {
532-
if (!mediaId) return;
533-
getMediaInfo(mediaId).then(setInfo).catch(() => {
534-
/* expected: attachment metadata is best-effort for workspace cards. */
535-
});
536-
}, [mediaId]);
537-
if (!info) return null;
538-
const filename = info.filename || 'file';
539-
const sizeStr = info.metadata?.size ? formatFileSize(info.metadata.size) : '';
540-
return html`
541-
<a href=${getMediaUrl(mediaId)} download=${filename} class="file-attachment"
542-
onClick=${(e) => e.stopPropagation()}>
543-
<svg class="file-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
544-
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
545-
<polyline points="14 2 14 8 20 8"/>
546-
<line x1="16" y1="13" x2="8" y2="13"/>
547-
<line x1="16" y1="17" x2="8" y2="17"/>
548-
<polyline points="10 9 9 9 8 9"/>
549-
</svg>
550-
<div class="file-info">
551-
<span class="file-name">${filename}</span>
552-
${sizeStr && html`<span class="file-size">${sizeStr}</span>`}
553-
</div>
554-
<svg class="download-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
555-
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
556-
<polyline points="7 10 12 15 17 10"/>
557-
<line x1="12" y1="15" x2="12" y2="3"/>
558-
</svg>
559-
</a>
560-
`;
525+
function triggerWorkspaceDownload(url) {
526+
if (typeof document === 'undefined' || !url) return;
527+
const link = document.createElement('a');
528+
link.href = url;
529+
link.setAttribute('download', '');
530+
link.rel = 'noopener';
531+
link.style.display = 'none';
532+
document.body.appendChild(link);
533+
link.click();
534+
link.remove();
561535
}
562536

563537
// ── WorkspaceExplorer ─────────────────────────────────────────────────────────
@@ -579,7 +553,7 @@ export function WorkspaceExplorer({
579553
const [renamingPath, setRenamingPath] = useState(null);
580554
const [renameValue, setRenameValue] = useState('');
581555
const [preview, setPreview] = useState(null);
582-
const [downloadId, setDownloadId] = useState(null);
556+
const [, setDownloadId] = useState(null);
583557
const [initialLoad, setInitialLoad] = useState(true);
584558
const [loadingPreview,setLoadingPreview]= useState(false);
585559
const [error, setError] = useState(null);
@@ -1756,15 +1730,10 @@ export function WorkspaceExplorer({
17561730
document.addEventListener('touchcancel', onUp);
17571731
}).current;
17581732

1759-
const handleDownload = async () => {
1760-
if (!selectedPath) return;
1761-
try {
1762-
const res = await attachWorkspaceFile(selectedPath);
1763-
if (res.media_id) setDownloadId(res.media_id);
1764-
} catch (err) {
1765-
setPreview(prev => ({ ...(prev || {}), error: err.message || 'Failed to attach' }));
1766-
}
1767-
};
1733+
const handleDownload = useCallback((path = selectedPath) => {
1734+
if (!path) return;
1735+
triggerWorkspaceDownload(getWorkspaceFileDownloadUrl(path));
1736+
}, [selectedPath]);
17681737

17691738
const handleDeleteFile = async () => {
17701739
if (!selectedPath || selectedIsDir) return;
@@ -1967,9 +1936,7 @@ export function WorkspaceExplorer({
19671936
const handleMenuDownloadFolder = useCallback(() => {
19681937
if (!selectedFolderDownloadUrl) return;
19691938
closeHeaderMenu();
1970-
if (typeof window !== 'undefined') {
1971-
window.open(selectedFolderDownloadUrl, '_blank', 'noopener');
1972-
}
1939+
triggerWorkspaceDownload(selectedFolderDownloadUrl);
19731940
}, [closeHeaderMenu, selectedFolderDownloadUrl]);
19741941

19751942
const handleMenuOpenTerminalTab = useCallback(() => {
@@ -2320,7 +2287,7 @@ export function WorkspaceExplorer({
23202287
<line x1="12" y1="3" x2="12" y2="15"/>
23212288
</svg>
23222289
</button>
2323-
<a class="workspace-download" href=${getWorkspaceDownloadUrl(selectedPath, showHidden)}
2290+
<a class="workspace-download" href=${getWorkspaceDownloadUrl(selectedPath, showHidden)} download
23242291
title="Download folder as zip" onClick=${(e) => e.stopPropagation()}>
23252292
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
23262293
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -2329,14 +2296,15 @@ export function WorkspaceExplorer({
23292296
<line x1="12" y1="15" x2="12" y2="3"/>
23302297
</svg>
23312298
</a>`
2332-
: html`<button class="workspace-download" onClick=${handleDownload} title="Download">
2299+
: html`<a class="workspace-download" href=${getWorkspaceFileDownloadUrl(selectedPath)} download
2300+
title="Download" onClick=${(e) => e.stopPropagation()}>
23332301
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
23342302
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
23352303
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
23362304
<polyline points="7 10 12 15 17 10"/>
23372305
<line x1="12" y1="15" x2="12" y2="3"/>
23382306
</svg>
2339-
</button>`}
2307+
</a>`}
23402308
</div>
23412309
</div>
23422310
${loadingPreview && html`<div class="workspace-loading">Loading preview…</div>`}
@@ -2355,11 +2323,6 @@ export function WorkspaceExplorer({
23552323
${preview && !preview.error && !selectedIsDir && html`
23562324
<div class="workspace-preview-body" ref=${previewPaneHostRef}></div>
23572325
`}
2358-
${downloadId && html`
2359-
<div class="workspace-download-card">
2360-
<${FileAttachmentCard} mediaId=${downloadId} />
2361-
</div>
2362-
`}
23632326
</div>
23642327
`}
23652328
${dragGhost && html`

0 commit comments

Comments
 (0)