From d390fb1dbadfbc90bc9e7834de9a6901faab2a45 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Tue, 28 Apr 2026 21:51:06 +0700 Subject: [PATCH] refactor: integrate FileManager into flow files page - replace the inline file tree implementation in flow-files with the new FileManager component - compose row actions via factory helpers (downloadAction / copyPathAction / deleteAction) instead of bespoke menus - delegate search highlighting, expand/collapse, multi-select and bulk delete to the shared component; the page now only owns upload, drag-and-drop, pull-from-container and per-file delete confirmation - switch to the typed axios helpers (api / unwrapApiResponse / getApiErrorMessage) for upload, pull and delete calls Made-with: Cursor --- .../src/features/flows/files/flow-files.tsx | 675 ++++++++---------- 1 file changed, 294 insertions(+), 381 deletions(-) diff --git a/frontend/src/features/flows/files/flow-files.tsx b/frontend/src/features/flows/files/flow-files.tsx index f44bccf..90cdfb4 100644 --- a/frontend/src/features/flows/files/flow-files.tsx +++ b/frontend/src/features/flows/files/flow-files.tsx @@ -1,26 +1,22 @@ import { zodResolver } from '@hookform/resolvers/zod'; import debounce from 'lodash/debounce'; -import { - ArrowDownToLine, - Copy, - Download, - File, - Folder, - FolderUp, - HardDrive, - Info, - Loader2, - Search, - Trash2, - X, -} from 'lucide-react'; +import { ArrowDownToLine, FolderUp, HardDrive, Info, Loader2, Search, X } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; +import { + copyPathAction, + deleteAction, + downloadAction, + FileManager, + type FileManagerAction, + type FileManagerRootGroup, + type FileNode, +} from '@/components/file-manager'; import ConfirmationDialog from '@/components/shared/confirmation-dialog'; -import { Button, buttonVariants } from '@/components/ui/button'; +import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'; import { Form, FormControl, FormField } from '@/components/ui/form'; @@ -37,32 +33,11 @@ import { useFlowFilesQuery, useFlowFileUpdatedSubscription, } from '@/graphql/types'; -import { axios } from '@/lib/axios'; +import { api, getApiErrorMessage, unwrapApiResponse } from '@/lib/axios'; import { copyToClipboard } from '@/lib/report'; -import { cn } from '@/lib/utils'; -import { formatDate } from '@/lib/utils/format'; import { baseUrl } from '@/models/api'; import { useFlow } from '@/providers/flow-provider'; -// ── types ───────────────────────────────────────────────────────────────────── - -interface ApiErrorData { - code?: string; - msg?: string; -} - -interface ApiResponse { - data?: T; - status: string; -} - -interface AxiosLikeError { - message?: string; - response?: { data?: ApiErrorData; status?: number }; - statusCode?: number; -} - -type FileSource = 'container' | 'unknown' | 'uploads'; type FlowFile = FlowFileFragmentFragment; interface FlowFilesResponse { @@ -70,207 +45,45 @@ interface FlowFilesResponse { total: number; } -interface FlowFileTreeItem { - depth: number; - file: FlowFile; -} - const searchFormSchema = z.object({ search: z.string(), }); -const unwrapFlowFilesResponse = (response: ApiResponse) => { - if (response.status !== 'success' || !response.data) { - throw new Error('Unexpected response from server'); - } - - return response.data; -}; - -const getErrorMessage = (error: unknown, fallback: string): string => { - const err = error as AxiosLikeError; - - if (err.statusCode === 409 || err.response?.status === 409) { - return err.response?.data?.msg ?? 'Entry already exists — enable "Overwrite" to replace it'; - } - - if (err.statusCode === 400 || err.response?.status === 400) { - return err.response?.data?.msg ?? err.message ?? fallback; - } - - return err.message ?? fallback; -}; - -const fileSource = (filePath: string): FileSource => { - if (filePath.startsWith('container/') || filePath === 'container') { - return 'container'; - } - - if (filePath.startsWith('uploads/') || filePath === 'uploads') { - return 'uploads'; - } - - return 'unknown'; -}; - -const formatFileSize = (size: number): string => { - if (size < 1024) { - return `${size} B`; - } - - const units = ['KB', 'MB', 'GB', 'TB']; - let unitIndex = -1; - let value = size; - - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex += 1; - } - - return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`; -}; - -const buildDownloadHref = (flowId: null | string, file: FlowFile) => +const buildDownloadHref = (flowId: null | string, file: FileNode) => `${baseUrl}/flows/${flowId}/files/download?path=${encodeURIComponent(file.path)}`; -const filePathParts = (filePath: string) => filePath.split('/').filter(Boolean); +const toFileNode = (file: FlowFile): FileNode => ({ + id: file.id, + isDir: file.isDir, + modifiedAt: file.modifiedAt, + name: file.name, + path: file.path, + size: file.size, +}); -const flowFileDepth = (filePath: string): number => { - const parts = filePathParts(filePath); - - return Math.max(parts.length - 2, 0); -}; - -const compareFlowFileTreePath = (a: FlowFile, b: FlowFile): number => { - const aParts = filePathParts(a.path).slice(1); - const bParts = filePathParts(b.path).slice(1); - const length = Math.min(aParts.length, bParts.length); - - for (let i = 0; i < length; i += 1) { - const partCompare = (aParts[i] ?? '').localeCompare(bParts[i] ?? ''); - - if (partCompare !== 0) { - return partCompare; - } - } - - return aParts.length - bParts.length; -}; - -const buildFlowFileTree = (files: FlowFile[]): FlowFileTreeItem[] => - [...files].sort(compareFlowFileTreePath).map((file) => ({ - depth: flowFileDepth(file.path), - file, - })); - -// ── section header ───────────────────────────────────────────────────────────── - -const SectionHeader = ({ children, icon }: { children: React.ReactNode; icon: React.ReactNode }) => ( -
-
- {icon} - {children} -
-
-
-); - -// ── file item ────────────────────────────────────────────────────────────────── - -interface FlowFileItemProps { - depth?: number; - file: FlowFile; - flowId: null | string; - onCopyPath: (path: string) => void; - onDelete: (file: FlowFile) => void; -} - -const FlowFileItem = ({ depth = 0, file, flowId, onCopyPath, onDelete }: FlowFileItemProps) => ( -
-
-
-
- {file.isDir ? ( - - ) : ( - - )} - {file.name} -
- - - - - - - - {file.isDir ? 'Download as ZIP' : 'Download'} - - - - - - - {file.isDir ? 'Delete directory' : 'Delete file'} - -
-
- -
- {!file.isDir && ( - <> - {formatFileSize(file.size)} - · - - )} - - - onCopyPath(file.path)} - /> - - Copy cache path - - · - {formatDate(new Date(file.modifiedAt))} -
-
-); +const ROOT_GROUPS: FileManagerRootGroup[] = [ + { defaultOpen: true, icon: FolderUp, id: 'uploads', label: 'Uploads', pathPrefix: 'uploads' }, + { defaultOpen: true, icon: HardDrive, id: 'container', label: 'Container', pathPrefix: 'container' }, +]; // ── pull dialog ──────────────────────────────────────────────────────────────── interface PullDialogProps { flowId: null | string; - isPulling: boolean; onClose: () => void; onSuccess: () => void; open: boolean; } -const PullDialog = ({ flowId, isPulling: externalIsPulling, onClose, onSuccess, open }: PullDialogProps) => { +const PullDialog = ({ flowId, onClose, onSuccess, open }: PullDialogProps) => { const [containerPath, setContainerPath] = useState(''); - const [force, setForce] = useState(false); + const [shouldOverwrite, setShouldOverwrite] = useState(false); const [isPulling, setIsPulling] = useState(false); useEffect(() => { - if (!open) { + if (open) { setContainerPath(''); - setForce(false); + setShouldOverwrite(false); } }, [open]); @@ -282,29 +95,36 @@ const PullDialog = ({ flowId, isPulling: externalIsPulling, onClose, onSuccess, setIsPulling(true); try { - await axios.post>(`/flows/${flowId}/files/pull`, { - force, - path: containerPath.trim(), - }); + await api.post( + `/flows/${flowId}/files/pull`, + { + force: shouldOverwrite, + path: containerPath.trim(), + }, + // Copying a directory out of the container can take longer than the default 30s + // (large logs, deep trees) — disable the per-call timeout entirely. + { timeout: 0 }, + ); toast.success('Pulled from container', { description: `Saved to local cache under container/`, }); onSuccess(); onClose(); } catch (error) { - const description = getErrorMessage(error, 'Failed to pull from container'); + const description = getApiErrorMessage(error, 'Failed to pull from container', { + 409: 'Entry already exists — enable "Overwrite" to replace it', + }); + toast.error('Pull failed', { description }); } finally { setIsPulling(false); } - }, [flowId, containerPath, force, onSuccess, onClose]); - - const isSubmitting = isPulling || externalIsPulling; + }, [flowId, containerPath, shouldOverwrite, onSuccess, onClose]); return ( { - if (!v && !isSubmitting) { + onOpenChange={(isOpen) => { + if (!isOpen && !isPulling) { onClose(); } }} @@ -328,11 +148,11 @@ const PullDialog = ({ flowId, isPulling: externalIsPulling, onClose, onSuccess, setContainerPath(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && containerPath.trim() && !isSubmitting) { + onChange={(event) => setContainerPath(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && containerPath.trim() && !isPulling) { void handlePull(); } }} @@ -343,10 +163,10 @@ const PullDialog = ({ flowId, isPulling: externalIsPulling, onClose, onSuccess,
@@ -383,8 +203,10 @@ const PullDialog = ({ flowId, isPulling: externalIsPulling, onClose, onSuccess, const FlowFiles = () => { const { flowId, flowStatus } = useFlow(); const inputRef = useRef(null); + const dragCounterRef = useRef(0); const [isUploading, setIsUploading] = useState(false); - const [fileToDelete, setFileToDelete] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [fileToDelete, setFileToDelete] = useState(null); const [showPullDialog, setShowPullDialog] = useState(false); const [debouncedSearch, setDebouncedSearch] = useState(''); const flowFilesVariables = useMemo(() => ({ flowId: flowId ?? '' }), [flowId]); @@ -421,12 +243,6 @@ const FlowFiles = () => { }; }, [searchValue, debouncedUpdateSearch]); - useEffect(() => { - return () => { - debouncedUpdateSearch.cancel(); - }; - }, [debouncedUpdateSearch]); - useEffect(() => { form.reset({ search: '' }); setDebouncedSearch(''); @@ -435,68 +251,82 @@ const FlowFiles = () => { const isContainerRunning = flowStatus === StatusType.Running || flowStatus === StatusType.Waiting; + // Pause subscriptions until the initial query has loaded so that the + // `flowFiles` cache field exists before subscription-driven updates arrive. + const isSubscriptionPaused = !flowId || isLoading; + useFlowFileAddedSubscription({ - skip: !flowId, + skip: isSubscriptionPaused, variables: flowFilesVariables, }); useFlowFileUpdatedSubscription({ - skip: !flowId, + skip: isSubscriptionPaused, variables: flowFilesVariables, }); useFlowFileDeletedSubscription({ - skip: !flowId, + skip: isSubscriptionPaused, variables: flowFilesVariables, }); useEffect(() => { if (flowFilesError) { - toast.error('Failed to load files', { description: flowFilesError.message }); + toast.error('Failed to load files', { + description: flowFilesError.message, + id: 'flow-files-error', + }); } }, [flowFilesError]); // ── upload ───────────────────────────────────────────────────────────────── - const handleCopyPath = useCallback(async (filePath: string) => { - const success = await copyToClipboard(filePath); - - if (success) { - toast.success('Path copied to clipboard'); - } else { - toast.error('Failed to copy path'); - } + const handleCopyPath = useCallback((file: FileNode) => { + void copyToClipboard(file.path).then((wasCopied) => { + if (wasCopied) { + toast.success('Path copied to clipboard'); + } else { + toast.error('Failed to copy path'); + } + }); }, []); - const handleFileSelection = useCallback( - async (event: React.ChangeEvent) => { - if (!flowId) { - return; - } + const getDownloadHrefForFile = useCallback( + (file: FileNode) => buildDownloadHref(flowId, file), + [flowId], + ); - const selectedFiles = Array.from(event.target.files ?? []); + const fileManagerActions = useMemo( + () => [ + downloadAction(getDownloadHrefForFile), + copyPathAction(handleCopyPath), + deleteAction(setFileToDelete), + ], + [getDownloadHrefForFile, handleCopyPath], + ); - if (selectedFiles.length === 0) { + const uploadFiles = useCallback( + async (selectedFiles: File[]) => { + if (!flowId || !selectedFiles.length) { return; } const formData = new FormData(); - for (const file of selectedFiles) { - formData.append('files', file); - } + selectedFiles.forEach((file) => formData.append('files', file)); setIsUploading(true); try { - const response = await axios.post>( + const response = await api.post( `/flows/${flowId}/files/`, formData, { - headers: { - 'Content-Type': undefined, - }, + // Browser sets the multipart boundary automatically when Content-Type is unset. + headers: { 'Content-Type': undefined }, + // Uploads can take longer than the default 30s — disable timeout for this call. + timeout: 0, }, ); - const data = unwrapFlowFilesResponse(response); + const data = unwrapApiResponse(response); const uploadedCount = data.files?.length ?? selectedFiles.length; toast.success(uploadedCount === 1 ? 'File uploaded' : `${uploadedCount} files uploaded`, { @@ -508,75 +338,194 @@ const FlowFiles = () => { await refetchFiles(); } catch (error) { - const description = getErrorMessage(error, 'Failed to upload files'); + const description = getApiErrorMessage(error, 'Failed to upload files', { + 409: 'Entry already exists — enable "Overwrite" to replace it', + }); + toast.error('Upload failed', { description }); } finally { setIsUploading(false); - event.target.value = ''; } }, [flowId, refetchFiles], ); + const handleFileSelection = useCallback( + async (event: React.ChangeEvent) => { + const selectedFiles = Array.from(event.target.files ?? []); + + try { + await uploadFiles(selectedFiles); + } finally { + event.target.value = ''; + } + }, + [uploadFiles], + ); + + // ── drag & drop ──────────────────────────────────────────────────────────── + + const canAcceptDrop = !!flowId && !isUploading; + + const handleDragEnter = useCallback( + (event: React.DragEvent) => { + if (!canAcceptDrop || !event.dataTransfer.types?.includes('Files')) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + dragCounterRef.current += 1; + setIsDragging(true); + }, + [canAcceptDrop], + ); + + const handleDragOver = useCallback( + (event: React.DragEvent) => { + if (!canAcceptDrop || !event.dataTransfer.types?.includes('Files')) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'copy'; + }, + [canAcceptDrop], + ); + + const handleDragLeave = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragCounterRef.current = Math.max(dragCounterRef.current - 1, 0); + + if (dragCounterRef.current === 0) { + setIsDragging(false); + } + }, []); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragCounterRef.current = 0; + setIsDragging(false); + + if (!canAcceptDrop) { + return; + } + + const droppedFiles = Array.from(event.dataTransfer.files ?? []); + + if (droppedFiles.length === 0) { + return; + } + + void uploadFiles(droppedFiles); + }, + [canAcceptDrop, uploadFiles], + ); + + // Reset overlay state when flow changes mid-drag. + useEffect(() => { + dragCounterRef.current = 0; + setIsDragging(false); + }, [flowId]); + const handleDeleteFile = useCallback(async () => { if (!flowId || !fileToDelete) { return; } try { - await axios.delete>(`/flows/${flowId}/files/`, { + await api.delete(`/flows/${flowId}/files/`, { params: { path: fileToDelete.path }, }); toast.success(fileToDelete.isDir ? 'Directory deleted' : 'File deleted'); await refetchFiles(); } catch (error) { - const description = getErrorMessage(error, 'Failed to delete file'); + const description = getApiErrorMessage(error, 'Failed to delete file'); + toast.error('Delete failed', { description }); } finally { setFileToDelete(null); } }, [flowId, fileToDelete, refetchFiles]); - // ── filtering + grouping ─────────────────────────────────────────────────── + const handleBulkDelete = useCallback( + async (filesToDelete: FileNode[]) => { + if (!flowId || filesToDelete.length === 0) { + return; + } + + const results = await Promise.allSettled( + filesToDelete.map((file) => + api.delete(`/flows/${flowId}/files/`, { + params: { path: file.path }, + }), + ), + ); + const succeeded = results.filter((result) => result.status === 'fulfilled').length; + const failed = results.length - succeeded; + + if (failed === 0) { + toast.success(`${succeeded} ${succeeded === 1 ? 'item' : 'items'} deleted`); + } else if (succeeded === 0) { + toast.error('Bulk delete failed', { + description: `Failed to delete ${failed} ${failed === 1 ? 'item' : 'items'}`, + }); + } else { + toast.warning(`${succeeded} succeeded · ${failed} failed`); + } + + await refetchFiles(); + }, + [flowId, refetchFiles], + ); const files = useMemo(() => flowFilesData?.flowFiles ?? [], [flowFilesData?.flowFiles]); + const fileNodes = useMemo(() => files.map(toFileNode), [files]); + const isInitialLoading = isLoading && fileNodes.length === 0; - const filteredFiles = useMemo(() => { - const search = debouncedSearch.toLowerCase().trim(); + const noFilesState = ( + + + + + + No files in cache + + Upload files to make them available at /work/uploads, or use Pull to sync files from + the running container. You can also drag & drop files here. + + + + ); - if (!search) { - return files; - } - - return files.filter((f) => f.name.toLowerCase().includes(search) || f.path.toLowerCase().includes(search)); - }, [files, debouncedSearch]); - - const groups = useMemo(() => { - const uploads: FlowFile[] = []; - const container: FlowFile[] = []; - - for (const f of filteredFiles) { - const src = fileSource(f.path); - - if (src === 'uploads') { - uploads.push(f); - } else if (src === 'container') { - container.push(f); - } - } - - return { - container: buildFlowFileTree(container), - uploads: buildFlowFileTree(uploads), - }; - }, [filteredFiles]); - - const hasFiles = filteredFiles.length > 0; + const noMatchesState = ( + + + + + + No matches + + No files match {debouncedSearch.trim()}. Try a different query. + + + + ); // ── render ───────────────────────────────────────────────────────────────── return ( -
+
{ type="file" /> - {/* Toolbar — same sticky pattern as screenshots / vector-stores */} + {isDragging && ( +
+
+ + Drop files to upload +
+
+ )} +
@@ -623,7 +580,6 @@ const FlowFiles = () => { )} /> - {/* Info hint */} - - Upload files - - - {/* Pull button — wraps in span so tooltip works when disabled */} + + + Upload files + + + + + +
- {/* File list grouped by source */} - {hasFiles ? ( -
- {groups.uploads.length > 0 && ( - <> - }>Uploads -
- {groups.uploads.map(({ depth, file }) => ( - void handleCopyPath(p)} - onDelete={setFileToDelete} - /> - ))} -
- - )} - - {groups.container.length > 0 && ( - <> - }>Container -
- {groups.container.map(({ depth, file }) => ( - void handleCopyPath(p)} - onDelete={setFileToDelete} - /> - ))} -
- - )} -
- ) : ( - - - - - - No files in cache - - Upload files to make them available at /work/uploads, or use Pull to sync files - from the running container. - - - - )} + setShowPullDialog(false)} onSuccess={() => void refetchFiles()} open={showPullDialog} @@ -751,7 +664,7 @@ const FlowFiles = () => { void handleDeleteFile()} + handleConfirm={handleDeleteFile} handleOpenChange={(isOpen) => { if (!isOpen) { setFileToDelete(null);