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:
parent
fdb8b5678b
commit
8f7282066e
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue