diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 2736e675..8d56c831 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -43,6 +43,7 @@ import { opencodeClient } from '@/lib/opencode/client'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { Icon } from "@/components/icon/Icon"; import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard'; +import { isFilesystemError } from '@/lib/api/files-errors'; import { isBrowserClientRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; @@ -54,6 +55,40 @@ type FileNode = { relativePath?: string; }; +type UploadConflicts = { + directory: string; + files: File[]; + runtimeKey: string; + workspaceRoot: string; +}; + +type UploadOutcome = 'uploaded' | 'conflict' | 'failed'; + +const MAX_PARALLEL_UPLOADS = 3; + +const hasExternalFiles = (dataTransfer: DataTransfer): boolean => ( + Array.from(dataTransfer.types).includes('Files') +); + +const getExternalFiles = (dataTransfer: DataTransfer): File[] => { + const items = Array.from(dataTransfer.items); + if (items.length === 0) return Array.from(dataTransfer.files); + + return items.flatMap((item) => { + if (item.kind !== 'file' || item.webkitGetAsEntry()?.isDirectory) return []; + const file = item.getAsFile(); + return file ? [file] : []; + }); +}; + +const getUploadName = (file: File): string | null => { + const name = file.name; + if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { + return null; + } + return name; +}; + const sortNodes = (items: FileNode[]) => items.slice().sort((a, b) => { if (a.type !== b.type) { @@ -93,6 +128,22 @@ const getRelativePath = (root: string, path: string): string => { return normalizedPath.slice(normalizedRoot.length + 1); }; +const getDropTargetLabel = (root: string, target: string): string => { + const relativePath = getRelativePath(root, target); + if (relativePath !== '.') return relativePath; + + const normalizedRoot = normalizePath(root); + return normalizedRoot.split('/').filter(Boolean).pop() ?? normalizedRoot; +}; + +const getParentPath = (value: string): string => { + const normalized = normalizePath(value); + const separatorIndex = normalized.lastIndexOf('/'); + if (separatorIndex < 0) return ''; + if (separatorIndex === 0) return '/'; + return normalized.slice(0, separatorIndex); +}; + const isAbsolutePath = (value: string): boolean => { return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value); }; @@ -194,6 +245,8 @@ interface FileRowProps { isBrowserClient: boolean; status?: FileStatus | null; badge?: { modified: number; added: number } | null; + isDropTarget: boolean; + canUpload: boolean; permissions: { canRename: boolean; canCreateFile: boolean; @@ -206,6 +259,8 @@ interface FileRowProps { onToggle: (path: string) => void; onRevealPath: (path: string) => void; onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void; + onSetDropTarget: (path: string | null) => void; + onDropFiles: (directory: string, dataTransfer: DataTransfer) => void; } const FileRow: React.FC = ({ @@ -216,15 +271,20 @@ const FileRow: React.FC = ({ isBrowserClient, status, badge, + isDropTarget, + canUpload, permissions, downloadFile, onSelect, onToggle, onRevealPath, onOpenDialog, + onSetDropTarget, + onDropFiles, }) => { const { t } = useI18n(); const isDir = node.type === 'directory'; + const uploadDirectory = isDir ? node.path : getParentPath(node.path); const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions; const canDownload = !isDir && Boolean(downloadFile); const canRevealPath = canReveal && !isBrowserClient; @@ -333,9 +393,40 @@ const FileRow: React.FC = ({ e.dataTransfer.effectAllowed = 'copy'; }, [node.path, root]); + const handleExternalDragOver = React.useCallback((event: React.DragEvent) => { + if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return; + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'copy'; + onSetDropTarget(uploadDirectory); + }, [canUpload, onSetDropTarget, uploadDirectory]); + + const handleExternalDragLeave = React.useCallback((event: React.DragEvent) => { + if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return; + if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return; + event.stopPropagation(); + onSetDropTarget(null); + }, [canUpload, onSetDropTarget, uploadDirectory]); + + const handleExternalDrop = React.useCallback((event: React.DragEvent) => { + if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return; + event.preventDefault(); + event.stopPropagation(); + onDropFiles(uploadDirectory, event.dataTransfer); + }, [canUpload, onDropFiles, uploadDirectory]); + return ( - }> + + )}> + + + + {/* CRUD dialogs (matching FilesView) */} !open && setActiveDialog(null)}> diff --git a/packages/ui/src/lib/api/files-errors.test.ts b/packages/ui/src/lib/api/files-errors.test.ts index 6d54d837..7c5ca4ec 100644 --- a/packages/ui/src/lib/api/files-errors.test.ts +++ b/packages/ui/src/lib/api/files-errors.test.ts @@ -25,4 +25,9 @@ describe('FilesystemError', () => { expect(parseFilesystemErrorReason('made-up')).toBe('unknown'); expect(parseFilesystemErrorReason(undefined)).toBe('unknown'); }); + + test('recognizes filesystem errors created across runtime boundaries', () => { + expect(isFilesystemError({ reason: 'already-exists' })).toBe(true); + expect(isFilesystemError({ reason: 409 })).toBe(false); + }); }); diff --git a/packages/ui/src/lib/api/files-errors.ts b/packages/ui/src/lib/api/files-errors.ts index bdac478a..7daee473 100644 --- a/packages/ui/src/lib/api/files-errors.ts +++ b/packages/ui/src/lib/api/files-errors.ts @@ -1,5 +1,6 @@ export type FilesystemErrorReason = | 'os-permission' + | 'already-exists' | 'not-found' | 'not-directory' | 'invalid-response' @@ -30,6 +31,7 @@ export const isFilesystemError = (error: unknown): error is FilesystemError => ( export const parseFilesystemErrorReason = (value: unknown): FilesystemErrorReason => { switch (value) { case 'os-permission': + case 'already-exists': case 'not-found': case 'not-directory': case 'invalid-response': diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 0a146327..58f126cb 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -606,6 +606,7 @@ export interface FilesAPI { readFile?(path: string, options?: FileReadOptions): Promise<{ content: string; path: string }>; readFileBinary?(path: string, options?: FileReadOptions): Promise<{ dataUrl: string; path: string }>; writeFile?(path: string, content: string): Promise<{ success: boolean; path: string }>; + uploadFile?(path: string, file: Blob, options?: { overwrite?: boolean; directory?: string }): Promise<{ success: boolean; path: string }>; delete?(path: string): Promise<{ success: boolean }>; rename?(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }>; revealPath?(path: string): Promise<{ success: boolean }>; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 21b72195..125994af 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1172,6 +1172,14 @@ export const dict = { 'sidebarFilesTree.toast.writeNotSupported': 'Schreiben nicht unterstützt', 'sidebarFilesTree.toast.fileCreated': 'Datei erstellt', 'sidebarFilesTree.toast.operationFailed': 'Operation fehlgeschlagen', + 'sidebarFilesTree.toast.uploaded': 'Dateien hochgeladen', + 'sidebarFilesTree.toast.uploadedWithoutConflicts': 'Dateien ohne Konflikte wurden hochgeladen', + 'sidebarFilesTree.toast.uploadFailed': 'Einige Dateien konnten nicht hochgeladen werden', + 'sidebarFilesTree.drop.target': 'In {path} hochladen', + 'sidebarFilesTree.drop.uploading': 'Dateien werden in {path} hochgeladen', + 'sidebarFilesTree.dialog.uploadConflicts.title': 'Vorhandene Dateien ersetzen?', + 'sidebarFilesTree.dialog.uploadConflicts.description': 'Dateien mit diesen Namen sind in {path} bereits vorhanden. Das Ersetzen kann nicht rückgängig gemacht werden.', + 'sidebarFilesTree.dialog.uploadConflicts.replace': 'Ersetzen', 'sidebarFilesTree.toast.folderNameRequired': 'Ordnername ist erforderlich', 'sidebarFilesTree.toast.folderCreated': 'Ordner erstellt', 'sidebarFilesTree.toast.nameRequired': 'Name ist erforderlich', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index eb82405d..41d8bfab 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1322,6 +1322,14 @@ export const dict = { 'sidebarFilesTree.toast.writeNotSupported': 'Write not supported', 'sidebarFilesTree.toast.fileCreated': 'File created', 'sidebarFilesTree.toast.operationFailed': 'Operation failed', + 'sidebarFilesTree.toast.uploaded': 'Files uploaded', + 'sidebarFilesTree.toast.uploadedWithoutConflicts': 'Files without conflicts were uploaded', + 'sidebarFilesTree.toast.uploadFailed': 'Some files could not be uploaded', + 'sidebarFilesTree.drop.target': 'Upload to {path}', + 'sidebarFilesTree.drop.uploading': 'Uploading files to {path}', + 'sidebarFilesTree.dialog.uploadConflicts.title': 'Replace existing files?', + 'sidebarFilesTree.dialog.uploadConflicts.description': 'Files with these names already exist in {path}. Replacing them cannot be undone.', + 'sidebarFilesTree.dialog.uploadConflicts.replace': 'Replace', 'sidebarFilesTree.toast.folderNameRequired': 'Folder name is required', 'sidebarFilesTree.toast.folderCreated': 'Folder created', 'sidebarFilesTree.toast.nameRequired': 'Name is required', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 0aa85137..3c7aebb1 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1288,6 +1288,14 @@ export const dict: Record = { "sidebarFilesTree.toast.writeNotSupported": "La escritura no es compatible", "sidebarFilesTree.toast.fileCreated": "Archivo creado", "sidebarFilesTree.toast.operationFailed": "No se pudo completar la operación", + "sidebarFilesTree.toast.uploaded": "Archivos subidos", + "sidebarFilesTree.toast.uploadedWithoutConflicts": "Se subieron los archivos sin conflictos", + "sidebarFilesTree.toast.uploadFailed": "No se pudieron subir algunos archivos", + "sidebarFilesTree.drop.target": "Subir a {path}", + "sidebarFilesTree.drop.uploading": "Subiendo archivos a {path}", + "sidebarFilesTree.dialog.uploadConflicts.title": "¿Reemplazar los archivos existentes?", + "sidebarFilesTree.dialog.uploadConflicts.description": "Ya existen archivos con estos nombres en {path}. El reemplazo no se puede deshacer.", + "sidebarFilesTree.dialog.uploadConflicts.replace": "Reemplazar", "sidebarFilesTree.toast.folderNameRequired": "El nombre de carpeta es obligatorio", "sidebarFilesTree.toast.folderCreated": "Carpeta creada", "sidebarFilesTree.toast.nameRequired": "El nombre es obligatorio", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index ddb5aa56..e2c0f1bd 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1089,6 +1089,14 @@ export const dict = { 'sidebarFilesTree.toast.writeNotSupported': 'Écriture non prise en charge', 'sidebarFilesTree.toast.fileCreated': 'Fichier créé', 'sidebarFilesTree.toast.operationFailed': 'L\'opération a échoué', + 'sidebarFilesTree.toast.uploaded': 'Fichiers téléversés', + 'sidebarFilesTree.toast.uploadedWithoutConflicts': 'Les fichiers sans conflit ont été téléversés', + 'sidebarFilesTree.toast.uploadFailed': 'Certains fichiers n’ont pas pu être téléversés', + 'sidebarFilesTree.drop.target': 'Téléverser dans {path}', + 'sidebarFilesTree.drop.uploading': 'Téléversement des fichiers dans {path}', + 'sidebarFilesTree.dialog.uploadConflicts.title': 'Remplacer les fichiers existants ?', + 'sidebarFilesTree.dialog.uploadConflicts.description': 'Des fichiers portant ces noms existent déjà dans {path}. Leur remplacement est irréversible.', + 'sidebarFilesTree.dialog.uploadConflicts.replace': 'Remplacer', 'sidebarFilesTree.toast.folderNameRequired': 'Le nom du dossier est requis', 'sidebarFilesTree.toast.folderCreated': 'Dossier créé', 'sidebarFilesTree.toast.nameRequired': 'Le nom est requis', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 3992ba78..f231df35 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1318,6 +1318,14 @@ export const dict: Record = { 'sidebarFilesTree.toast.writeNotSupported': '書き込みはサポートされていません', 'sidebarFilesTree.toast.fileCreated': 'ファイルを作成しました', 'sidebarFilesTree.toast.operationFailed': '操作に失敗しました', + 'sidebarFilesTree.toast.uploaded': 'ファイルをアップロードしました', + 'sidebarFilesTree.toast.uploadedWithoutConflicts': '競合のないファイルをアップロードしました', + 'sidebarFilesTree.toast.uploadFailed': '一部のファイルをアップロードできませんでした', + 'sidebarFilesTree.drop.target': '{path} にアップロード', + 'sidebarFilesTree.drop.uploading': '{path} にファイルをアップロードしています', + 'sidebarFilesTree.dialog.uploadConflicts.title': '既存のファイルを置き換えますか?', + 'sidebarFilesTree.dialog.uploadConflicts.description': '同じ名前のファイルが {path} に既に存在します。置き換えは元に戻せません。', + 'sidebarFilesTree.dialog.uploadConflicts.replace': '置き換える', 'sidebarFilesTree.toast.folderNameRequired': 'フォルダ名が必要です', 'sidebarFilesTree.toast.folderCreated': 'フォルダを作成しました', 'sidebarFilesTree.toast.nameRequired': '名前が必要です', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index dde3d0f1..4d924744 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1324,6 +1324,14 @@ export const dict: Record = { 'sidebarFilesTree.toast.writeNotSupported': '쓰기를 지원하지 않음', 'sidebarFilesTree.toast.fileCreated': '파일 생성됨', 'sidebarFilesTree.toast.operationFailed': '작업 실패', + 'sidebarFilesTree.toast.uploaded': '파일을 업로드했습니다', + 'sidebarFilesTree.toast.uploadedWithoutConflicts': '충돌하지 않은 파일을 업로드했습니다', + 'sidebarFilesTree.toast.uploadFailed': '일부 파일을 업로드하지 못했습니다', + 'sidebarFilesTree.drop.target': '{path}에 업로드', + 'sidebarFilesTree.drop.uploading': '{path}에 파일 업로드 중', + 'sidebarFilesTree.dialog.uploadConflicts.title': '기존 파일을 교체할까요?', + 'sidebarFilesTree.dialog.uploadConflicts.description': '같은 이름의 파일이 {path}에 이미 있습니다. 교체 작업은 취소할 수 없습니다.', + 'sidebarFilesTree.dialog.uploadConflicts.replace': '교체', 'sidebarFilesTree.toast.folderNameRequired': '폴더 이름 필수', 'sidebarFilesTree.toast.folderCreated': '폴더 생성됨', 'sidebarFilesTree.toast.nameRequired': '이름 필수', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index d0c85d40..91123a37 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2845,6 +2845,14 @@ export const dict: Record = { 'sidebarFilesTree.toast.folderNameRequired': 'Nazwa folderu jest wymagana', 'sidebarFilesTree.toast.nameRequired': 'Nazwa jest wymagana', 'sidebarFilesTree.toast.operationFailed': 'Operacja nie powiodła się', + 'sidebarFilesTree.toast.uploaded': 'Pliki przesłano', + 'sidebarFilesTree.toast.uploadedWithoutConflicts': 'Przesłano pliki bez konfliktów', + 'sidebarFilesTree.toast.uploadFailed': 'Nie udało się przesłać niektórych plików', + 'sidebarFilesTree.drop.target': 'Prześlij do {path}', + 'sidebarFilesTree.drop.uploading': 'Przesyłanie plików do {path}', + 'sidebarFilesTree.dialog.uploadConflicts.title': 'Zastąpić istniejące pliki?', + 'sidebarFilesTree.dialog.uploadConflicts.description': 'Pliki o tych nazwach już istnieją w {path}. Zastąpienia nie można cofnąć.', + 'sidebarFilesTree.dialog.uploadConflicts.replace': 'Zastąp', 'sidebarFilesTree.toast.pathCopied': 'Ścieżka skopiowana', 'sidebarFilesTree.toast.renameNotSupported': 'Zmiana nazwy nie jest obsługiwana', 'sidebarFilesTree.toast.renamedSuccessfully': 'Zmieniono nazwę pomyślnie', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 8275d93c..925ef6eb 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1288,6 +1288,14 @@ export const dict: Record = { "sidebarFilesTree.toast.writeNotSupported": "A escrita não é compatível", "sidebarFilesTree.toast.fileCreated": "Arquivo criado", "sidebarFilesTree.toast.operationFailed": "Não foi possível completar a operación", + "sidebarFilesTree.toast.uploaded": "Arquivos enviados", + "sidebarFilesTree.toast.uploadedWithoutConflicts": "Os arquivos sem conflitos foram enviados", + "sidebarFilesTree.toast.uploadFailed": "Não foi possível enviar alguns arquivos", + "sidebarFilesTree.drop.target": "Enviar para {path}", + "sidebarFilesTree.drop.uploading": "Enviando arquivos para {path}", + "sidebarFilesTree.dialog.uploadConflicts.title": "Substituir arquivos existentes?", + "sidebarFilesTree.dialog.uploadConflicts.description": "Já existem arquivos com esses nomes em {path}. A substituição não pode ser desfeita.", + "sidebarFilesTree.dialog.uploadConflicts.replace": "Substituir", "sidebarFilesTree.toast.folderNameRequired": "O nome de pasta é obrigatório", "sidebarFilesTree.toast.folderCreated": "Pasta criada", "sidebarFilesTree.toast.nameRequired": "O nome é obrigatório", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 3173b933..d27adfb2 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1288,6 +1288,14 @@ export const dict: Record = { "sidebarFilesTree.toast.writeNotSupported": "Запис не підтримується", "sidebarFilesTree.toast.fileCreated": "Файл створено", "sidebarFilesTree.toast.operationFailed": "Операція не вдалася", + "sidebarFilesTree.toast.uploaded": "Файли завантажено", + "sidebarFilesTree.toast.uploadedWithoutConflicts": "Файли без конфліктів завантажено", + "sidebarFilesTree.toast.uploadFailed": "Деякі файли не вдалося завантажити", + "sidebarFilesTree.drop.target": "Завантажити в {path}", + "sidebarFilesTree.drop.uploading": "Завантаження файлів у {path}", + "sidebarFilesTree.dialog.uploadConflicts.title": "Замінити наявні файли?", + "sidebarFilesTree.dialog.uploadConflicts.description": "Файли з такими назвами вже існують у {path}. Заміну неможливо скасувати.", + "sidebarFilesTree.dialog.uploadConflicts.replace": "Замінити", "sidebarFilesTree.toast.folderNameRequired": "Потрібно вказати назву папки", "sidebarFilesTree.toast.folderCreated": "Папку створено", "sidebarFilesTree.toast.nameRequired": "Потрібно вказати назву", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 58cca12a..df562496 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1288,6 +1288,14 @@ export const dict: Record = { 'sidebarFilesTree.toast.writeNotSupported': '不支持写入', 'sidebarFilesTree.toast.fileCreated': '文件已创建', 'sidebarFilesTree.toast.operationFailed': '操作失败', + 'sidebarFilesTree.toast.uploaded': '文件已上传', + 'sidebarFilesTree.toast.uploadedWithoutConflicts': '无冲突的文件已上传', + 'sidebarFilesTree.toast.uploadFailed': '部分文件无法上传', + 'sidebarFilesTree.drop.target': '上传到 {path}', + 'sidebarFilesTree.drop.uploading': '正在将文件上传到 {path}', + 'sidebarFilesTree.dialog.uploadConflicts.title': '替换现有文件?', + 'sidebarFilesTree.dialog.uploadConflicts.description': '{path} 中已存在同名文件。替换后无法撤销。', + 'sidebarFilesTree.dialog.uploadConflicts.replace': '替换', 'sidebarFilesTree.toast.folderNameRequired': '文件夹名不能为空', 'sidebarFilesTree.toast.folderCreated': '文件夹已创建', 'sidebarFilesTree.toast.nameRequired': '名称不能为空', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 98202726..d908d97b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1300,6 +1300,14 @@ export const dict: Record = { 'sidebarFilesTree.toast.writeNotSupported': '不支援寫入', 'sidebarFilesTree.toast.fileCreated': '檔案已建立', 'sidebarFilesTree.toast.operationFailed': '操作失敗', + 'sidebarFilesTree.toast.uploaded': '檔案已上傳', + 'sidebarFilesTree.toast.uploadedWithoutConflicts': '無衝突的檔案已上傳', + 'sidebarFilesTree.toast.uploadFailed': '部分檔案無法上傳', + 'sidebarFilesTree.drop.target': '上傳至 {path}', + 'sidebarFilesTree.drop.uploading': '正在將檔案上傳至 {path}', + 'sidebarFilesTree.dialog.uploadConflicts.title': '取代現有檔案?', + 'sidebarFilesTree.dialog.uploadConflicts.description': '{path} 中已有同名檔案。取代後無法復原。', + 'sidebarFilesTree.dialog.uploadConflicts.replace': '取代', 'sidebarFilesTree.toast.folderNameRequired': '資料夾名稱不能為空', 'sidebarFilesTree.toast.folderCreated': '資料夾已建立', 'sidebarFilesTree.toast.nameRequired': '名稱不能為空', diff --git a/packages/web/server/lib/fs/DOCUMENTATION.md b/packages/web/server/lib/fs/DOCUMENTATION.md index f184e8c1..cdf2fd62 100644 --- a/packages/web/server/lib/fs/DOCUMENTATION.md +++ b/packages/web/server/lib/fs/DOCUMENTATION.md @@ -16,6 +16,7 @@ Own filesystem API behavior for the web server runtime, including workspace-boun - `GET /api/fs/raw` - `GET /api/fs/serve/:path(*)` - `POST /api/fs/write` + - `POST /api/fs/upload` - `POST /api/fs/delete` - `POST /api/fs/rename` - `POST /api/fs/reveal` @@ -38,3 +39,4 @@ Own filesystem API behavior for the web server runtime, including workspace-boun - Read-only routes authorize the requested path against the workspace before resolving symlinks. A symlink reached through the workspace may therefore target a file outside it, while a directly requested outside path still requires an exact-path grant. Write routes keep canonical-target boundary checks. - If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document. - `GET /api/fs/list` may resolve symlinks with `realpath` to read directory contents, but the response `path` and each entry `path` must stay in the caller's requested path space (`path.join(requestedPath, name)`). Returning real paths breaks file-tree expansion for directories reached through workspace symlinks. +- `POST /api/fs/upload` accepts one `application/octet-stream` body (up to 100 MB) with `path` and optional `overwrite=true` query parameters. It rejects existing files with `409` unless overwrite is explicit, and resolves the destination parent before writing so uploads cannot escape through workspace symlinks. diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index 20cc32b7..7888742c 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -139,6 +139,29 @@ const FILE_MIME_MAP = Object.freeze({ }); const MAX_SERVE_BYTES = 100 * 1024 * 1024; +const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; + +const readUploadBody = async (req) => { + const declaredSize = Number.parseInt(req.headers?.['content-length'] || '0', 10); + if (Number.isFinite(declaredSize) && declaredSize > MAX_UPLOAD_BYTES) { + req.resume?.(); + return null; + } + + const chunks = []; + let size = 0; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_UPLOAD_BYTES) { + req.resume?.(); + return null; + } + chunks.push(buffer); + } + + return Buffer.concat(chunks, size); +}; // Only deterministic, side-effect-free git plumbing path queries are cacheable. // Anything outside this allowlist (including any non-git command) runs normally @@ -1041,6 +1064,83 @@ export const registerFsRoutes = (app, dependencies) => { } }); + app.post('/api/fs/upload', async (req, res) => { + const filePath = typeof req.query?.path === 'string' ? req.query.path.trim() : ''; + const overwrite = req.query?.overwrite === 'true'; + if (!filePath) { + return res.status(400).json({ error: 'Path is required' }); + } + if (!String(req.headers?.['content-type'] || '').toLowerCase().startsWith('application/octet-stream')) { + return res.status(415).json({ error: 'Content-Type must be application/octet-stream' }); + } + + try { + const resolved = await resolveWorkspacePathFromContext({ + req, + targetPath: filePath, + resolveProjectDirectory, + path, + os, + normalizeDirectoryPath, + openchamberUserConfigRoot, + }); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); + } + + const canonicalBase = await fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)); + const requestedParent = path.dirname(resolved.resolved); + const canonicalParent = await fsPromises.realpath(requestedParent); + if (!isPathWithinRoot(canonicalParent, canonicalBase, path, os)) { + return res.status(403).json({ error: 'Access denied' }); + } + + const existingPath = await fsPromises.realpath(resolved.resolved).catch((error) => { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return null; + } + throw error; + }); + const writePath = existingPath || path.join(canonicalParent, path.basename(resolved.resolved)); + if (!isPathWithinRoot(writePath, canonicalBase, path, os)) { + return res.status(403).json({ error: 'Access denied' }); + } + + const body = await readUploadBody(req); + if (!body) { + return res.status(413).json({ error: `File exceeds maximum size of ${MAX_UPLOAD_BYTES} bytes` }); + } + + if (!overwrite) { + await fsPromises.writeFile(writePath, body, { flag: 'wx' }); + } else { + const tmp = `${writePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + try { + await fsPromises.writeFile(tmp, body, { flag: 'wx' }); + await fsPromises.rename(tmp, writePath); + } catch (error) { + await fsPromises.unlink(tmp).catch(() => {}); + throw error; + } + } + + return res.json({ success: true, path: resolved.resolved }); + } catch (error) { + const err = error; + if (err && typeof err === 'object' && err.code === 'EEXIST') { + return res.status(409).json({ error: 'File already exists', reason: 'already-exists' }); + } + if (err && typeof err === 'object' && err.code === 'ENOENT') { + return res.status(404).json({ error: 'Destination directory not found', reason: 'not-found' }); + } + if (isOsPermissionError(err)) { + return sendOsPermissionDenied(res, 'Access denied'); + } + console.error('Failed to upload file:', error); + return res.status(500).json({ error: (error && error.message) || 'Failed to upload file' }); + } + }); + app.post('/api/fs/delete', async (req, res) => { const { path: targetPath } = req.body || {}; if (!targetPath || typeof targetPath !== 'string') { diff --git a/packages/web/server/lib/fs/routes.test.js b/packages/web/server/lib/fs/routes.test.js index b238c559..dbda0a27 100644 --- a/packages/web/server/lib/fs/routes.test.js +++ b/packages/web/server/lib/fs/routes.test.js @@ -140,6 +140,26 @@ const registerWrite = (fsPromises) => { return getRoute('POST', '/api/fs/write'); }; +const registerUpload = (fsPromises) => { + const { app, getRoute } = createRouteRegistry(); + registerFsRoutes(app, { + os: { homedir: () => '/home/user' }, + path: path.posix, + fsPromises: { + realpath: async (targetPath) => targetPath, + ...fsPromises, + }, + spawn: vi.fn(), + crypto: { randomUUID: () => 'job-0' }, + normalizeDirectoryPath: (p) => p, + resolveProjectDirectory: async () => ({ directory: '/repo' }), + buildAugmentedPath: () => '/usr/bin', + resolveGitBinaryForSpawn: () => 'git', + openchamberUserConfigRoot: '/home/user/.config', + }); + return getRoute('POST', '/api/fs/upload'); +}; + const registerRead = (fsPromises) => { const { app, getRoute } = createRouteRegistry(); registerFsRoutes(app, { @@ -233,6 +253,22 @@ const callWrite = async (handler, body) => { return res; }; +const callUpload = async (handler, { body = Buffer.from('upload'), path: filePath = '/repo/file.bin', overwrite = false } = {}) => { + const res = createMockResponse(); + const req = { + headers: { + 'content-type': 'application/octet-stream', + 'content-length': String(body.length), + }, + query: { path: filePath, overwrite: overwrite ? 'true' : undefined }, + async *[Symbol.asyncIterator]() { + yield body; + }, + }; + await handler(req, res); + return res; +}; + const callRead = async (handler, query) => { const res = createMockResponse(); await handler({ query }, res); @@ -340,6 +376,95 @@ describe('fs write', () => { }); }); +describe('fs upload', () => { + it('creates a binary file without overwriting existing content', async () => { + const fsPromises = { + writeFile: vi.fn(async () => undefined), + rename: vi.fn(async () => undefined), + unlink: vi.fn(async () => undefined), + }; + const handler = registerUpload(fsPromises); + + const res = await callUpload(handler, { body: Buffer.from([0, 1, 2, 255]) }); + + expect(res.body).toEqual({ success: true, path: '/repo/file.bin' }); + expect(fsPromises.writeFile).toHaveBeenCalledWith( + '/repo/file.bin', + Buffer.from([0, 1, 2, 255]), + { flag: 'wx' }, + ); + expect(fsPromises.rename).not.toHaveBeenCalled(); + }); + + it('returns a conflict instead of silently replacing an existing file', async () => { + const error = Object.assign(new Error('exists'), { code: 'EEXIST' }); + const fsPromises = { + writeFile: vi.fn(async () => { throw error; }), + }; + const handler = registerUpload(fsPromises); + + const res = await callUpload(handler); + + expect(res.statusCode).toBe(409); + expect(res.body).toEqual({ error: 'File already exists', reason: 'already-exists' }); + }); + + it('atomically replaces a file only when overwrite is explicit', async () => { + const fsPromises = { + writeFile: vi.fn(async () => undefined), + rename: vi.fn(async () => undefined), + unlink: vi.fn(async () => undefined), + }; + const handler = registerUpload(fsPromises); + + const res = await callUpload(handler, { overwrite: true }); + + expect(res.body).toEqual({ success: true, path: '/repo/file.bin' }); + const tmp = fsPromises.writeFile.mock.calls[0][0]; + expect(tmp).toMatch(/^\/repo\/file\.bin\.tmp-/); + expect(fsPromises.writeFile).toHaveBeenCalledWith(tmp, Buffer.from('upload'), { flag: 'wx' }); + expect(fsPromises.rename).toHaveBeenCalledWith(tmp, '/repo/file.bin'); + }); + + it('rejects a destination parent that resolves outside the workspace', async () => { + const fsPromises = { + realpath: vi.fn(async (targetPath) => targetPath === '/repo/link' ? '/outside' : targetPath), + writeFile: vi.fn(async () => undefined), + }; + const handler = registerUpload(fsPromises); + + const res = await callUpload(handler, { path: '/repo/link/file.bin' }); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ error: 'Access denied' }); + expect(fsPromises.writeFile).not.toHaveBeenCalled(); + }); + + it('rejects streamed bodies larger than 100 MB', async () => { + const fsPromises = { + writeFile: vi.fn(async () => undefined), + }; + const handler = registerUpload(fsPromises); + const chunk = Buffer.alloc(1024 * 1024); + const req = { + headers: { 'content-type': 'application/octet-stream' }, + query: { path: '/repo/file.bin' }, + async *[Symbol.asyncIterator]() { + for (let index = 0; index < 101; index += 1) { + yield chunk; + } + }, + }; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.statusCode).toBe(413); + expect(res.body).toEqual({ error: `File exceeds maximum size of ${100 * 1024 * 1024} bytes` }); + expect(fsPromises.writeFile).not.toHaveBeenCalled(); + }); +}); + describe('fs read', () => { it('reads workspace files through symlinks that resolve outside the workspace', async () => { const fsPromises = { diff --git a/packages/web/src/api/files.test.ts b/packages/web/src/api/files.test.ts index beb88c9c..f353acfa 100644 --- a/packages/web/src/api/files.test.ts +++ b/packages/web/src/api/files.test.ts @@ -92,6 +92,39 @@ describe('createWebFilesAPI', () => { }); }); + it('uploads binary file contents to the active workspace', async () => { + const { createWebFilesAPI } = await import('./files'); + const api = createWebFilesAPI({ urls, getDirectory: () => '/workspace' }); + const file = new Blob([new Uint8Array([0, 1, 255])]); + runtimeFetchMock.mockResolvedValueOnce(Response.json({ success: true, path: '/workspace/image.bin' })); + + await api.uploadFile?.('/workspace/image.bin', file, { directory: '/workspace' }); + + expect(runtimeFetchMock).toHaveBeenLastCalledWith('/api/fs/upload', { + method: 'POST', + query: { path: '/workspace/image.bin', overwrite: undefined }, + headers: { + 'Content-Type': 'application/octet-stream', + 'x-opencode-directory': '/workspace', + }, + body: file, + }); + }); + + it('preserves upload conflict details for explicit overwrite handling', async () => { + const { createWebFilesAPI } = await import('./files'); + const api = createWebFilesAPI({ urls, getDirectory: () => '/workspace' }); + runtimeFetchMock.mockResolvedValueOnce(Response.json( + { error: 'File already exists', reason: 'already-exists' }, + { status: 409 }, + )); + + await expect(api.uploadFile?.('/workspace/file.txt', new Blob(['new']))).rejects.toMatchObject({ + reason: 'already-exists', + status: 409, + }); + }); + it('opens the native share sheet for downloads in the Capacitor app', async () => { const { createWebFilesAPI } = await import('./files'); const api = createWebFilesAPI({ urls, getDirectory: () => '/workspace' }); diff --git a/packages/web/src/api/files.ts b/packages/web/src/api/files.ts index 6655d365..696c6f60 100644 --- a/packages/web/src/api/files.ts +++ b/packages/web/src/api/files.ts @@ -7,6 +7,7 @@ import type { import { FilesystemError, parseFilesystemErrorReason, + type FilesystemErrorReason, } from '@openchamber/ui/lib/api/files-errors'; import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch'; @@ -31,6 +32,13 @@ type WebDirectoryListResponse = { entries?: WebDirectoryEntry[]; }; +type WebFileUploadResponse = { + success?: boolean; + path?: string; + error?: string; + reason?: FilesystemErrorReason; +}; + const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryListResponse): DirectoryListResult => { if (!payload || !Array.isArray(payload.entries)) { throw new FilesystemError('Directory listing returned an invalid response', { @@ -222,6 +230,36 @@ export const createWebFilesAPI = ({ getDirectory }: WebFilesAPIOptions): FilesAP }; }, + async uploadFile(path: string, file: Blob, options): Promise<{ success: boolean; path: string }> { + const target = normalizePath(path); + const response = await runtimeFetch('/api/fs/upload', { + method: 'POST', + query: { + path: target, + overwrite: options?.overwrite ? 'true' : undefined, + }, + headers: { + 'Content-Type': 'application/octet-stream', + ...directoryHeaders(getDirectory, options?.directory), + }, + body: file, + }); + + if (!response.ok) { + const error: WebFileUploadResponse = await response.json().catch(() => ({ error: response.statusText })); + throw new FilesystemError(error.error || 'Failed to upload file', { + reason: parseFilesystemErrorReason(error.reason), + status: response.status, + }); + } + + const result: WebFileUploadResponse = await response.json().catch(() => ({})); + return { + success: Boolean(result.success), + path: result.path ? normalizePath(result.path) : target, + }; + }, + async delete(path: string): Promise<{ success: boolean }> { const target = normalizePath(path); const response = await runtimeFetch('/api/fs/delete', {