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
This commit is contained in:
yyh
2026-01-26 15:40:27 +08:00
parent 694ed4f5e3
commit 166b4a5a2b
4 changed files with 230 additions and 0 deletions

View File

@ -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<SandboxFileNode[]>())
export const downloadFileContract = base
.route({
path: '/sandboxes/{sandboxId}/files/download',
method: 'POST',
})
.input(type<{
params: { sandboxId: string }
body: SandboxFileDownloadRequest
}>())
.output(type<SandboxFileDownloadTicket>())

View File

@ -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,

View File

@ -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<string, SandboxFileTreeNode>()
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,
}
}

71
web/types/sandbox-file.ts Normal file
View File

@ -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
}