From 166b4a5a2b7ff421199c4dbbb2958cd238f5d206 Mon Sep 17 00:00:00 2001 From: yyh Date: Mon, 26 Jan 2026 15:40:27 +0800 Subject: [PATCH] feat(sandbox): add sandbox file API service layer - Add types for sandbox file API (SandboxFileNode, SandboxFileDownloadTicket) - Add oRPC contracts for listFiles and downloadFile endpoints - Add TanStack Query hooks (useGetSandboxFiles, useDownloadSandboxFile) - Add useSandboxFilesTree hook with flat-to-tree conversion --- web/contract/console/sandbox-file.ts | 30 +++++++ web/contract/router.ts | 8 ++ web/service/use-sandbox-file.ts | 121 +++++++++++++++++++++++++++ web/types/sandbox-file.ts | 71 ++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 web/contract/console/sandbox-file.ts create mode 100644 web/service/use-sandbox-file.ts create mode 100644 web/types/sandbox-file.ts diff --git a/web/contract/console/sandbox-file.ts b/web/contract/console/sandbox-file.ts new file mode 100644 index 0000000000..696211e0bc --- /dev/null +++ b/web/contract/console/sandbox-file.ts @@ -0,0 +1,30 @@ +import type { + SandboxFileDownloadRequest, + SandboxFileDownloadTicket, + SandboxFileListQuery, + SandboxFileNode, +} from '@/types/sandbox-file' +import { type } from '@orpc/contract' +import { base } from '../base' + +export const listFilesContract = base + .route({ + path: '/sandboxes/{sandboxId}/files', + method: 'GET', + }) + .input(type<{ + params: { sandboxId: string } + query?: SandboxFileListQuery + }>()) + .output(type()) + +export const downloadFileContract = base + .route({ + path: '/sandboxes/{sandboxId}/files/download', + method: 'POST', + }) + .input(type<{ + params: { sandboxId: string } + body: SandboxFileDownloadRequest + }>()) + .output(type()) diff --git a/web/contract/router.ts b/web/contract/router.ts index f1118d0e06..29fbd9b2fb 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -16,6 +16,10 @@ import { } from './console/app-asset' import { workflowOnlineUsersContract } from './console/apps' import { bindPartnerStackContract, invoicesContract } from './console/billing' +import { + downloadFileContract, + listFilesContract, +} from './console/sandbox-file' import { activateSandboxProviderContract, deleteSandboxProviderConfigContract, @@ -62,6 +66,10 @@ export const consoleRouterContract = { deleteSandboxProviderConfig: deleteSandboxProviderConfigContract, activateSandboxProvider: activateSandboxProviderContract, }, + sandboxFile: { + listFiles: listFilesContract, + downloadFile: downloadFileContract, + }, appAsset: { tree: treeContract, createFolder: createFolderContract, diff --git a/web/service/use-sandbox-file.ts b/web/service/use-sandbox-file.ts new file mode 100644 index 0000000000..20288b2818 --- /dev/null +++ b/web/service/use-sandbox-file.ts @@ -0,0 +1,121 @@ +import type { + SandboxFileListQuery, + SandboxFileNode, + SandboxFileTreeNode, +} from '@/types/sandbox-file' +import { + useMutation, + useQuery, +} from '@tanstack/react-query' +import { useMemo } from 'react' +import { consoleClient, consoleQuery } from '@/service/client' + +type UseGetSandboxFilesOptions = { + path?: string + recursive?: boolean + enabled?: boolean + refetchInterval?: number | false +} + +export function useGetSandboxFiles( + sandboxId: string | undefined, + options?: UseGetSandboxFilesOptions, +) { + const query: SandboxFileListQuery = { + path: options?.path, + recursive: options?.recursive, + } + + return useQuery({ + queryKey: consoleQuery.sandboxFile.listFiles.queryKey({ + input: { params: { sandboxId: sandboxId! }, query }, + }), + queryFn: () => consoleClient.sandboxFile.listFiles({ + params: { sandboxId: sandboxId! }, + query, + }), + enabled: !!sandboxId && (options?.enabled ?? true), + refetchInterval: options?.refetchInterval, + }) +} + +export function useDownloadSandboxFile(sandboxId: string | undefined) { + return useMutation({ + mutationKey: consoleQuery.sandboxFile.downloadFile.mutationKey(), + mutationFn: (path: string) => { + if (!sandboxId) + throw new Error('sandboxId is required') + return consoleClient.sandboxFile.downloadFile({ + params: { sandboxId }, + body: { path }, + }) + }, + }) +} + +function buildTreeFromFlatList(nodes: SandboxFileNode[]): SandboxFileTreeNode[] { + const nodeMap = new Map() + const roots: SandboxFileTreeNode[] = [] + + const sorted = [...nodes].sort((a, b) => + a.path.split('/').length - b.path.split('/').length, + ) + + for (const node of sorted) { + const parts = node.path.split('/') + const name = parts[parts.length - 1] + const parentPath = parts.slice(0, -1).join('/') + + const treeNode: SandboxFileTreeNode = { + id: node.path, + name, + path: node.path, + node_type: node.is_dir ? 'folder' : 'file', + size: node.size, + mtime: node.mtime, + children: [], + } + + nodeMap.set(node.path, treeNode) + + if (parentPath === '') { + roots.push(treeNode) + } + else { + const parent = nodeMap.get(parentPath) + if (parent) + parent.children.push(treeNode) + } + } + + return roots +} + +export function useSandboxFilesTree( + sandboxId: string | undefined, + options?: UseGetSandboxFilesOptions, +) { + const { data, isLoading, error, refetch } = useGetSandboxFiles(sandboxId, { + ...options, + recursive: true, + }) + + const treeData = useMemo(() => { + if (!data) + return undefined + return buildTreeFromFlatList(data) + }, [data]) + + const hasFiles = useMemo(() => { + return (data?.length ?? 0) > 0 + }, [data]) + + return { + data: treeData, + flatData: data, + hasFiles, + isLoading, + error, + refetch, + } +} diff --git a/web/types/sandbox-file.ts b/web/types/sandbox-file.ts new file mode 100644 index 0000000000..332947438c --- /dev/null +++ b/web/types/sandbox-file.ts @@ -0,0 +1,71 @@ +/** + * Sandbox File Types + * + * Types for sandbox file API - file listing and download operations. + * These files are generated by agent during test runs and may be cleared later. + */ + +/** + * Sandbox file node from API (flat format) + * Returned by GET /sandboxes/{sandbox_id}/files + */ +export type SandboxFileNode = { + /** Relative path (POSIX format), e.g. "folder/file.txt" */ + path: string + /** Whether this is a directory */ + is_dir: boolean + /** File size in bytes (null for directories) */ + size: number | null + /** Modification timestamp in seconds (null for some directories) */ + mtime: number | null +} + +/** + * Download ticket returned by POST /sandboxes/{sandbox_id}/files/download + */ +export type SandboxFileDownloadTicket = { + /** Signed download URL */ + download_url: string + /** Expiration time in seconds */ + expires_in: number + /** Export ID (16-char hex) */ + export_id: string +} + +/** + * Tree node for frontend rendering (converted from flat list) + */ +export type SandboxFileTreeNode = { + /** Unique ID (uses path as ID) */ + id: string + /** File/folder name extracted from path */ + name: string + /** Full relative path */ + path: string + /** Node type for compatibility with existing tree components */ + node_type: 'file' | 'folder' + /** File size in bytes (null for directories) */ + size: number | null + /** Modification timestamp */ + mtime: number | null + /** Child nodes (for folders) */ + children: SandboxFileTreeNode[] +} + +/** + * Request payload for listing files + */ +export type SandboxFileListQuery = { + /** Workspace-relative path, defaults to "." */ + path?: string + /** Whether to list recursively */ + recursive?: boolean +} + +/** + * Request payload for downloading a file + */ +export type SandboxFileDownloadRequest = { + /** Workspace-relative file path */ + path: string +}