mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-04 12:41:15 +08:00
feat: add file tree upload progress
Users need a visible upload path from the explorer itself, not only drag and drop behavior with no progress feedback. Routing picker and drop uploads through one XHR-backed hook keeps progress, validation, refresh, and success counts consistent for every upload source. The 200MB limit is mirrored in the client, multer, and nginx template so large uploads fail predictably instead of being blocked by whichever layer sees the request first. The server also returns explicit requested and uploaded counts so partial or multi-file batches can render accurate status text.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
|
||||
import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
|
||||
@@ -13,10 +14,12 @@ import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
|
||||
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
|
||||
import { Project } from '../../../types/app';
|
||||
import { ScrollArea, Input } from '../../../shared/view/ui';
|
||||
|
||||
import FileTreeBody from './FileTreeBody';
|
||||
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
|
||||
import FileTreeHeader from './FileTreeHeader';
|
||||
import FileTreeLoadingState from './FileTreeLoadingState';
|
||||
import FileTreeUploadProgress from './FileTreeUploadProgress';
|
||||
import ImageViewer from './ImageViewer';
|
||||
|
||||
|
||||
@@ -66,6 +69,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
||||
onRefresh: refreshFiles,
|
||||
showToast,
|
||||
});
|
||||
const operationLoading = operations.operationLoading || upload.operationLoading;
|
||||
|
||||
// Focus input when creating new item
|
||||
useEffect(() => {
|
||||
@@ -146,14 +150,19 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
||||
onViewModeChange={changeViewMode}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onUploadFiles={upload.handleFileSelect}
|
||||
onNewFile={() => operations.handleStartCreate('', 'file')}
|
||||
onNewFolder={() => operations.handleStartCreate('', 'directory')}
|
||||
onRefresh={refreshFiles}
|
||||
onCollapseAll={collapseAll}
|
||||
loading={loading}
|
||||
operationLoading={operations.operationLoading}
|
||||
operationLoading={operationLoading}
|
||||
isUploading={upload.uploadProgress?.status === 'uploading'}
|
||||
uploadProgress={upload.uploadProgress?.progress ?? null}
|
||||
/>
|
||||
|
||||
<FileTreeUploadProgress upload={upload.uploadProgress} />
|
||||
|
||||
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
|
||||
|
||||
<ScrollArea className="flex-1 px-2 py-1">
|
||||
@@ -184,7 +193,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
||||
}, 100);
|
||||
}}
|
||||
className="h-6 flex-1 text-sm"
|
||||
disabled={operations.operationLoading}
|
||||
disabled={operationLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -213,7 +222,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
||||
handleConfirmRename={operations.handleConfirmRename}
|
||||
handleCancelRename={operations.handleCancelRename}
|
||||
renameInputRef={renameInputRef}
|
||||
operationLoading={operations.operationLoading}
|
||||
operationLoading={operationLoading}
|
||||
/>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -251,17 +260,17 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={operations.handleCancelDelete}
|
||||
disabled={operations.operationLoading}
|
||||
disabled={operationLoading}
|
||||
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={operations.handleConfirmDelete}
|
||||
disabled={operations.operationLoading}
|
||||
disabled={operationLoading}
|
||||
className="flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{operations.operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('fileTree.delete.confirm', 'Delete')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { ChevronDown, Eye, FileText, FolderPlus, List, Loader2, RefreshCw, Search, TableProperties, Upload, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Input } from '../../../shared/view/ui';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { MAX_FILE_UPLOAD_SIZE_LABEL } from '../constants/constants';
|
||||
import type { FileTreeViewMode } from '../types/types';
|
||||
|
||||
type FileTreeHeaderProps = {
|
||||
@@ -12,11 +16,14 @@ type FileTreeHeaderProps = {
|
||||
// Toolbar actions
|
||||
onNewFile?: () => void;
|
||||
onNewFolder?: () => void;
|
||||
onUploadFiles?: (files: FileList) => void;
|
||||
onRefresh?: () => void;
|
||||
onCollapseAll?: () => void;
|
||||
// Loading state
|
||||
loading?: boolean;
|
||||
operationLoading?: boolean;
|
||||
isUploading?: boolean;
|
||||
uploadProgress?: number | null;
|
||||
};
|
||||
|
||||
export default function FileTreeHeader({
|
||||
@@ -26,12 +33,24 @@ export default function FileTreeHeader({
|
||||
onSearchQueryChange,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
onUploadFiles,
|
||||
onRefresh,
|
||||
onCollapseAll,
|
||||
loading,
|
||||
operationLoading,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
}: FileTreeHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUploadInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { files } = event.target;
|
||||
if (files && files.length > 0) {
|
||||
onUploadFiles?.(files);
|
||||
}
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-b border-border px-3 pb-2 pt-3">
|
||||
@@ -40,6 +59,50 @@ export default function FileTreeHeader({
|
||||
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Action buttons */}
|
||||
{onUploadFiles && (
|
||||
<>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUploadInputChange}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative h-7 w-7 p-0"
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
title={
|
||||
isUploading
|
||||
? t('fileTree.uploadingFiles', 'Uploading files')
|
||||
: t('fileTree.uploadFiles', 'Upload files (max {{size}} each)', {
|
||||
size: MAX_FILE_UPLOAD_SIZE_LABEL,
|
||||
})
|
||||
}
|
||||
aria-label={t('fileTree.uploadFiles', 'Upload files (max {{size}} each)', {
|
||||
size: MAX_FILE_UPLOAD_SIZE_LABEL,
|
||||
})}
|
||||
disabled={operationLoading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isUploading && typeof uploadProgress === 'number' && (
|
||||
<span className="absolute bottom-0.5 left-1/2 h-0.5 w-4 -translate-x-1/2 overflow-hidden rounded-full bg-primary/20">
|
||||
<span
|
||||
className="block h-full rounded-full bg-primary transition-[width] duration-150"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{onNewFile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
90
src/components/file-tree/view/FileTreeUploadProgress.tsx
Normal file
90
src/components/file-tree/view/FileTreeUploadProgress.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { AlertCircle, CheckCircle2, Upload } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { FileTreeUploadProgressState } from '../hooks/useFileTreeUpload';
|
||||
|
||||
type FileTreeUploadProgressProps = {
|
||||
upload: FileTreeUploadProgressState | null;
|
||||
};
|
||||
|
||||
const clampProgress = (progress: number) => Math.min(100, Math.max(0, progress));
|
||||
|
||||
const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files');
|
||||
|
||||
export default function FileTreeUploadProgress({ upload }: FileTreeUploadProgressProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!upload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const progress = clampProgress(upload.progress);
|
||||
const isUploading = upload.status === 'uploading';
|
||||
const isComplete = upload.status === 'complete';
|
||||
const isError = upload.status === 'error';
|
||||
const fileSummary =
|
||||
upload.fileCount === 1 && upload.fileName
|
||||
? upload.fileName
|
||||
: `${upload.fileCount} ${pluralizeFiles(upload.fileCount)}`;
|
||||
|
||||
const title = isUploading
|
||||
? t('fileTree.uploadingFiles', 'Uploading files')
|
||||
: isComplete
|
||||
? t('fileTree.uploadComplete', 'Upload complete')
|
||||
: t('fileTree.uploadFailed', 'Upload failed');
|
||||
|
||||
const detail = isError
|
||||
? upload.error
|
||||
: isComplete && typeof upload.uploadedCount === 'number'
|
||||
? t('fileTree.uploadedCount', 'Uploaded {{uploaded}} of {{total}} {{label}}', {
|
||||
uploaded: upload.uploadedCount,
|
||||
total: upload.fileCount,
|
||||
label: pluralizeFiles(upload.fileCount),
|
||||
})
|
||||
: fileSummary;
|
||||
|
||||
const Icon = isError ? AlertCircle : isComplete ? CheckCircle2 : Upload;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-b px-3 py-2 transition-colors',
|
||||
isError
|
||||
? 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||
: isComplete
|
||||
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||
: 'border-primary/20 bg-primary/10 text-foreground',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-[36px] items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md',
|
||||
isError ? 'bg-red-500/15' : isComplete ? 'bg-emerald-500/15' : 'bg-primary/15',
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-3.5 w-3.5', isUploading && 'animate-pulse')} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate text-xs font-medium">{title}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{isUploading ? `${progress}%` : isComplete ? t('common.done', 'Done') : t('common.failed', 'Failed')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[11px] text-muted-foreground">{detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-background/80">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-[width] duration-200',
|
||||
isError ? 'bg-red-500' : isComplete ? 'bg-emerald-500' : 'bg-primary',
|
||||
)}
|
||||
style={{ width: `${isError ? Math.max(progress, 8) : progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user