feat(files): upload files with drag and drop
This commit is contained in:
@@ -43,6 +43,7 @@ import { opencodeClient } from '@/lib/opencode/client';
|
|||||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
|
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
|
||||||
|
import { isFilesystemError } from '@/lib/api/files-errors';
|
||||||
import { isBrowserClientRuntime } from '@/lib/desktop';
|
import { isBrowserClientRuntime } from '@/lib/desktop';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -54,6 +55,40 @@ type FileNode = {
|
|||||||
relativePath?: string;
|
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[]) =>
|
const sortNodes = (items: FileNode[]) =>
|
||||||
items.slice().sort((a, b) => {
|
items.slice().sort((a, b) => {
|
||||||
if (a.type !== b.type) {
|
if (a.type !== b.type) {
|
||||||
@@ -93,6 +128,22 @@ const getRelativePath = (root: string, path: string): string => {
|
|||||||
return normalizedPath.slice(normalizedRoot.length + 1);
|
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 => {
|
const isAbsolutePath = (value: string): boolean => {
|
||||||
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
|
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
|
||||||
};
|
};
|
||||||
@@ -194,6 +245,8 @@ interface FileRowProps {
|
|||||||
isBrowserClient: boolean;
|
isBrowserClient: boolean;
|
||||||
status?: FileStatus | null;
|
status?: FileStatus | null;
|
||||||
badge?: { modified: number; added: number } | null;
|
badge?: { modified: number; added: number } | null;
|
||||||
|
isDropTarget: boolean;
|
||||||
|
canUpload: boolean;
|
||||||
permissions: {
|
permissions: {
|
||||||
canRename: boolean;
|
canRename: boolean;
|
||||||
canCreateFile: boolean;
|
canCreateFile: boolean;
|
||||||
@@ -206,6 +259,8 @@ interface FileRowProps {
|
|||||||
onToggle: (path: string) => void;
|
onToggle: (path: string) => void;
|
||||||
onRevealPath: (path: string) => void;
|
onRevealPath: (path: string) => void;
|
||||||
onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => 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<FileRowProps> = ({
|
const FileRow: React.FC<FileRowProps> = ({
|
||||||
@@ -216,15 +271,20 @@ const FileRow: React.FC<FileRowProps> = ({
|
|||||||
isBrowserClient,
|
isBrowserClient,
|
||||||
status,
|
status,
|
||||||
badge,
|
badge,
|
||||||
|
isDropTarget,
|
||||||
|
canUpload,
|
||||||
permissions,
|
permissions,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
onSelect,
|
onSelect,
|
||||||
onToggle,
|
onToggle,
|
||||||
onRevealPath,
|
onRevealPath,
|
||||||
onOpenDialog,
|
onOpenDialog,
|
||||||
|
onSetDropTarget,
|
||||||
|
onDropFiles,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isDir = node.type === 'directory';
|
const isDir = node.type === 'directory';
|
||||||
|
const uploadDirectory = isDir ? node.path : getParentPath(node.path);
|
||||||
const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions;
|
const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions;
|
||||||
const canDownload = !isDir && Boolean(downloadFile);
|
const canDownload = !isDir && Boolean(downloadFile);
|
||||||
const canRevealPath = canReveal && !isBrowserClient;
|
const canRevealPath = canReveal && !isBrowserClient;
|
||||||
@@ -333,9 +393,40 @@ const FileRow: React.FC<FileRowProps> = ({
|
|||||||
e.dataTransfer.effectAllowed = 'copy';
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
}, [node.path, root]);
|
}, [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 (
|
return (
|
||||||
<ContextMenu open={rightClickOpen} onOpenChange={setRightClickOpen}>
|
<ContextMenu open={rightClickOpen} onOpenChange={setRightClickOpen}>
|
||||||
<ContextMenuTrigger render={<div className="group relative flex items-center" onContextMenu={handleContextMenu} />}>
|
<ContextMenuTrigger render={(
|
||||||
|
<div
|
||||||
|
className="group relative flex items-center"
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
onDragEnter={handleExternalDragOver}
|
||||||
|
onDragOver={handleExternalDragOver}
|
||||||
|
onDragLeave={handleExternalDragLeave}
|
||||||
|
onDrop={handleExternalDrop}
|
||||||
|
/>
|
||||||
|
)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleInteraction}
|
onClick={handleInteraction}
|
||||||
@@ -344,7 +435,9 @@ const FileRow: React.FC<FileRowProps> = ({
|
|||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors pr-8 select-none',
|
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors pr-8 select-none',
|
||||||
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40',
|
isDropTarget
|
||||||
|
? 'bg-interactive-selection ring-2 ring-inset ring-primary'
|
||||||
|
: (isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'),
|
||||||
'cursor-grab active:cursor-grabbing'
|
'cursor-grab active:cursor-grabbing'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -415,12 +508,16 @@ const areFileRowPropsEqual = (prev: FileRowProps, next: FileRowProps): boolean =
|
|||||||
&& prev.isBrowserClient === next.isBrowserClient
|
&& prev.isBrowserClient === next.isBrowserClient
|
||||||
&& prev.status === next.status
|
&& prev.status === next.status
|
||||||
&& prev.badge === next.badge
|
&& prev.badge === next.badge
|
||||||
|
&& prev.isDropTarget === next.isDropTarget
|
||||||
|
&& prev.canUpload === next.canUpload
|
||||||
&& prev.permissions === next.permissions
|
&& prev.permissions === next.permissions
|
||||||
&& prev.downloadFile === next.downloadFile
|
&& prev.downloadFile === next.downloadFile
|
||||||
&& prev.onSelect === next.onSelect
|
&& prev.onSelect === next.onSelect
|
||||||
&& prev.onToggle === next.onToggle
|
&& prev.onToggle === next.onToggle
|
||||||
&& prev.onRevealPath === next.onRevealPath
|
&& prev.onRevealPath === next.onRevealPath
|
||||||
&& prev.onOpenDialog === next.onOpenDialog
|
&& prev.onOpenDialog === next.onOpenDialog
|
||||||
|
&& prev.onSetDropTarget === next.onSetDropTarget
|
||||||
|
&& prev.onDropFiles === next.onDropFiles
|
||||||
);
|
);
|
||||||
|
|
||||||
const MemoizedFileRow = React.memo(FileRow, areFileRowPropsEqual);
|
const MemoizedFileRow = React.memo(FileRow, areFileRowPropsEqual);
|
||||||
@@ -444,6 +541,12 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
const [searchResults, setSearchResults] = React.useState<FileNode[]>([]);
|
const [searchResults, setSearchResults] = React.useState<FileNode[]>([]);
|
||||||
const [searching, setSearching] = React.useState(false);
|
const [searching, setSearching] = React.useState(false);
|
||||||
|
const [dropTarget, setDropTarget] = React.useState<string | null>(null);
|
||||||
|
const [isUploading, setIsUploading] = React.useState(false);
|
||||||
|
const [uploadConflicts, setUploadConflicts] = React.useState<UploadConflicts | null>(null);
|
||||||
|
const uploadingRef = React.useRef(false);
|
||||||
|
const rootRef = React.useRef(root);
|
||||||
|
rootRef.current = root;
|
||||||
|
|
||||||
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
|
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
|
||||||
const [loadErrorsByDir, setLoadErrorsByDir] = React.useState<Record<string, string>>({});
|
const [loadErrorsByDir, setLoadErrorsByDir] = React.useState<Record<string, string>>({});
|
||||||
@@ -457,6 +560,8 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
// combining the two means the tree re-paints with cached data instead
|
// combining the two means the tree re-paints with cached data instead
|
||||||
// of blanking out and re-listing every directory.
|
// of blanking out and re-listing every directory.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
setDropTarget(null);
|
||||||
|
setUploadConflicts(null);
|
||||||
if (!root) {
|
if (!root) {
|
||||||
setChildrenByDir({});
|
setChildrenByDir({});
|
||||||
setLoadErrorsByDir({});
|
setLoadErrorsByDir({});
|
||||||
@@ -544,6 +649,7 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
const canRename = Boolean(files.rename);
|
const canRename = Boolean(files.rename);
|
||||||
const canDelete = Boolean(files.delete);
|
const canDelete = Boolean(files.delete);
|
||||||
const canReveal = Boolean(files.revealPath);
|
const canReveal = Boolean(files.revealPath);
|
||||||
|
const canUpload = Boolean(files.uploadFile);
|
||||||
|
|
||||||
const fileRowPermissions = React.useMemo(
|
const fileRowPermissions = React.useMemo(
|
||||||
() => ({ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }),
|
() => ({ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }),
|
||||||
@@ -897,6 +1003,101 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [loadDirectory, root, toggleExpandedPath]);
|
}, [loadDirectory, root, toggleExpandedPath]);
|
||||||
|
|
||||||
|
const uploadDroppedFiles = React.useCallback(async (
|
||||||
|
directory: string,
|
||||||
|
droppedFiles: File[],
|
||||||
|
overwrite = false,
|
||||||
|
) => {
|
||||||
|
const uploadFile = files.uploadFile;
|
||||||
|
if (!uploadFile || droppedFiles.length === 0 || uploadingRef.current || !root) return;
|
||||||
|
|
||||||
|
const operationRoot = root;
|
||||||
|
const operationRuntime = getRuntimeKey();
|
||||||
|
uploadingRef.current = true;
|
||||||
|
setIsUploading(true);
|
||||||
|
setDropTarget(directory);
|
||||||
|
if (overwrite) setUploadConflicts(null);
|
||||||
|
|
||||||
|
const outcomes: UploadOutcome[] = [];
|
||||||
|
for (let index = 0; index < droppedFiles.length; index += MAX_PARALLEL_UPLOADS) {
|
||||||
|
const batch = droppedFiles.slice(index, index + MAX_PARALLEL_UPLOADS);
|
||||||
|
const batchOutcomes = await Promise.all(batch.map(async (file): Promise<UploadOutcome> => {
|
||||||
|
const name = getUploadName(file);
|
||||||
|
if (!name || getRuntimeKey() !== operationRuntime) return 'failed';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadFile(normalizePath(`${directory}/${name}`), file, {
|
||||||
|
directory: operationRoot,
|
||||||
|
overwrite,
|
||||||
|
});
|
||||||
|
return result.success ? 'uploaded' : 'failed';
|
||||||
|
} catch (error) {
|
||||||
|
if (!overwrite && isFilesystemError(error) && error.reason === 'already-exists') {
|
||||||
|
return 'conflict';
|
||||||
|
}
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
outcomes.push(...batchOutcomes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedCount = outcomes.filter((outcome) => outcome === 'uploaded').length;
|
||||||
|
const failedCount = outcomes.filter((outcome) => outcome === 'failed').length;
|
||||||
|
const conflictingFiles = droppedFiles.filter((_, index) => outcomes[index] === 'conflict');
|
||||||
|
const isCurrentDestination = rootRef.current === operationRoot && getRuntimeKey() === operationRuntime;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (uploadedCount > 0 && isCurrentDestination) {
|
||||||
|
await refreshDirectory(directory);
|
||||||
|
}
|
||||||
|
if (uploadedCount > 0) {
|
||||||
|
toast.success(t(conflictingFiles.length > 0
|
||||||
|
? 'sidebarFilesTree.toast.uploadedWithoutConflicts'
|
||||||
|
: 'sidebarFilesTree.toast.uploaded'));
|
||||||
|
}
|
||||||
|
if (failedCount > 0) {
|
||||||
|
toast.error(t('sidebarFilesTree.toast.uploadFailed'));
|
||||||
|
}
|
||||||
|
if (conflictingFiles.length > 0 && isCurrentDestination) {
|
||||||
|
setUploadConflicts({
|
||||||
|
directory,
|
||||||
|
files: conflictingFiles,
|
||||||
|
runtimeKey: operationRuntime,
|
||||||
|
workspaceRoot: operationRoot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uploadingRef.current = false;
|
||||||
|
setIsUploading(false);
|
||||||
|
setDropTarget(null);
|
||||||
|
}
|
||||||
|
}, [files.uploadFile, refreshDirectory, root, t]);
|
||||||
|
|
||||||
|
const handleDropFiles = React.useCallback((directory: string, dataTransfer: DataTransfer) => {
|
||||||
|
const droppedFiles = getExternalFiles(dataTransfer);
|
||||||
|
if (droppedFiles.length === 0) return;
|
||||||
|
void uploadDroppedFiles(directory, droppedFiles);
|
||||||
|
}, [uploadDroppedFiles]);
|
||||||
|
|
||||||
|
const handleRootDragOver = React.useCallback((event: React.DragEvent) => {
|
||||||
|
if (!canUpload || uploadingRef.current || !root || !hasExternalFiles(event.dataTransfer)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
|
setDropTarget(root);
|
||||||
|
}, [canUpload, root]);
|
||||||
|
|
||||||
|
const handleRootDragLeave = React.useCallback((event: React.DragEvent) => {
|
||||||
|
if (!hasExternalFiles(event.dataTransfer)) return;
|
||||||
|
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
|
||||||
|
setDropTarget(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRootDrop = React.useCallback((event: React.DragEvent) => {
|
||||||
|
if (!canUpload || uploadingRef.current || !root || !hasExternalFiles(event.dataTransfer)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
handleDropFiles(root, event.dataTransfer);
|
||||||
|
}, [canUpload, handleDropFiles, root]);
|
||||||
|
|
||||||
// --- Dialog submit (matching FilesView) ---
|
// --- Dialog submit (matching FilesView) ---
|
||||||
|
|
||||||
const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => {
|
const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => {
|
||||||
@@ -1056,12 +1257,16 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
isBrowserClient={isBrowserClient}
|
isBrowserClient={isBrowserClient}
|
||||||
status={!isDir ? getFileStatus(node.path) : undefined}
|
status={!isDir ? getFileStatus(node.path) : undefined}
|
||||||
badge={isDir ? getFolderBadge(node.path) : undefined}
|
badge={isDir ? getFolderBadge(node.path) : undefined}
|
||||||
|
isDropTarget={isDir && dropTarget === node.path}
|
||||||
|
canUpload={canUpload && !isUploading}
|
||||||
permissions={fileRowPermissions}
|
permissions={fileRowPermissions}
|
||||||
downloadFile={files.downloadFile}
|
downloadFile={files.downloadFile}
|
||||||
onSelect={handleOpenFile}
|
onSelect={handleOpenFile}
|
||||||
onToggle={toggleDirectory}
|
onToggle={toggleDirectory}
|
||||||
onRevealPath={handleRevealPath}
|
onRevealPath={handleRevealPath}
|
||||||
onOpenDialog={handleOpenDialog}
|
onOpenDialog={handleOpenDialog}
|
||||||
|
onSetDropTarget={setDropTarget}
|
||||||
|
onDropFiles={handleDropFiles}
|
||||||
/>
|
/>
|
||||||
{isDir && isExpanded && (
|
{isDir && isExpanded && (
|
||||||
<ul className="flex flex-col gap-1 ml-3 pl-3 border-l border-border/40 relative">
|
<ul className="flex flex-col gap-1 ml-3 pl-3 border-l border-border/40 relative">
|
||||||
@@ -1084,6 +1289,7 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
|
|
||||||
const hasTree = Boolean(root && childrenByDir[root]);
|
const hasTree = Boolean(root && childrenByDir[root]);
|
||||||
const rootLoadError = root ? loadErrorsByDir[root] : null;
|
const rootLoadError = root ? loadErrorsByDir[root] : null;
|
||||||
|
const dropTargetLabel = dropTarget ? getDropTargetLabel(root, dropTarget) : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full min-h-0 flex-col overflow-hidden">
|
<section className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
@@ -1182,7 +1388,15 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-2">
|
<div className="relative flex-1 min-h-0">
|
||||||
|
<ScrollableOverlay
|
||||||
|
outerClassName="h-full min-h-0"
|
||||||
|
className={cn('p-2', dropTarget === root && 'bg-interactive-selection/10')}
|
||||||
|
onDragEnter={handleRootDragOver}
|
||||||
|
onDragOver={handleRootDragOver}
|
||||||
|
onDragLeave={handleRootDragLeave}
|
||||||
|
onDrop={handleRootDrop}
|
||||||
|
>
|
||||||
<ul className="flex flex-col">
|
<ul className="flex flex-col">
|
||||||
{searching ? (
|
{searching ? (
|
||||||
<li className="flex items-center gap-1.5 px-2 py-1 typography-meta text-muted-foreground">
|
<li className="flex items-center gap-1.5 px-2 py-1 typography-meta text-muted-foreground">
|
||||||
@@ -1235,7 +1449,52 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
<li className="px-2 py-1 typography-meta text-muted-foreground">{t('sidebarFilesTree.state.loading')}</li>
|
<li className="px-2 py-1 typography-meta text-muted-foreground">{t('sidebarFilesTree.state.loading')}</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
|
{dropTarget ? (
|
||||||
|
<div className="pointer-events-none absolute left-2 right-2 top-2 z-50 flex items-center gap-2 rounded-md border border-primary bg-background/95 px-2 py-1.5 shadow-sm">
|
||||||
|
<Icon name={isUploading ? 'loader-4' : 'folder-received'} className={cn('size-4 flex-shrink-0', isUploading && 'animate-spin')} />
|
||||||
|
<span className="min-w-0 truncate typography-meta" title={dropTargetLabel}>
|
||||||
|
{t(isUploading ? 'sidebarFilesTree.drop.uploading' : 'sidebarFilesTree.drop.target', { path: dropTargetLabel })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(uploadConflicts)} onOpenChange={(open: boolean) => !open && setUploadConflicts(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('sidebarFilesTree.dialog.uploadConflicts.title')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('sidebarFilesTree.dialog.uploadConflicts.description', { path: uploadConflicts?.directory ?? '' })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollableOverlay outerClassName="max-h-52" className="flex flex-col gap-1 pr-2">
|
||||||
|
{uploadConflicts?.files.map((file, index) => (
|
||||||
|
<div key={`${file.name}-${file.size}-${index}`} className="truncate rounded-md bg-muted px-2 py-1 typography-meta" title={file.name}>
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollableOverlay>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setUploadConflicts(null)} disabled={isUploading}>
|
||||||
|
{t('sidebarFilesTree.dialog.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!uploadConflicts) return;
|
||||||
|
if (uploadConflicts.runtimeKey !== getRuntimeKey() || uploadConflicts.workspaceRoot !== root) {
|
||||||
|
setUploadConflicts(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void uploadDroppedFiles(uploadConflicts.directory, uploadConflicts.files, true);
|
||||||
|
}}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
{t('sidebarFilesTree.dialog.uploadConflicts.replace')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* CRUD dialogs (matching FilesView) */}
|
{/* CRUD dialogs (matching FilesView) */}
|
||||||
<Dialog open={!!activeDialog} onOpenChange={(open) => !open && setActiveDialog(null)}>
|
<Dialog open={!!activeDialog} onOpenChange={(open) => !open && setActiveDialog(null)}>
|
||||||
|
|||||||
@@ -25,4 +25,9 @@ describe('FilesystemError', () => {
|
|||||||
expect(parseFilesystemErrorReason('made-up')).toBe('unknown');
|
expect(parseFilesystemErrorReason('made-up')).toBe('unknown');
|
||||||
expect(parseFilesystemErrorReason(undefined)).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export type FilesystemErrorReason =
|
export type FilesystemErrorReason =
|
||||||
| 'os-permission'
|
| 'os-permission'
|
||||||
|
| 'already-exists'
|
||||||
| 'not-found'
|
| 'not-found'
|
||||||
| 'not-directory'
|
| 'not-directory'
|
||||||
| 'invalid-response'
|
| 'invalid-response'
|
||||||
@@ -30,6 +31,7 @@ export const isFilesystemError = (error: unknown): error is FilesystemError => (
|
|||||||
export const parseFilesystemErrorReason = (value: unknown): FilesystemErrorReason => {
|
export const parseFilesystemErrorReason = (value: unknown): FilesystemErrorReason => {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'os-permission':
|
case 'os-permission':
|
||||||
|
case 'already-exists':
|
||||||
case 'not-found':
|
case 'not-found':
|
||||||
case 'not-directory':
|
case 'not-directory':
|
||||||
case 'invalid-response':
|
case 'invalid-response':
|
||||||
|
|||||||
@@ -606,6 +606,7 @@ export interface FilesAPI {
|
|||||||
readFile?(path: string, options?: FileReadOptions): Promise<{ content: string; path: string }>;
|
readFile?(path: string, options?: FileReadOptions): Promise<{ content: string; path: string }>;
|
||||||
readFileBinary?(path: string, options?: FileReadOptions): Promise<{ dataUrl: string; path: string }>;
|
readFileBinary?(path: string, options?: FileReadOptions): Promise<{ dataUrl: string; path: string }>;
|
||||||
writeFile?(path: string, content: string): Promise<{ success: boolean; 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 }>;
|
delete?(path: string): Promise<{ success: boolean }>;
|
||||||
rename?(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }>;
|
rename?(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }>;
|
||||||
revealPath?(path: string): Promise<{ success: boolean }>;
|
revealPath?(path: string): Promise<{ success: boolean }>;
|
||||||
|
|||||||
@@ -1172,6 +1172,14 @@ export const dict = {
|
|||||||
'sidebarFilesTree.toast.writeNotSupported': 'Schreiben nicht unterstützt',
|
'sidebarFilesTree.toast.writeNotSupported': 'Schreiben nicht unterstützt',
|
||||||
'sidebarFilesTree.toast.fileCreated': 'Datei erstellt',
|
'sidebarFilesTree.toast.fileCreated': 'Datei erstellt',
|
||||||
'sidebarFilesTree.toast.operationFailed': 'Operation fehlgeschlagen',
|
'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.folderNameRequired': 'Ordnername ist erforderlich',
|
||||||
'sidebarFilesTree.toast.folderCreated': 'Ordner erstellt',
|
'sidebarFilesTree.toast.folderCreated': 'Ordner erstellt',
|
||||||
'sidebarFilesTree.toast.nameRequired': 'Name ist erforderlich',
|
'sidebarFilesTree.toast.nameRequired': 'Name ist erforderlich',
|
||||||
|
|||||||
@@ -1322,6 +1322,14 @@ export const dict = {
|
|||||||
'sidebarFilesTree.toast.writeNotSupported': 'Write not supported',
|
'sidebarFilesTree.toast.writeNotSupported': 'Write not supported',
|
||||||
'sidebarFilesTree.toast.fileCreated': 'File created',
|
'sidebarFilesTree.toast.fileCreated': 'File created',
|
||||||
'sidebarFilesTree.toast.operationFailed': 'Operation failed',
|
'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.folderNameRequired': 'Folder name is required',
|
||||||
'sidebarFilesTree.toast.folderCreated': 'Folder created',
|
'sidebarFilesTree.toast.folderCreated': 'Folder created',
|
||||||
'sidebarFilesTree.toast.nameRequired': 'Name is required',
|
'sidebarFilesTree.toast.nameRequired': 'Name is required',
|
||||||
|
|||||||
@@ -1288,6 +1288,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sidebarFilesTree.toast.writeNotSupported": "La escritura no es compatible",
|
"sidebarFilesTree.toast.writeNotSupported": "La escritura no es compatible",
|
||||||
"sidebarFilesTree.toast.fileCreated": "Archivo creado",
|
"sidebarFilesTree.toast.fileCreated": "Archivo creado",
|
||||||
"sidebarFilesTree.toast.operationFailed": "No se pudo completar la operación",
|
"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.folderNameRequired": "El nombre de carpeta es obligatorio",
|
||||||
"sidebarFilesTree.toast.folderCreated": "Carpeta creada",
|
"sidebarFilesTree.toast.folderCreated": "Carpeta creada",
|
||||||
"sidebarFilesTree.toast.nameRequired": "El nombre es obligatorio",
|
"sidebarFilesTree.toast.nameRequired": "El nombre es obligatorio",
|
||||||
|
|||||||
@@ -1089,6 +1089,14 @@ export const dict = {
|
|||||||
'sidebarFilesTree.toast.writeNotSupported': 'Écriture non prise en charge',
|
'sidebarFilesTree.toast.writeNotSupported': 'Écriture non prise en charge',
|
||||||
'sidebarFilesTree.toast.fileCreated': 'Fichier créé',
|
'sidebarFilesTree.toast.fileCreated': 'Fichier créé',
|
||||||
'sidebarFilesTree.toast.operationFailed': 'L\'opération a échoué',
|
'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.folderNameRequired': 'Le nom du dossier est requis',
|
||||||
'sidebarFilesTree.toast.folderCreated': 'Dossier créé',
|
'sidebarFilesTree.toast.folderCreated': 'Dossier créé',
|
||||||
'sidebarFilesTree.toast.nameRequired': 'Le nom est requis',
|
'sidebarFilesTree.toast.nameRequired': 'Le nom est requis',
|
||||||
|
|||||||
@@ -1318,6 +1318,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sidebarFilesTree.toast.writeNotSupported': '書き込みはサポートされていません',
|
'sidebarFilesTree.toast.writeNotSupported': '書き込みはサポートされていません',
|
||||||
'sidebarFilesTree.toast.fileCreated': 'ファイルを作成しました',
|
'sidebarFilesTree.toast.fileCreated': 'ファイルを作成しました',
|
||||||
'sidebarFilesTree.toast.operationFailed': '操作に失敗しました',
|
'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.folderNameRequired': 'フォルダ名が必要です',
|
||||||
'sidebarFilesTree.toast.folderCreated': 'フォルダを作成しました',
|
'sidebarFilesTree.toast.folderCreated': 'フォルダを作成しました',
|
||||||
'sidebarFilesTree.toast.nameRequired': '名前が必要です',
|
'sidebarFilesTree.toast.nameRequired': '名前が必要です',
|
||||||
|
|||||||
@@ -1324,6 +1324,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sidebarFilesTree.toast.writeNotSupported': '쓰기를 지원하지 않음',
|
'sidebarFilesTree.toast.writeNotSupported': '쓰기를 지원하지 않음',
|
||||||
'sidebarFilesTree.toast.fileCreated': '파일 생성됨',
|
'sidebarFilesTree.toast.fileCreated': '파일 생성됨',
|
||||||
'sidebarFilesTree.toast.operationFailed': '작업 실패',
|
'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.folderNameRequired': '폴더 이름 필수',
|
||||||
'sidebarFilesTree.toast.folderCreated': '폴더 생성됨',
|
'sidebarFilesTree.toast.folderCreated': '폴더 생성됨',
|
||||||
'sidebarFilesTree.toast.nameRequired': '이름 필수',
|
'sidebarFilesTree.toast.nameRequired': '이름 필수',
|
||||||
|
|||||||
@@ -2845,6 +2845,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sidebarFilesTree.toast.folderNameRequired': 'Nazwa folderu jest wymagana',
|
'sidebarFilesTree.toast.folderNameRequired': 'Nazwa folderu jest wymagana',
|
||||||
'sidebarFilesTree.toast.nameRequired': 'Nazwa jest wymagana',
|
'sidebarFilesTree.toast.nameRequired': 'Nazwa jest wymagana',
|
||||||
'sidebarFilesTree.toast.operationFailed': 'Operacja nie powiodła się',
|
'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.pathCopied': 'Ścieżka skopiowana',
|
||||||
'sidebarFilesTree.toast.renameNotSupported': 'Zmiana nazwy nie jest obsługiwana',
|
'sidebarFilesTree.toast.renameNotSupported': 'Zmiana nazwy nie jest obsługiwana',
|
||||||
'sidebarFilesTree.toast.renamedSuccessfully': 'Zmieniono nazwę pomyślnie',
|
'sidebarFilesTree.toast.renamedSuccessfully': 'Zmieniono nazwę pomyślnie',
|
||||||
|
|||||||
@@ -1288,6 +1288,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sidebarFilesTree.toast.writeNotSupported": "A escrita não é compatível",
|
"sidebarFilesTree.toast.writeNotSupported": "A escrita não é compatível",
|
||||||
"sidebarFilesTree.toast.fileCreated": "Arquivo criado",
|
"sidebarFilesTree.toast.fileCreated": "Arquivo criado",
|
||||||
"sidebarFilesTree.toast.operationFailed": "Não foi possível completar a operación",
|
"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.folderNameRequired": "O nome de pasta é obrigatório",
|
||||||
"sidebarFilesTree.toast.folderCreated": "Pasta criada",
|
"sidebarFilesTree.toast.folderCreated": "Pasta criada",
|
||||||
"sidebarFilesTree.toast.nameRequired": "O nome é obrigatório",
|
"sidebarFilesTree.toast.nameRequired": "O nome é obrigatório",
|
||||||
|
|||||||
@@ -1288,6 +1288,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sidebarFilesTree.toast.writeNotSupported": "Запис не підтримується",
|
"sidebarFilesTree.toast.writeNotSupported": "Запис не підтримується",
|
||||||
"sidebarFilesTree.toast.fileCreated": "Файл створено",
|
"sidebarFilesTree.toast.fileCreated": "Файл створено",
|
||||||
"sidebarFilesTree.toast.operationFailed": "Операція не вдалася",
|
"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.folderNameRequired": "Потрібно вказати назву папки",
|
||||||
"sidebarFilesTree.toast.folderCreated": "Папку створено",
|
"sidebarFilesTree.toast.folderCreated": "Папку створено",
|
||||||
"sidebarFilesTree.toast.nameRequired": "Потрібно вказати назву",
|
"sidebarFilesTree.toast.nameRequired": "Потрібно вказати назву",
|
||||||
|
|||||||
@@ -1288,6 +1288,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sidebarFilesTree.toast.writeNotSupported': '不支持写入',
|
'sidebarFilesTree.toast.writeNotSupported': '不支持写入',
|
||||||
'sidebarFilesTree.toast.fileCreated': '文件已创建',
|
'sidebarFilesTree.toast.fileCreated': '文件已创建',
|
||||||
'sidebarFilesTree.toast.operationFailed': '操作失败',
|
'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.folderNameRequired': '文件夹名不能为空',
|
||||||
'sidebarFilesTree.toast.folderCreated': '文件夹已创建',
|
'sidebarFilesTree.toast.folderCreated': '文件夹已创建',
|
||||||
'sidebarFilesTree.toast.nameRequired': '名称不能为空',
|
'sidebarFilesTree.toast.nameRequired': '名称不能为空',
|
||||||
|
|||||||
@@ -1300,6 +1300,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sidebarFilesTree.toast.writeNotSupported': '不支援寫入',
|
'sidebarFilesTree.toast.writeNotSupported': '不支援寫入',
|
||||||
'sidebarFilesTree.toast.fileCreated': '檔案已建立',
|
'sidebarFilesTree.toast.fileCreated': '檔案已建立',
|
||||||
'sidebarFilesTree.toast.operationFailed': '操作失敗',
|
'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.folderNameRequired': '資料夾名稱不能為空',
|
||||||
'sidebarFilesTree.toast.folderCreated': '資料夾已建立',
|
'sidebarFilesTree.toast.folderCreated': '資料夾已建立',
|
||||||
'sidebarFilesTree.toast.nameRequired': '名稱不能為空',
|
'sidebarFilesTree.toast.nameRequired': '名稱不能為空',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
|
|||||||
- `GET /api/fs/raw`
|
- `GET /api/fs/raw`
|
||||||
- `GET /api/fs/serve/:path(*)`
|
- `GET /api/fs/serve/:path(*)`
|
||||||
- `POST /api/fs/write`
|
- `POST /api/fs/write`
|
||||||
|
- `POST /api/fs/upload`
|
||||||
- `POST /api/fs/delete`
|
- `POST /api/fs/delete`
|
||||||
- `POST /api/fs/rename`
|
- `POST /api/fs/rename`
|
||||||
- `POST /api/fs/reveal`
|
- `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.
|
- 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.
|
- 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.
|
- `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.
|
||||||
|
|||||||
@@ -139,6 +139,29 @@ const FILE_MIME_MAP = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const MAX_SERVE_BYTES = 100 * 1024 * 1024;
|
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.
|
// Only deterministic, side-effect-free git plumbing path queries are cacheable.
|
||||||
// Anything outside this allowlist (including any non-git command) runs normally
|
// 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) => {
|
app.post('/api/fs/delete', async (req, res) => {
|
||||||
const { path: targetPath } = req.body || {};
|
const { path: targetPath } = req.body || {};
|
||||||
if (!targetPath || typeof targetPath !== 'string') {
|
if (!targetPath || typeof targetPath !== 'string') {
|
||||||
|
|||||||
@@ -140,6 +140,26 @@ const registerWrite = (fsPromises) => {
|
|||||||
return getRoute('POST', '/api/fs/write');
|
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 registerRead = (fsPromises) => {
|
||||||
const { app, getRoute } = createRouteRegistry();
|
const { app, getRoute } = createRouteRegistry();
|
||||||
registerFsRoutes(app, {
|
registerFsRoutes(app, {
|
||||||
@@ -233,6 +253,22 @@ const callWrite = async (handler, body) => {
|
|||||||
return res;
|
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 callRead = async (handler, query) => {
|
||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
await handler({ query }, res);
|
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', () => {
|
describe('fs read', () => {
|
||||||
it('reads workspace files through symlinks that resolve outside the workspace', async () => {
|
it('reads workspace files through symlinks that resolve outside the workspace', async () => {
|
||||||
const fsPromises = {
|
const fsPromises = {
|
||||||
|
|||||||
@@ -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 () => {
|
it('opens the native share sheet for downloads in the Capacitor app', async () => {
|
||||||
const { createWebFilesAPI } = await import('./files');
|
const { createWebFilesAPI } = await import('./files');
|
||||||
const api = createWebFilesAPI({ urls, getDirectory: () => '/workspace' });
|
const api = createWebFilesAPI({ urls, getDirectory: () => '/workspace' });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
FilesystemError,
|
FilesystemError,
|
||||||
parseFilesystemErrorReason,
|
parseFilesystemErrorReason,
|
||||||
|
type FilesystemErrorReason,
|
||||||
} from '@openchamber/ui/lib/api/files-errors';
|
} from '@openchamber/ui/lib/api/files-errors';
|
||||||
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
|
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
|
||||||
|
|
||||||
@@ -31,6 +32,13 @@ type WebDirectoryListResponse = {
|
|||||||
entries?: WebDirectoryEntry[];
|
entries?: WebDirectoryEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WebFileUploadResponse = {
|
||||||
|
success?: boolean;
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
reason?: FilesystemErrorReason;
|
||||||
|
};
|
||||||
|
|
||||||
const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryListResponse): DirectoryListResult => {
|
const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryListResponse): DirectoryListResult => {
|
||||||
if (!payload || !Array.isArray(payload.entries)) {
|
if (!payload || !Array.isArray(payload.entries)) {
|
||||||
throw new FilesystemError('Directory listing returned an invalid response', {
|
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 }> {
|
async delete(path: string): Promise<{ success: boolean }> {
|
||||||
const target = normalizePath(path);
|
const target = normalizePath(path);
|
||||||
const response = await runtimeFetch('/api/fs/delete', {
|
const response = await runtimeFetch('/api/fs/delete', {
|
||||||
|
|||||||
Reference in New Issue
Block a user