Compare commits

..

3 Commits

Author SHA1 Message Date
Haileyesus
3cd89956ba fix: update naming convention 2026-06-08 16:10:24 +03:00
Haileyesus
01dbe2a8bf chore: add prism plugin 2026-06-08 15:55:40 +03:00
Noah
f4a1614a0a fix(sandbox): prevent server SIGHUP on sbx exec exit (#792)
Replace bare background operator with nohup+disown so the cloudcli
server process survives after the sbx exec session terminates.
Also redirects stdout/stderr to /tmp/cloudcli-ui.log for debugging
via `cloudcli sandbox logs`.

Fixes #791

Co-authored-by: NoahHahm <noah@naverz-corp.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-08 15:28:35 +03:00
11 changed files with 127 additions and 539 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 200m; client_max_body_size 100m;
# 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', 'cloudcli start --port 3001 &']); sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
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', 'cloudcli start --port 3001 &']); sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
// 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,9 +84,6 @@ 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);
@@ -900,27 +897,27 @@ const uploadFilesHandler = async (req, res) => {
} }
}), }),
limits: { limits: {
fileSize: MAX_FILE_UPLOAD_SIZE_BYTES, fileSize: 50 * 1024 * 1024, // 50MB limit
files: MAX_FILE_UPLOAD_COUNT files: 20 // Max 20 files at once
} }
}); });
// Use multer middleware // Use multer middleware
uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => { uploadMiddleware.array('files', 20)(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 ${MAX_FILE_UPLOAD_SIZE_MB}MB.` }); return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
} }
if (err.code === 'LIMIT_FILE_COUNT') { if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` }); return res.status(400).json({ error: 'Too many files. Maximum is 20 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, requestedFileCount: requestedFileCountRaw } = req.body; const { targetPath, relativePaths } = req.body;
// Parse relative paths if provided (for folder uploads) // Parse relative paths if provided (for folder uploads)
let filePaths = []; let filePaths = [];
@@ -944,11 +941,6 @@ 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) {
@@ -1027,10 +1019,8 @@ 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} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully` message: `Uploaded ${uploadedFiles.length} file(s) successfully`
}); });
} catch (error) { } catch (error) {
console.error('Error uploading files:', error); console.error('Error uploading files:', error);

View File

@@ -1,7 +1,8 @@
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,14 +6,6 @@ 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,13 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useState, useRef } 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 { import { api } from '../../../utils/api';
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;
@@ -15,141 +8,6 @@ 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[] = [];
@@ -199,26 +57,56 @@ const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry,
return files; return files;
}; };
const collectDroppedFiles = async (dataTransfer: DataTransfer) => { 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 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 } = dataTransfer; const items = e.dataTransfer.items;
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') {
continue;
}
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null; 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);
@@ -230,207 +118,73 @@ const collectDroppedFiles = async (dataTransfer: DataTransfer) => {
files.push(...dirFiles); files.push(...dirFiles);
} }
} }
return files;
} }
}
} else {
// Fallback for browsers that don't support webkitGetAsEntry // Fallback for browsers that don't support webkitGetAsEntry
for (const file of Array.from(dataTransfer.files)) { const fileList = e.dataTransfer.files;
for (const file of Array.from(fileList)) {
files.push(file); 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 fileName = files.length === 1 ? getFileDisplayName(files[0]) : undefined; const formData = new FormData();
formData.append('targetPath', targetPath);
if (!selectedProject) { // Store relative paths separately since FormData strips path info from File.name
const message = 'Select a project before uploading files.'; const relativePaths: string[] = [];
showToast(message, 'error'); files.forEach((file) => {
setUploadError(message, files.length, targetPath, fileName); // Create a new file with just the filename (without path) for FormData
return; // but store the relative path separately
} const cleanFile = new File([file], file.name.split('/').pop()!, {
type: file.type,
const validationError = validateFilesForUpload(files); lastModified: file.lastModified
if (validationError) { });
showToast(validationError, 'error'); formData.append('files', cleanFile);
setUploadError(validationError, files.length, targetPath, fileName); relativePaths.push(file.name); // Keep the full relative path
return;
}
clearProgressTimer();
setOperationLoading(true);
setUploadProgress({
status: 'uploading',
progress: 0,
fileCount: files.length,
fileName,
targetPath,
}); });
let latestProgress = 0; // Send relative paths as a JSON array
formData.append('relativePaths', JSON.stringify(relativePaths));
try { const response = await api.post(
const response = await uploadFormDataWithProgress( // File upload endpoint is keyed by DB projectId post-migration.
selectedProject.projectId, `/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`,
buildUploadFormData(files, targetPath), formData
(progress) => {
latestProgress = progress;
setUploadProgress((current) =>
current && current.status === 'uploading'
? { ...current, progress }
: current,
);
},
); );
const uploadedCount = if (!response.ok) {
typeof response.uploadedCount === 'number' ? response.uploadedCount : response.files?.length ?? files.length; const data = await response.json();
const requestedFileCount = throw new Error(data.error || 'Upload failed');
typeof response.requestedFileCount === 'number' ? response.requestedFileCount : files.length; }
setUploadProgress({ showToast(
status: 'complete', `Uploaded ${files.length} file(s)`,
progress: 100, 'success'
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(message, 'error'); showToast(err instanceof Error ? err.message : 'Upload failed', '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 handleFileSelect = useCallback( const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => {
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: DragEvent, itemPath: string) => { const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setDropTarget(itemPath); setDropTarget(itemPath);
@@ -440,9 +194,7 @@ export const useFileTreeUpload = ({
isDragOver, isDragOver,
dropTarget, dropTarget,
operationLoading, operationLoading,
uploadProgress,
treeRef, treeRef,
handleFileSelect,
handleDragEnter, handleDragEnter,
handleDragOver, handleDragOver,
handleDragLeave, handleDragLeave,

View File

@@ -1,7 +1,6 @@
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';
@@ -14,12 +13,10 @@ 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';
@@ -69,7 +66,6 @@ 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(() => {
@@ -150,19 +146,14 @@ 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={operationLoading} operationLoading={operations.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">
@@ -193,7 +184,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={operationLoading} disabled={operations.operationLoading}
/> />
</div> </div>
)} )}
@@ -222,7 +213,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={operationLoading} operationLoading={operations.operationLoading}
/> />
</ScrollArea> </ScrollArea>
@@ -260,17 +251,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={operationLoading} disabled={operations.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={operationLoading} disabled={operations.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"
> >
{operationLoading && <Loader2 className="h-4 w-4 animate-spin" />} {operations.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,11 +1,7 @@
import { useRef } from 'react'; import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-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 = {
@@ -16,14 +12,11 @@ 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({
@@ -33,24 +26,12 @@ 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">
@@ -59,50 +40,6 @@ 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

@@ -1,90 +0,0 @@
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,6 +26,7 @@ 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;
@@ -72,6 +73,14 @@ 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,6 +502,12 @@
"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",