Add workspace file downloads

Add first-class workspace file downloads, broader attachment content-type support, and the stream-lifetime limiter fix from PR review.
This commit is contained in:
Dotta 2026-06-26 06:05:57 -05:00 committed by GitHub
parent fdb8b5678b
commit 8f7282066e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 373 additions and 31 deletions

View File

@ -34,7 +34,7 @@ export interface ResolvedWorkspaceResource {
denialReason?: string | null;
capabilities: {
preview: boolean;
download: false;
download: boolean;
listChildren: boolean;
};
}
@ -61,10 +61,10 @@ export interface WorkspaceFileListFileItem {
contentType?: string | null;
byteSize?: number | null;
modifiedAt?: string | null;
previewKind: Exclude<WorkspaceFilePreviewKind, "unsupported">;
previewKind: WorkspaceFilePreviewKind;
capabilities: {
preview: true;
download: false;
preview: boolean;
download: true;
listChildren: false;
};
}

View File

@ -90,7 +90,7 @@ export const resolvedWorkspaceResourceSchema = z.object({
denialReason: z.string().nullable().optional(),
capabilities: z.object({
preview: z.boolean(),
download: z.literal(false),
download: z.boolean(),
listChildren: z.boolean(),
}),
});

View File

@ -231,6 +231,53 @@ describeEmbeddedPostgres("workspace file resources", () => {
expect(res.body.content.data).toContain("export const ok");
});
it("lists and downloads non-previewable workspace files", async () => {
const { root, projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
const relativePath = "artifacts/archive.bin";
const bytes = Buffer.from([0, 1, 2, 3, 4, 255]);
await fs.mkdir(path.join(projectRoot, "artifacts"), { recursive: true });
await fs.writeFile(path.join(projectRoot, relativePath), bytes);
const app = createApp(db, {
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
});
const listed = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/list`)
.query({ workspace: "project", mode: "all", path: "artifacts" });
expect(listed.status).toBe(200);
expect(listed.body.items).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "file",
relativePath,
previewKind: "unsupported",
capabilities: { preview: false, download: true, listChildren: false },
}),
]));
expect(JSON.stringify(listed.body)).not.toContain(root);
const downloaded = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/content`)
.query({ workspace: "project", path: relativePath, download: "1" })
.buffer(true)
.parse((res, callback) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
res.on("end", () => callback(null, Buffer.concat(chunks)));
});
expect(downloaded.status).toBe(200);
expect(downloaded.headers["content-disposition"]).toBe('attachment; filename="archive.bin"');
expect(downloaded.headers["x-content-type-options"]).toBe("nosniff");
expect(Buffer.compare(downloaded.body as Buffer, bytes)).toBe(0);
});
it("falls back from an execution workspace miss to the project workspace", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
@ -1125,12 +1172,15 @@ describeEmbeddedPostgres("workspace file resources", () => {
workspaceKind: "project_workspace",
workspaceId: "11111111-1111-4111-8111-111111111111",
previewKind: "text",
capabilities: { preview: true, download: false, listChildren: false },
capabilities: { preview: true, download: true, listChildren: false },
};
}),
readContent: vi.fn(async () => {
throw new Error("not used");
}),
prepareDownload: vi.fn(async () => {
throw new Error("not used");
}),
};
const resolveLimitedApp = createApp(
db,
@ -1187,11 +1237,14 @@ describeEmbeddedPostgres("workspace file resources", () => {
workspaceKind: "project_workspace",
workspaceId: "11111111-1111-4111-8111-111111111111",
previewKind: "text",
capabilities: { preview: true, download: false, listChildren: false },
capabilities: { preview: true, download: true, listChildren: false },
},
content: { encoding: "utf8", data: "# Visible\n" },
};
}),
prepareDownload: vi.fn(async () => {
throw new Error("not used");
}),
};
const contentLimitedApp = createApp(
db,
@ -1225,6 +1278,92 @@ describeEmbeddedPostgres("workspace file resources", () => {
expect(JSON.stringify(rowsAfterLimits.map((row) => row.details))).not.toContain(projectRoot);
});
it("holds download concurrency slots until the file stream completes", async () => {
if (process.platform === "win32") return;
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
const fifoPath = path.join(projectRoot, "slow-download.bin");
await execFileAsync("mkfifo", [fifoPath]);
let slowDownloadStarted: (() => void) | null = null;
const downloadStarted = new Promise<void>((resolve) => {
slowDownloadStarted = resolve;
});
const service: WorkspaceFileResourceService = {
getIssue: vi.fn(async () => ({ companyId: graph.companyId })),
list: vi.fn(async () => {
throw new Error("not used");
}),
resolve: vi.fn(async () => {
throw new Error("not used");
}),
readContent: vi.fn(async () => {
throw new Error("not used");
}),
prepareDownload: vi.fn(async () => {
slowDownloadStarted?.();
return {
resource: {
kind: "file",
provider: "local_fs",
title: "slow-download.bin",
displayPath: "slow-download.bin",
workspaceLabel: "Workspace",
workspaceKind: "project_workspace",
workspaceId: "11111111-1111-4111-8111-111111111111",
previewKind: "unsupported",
contentType: "application/octet-stream",
byteSize: null,
capabilities: { preview: false, download: true, listChildren: false },
},
realPath: fifoPath,
};
}),
};
const app = createApp(
db,
{
type: "board",
userId: "board-user",
companyIds: [graph.companyId],
source: "session",
isInstanceAdmin: false,
},
{
service,
limiter: createFileResourceLimiter({ maxConcurrent: 1, maxRequests: 100, windowMs: 60_000 }),
},
);
const firstDownload = request(app)
.get(`/api/issues/${graph.issueId}/file-resources/content`)
.query({ path: "slow-download.bin", download: "1" })
.buffer(true)
.parse((res, callback) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
res.on("end", () => callback(null, Buffer.concat(chunks)));
});
const firstDownloadResponse = firstDownload.then((res) => res);
await downloadStarted;
await new Promise((resolve) => setImmediate(resolve));
const secondDownload = await request(app)
.get(`/api/issues/${graph.issueId}/file-resources/content`)
.query({ path: "slow-download.bin", download: "1" });
expect(secondDownload.status).toBe(429);
const writer = await fs.open(fifoPath, "w");
await writer.write(Buffer.from("slow"));
await writer.close();
const first = await firstDownloadResponse;
expect(first.status).toBe(200);
expect(first.headers["content-length"]).toBeUndefined();
expect(first.headers["content-disposition"]).toBe('attachment; filename="slow-download.bin"');
expect(Buffer.compare(first.body as Buffer, Buffer.from("slow"))).toBe(0);
});
it("uses tighter list-specific rate and concurrency limits", async () => {
const { projectRoot, executionRoot } = await makeWorkspace();
const graph = await seedGraph(db, { projectRoot, executionRoot });
@ -1267,6 +1406,9 @@ describeEmbeddedPostgres("workspace file resources", () => {
readContent: vi.fn(async () => {
throw new Error("not used");
}),
prepareDownload: vi.fn(async () => {
throw new Error("not used");
}),
};
const app = createApp(
db,
@ -1340,12 +1482,15 @@ describeEmbeddedPostgres("file resource route guards", () => {
workspaceKind: "project_workspace",
workspaceId: "11111111-1111-4111-8111-111111111111",
previewKind: "text",
capabilities: { preview: true, download: false, listChildren: false },
capabilities: { preview: true, download: true, listChildren: false },
};
}),
readContent: vi.fn(async () => {
throw new Error("not used");
}),
prepareDownload: vi.fn(async () => {
throw new Error("not used");
}),
};
const app = express();
app.use((req, _res, next) => {

View File

@ -302,23 +302,32 @@ describe("issue attachment routes", () => {
});
});
it("rejects unsupported upload content types before storing the file", async () => {
it("accepts arbitrary upload content types while preserving the stored MIME type", async () => {
const storage = createStorageService();
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
});
mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/x-msdownload", "payload.exe"));
const app = await createApp(storage);
const res = await request(app)
.post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments")
.attach("file", Buffer.from("exe"), { filename: "payload.exe", contentType: "application/x-msdownload" });
expect(res.status).toBe(422);
expect(res.body.error).toBe("Unsupported attachment content type: application/x-msdownload");
expect(storage.__calls.putFile).toBeUndefined();
expect(mockIssueService.createAttachment).not.toHaveBeenCalled();
expect(res.status).toBe(201);
expect(storage.__calls.putFile).toMatchObject({
contentType: "application/x-msdownload",
originalFilename: "payload.exe",
});
expect(mockIssueService.createAttachment).toHaveBeenCalledWith(
expect.objectContaining({
contentType: "application/x-msdownload",
originalFilename: "payload.exe",
}),
);
expect(res.body.contentType).toBe("application/x-msdownload");
});
it("enforces the process-level issue attachment limit even when the company limit allows more", async () => {
@ -383,6 +392,22 @@ describe("issue attachment routes", () => {
expect(res.headers["x-content-type-options"]).toBe("nosniff");
});
it("serves arbitrary binary attachments as downloads with nosniff", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("application/x-msdownload", "payload.exe"));
const app = await createApp(storage);
const res = await request(app)
.get("/api/attachments/attachment-1/content")
.buffer(true)
.parse(parseBinaryResponse);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("application/x-msdownload");
expect(res.headers["content-disposition"]).toBe('attachment; filename="payload.exe"');
expect(res.headers["x-content-type-options"]).toBe("nosniff");
});
it("keeps image attachments inline for previews", async () => {
const storage = createStorageService();
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("image/png", "preview.png"));

View File

@ -1,3 +1,5 @@
import { createReadStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { Router } from "express";
import { ZodError } from "zod";
import type { Db } from "@paperclipai/db";
@ -35,6 +37,11 @@ export type WorkspaceFileResourceService = {
input: { path: string; workspace?: "auto" | "execution" | "project" | null; projectId?: string | null; workspaceId?: string | null },
opts?: { issue?: Awaited<ReturnType<WorkspaceFileResourceService["getIssue"]>> },
): Promise<WorkspaceFileContent>;
prepareDownload(
issueId: string,
input: { path: string; workspace?: "auto" | "execution" | "project" | null; projectId?: string | null; workspaceId?: string | null },
opts?: { issue?: Awaited<ReturnType<WorkspaceFileResourceService["getIssue"]>> },
): Promise<{ resource: ResolvedWorkspaceResource; realPath: string }>;
};
type FileResourceLimiter = {
@ -104,6 +111,14 @@ function limiterKey(companyId: string, actorId: string, issueId: string) {
return `${companyId}:${actorId}:${issueId}`;
}
function parseBooleanQuery(value: unknown) {
return value === true || value === "true" || value === "1";
}
function safeAttachmentFilename(value: string) {
return value.replaceAll("\"", "").replace(/[\\/\r\n]/g, "_") || "workspace-file";
}
function readQuery(query: unknown) {
let parsed;
try {
@ -267,7 +282,7 @@ export function fileResourceRoutes(db: Db, opts: {
projectId?: string | null;
workspaceId?: string | null;
error: unknown;
action?: "issue.file_resource_content_denied" | "issue.file_resource_resolve_denied";
action?: "issue.file_resource_content_denied" | "issue.file_resource_resolve_denied" | "issue.file_resource_download_denied";
}) {
await logActivity(db, {
companyId: input.companyId,
@ -595,6 +610,56 @@ export function fileResourceRoutes(db: Db, opts: {
throw error;
}
try {
if (parseBooleanQuery(req.query.download)) {
let result: Awaited<ReturnType<WorkspaceFileResourceService["prepareDownload"]>> | null = null;
try {
result = await svc.prepareDownload(req.params.issueId, query, { issue });
} catch (error) {
await logDeniedAttempt({
companyId: issue.companyId,
actor,
issueId: req.params.issueId,
displayPath: query.path,
projectId: query.projectId,
workspaceId: query.workspaceId,
error,
action: "issue.file_resource_download_denied",
});
throw error;
}
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
action: "issue.file_resource_download",
entityType: "issue",
entityId: req.params.issueId,
agentId: actor.agentId,
runId: actor.runId,
details: activityDetails({
outcome: "success",
workspaceKind: result.resource.workspaceKind,
workspaceId: result.resource.workspaceId,
projectId: result.resource.projectId ?? null,
projectName: result.resource.projectName ?? null,
displayPath: result.resource.displayPath,
byteSize: result.resource.byteSize ?? null,
contentType: result.resource.contentType ?? null,
}),
});
res.setHeader("Content-Type", result.resource.contentType ?? "application/octet-stream");
if (result.resource.byteSize != null) {
res.setHeader("Content-Length", String(result.resource.byteSize));
}
res.setHeader("Cache-Control", "private, max-age=60");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Disposition", `attachment; filename="${safeAttachmentFilename(result.resource.title)}"`);
await pipeline(createReadStream(result.realPath), res);
return;
}
let result: WorkspaceFileContent | null = null;
try {
result = await svc.readContent(req.params.issueId, query, { issue });

View File

@ -109,7 +109,6 @@ import {
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
import {
isInlineAttachmentContentType,
isAllowedContentType,
normalizeIssueAttachmentMaxBytes,
normalizeContentType,
SVG_CONTENT_TYPE,
@ -8198,10 +8197,6 @@ export function issueRoutes(
res.status(422).json({ error: "Attachment is empty" });
return;
}
if (!isAllowedContentType(contentType)) {
res.status(422).json({ error: `Unsupported attachment content type: ${contentType}` });
return;
}
const parsedMeta = createIssueAttachmentMetadataSchema.safeParse(req.body ?? {});
if (!parsedMeta.success) {

View File

@ -242,9 +242,8 @@ function listItemFromStat(input: {
stat: { size: number; mtime: Date };
}): WorkspaceFileListItem | null {
const contentType = contentTypeForPath(input.relativePath);
const previewKind = previewKindForKnownContentType(contentType);
if (!previewKind || previewKind === "unsupported") return null;
if (input.stat.size > previewCapForKind(previewKind)) return null;
const previewKind = previewKindForKnownContentType(contentType) ?? "unsupported";
const previewable = previewKind !== "unsupported" && input.stat.size <= previewCapForKind(previewKind);
return {
kind: "file",
@ -262,8 +261,8 @@ function listItemFromStat(input: {
modifiedAt: input.stat.mtime.toISOString(),
previewKind,
capabilities: {
preview: true,
download: false,
preview: previewable,
download: true,
listChildren: false,
},
};
@ -471,7 +470,7 @@ async function statLocalCandidate(candidate: WorkspaceCandidate, normalized: Nor
denialReason,
capabilities: {
preview: !tooLarge && !unsupported,
download: false,
download: true,
listChildren: false,
},
},
@ -1394,10 +1393,59 @@ export function workspaceFileResourceService(db: Db) {
throw unprocessable("No local-readable workspace is available for this issue", { code: "no_local_workspace" });
}
async function prepareDownload(issueId: string, input: {
path: string;
workspace?: WorkspaceFileSelector | null;
projectId?: string | null;
workspaceId?: string | null;
}, opts: { issue?: IssueRow } = {}): Promise<LocalResolvedFile> {
const issue = opts.issue ?? await getIssue(issueId);
const selector = input.workspace ?? "auto";
const explicitTarget = Boolean(input.projectId || input.workspaceId);
const normalized = normalizeWorkspaceRelativePath(input.path);
const candidates = await listCandidates(issue, selector, input);
if (candidates.length === 0) {
throw unprocessable("No workspace is available for this issue", { code: "no_workspace" });
}
let lastNotFound: unknown = null;
for (const candidate of candidates) {
if (candidate.remote) {
if (explicitTarget || selector !== "auto") {
throw unprocessable("Remote workspaces cannot be downloaded by the server", { code: "remote_workspace" });
}
continue;
}
try {
return await statLocalCandidate(candidate, normalized);
} catch (error) {
if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) {
lastNotFound = error;
continue;
}
throw error;
}
}
if (lastNotFound && !explicitTarget && selector === "auto") {
const discovered = await discoverUniqueProjectWorkspaceMatch(
issue,
new Set(candidates.map((candidate) => candidate.workspaceId)),
async (candidate) => statLocalCandidate(candidate, normalized),
);
if (discovered.state === "ambiguous") throwAmbiguousWorkspacePath(discovered.count);
if (discovered.state === "one") return discovered.value;
}
if (lastNotFound) throw lastNotFound;
throw unprocessable("No local-readable workspace is available for this issue", { code: "no_local_workspace" });
}
return {
getIssue,
list,
resolve,
readContent,
prepareDownload,
};
}

View File

@ -42,6 +42,12 @@ function buildQuery(query: FileResourceQuery | FileResourceListQuery): string {
return params.toString();
}
export function buildFileResourceDownloadUrl(issueId: string, query: FileResourceQuery): string {
const params = new URLSearchParams(buildQuery(query));
params.set("download", "1");
return `/api/issues/${encodeURIComponent(issueId)}/file-resources/content?${params.toString()}`;
}
export const fileResourcesApi = {
list(issueId: string, query: FileResourceListQuery = {}): Promise<WorkspaceFileListResponse> {
const search = buildQuery(query);
@ -62,4 +68,6 @@ export const fileResourcesApi = {
`/issues/${encodeURIComponent(issueId)}/file-resources/content?${buildQuery(query)}`,
);
},
downloadUrl: buildFileResourceDownloadUrl,
};

View File

@ -60,7 +60,7 @@ const resolvedResource: ResolvedWorkspaceResource = {
contentType: "text/markdown; charset=utf-8",
byteSize: 42,
previewKind: "text",
capabilities: { preview: true, download: false, listChildren: false },
capabilities: { preview: true, download: true, listChildren: false },
};
const content: WorkspaceFileContent = {

View File

@ -61,7 +61,7 @@ describe("FileContentViewer", () => {
contentType: "text/plain; charset=utf-8",
byteSize: 18,
previewKind: "text",
capabilities: { preview: true, download: false, listChildren: false },
capabilities: { preview: true, download: true, listChildren: false },
},
content: {
encoding: "utf8",

View File

@ -16,6 +16,7 @@ import {
Check,
Cloud,
Copy,
Download,
Eye,
FileCode2,
FileSearch,
@ -529,6 +530,9 @@ export function FileViewerSheet({
const resolvedResource: ResolvedWorkspaceResource | undefined = resolveQuery.data;
const canPreview = resolvedResource?.capabilities.preview ?? false;
const downloadUrl = state && resolvedResource?.capabilities.download
? fileResourcesApi.downloadUrl(issueId, state)
: null;
const contentQuery = useQuery({
queryKey: state
@ -780,6 +784,25 @@ export function FileViewerSheet({
Back to files
</Button>
) : null}
{state ? (
downloadUrl ? (
<Button
asChild
variant="ghost"
size="icon-sm"
className="h-7 w-7"
>
<a
href={downloadUrl}
download={resolvedResource?.title ?? basename(state.path)}
aria-label="Download file"
title="Download file"
>
<Download className="h-4 w-4" />
</a>
</Button>
) : null
) : null}
{state ? (
<Button
type="button"

View File

@ -59,7 +59,7 @@ function createItem(overrides: Partial<WorkspaceFileListFileItem> = {}): Workspa
byteSize: 2048,
modifiedAt: new Date(Date.now() - 120_000).toISOString(),
previewKind: "text",
capabilities: { preview: true, download: false, listChildren: false },
capabilities: { preview: true, download: true, listChildren: false },
...overrides,
};
}
@ -243,6 +243,10 @@ describe("WorkspaceFileBrowser", () => {
(el) => el.getAttribute("title") === "ui/src/pages/IssueDetail.tsx",
);
expect(option).not.toBeUndefined();
const download = option!.querySelector<HTMLAnchorElement>('a[aria-label="Download IssueDetail.tsx"]');
expect(download?.getAttribute("href")).toBe(
"/api/issues/issue-1/file-resources/content?path=ui%2Fsrc%2Fpages%2FIssueDetail.tsx&download=1",
);
act(() => {
option!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));

View File

@ -8,7 +8,7 @@ import {
type ReactNode,
} from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import { AlertTriangle, ChevronDown, ChevronRight, Cloud, FileCode2, FolderOpen, Loader2, Search } from "lucide-react";
import { AlertTriangle, ChevronDown, ChevronRight, Cloud, Download, FileCode2, FolderOpen, Loader2, Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { fileResourcesApi } from "@/api/file-resources";
@ -171,9 +171,10 @@ interface WorkspaceFileRowProps {
depth: number;
onOpen: () => void;
onHover: () => void;
downloadUrl: string | null;
}
function WorkspaceFileRow({ item, treeItemId, selected, highlighted, depth, onOpen, onHover }: WorkspaceFileRowProps) {
function WorkspaceFileRow({ item, treeItemId, selected, highlighted, depth, onOpen, onHover, downloadUrl }: WorkspaceFileRowProps) {
const name = basename(item.relativePath);
return (
<div
@ -191,6 +192,18 @@ function WorkspaceFileRow({ item, treeItemId, selected, highlighted, depth, onOp
>
<FileCode2 aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">{name}</span>
{downloadUrl ? (
<a
href={downloadUrl}
download={name}
aria-label={`Download ${name}`}
title={`Download ${name}`}
onClick={(event) => event.stopPropagation()}
className="ml-auto inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-70 hover:bg-background/70 hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Download aria-hidden="true" className="h-3.5 w-3.5" />
</a>
) : null}
</div>
);
}
@ -335,6 +348,7 @@ interface WorkspaceFileTreeProps {
onToggleFolder: (key: string) => void;
onOpen: (item: WorkspaceFileListFileItem) => void;
onHoverFile: (item: WorkspaceFileListFileItem) => void;
getDownloadUrl: (item: WorkspaceFileListFileItem) => string | null;
}
function WorkspaceFileTree({
@ -352,6 +366,7 @@ function WorkspaceFileTree({
onToggleFolder,
onOpen,
onHoverFile,
getDownloadUrl,
}: WorkspaceFileTreeProps) {
function renderNode(node: WorkspaceFileTreeNode): ReactNode {
if (node.kind === "folder") {
@ -419,6 +434,7 @@ function WorkspaceFileTree({
depth={node.depth}
onOpen={() => onOpen(node.item)}
onHover={() => onHoverFile(node.item)}
downloadUrl={getDownloadUrl(node.item)}
/>
);
}
@ -918,6 +934,18 @@ export function WorkspaceFileBrowser({
if (index >= 0) setHighlightedIndex(index);
}
function getDownloadUrl(item: WorkspaceFileListFileItem): string | null {
if (!item.capabilities.download) return null;
const itemTarget = item.projectId
? { projectId: item.projectId, workspaceId: item.workspaceId }
: targetRef;
return fileResourcesApi.downloadUrl(issueId, {
path: item.relativePath,
workspace: effectiveWorkspace,
...itemTarget,
});
}
function lazyChildren(path: string, depth: number) {
const children = buildWorkspaceDirectoryTree(lazyItemsByFolder.get(path) ?? []);
return children.map((node) => ({ ...node, depth }));
@ -1000,6 +1028,7 @@ export function WorkspaceFileBrowser({
onToggleFolder={toggleFolder}
onOpen={openItem}
onHoverFile={handleHoverFile}
getDownloadUrl={getDownloadUrl}
/>
);
}

View File

@ -39,7 +39,7 @@ function item(relativePath: string, minutesAgo: number, overrides: Partial<Works
byteSize: 2048,
modifiedAt: new Date(Date.now() - minutesAgo * 60_000).toISOString(),
previewKind: "text",
capabilities: { preview: true, download: false, listChildren: false },
capabilities: { preview: true, download: true, listChildren: false },
...overrides,
};
}