Compare commits

..

1 Commits

Author SHA1 Message Date
Haileyesus
c235b05e1d 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.
2026-06-08 14:52:09 +03:00
11 changed files with 538 additions and 126 deletions

View File

@@ -72,7 +72,7 @@ http {
set $cloudcli_upstream http://127.0.0.1:3001; set $cloudcli_upstream http://127.0.0.1:3001;
# Allow larger file uploads through the code editor/project file APIs. # Allow larger file uploads through the code editor/project file APIs.
client_max_body_size 100m; client_max_body_size 200m;
# Redirect /ai to /ai/ so relative browser URL resolution is stable. # Redirect /ai to /ai/ so relative browser URL resolution is stable.
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath. # [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.

View File

@@ -455,7 +455,7 @@ async function sandboxCommand(args) {
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 5000));
console.log(`${c.info('▶')} Launching CloudCLI web server...`); console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']); sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`); console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
try { try {
@@ -554,7 +554,7 @@ async function sandboxCommand(args) {
// Step 3: Start CloudCLI inside the sandbox // Step 3: Start CloudCLI inside the sandbox
console.log(`${c.info('▶')} Launching CloudCLI web server...`); console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']); sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
// Step 4: Forward port // Step 4: Forward port
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`); console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);

View File

@@ -84,6 +84,9 @@ const __dirname = getModuleDir(import.meta.url);
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts. // Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
const APP_ROOT = findAppRoot(__dirname); const APP_ROOT = findAppRoot(__dirname);
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm'; const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
const MAX_FILE_UPLOAD_SIZE_MB = 200;
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
const MAX_FILE_UPLOAD_COUNT = 20;
console.log('SERVER_PORT from env:', process.env.SERVER_PORT); console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
@@ -897,27 +900,27 @@ const uploadFilesHandler = async (req, res) => {
} }
}), }),
limits: { limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit fileSize: MAX_FILE_UPLOAD_SIZE_BYTES,
files: 20 // Max 20 files at once files: MAX_FILE_UPLOAD_COUNT
} }
}); });
// Use multer middleware // Use multer middleware
uploadMiddleware.array('files', 20)(req, res, async (err) => { uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => {
if (err) { if (err) {
console.error('Multer error:', err); console.error('Multer error:', err);
if (err.code === 'LIMIT_FILE_SIZE') { if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' }); return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_UPLOAD_SIZE_MB}MB.` });
} }
if (err.code === 'LIMIT_FILE_COUNT') { if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' }); return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` });
} }
return res.status(500).json({ error: err.message }); return res.status(500).json({ error: err.message });
} }
try { try {
const { projectId } = req.params; const { projectId } = req.params;
const { targetPath, relativePaths } = req.body; const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body;
// Parse relative paths if provided (for folder uploads) // Parse relative paths if provided (for folder uploads)
let filePaths = []; let filePaths = [];
@@ -941,6 +944,11 @@ const uploadFilesHandler = async (req, res) => {
return res.status(400).json({ error: 'No files provided' }); return res.status(400).json({ error: 'No files provided' });
} }
const parsedRequestedFileCount = Number.parseInt(requestedFileCountRaw, 10);
const requestedFileCount = Number.isFinite(parsedRequestedFileCount) && parsedRequestedFileCount > 0
? parsedRequestedFileCount
: req.files.length;
// Resolve the project directory through the DB using the new projectId. // Resolve the project directory through the DB using the new projectId.
const projectRoot = await projectsDb.getProjectPathById(projectId); const projectRoot = await projectsDb.getProjectPathById(projectId);
if (!projectRoot) { if (!projectRoot) {
@@ -1019,8 +1027,10 @@ const uploadFilesHandler = async (req, res) => {
res.json({ res.json({
success: true, success: true,
files: uploadedFiles, files: uploadedFiles,
uploadedCount: uploadedFiles.length,
requestedFileCount,
targetPath: resolvedTargetDir, targetPath: resolvedTargetDir,
message: `Uploaded ${uploadedFiles.length} file(s) successfully` message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully`
}); });
} catch (error) { } catch (error) {
console.error('Error uploading files:', error); console.error('Error uploading files:', error);

View File

@@ -1,8 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
import { spawn } from 'child_process';
import { spawn } from 'cross-spawn';
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins'); const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json'); const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');

View File

@@ -6,6 +6,14 @@ export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed']; export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
export const MAX_FILE_UPLOAD_SIZE_MB = 200;
export const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
export const MAX_FILE_UPLOAD_SIZE_LABEL = `${MAX_FILE_UPLOAD_SIZE_MB}MB`;
export const MAX_FILE_UPLOAD_COUNT = 20;
export const IMAGE_FILE_EXTENSIONS = new Set([ export const IMAGE_FILE_EXTENSIONS = new Set([
'png', 'png',
'jpg', 'jpg',

View File

@@ -1,6 +1,13 @@
import { useCallback, useState, useRef } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { IS_PLATFORM } from '../../../constants/config';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
import { api } from '../../../utils/api'; import {
MAX_FILE_UPLOAD_COUNT,
MAX_FILE_UPLOAD_SIZE_BYTES,
MAX_FILE_UPLOAD_SIZE_LABEL,
} from '../constants/constants';
type UseFileTreeUploadOptions = { type UseFileTreeUploadOptions = {
selectedProject: Project | null; selectedProject: Project | null;
@@ -8,6 +15,141 @@ type UseFileTreeUploadOptions = {
showToast: (message: string, type: 'success' | 'error') => void; showToast: (message: string, type: 'success' | 'error') => void;
}; };
export type FileTreeUploadProgressState = {
status: 'uploading' | 'complete' | 'error';
progress: number;
fileCount: number;
uploadedCount?: number;
fileName?: string;
targetPath?: string;
error?: string;
};
type UploadResponse = {
error?: string;
message?: string;
files?: unknown[];
uploadedCount?: number;
requestedFileCount?: number;
};
const COMPLETE_PROGRESS_CLEAR_DELAY_MS = 1400;
const ERROR_PROGRESS_CLEAR_DELAY_MS = 3200;
const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files');
const getRelativePath = (file: File) => {
const fileWithRelativePath = file as File & { webkitRelativePath?: string };
return fileWithRelativePath.webkitRelativePath || file.name;
};
const getFileDisplayName = (file: File) => {
const relativePath = getRelativePath(file);
return relativePath.split(/[\\/]/).pop() || file.name;
};
const validateFilesForUpload = (files: File[]): string | null => {
if (files.length > MAX_FILE_UPLOAD_COUNT) {
return `You can upload up to ${MAX_FILE_UPLOAD_COUNT} files at once.`;
}
const oversizedFile = files.find((file) => file.size > MAX_FILE_UPLOAD_SIZE_BYTES);
if (oversizedFile) {
return `${getFileDisplayName(oversizedFile)} is larger than ${MAX_FILE_UPLOAD_SIZE_LABEL}.`;
}
return null;
};
const parseUploadResponse = (xhr: XMLHttpRequest): UploadResponse => {
if (!xhr.responseText) {
return {};
}
try {
return JSON.parse(xhr.responseText) as UploadResponse;
} catch {
return {};
}
};
const formatUploadSuccessMessage = (uploadedCount: number, requestedFileCount: number) => {
if (uploadedCount !== requestedFileCount) {
return `Uploaded ${uploadedCount} of ${requestedFileCount} ${pluralizeFiles(requestedFileCount)}`;
}
return `Uploaded ${uploadedCount} ${pluralizeFiles(uploadedCount)} successfully`;
};
const buildUploadFormData = (files: File[], targetPath: string) => {
const formData = new FormData();
const relativePaths: string[] = [];
formData.append('targetPath', targetPath);
formData.append('requestedFileCount', String(files.length));
files.forEach((file) => {
const relativePath = getRelativePath(file);
const cleanFile = new File([file], relativePath.split(/[\\/]/).pop() || file.name, {
type: file.type,
lastModified: file.lastModified,
});
formData.append('files', cleanFile);
relativePaths.push(relativePath);
});
formData.append('relativePaths', JSON.stringify(relativePaths));
return formData;
};
const uploadFormDataWithProgress = (
projectId: string,
formData: FormData,
onProgress: (progress: number) => void,
) =>
new Promise<UploadResponse>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/projects/${encodeURIComponent(projectId)}/files/upload`);
const token = localStorage.getItem('auth-token');
if (!IS_PLATFORM && token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) {
return;
}
// Keep 100% for the server response so the UI can distinguish transfer
// completion from the final write/refresh step.
onProgress(Math.min(99, Math.round((event.loaded / event.total) * 100)));
};
xhr.onload = () => {
const refreshedToken = xhr.getResponseHeader('X-Refreshed-Token');
if (refreshedToken) {
localStorage.setItem('auth-token', refreshedToken);
}
const payload = parseUploadResponse(xhr);
if (xhr.status >= 200 && xhr.status < 300) {
resolve(payload);
return;
}
reject(new Error(payload.error || payload.message || `Upload failed with status ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('Upload failed. Check your connection and try again.'));
xhr.onabort = () => reject(new Error('Upload canceled.'));
xhr.send(formData);
});
// Helper function to read all files from a directory entry recursively // Helper function to read all files from a directory entry recursively
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => { const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
const files: File[] = []; const files: File[] = [];
@@ -57,56 +199,26 @@ const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry,
return files; return files;
}; };
export const useFileTreeUpload = ({ const collectDroppedFiles = async (dataTransfer: DataTransfer) => {
selectedProject,
onRefresh,
showToast,
}: UseFileTreeUploadOptions) => {
const [isDragOver, setIsDragOver] = useState(false);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [operationLoading, setOperationLoading] = useState(false);
const treeRef = useRef<HTMLDivElement>(null);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set isDragOver to false if we're leaving the entire tree
if (treeRef.current && !treeRef.current.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
setDropTarget(null);
}
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const targetPath = dropTarget || '';
setOperationLoading(true);
try {
const files: File[] = []; const files: File[] = [];
// Use DataTransferItemList for folder support // Use DataTransferItemList for folder support
const items = e.dataTransfer.items; const { items } = dataTransfer;
if (items) { if (items) {
for (const item of Array.from(items)) { for (const item of Array.from(items)) {
if (item.kind === 'file') { if (item.kind !== 'file') {
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null; continue;
}
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (!entry) {
const file = item.getAsFile();
if (file) {
files.push(file);
}
continue;
}
if (entry) {
if (entry.isFile) { if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => { const file = await new Promise<File>((resolve, reject) => {
(entry as FileSystemFileEntry).file(resolve, reject); (entry as FileSystemFileEntry).file(resolve, reject);
@@ -118,73 +230,207 @@ export const useFileTreeUpload = ({
files.push(...dirFiles); files.push(...dirFiles);
} }
} }
} return files;
}
} else {
// Fallback for browsers that don't support webkitGetAsEntry
const fileList = e.dataTransfer.files;
for (const file of Array.from(fileList)) {
files.push(file);
}
} }
// Fallback for browsers that don't support webkitGetAsEntry
for (const file of Array.from(dataTransfer.files)) {
files.push(file);
}
return files;
};
export const useFileTreeUpload = ({
selectedProject,
onRefresh,
showToast,
}: UseFileTreeUploadOptions) => {
const [isDragOver, setIsDragOver] = useState(false);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [operationLoading, setOperationLoading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<FileTreeUploadProgressState | null>(null);
const treeRef = useRef<HTMLDivElement>(null);
const clearProgressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearProgressTimer = useCallback(() => {
if (clearProgressTimerRef.current) {
clearTimeout(clearProgressTimerRef.current);
clearProgressTimerRef.current = null;
}
}, []);
const scheduleProgressClear = useCallback(
(delay: number) => {
clearProgressTimer();
clearProgressTimerRef.current = setTimeout(() => {
setUploadProgress(null);
clearProgressTimerRef.current = null;
}, delay);
},
[clearProgressTimer],
);
useEffect(() => clearProgressTimer, [clearProgressTimer]);
const setUploadError = useCallback(
(message: string, fileCount: number, targetPath = '', fileName?: string, progress = 0) => {
setUploadProgress({
status: 'error',
progress,
fileCount,
fileName,
targetPath,
error: message,
});
scheduleProgressClear(ERROR_PROGRESS_CLEAR_DELAY_MS);
},
[scheduleProgressClear],
);
const uploadFiles = useCallback(
async (files: File[], targetPath = '') => {
if (files.length === 0) { if (files.length === 0) {
setOperationLoading(false);
setDropTarget(null); setDropTarget(null);
return; return;
} }
const formData = new FormData(); const fileName = files.length === 1 ? getFileDisplayName(files[0]) : undefined;
formData.append('targetPath', targetPath);
// Store relative paths separately since FormData strips path info from File.name if (!selectedProject) {
const relativePaths: string[] = []; const message = 'Select a project before uploading files.';
files.forEach((file) => { showToast(message, 'error');
// Create a new file with just the filename (without path) for FormData setUploadError(message, files.length, targetPath, fileName);
// but store the relative path separately return;
const cleanFile = new File([file], file.name.split('/').pop()!, {
type: file.type,
lastModified: file.lastModified
});
formData.append('files', cleanFile);
relativePaths.push(file.name); // Keep the full relative path
});
// Send relative paths as a JSON array
formData.append('relativePaths', JSON.stringify(relativePaths));
const response = await api.post(
// File upload endpoint is keyed by DB projectId post-migration.
`/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`,
formData
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Upload failed');
} }
showToast( const validationError = validateFilesForUpload(files);
`Uploaded ${files.length} file(s)`, if (validationError) {
'success' showToast(validationError, 'error');
setUploadError(validationError, files.length, targetPath, fileName);
return;
}
clearProgressTimer();
setOperationLoading(true);
setUploadProgress({
status: 'uploading',
progress: 0,
fileCount: files.length,
fileName,
targetPath,
});
let latestProgress = 0;
try {
const response = await uploadFormDataWithProgress(
selectedProject.projectId,
buildUploadFormData(files, targetPath),
(progress) => {
latestProgress = progress;
setUploadProgress((current) =>
current && current.status === 'uploading'
? { ...current, progress }
: current,
); );
},
);
const uploadedCount =
typeof response.uploadedCount === 'number' ? response.uploadedCount : response.files?.length ?? files.length;
const requestedFileCount =
typeof response.requestedFileCount === 'number' ? response.requestedFileCount : files.length;
setUploadProgress({
status: 'complete',
progress: 100,
fileCount: requestedFileCount,
uploadedCount,
fileName,
targetPath,
});
showToast(formatUploadSuccessMessage(uploadedCount, requestedFileCount), 'success');
scheduleProgressClear(COMPLETE_PROGRESS_CLEAR_DELAY_MS);
onRefresh(); onRefresh();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Upload failed';
console.error('Upload error:', err); console.error('Upload error:', err);
showToast(err instanceof Error ? err.message : 'Upload failed', 'error'); showToast(message, 'error');
setUploadError(message, files.length, targetPath, fileName, latestProgress);
} finally { } finally {
setOperationLoading(false); setOperationLoading(false);
setDropTarget(null); setDropTarget(null);
} }
}, [dropTarget, selectedProject, onRefresh, showToast]); },
[
clearProgressTimer,
onRefresh,
scheduleProgressClear,
selectedProject,
setUploadError,
showToast,
],
);
const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => { const handleFileSelect = useCallback(
async (fileList: FileList | File[]) => {
await uploadFiles(Array.from(fileList), '');
},
[uploadFiles],
);
const handleDragEnter = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragLeave = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set isDragOver to false if we're leaving the entire tree
if (treeRef.current && !treeRef.current.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
setDropTarget(null);
}
}, []);
const handleDrop = useCallback(
async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const targetPath = dropTarget || '';
try {
const files = await collectDroppedFiles(e.dataTransfer);
await uploadFiles(files, targetPath);
} catch (err) {
const message = err instanceof Error ? err.message : 'Could not read dropped files';
console.error('Upload error:', err);
showToast(message, 'error');
setUploadError(message, 0, targetPath);
setDropTarget(null);
}
},
[dropTarget, setUploadError, showToast, uploadFiles],
);
const handleItemDragOver = useCallback((e: DragEvent, itemPath: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setDropTarget(itemPath); setDropTarget(itemPath);
}, []); }, []);
const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => { const handleItemDrop = useCallback((e: DragEvent, itemPath: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setDropTarget(itemPath); setDropTarget(itemPath);
@@ -194,7 +440,9 @@ export const useFileTreeUpload = ({
isDragOver, isDragOver,
dropTarget, dropTarget,
operationLoading, operationLoading,
uploadProgress,
treeRef, treeRef,
handleFileSelect,
handleDragEnter, handleDragEnter,
handleDragOver, handleDragOver,
handleDragLeave, handleDragLeave,

View File

@@ -1,6 +1,7 @@
import { useCallback, useState, useEffect, useRef } from 'react'; import { useCallback, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react'; import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
import { cn } from '../../../lib/utils'; import { cn } from '../../../lib/utils';
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons'; import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
import { useExpandedDirectories } from '../hooks/useExpandedDirectories'; import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
@@ -13,10 +14,12 @@ import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils'; import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
import { Project } from '../../../types/app'; import { Project } from '../../../types/app';
import { ScrollArea, Input } from '../../../shared/view/ui'; import { ScrollArea, Input } from '../../../shared/view/ui';
import FileTreeBody from './FileTreeBody'; import FileTreeBody from './FileTreeBody';
import FileTreeDetailedColumns from './FileTreeDetailedColumns'; import FileTreeDetailedColumns from './FileTreeDetailedColumns';
import FileTreeHeader from './FileTreeHeader'; import FileTreeHeader from './FileTreeHeader';
import FileTreeLoadingState from './FileTreeLoadingState'; import FileTreeLoadingState from './FileTreeLoadingState';
import FileTreeUploadProgress from './FileTreeUploadProgress';
import ImageViewer from './ImageViewer'; import ImageViewer from './ImageViewer';
@@ -66,6 +69,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
onRefresh: refreshFiles, onRefresh: refreshFiles,
showToast, showToast,
}); });
const operationLoading = operations.operationLoading || upload.operationLoading;
// Focus input when creating new item // Focus input when creating new item
useEffect(() => { useEffect(() => {
@@ -146,14 +150,19 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
onViewModeChange={changeViewMode} onViewModeChange={changeViewMode}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery} onSearchQueryChange={setSearchQuery}
onUploadFiles={upload.handleFileSelect}
onNewFile={() => operations.handleStartCreate('', 'file')} onNewFile={() => operations.handleStartCreate('', 'file')}
onNewFolder={() => operations.handleStartCreate('', 'directory')} onNewFolder={() => operations.handleStartCreate('', 'directory')}
onRefresh={refreshFiles} onRefresh={refreshFiles}
onCollapseAll={collapseAll} onCollapseAll={collapseAll}
loading={loading} 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 />} {viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
<ScrollArea className="flex-1 px-2 py-1"> <ScrollArea className="flex-1 px-2 py-1">
@@ -184,7 +193,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
}, 100); }, 100);
}} }}
className="h-6 flex-1 text-sm" className="h-6 flex-1 text-sm"
disabled={operations.operationLoading} disabled={operationLoading}
/> />
</div> </div>
)} )}
@@ -213,7 +222,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
handleConfirmRename={operations.handleConfirmRename} handleConfirmRename={operations.handleConfirmRename}
handleCancelRename={operations.handleCancelRename} handleCancelRename={operations.handleCancelRename}
renameInputRef={renameInputRef} renameInputRef={renameInputRef}
operationLoading={operations.operationLoading} operationLoading={operationLoading}
/> />
</ScrollArea> </ScrollArea>
@@ -251,17 +260,17 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
onClick={operations.handleCancelDelete} onClick={operations.handleCancelDelete}
disabled={operations.operationLoading} disabled={operationLoading}
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent" className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
> >
{t('common.cancel', 'Cancel')} {t('common.cancel', 'Cancel')}
</button> </button>
<button <button
onClick={operations.handleConfirmDelete} 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" 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')} {t('fileTree.delete.confirm', 'Delete')}
</button> </button>
</div> </div>

View File

@@ -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 { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../shared/view/ui'; import { Button, Input } from '../../../shared/view/ui';
import { cn } from '../../../lib/utils'; import { cn } from '../../../lib/utils';
import { MAX_FILE_UPLOAD_SIZE_LABEL } from '../constants/constants';
import type { FileTreeViewMode } from '../types/types'; import type { FileTreeViewMode } from '../types/types';
type FileTreeHeaderProps = { type FileTreeHeaderProps = {
@@ -12,11 +16,14 @@ type FileTreeHeaderProps = {
// Toolbar actions // Toolbar actions
onNewFile?: () => void; onNewFile?: () => void;
onNewFolder?: () => void; onNewFolder?: () => void;
onUploadFiles?: (files: FileList) => void;
onRefresh?: () => void; onRefresh?: () => void;
onCollapseAll?: () => void; onCollapseAll?: () => void;
// Loading state // Loading state
loading?: boolean; loading?: boolean;
operationLoading?: boolean; operationLoading?: boolean;
isUploading?: boolean;
uploadProgress?: number | null;
}; };
export default function FileTreeHeader({ export default function FileTreeHeader({
@@ -26,12 +33,24 @@ export default function FileTreeHeader({
onSearchQueryChange, onSearchQueryChange,
onNewFile, onNewFile,
onNewFolder, onNewFolder,
onUploadFiles,
onRefresh, onRefresh,
onCollapseAll, onCollapseAll,
loading, loading,
operationLoading, operationLoading,
isUploading,
uploadProgress,
}: FileTreeHeaderProps) { }: FileTreeHeaderProps) {
const { t } = useTranslation(); 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 ( return (
<div className="space-y-2 border-b border-border px-3 pb-2 pt-3"> <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> <h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
{/* Action buttons */} {/* 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 && ( {onNewFile && (
<Button <Button
variant="ghost" variant="ghost"

View 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>
);
}

View File

@@ -26,7 +26,6 @@ const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-start
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal'; const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron'; const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch'; const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
const PRISM_CLOUDCLI_PLUGIN_URL = 'https://github.com/jakeefr/cloudcli-plugin-prism';
type PluginRecommendation = { type PluginRecommendation = {
id: string; id: string;
@@ -73,14 +72,6 @@ const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
icon: Clock, icon: Clock,
source: 'unofficial', source: 'unofficial',
}, },
{
id: 'prism-cloudcli',
translationKey: 'prismCloudCLI',
repoUrl: PRISM_CLOUDCLI_PLUGIN_URL,
installedNames: ['prism-cloudcli'],
icon: Activity,
source: 'unofficial'
}
]; ];
function repoSlug(repoUrl: string) { function repoSlug(repoUrl: string) {

View File

@@ -502,12 +502,6 @@
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.", "description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
"install": "Install" "install": "Install"
}, },
"prismCloudCLI": {
"name": "PRISM CloudCLI",
"badge": "unofficial",
"description": "Session intelligence for Claude Code, inside CloudCLI. See why your sessions are burning tokens without leaving the browser.",
"install": "Install"
},
"morePlugins": "More", "morePlugins": "More",
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",