Commit d1ab0854 by DigHuang Committed by GitHub

feat(sandbox): support ReadableStream upload and auto refresh workspace (#7209)

parent 8271ef53
...@@ -605,7 +605,7 @@ export const useSandboxFileStore = ({ ...@@ -605,7 +605,7 @@ export const useSandboxFileStore = ({
if (!hasPendingWorkspaceRefreshRef.current) { if (!hasPendingWorkspaceRefreshRef.current) {
const filesToReload = const filesToReload =
openedFilesRef.current openedFilesRef.current
?.filter((f) => !f.isDirty && !f.isLoading && !f.isBinary && !f.isUnknown) ?.filter((f) => !f.isDirty && !f.isLoading && !f.isUnknown)
.map((f) => ({ .map((f) => ({
path: f.path, path: f.path,
language: f.language, language: f.language,
...@@ -654,6 +654,14 @@ export const useSandboxFileStore = ({ ...@@ -654,6 +654,14 @@ export const useSandboxFileStore = ({
item.content === snapshot.content && item.content === snapshot.content &&
item.etag === snapshot.etag item.etag === snapshot.etag
) { ) {
if (
item.isBinary &&
item.content.startsWith('blob:') &&
item.content !== update.content
) {
URL.revokeObjectURL(item.content);
}
return { return {
...item, ...item,
content: update.content, content: update.content,
...@@ -1579,6 +1587,7 @@ export const useSandboxFileStore = ({ ...@@ -1579,6 +1587,7 @@ export const useSandboxFileStore = ({
uploadTasks.push({ path, file }); uploadTasks.push({ path, file });
} }
let hasUploaded = false;
try { try {
for (const task of uploadTasks) { for (const task of uploadTasks) {
await uploadSandboxFile({ await uploadSandboxFile({
...@@ -1588,6 +1597,7 @@ export const useSandboxFileStore = ({ ...@@ -1588,6 +1597,7 @@ export const useSandboxFileStore = ({
path: task.path, path: task.path,
file: task.file file: task.file
}); });
hasUploaded = true;
} }
} catch (error) { } catch (error) {
console.error('Failed to upload files:', error); console.error('Failed to upload files:', error);
...@@ -1596,9 +1606,23 @@ export const useSandboxFileStore = ({ ...@@ -1596,9 +1606,23 @@ export const useSandboxFileStore = ({
description: getErrText(error), description: getErrText(error),
status: 'error' status: 'error'
}); });
} finally {
if (hasUploaded) {
await refreshWorkspace({ preserveExpandedDirs: true });
}
} }
}, },
[chatId, fileTree, maxFileBytes, maxFileSizeMB, outLinkAuthData, sandboxTarget, toast, t] [
chatId,
fileTree,
maxFileBytes,
maxFileSizeMB,
outLinkAuthData,
refreshWorkspace,
sandboxTarget,
toast,
t
]
); );
// 展开折叠目录 // 展开折叠目录
......
...@@ -10,6 +10,15 @@ import { ...@@ -10,6 +10,15 @@ import {
type UploadFileParams, type UploadFileParams,
type UploadResponseData type UploadResponseData
} from './type'; } from './type';
import { isReadableStreamData } from '@/utils/files';
type StreamingRequestInit = RequestInit & {
/**
* Node fetch requires this extension when the request body is a stream.
* Browser typings do not expose it yet, but undici accepts the field.
*/
duplex?: 'half';
};
export class DevboxApiError extends Error { export class DevboxApiError extends Error {
constructor( constructor(
...@@ -138,11 +147,16 @@ export class DevboxApi { ...@@ -138,11 +147,16 @@ export class DevboxApi {
if (params.timeoutSeconds != null) queryParams.timeoutSeconds = String(params.timeoutSeconds); if (params.timeoutSeconds != null) queryParams.timeoutSeconds = String(params.timeoutSeconds);
if (params.container) queryParams.container = params.container; if (params.container) queryParams.container = params.container;
return this.request(this.url(`/api/v1/devbox/${name}/files/upload`, queryParams), { const requestInit: StreamingRequestInit = {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' }, headers: { 'Content-Type': 'application/octet-stream' },
body: content body: content
}); };
if (isReadableStreamData(content)) {
requestInit.duplex = 'half';
}
return this.request(this.url(`/api/v1/devbox/${name}/files/upload`, queryParams), requestInit);
} }
private buildDownloadFileUrl(name: string, params: DownloadFileParams): string { private buildDownloadFileUrl(name: string, params: DownloadFileParams): string {
......
...@@ -19,7 +19,7 @@ import { DevboxApi, DevboxApiError } from './api'; ...@@ -19,7 +19,7 @@ import { DevboxApi, DevboxApiError } from './api';
import { DevboxPhaseEnum, type DevboxCreateRequest, type DevboxInfoData } from './type'; import { DevboxPhaseEnum, type DevboxCreateRequest, type DevboxInfoData } from './type';
import { formatImageSpec, parseImageSpec } from '@/utils/image'; import { formatImageSpec, parseImageSpec } from '@/utils/image';
import { joinUrlPath, normalizePathPrefix } from '@/utils/url'; import { joinUrlPath, normalizePathPrefix } from '@/utils/url';
import { fileDataToUint8Array } from '@/utils/files'; import { fileDataToUint8Array, isReadableStreamData } from '@/utils/files';
const GET_INFO_RETRY_TIMEOUT_MS = 30_000; const GET_INFO_RETRY_TIMEOUT_MS = 30_000;
const GET_INFO_RETRY_INTERVAL_MS = 1_000; const GET_INFO_RETRY_INTERVAL_MS = 1_000;
...@@ -319,8 +319,13 @@ export class SealosDevboxAdapter extends BaseSandboxAdapter { ...@@ -319,8 +319,13 @@ export class SealosDevboxAdapter extends BaseSandboxAdapter {
for (const entry of entries) { for (const entry of entries) {
const normalizedPath = this.normalizePath(entry.path); const normalizedPath = this.normalizePath(entry.path);
try { try {
const content = await fileDataToUint8Array(entry.data); const uploadBody = await (async () => {
const bytesWritten = content.byteLength; if (isReadableStreamData(entry.data)) {
return entry.data;
}
return fileDataToUint8Array(entry.data);
})();
let modeStr: string | undefined; let modeStr: string | undefined;
if (entry.mode !== undefined) { if (entry.mode !== undefined) {
...@@ -333,14 +338,21 @@ export class SealosDevboxAdapter extends BaseSandboxAdapter { ...@@ -333,14 +338,21 @@ export class SealosDevboxAdapter extends BaseSandboxAdapter {
path: normalizedPath, path: normalizedPath,
mode: modeStr mode: modeStr
}, },
content uploadBody
); );
if (res.code !== 200) { if (res.code !== 200) {
throw new Error(res.message || `Upload failed with code ${res.code}`); throw new Error(res.message || `Upload failed with code ${res.code}`);
} }
results.push({ path: normalizedPath, bytesWritten, error: null }); const bytesWritten =
res.data?.sizeBytes ?? (uploadBody instanceof Uint8Array ? uploadBody.byteLength : 0);
results.push({
path: normalizedPath,
bytesWritten,
error: null
});
} catch (error) { } catch (error) {
results.push({ results.push({
path: normalizedPath, path: normalizedPath,
......
...@@ -549,6 +549,59 @@ describe('SealosDevboxAdapter', () => { ...@@ -549,6 +549,59 @@ describe('SealosDevboxAdapter', () => {
body: new TextEncoder().encode('hello world') body: new TextEncoder().encode('hello world')
}) })
); );
expect(fetchMock.mock.calls[0]?.[1]).not.toHaveProperty('duplex');
});
it('should upload ReadableStream through SealosDevbox without buffering', async () => {
const uploadStream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('streamed '));
controller.enqueue(new TextEncoder().encode('content'));
controller.close();
}
});
const fetchMock = vi.fn(async () => ({
json: async () => ({
code: 200,
message: 'ok',
data: {
name: 'devbox-1',
podName: 'pod-1',
container: 'c1',
path: '/home/devbox/workspace/stream.txt',
sizeBytes: 16,
mode: '0644',
uploadedAt: '2026-06-02T10:00:00Z',
timeoutSecond: 300
}
})
}));
vi.stubGlobal('fetch', fetchMock);
const adapter = new SealosDevboxAdapter(CONFIG, {
workingDir: '/home/devbox/workspace'
});
const results = await adapter.writeFiles([
{
path: 'stream.txt',
data: uploadStream
}
]);
expect(results).toHaveLength(1);
expect(results[0].error).toBeNull();
expect(results[0].bytesWritten).toBe(16);
expect(results[0].path).toBe('/home/devbox/workspace/stream.txt');
expect(fetchMock).toHaveBeenCalledWith(
'https://devbox-server.example.com/api/v1/devbox/devbox-1/files/upload?path=%2Fhome%2Fdevbox%2Fworkspace%2Fstream.txt',
expect.objectContaining({
method: 'POST',
headers: expect.any(Headers),
body: uploadStream,
duplex: 'half'
})
);
}); });
it('should download file through SealosDevbox api.downloadFile', async () => { it('should download file through SealosDevbox api.downloadFile', async () => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment