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 = ({
if (!hasPendingWorkspaceRefreshRef.current) {
const filesToReload =
openedFilesRef.current
?.filter((f) => !f.isDirty && !f.isLoading && !f.isBinary && !f.isUnknown)
?.filter((f) => !f.isDirty && !f.isLoading && !f.isUnknown)
.map((f) => ({
path: f.path,
language: f.language,
......@@ -654,6 +654,14 @@ export const useSandboxFileStore = ({
item.content === snapshot.content &&
item.etag === snapshot.etag
) {
if (
item.isBinary &&
item.content.startsWith('blob:') &&
item.content !== update.content
) {
URL.revokeObjectURL(item.content);
}
return {
...item,
content: update.content,
......@@ -1579,6 +1587,7 @@ export const useSandboxFileStore = ({
uploadTasks.push({ path, file });
}
let hasUploaded = false;
try {
for (const task of uploadTasks) {
await uploadSandboxFile({
......@@ -1588,6 +1597,7 @@ export const useSandboxFileStore = ({
path: task.path,
file: task.file
});
hasUploaded = true;
}
} catch (error) {
console.error('Failed to upload files:', error);
......@@ -1596,9 +1606,23 @@ export const useSandboxFileStore = ({
description: getErrText(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 {
type UploadFileParams,
type UploadResponseData
} 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 {
constructor(
......@@ -138,11 +147,16 @@ export class DevboxApi {
if (params.timeoutSeconds != null) queryParams.timeoutSeconds = String(params.timeoutSeconds);
if (params.container) queryParams.container = params.container;
return this.request(this.url(`/api/v1/devbox/${name}/files/upload`, queryParams), {
const requestInit: StreamingRequestInit = {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
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 {
......
......@@ -19,7 +19,7 @@ import { DevboxApi, DevboxApiError } from './api';
import { DevboxPhaseEnum, type DevboxCreateRequest, type DevboxInfoData } from './type';
import { formatImageSpec, parseImageSpec } from '@/utils/image';
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_INTERVAL_MS = 1_000;
......@@ -319,8 +319,13 @@ export class SealosDevboxAdapter extends BaseSandboxAdapter {
for (const entry of entries) {
const normalizedPath = this.normalizePath(entry.path);
try {
const content = await fileDataToUint8Array(entry.data);
const bytesWritten = content.byteLength;
const uploadBody = await (async () => {
if (isReadableStreamData(entry.data)) {
return entry.data;
}
return fileDataToUint8Array(entry.data);
})();
let modeStr: string | undefined;
if (entry.mode !== undefined) {
......@@ -333,14 +338,21 @@ export class SealosDevboxAdapter extends BaseSandboxAdapter {
path: normalizedPath,
mode: modeStr
},
content
uploadBody
);
if (res.code !== 200) {
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) {
results.push({
path: normalizedPath,
......
......@@ -549,6 +549,59 @@ describe('SealosDevboxAdapter', () => {
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 () => {
......
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