diff --git a/frontend/src/features/flows/files/flow-files-constants.ts b/frontend/src/features/flows/files/flow-files-constants.ts new file mode 100644 index 00000000..25e693e1 --- /dev/null +++ b/frontend/src/features/flows/files/flow-files-constants.ts @@ -0,0 +1,21 @@ +import { Folder, FolderUp, HardDrive } from 'lucide-react'; + +import type { FileManagerRootGroup } from '@/components/file-manager'; + +export const SEARCH_DEBOUNCE_MS = 300; + +export const UPLOADS_PATH_PREFIX = 'uploads'; +export const RESOURCES_PATH_PREFIX = 'resources'; +export const CONTAINER_PATH_PREFIX = 'container'; + +export const ROOT_GROUPS: FileManagerRootGroup[] = [ + { defaultOpen: true, icon: FolderUp, id: 'uploads', label: 'Uploads', pathPrefix: UPLOADS_PATH_PREFIX }, + { defaultOpen: true, icon: Folder, id: 'resources', label: 'Resources', pathPrefix: RESOURCES_PATH_PREFIX }, + { defaultOpen: true, icon: HardDrive, id: 'container', label: 'Container', pathPrefix: CONTAINER_PATH_PREFIX }, +]; + +export const FLOW_FILES_API_PATH = (flowId: string) => `/flows/${flowId}/files/`; +export const FLOW_FILES_PULL_API_PATH = (flowId: string) => `/flows/${flowId}/files/pull`; + +export const UPLOADS_TARGET_DIRECTORY = '/work/uploads'; +export const CONTAINER_TARGET_DIRECTORY = 'container/'; diff --git a/frontend/src/features/flows/files/flow-files-pull-dialog.tsx b/frontend/src/features/flows/files/flow-files-pull-dialog.tsx new file mode 100644 index 00000000..a23e3fb1 --- /dev/null +++ b/frontend/src/features/flows/files/flow-files-pull-dialog.tsx @@ -0,0 +1,158 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { ArrowDownToLine, Loader2 } from 'lucide-react'; +import { useForm } from 'react-hook-form'; + +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; + +import { flowFilesPullFormSchema, type FlowFilesPullFormValues, useFlowFilesPull } from './use-flow-files-pull'; + +interface FlowFilesPullDialogFormProps { + flowId: null | string; + onClose: () => void; + onSuccess: () => void; +} + +interface FlowFilesPullDialogProps { + flowId: null | string; + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +const DEFAULT_PULL_FORM_VALUES: FlowFilesPullFormValues = { + containerPath: '', + shouldOverwrite: false, +}; + +/** + * Inner component holding the form state. Mounted only while the dialog is open + * so closing the dialog discards every transient field without an imperative reset. + */ +const FlowFilesPullDialogForm = ({ flowId, onClose, onSuccess }: FlowFilesPullDialogFormProps) => { + const { isPulling, pull } = useFlowFilesPull({ flowId, onSuccess }); + + const form = useForm({ + defaultValues: DEFAULT_PULL_FORM_VALUES, + mode: 'onChange', + resolver: zodResolver(flowFilesPullFormSchema), + }); + + const handleSubmit = form.handleSubmit(async (values) => { + const wasPulled = await pull(values); + + if (wasPulled) { + onClose(); + } + }); + + const isSubmitDisabled = !form.formState.isValid || isPulling; + + return ( + + + + + Pull from container + + + Enter a path inside the running container. The file or directory will be synced to the local cache + under container/. + + + +
+ + ( + + Container path + + + + + + )} + /> + + ( + + + + + + Overwrite if already cached + + + Replace the cached entry when it already exists. + + + )} + /> + +
+ + +
+ + +
+ ); +}; + +export const FlowFilesPullDialog = ({ flowId, isOpen, onClose, onSuccess }: FlowFilesPullDialogProps) => { + const handleDialogOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + onClose(); + } + }; + + return ( + + {isOpen && ( + + )} + + ); +}; diff --git a/frontend/src/features/flows/files/flow-files-utils.ts b/frontend/src/features/flows/files/flow-files-utils.ts new file mode 100644 index 00000000..0681e70b --- /dev/null +++ b/frontend/src/features/flows/files/flow-files-utils.ts @@ -0,0 +1,35 @@ +import type { FileNode } from '@/components/file-manager'; +import type { FlowFileFragmentFragment } from '@/graphql/types'; + +import { baseUrl } from '@/models/api'; + +export type FlowFile = FlowFileFragmentFragment; + +export interface FlowFilesResponse { + files: Array; + total: number; +} + +/** + * Builds the absolute URL the browser hits to download a single file or directory archive + * for the given flow. Returns `null` when no flow is selected so callers can disable + * the download UI without checking `flowId` themselves. + */ +export const buildDownloadHref = (flowId: null | string, file: FileNode): null | string => { + if (!flowId) { + return null; + } + + return `${baseUrl}/flows/${flowId}/files/download?path=${encodeURIComponent(file.path)}`; +}; + +export const toFileNode = (file: FlowFile): FileNode => ({ + id: file.id, + isDir: file.isDir, + modifiedAt: file.modifiedAt, + name: file.name, + path: file.path, + size: file.size, +}); + +export const pluralizeItems = (count: number): string => (count === 1 ? 'item' : 'items'); diff --git a/frontend/src/features/flows/files/flow-files.tsx b/frontend/src/features/flows/files/flow-files.tsx index b7343403..29eb7cd9 100644 --- a/frontend/src/features/flows/files/flow-files.tsx +++ b/frontend/src/features/flows/files/flow-files.tsx @@ -1,10 +1,6 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import debounce from 'lodash/debounce'; -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 { ArrowDownToLine, FolderUp, Info, Loader2, Search, X } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; -import { z } from 'zod'; import { copyPathAction, @@ -12,469 +8,90 @@ import { downloadAction, FileManager, type FileManagerAction, - type FileManagerRootGroup, type FileNode, } from '@/components/file-manager'; import ConfirmationDialog from '@/components/shared/confirmation-dialog'; 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'; -import { Input } from '@/components/ui/input'; +import { Form, FormControl, FormField, FormItem } from '@/components/ui/form'; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'; -import { Label } from '@/components/ui/label'; -import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { - type FlowFileFragmentFragment, - StatusType, - useFlowFileAddedSubscription, - useFlowFileDeletedSubscription, - useFlowFilesQuery, - useFlowFileUpdatedSubscription, -} from '@/graphql/types'; -import { api, getApiErrorMessage, unwrapApiResponse } from '@/lib/axios'; +import { StatusType } from '@/graphql/types'; import { copyToClipboard } from '@/lib/report'; -import { baseUrl } from '@/models/api'; import { useFlow } from '@/providers/flow-provider'; -type FlowFile = FlowFileFragmentFragment; +import { ROOT_GROUPS } from './flow-files-constants'; +import { FlowFilesPullDialog } from './flow-files-pull-dialog'; +import { buildDownloadHref } from './flow-files-utils'; +import { useFilesDragAndDrop } from './use-files-drag-and-drop'; +import { useFlowFilesData } from './use-flow-files-data'; +import { useFlowFilesDelete } from './use-flow-files-delete'; +import { useFlowFilesRealtime } from './use-flow-files-realtime'; +import { useFlowFilesSearch } from './use-flow-files-search'; +import { useFlowFilesUpload } from './use-flow-files-upload'; -interface FlowFilesResponse { - files: Array; - total: number; -} - -const searchFormSchema = z.object({ - search: z.string(), -}); - -const buildDownloadHref = (flowId: null | string, file: FileNode) => - `${baseUrl}/flows/${flowId}/files/download?path=${encodeURIComponent(file.path)}`; - -const toFileNode = (file: FlowFile): FileNode => ({ - id: file.id, - isDir: file.isDir, - modifiedAt: file.modifiedAt, - name: file.name, - path: file.path, - size: file.size, -}); - -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 { +interface FlowFilesContentProps { flowId: null | string; - onClose: () => void; - onSuccess: () => void; - open: boolean; + flowStatus: StatusType | undefined; } -const PullDialog = ({ flowId, onClose, onSuccess, open }: PullDialogProps) => { - const [containerPath, setContainerPath] = useState(''); - const [shouldOverwrite, setShouldOverwrite] = useState(false); - const [isPulling, setIsPulling] = useState(false); +/** + * Holds every piece of local state for a single flow. The outer `` + * remounts this component via `key={flowId}` so switching flows is a fresh mount + * with no leftover form values, drag overlays or pending toasts. + */ +const FlowFilesContent = ({ flowId, flowStatus }: FlowFilesContentProps) => { + const [isPullDialogOpen, setIsPullDialogOpen] = useState(false); - useEffect(() => { - if (open) { - setContainerPath(''); - setShouldOverwrite(false); - } - }, [open]); + const { fileNodes, isInitialLoading, isLoading, refetchFiles } = useFlowFilesData({ flowId }); - const handlePull = useCallback(async () => { - if (!flowId || !containerPath.trim()) { - return; - } + useFlowFilesRealtime({ flowId, isPaused: isLoading }); - setIsPulling(true); + const search = useFlowFilesSearch(); + const upload = useFlowFilesUpload({ flowId, refetchFiles }); + const deletion = useFlowFilesDelete({ flowId, refetchFiles }); - try { - 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 = 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, shouldOverwrite, onSuccess, onClose]); - - return ( - { - if (!isOpen && !isPulling) { - onClose(); - } - }} - open={open} - > - - - - - Pull from container - - - Enter a path inside the running container. The file or directory will be synced to the local - cache under container/. - - - -
-
- - setContainerPath(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter' && containerPath.trim() && !isPulling) { - void handlePull(); - } - }} - placeholder="/etc/nginx/conf" - value={containerPath} - /> -
- -
- - -
-
- -
- - -
-
-
- ); -}; - -// ── main component ───────────────────────────────────────────────────────────── - -const FlowFiles = () => { - const { flowId, flowStatus } = useFlow(); - const inputRef = useRef(null); - const dragCounterRef = useRef(0); - const [isUploading, setIsUploading] = useState(false); - 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]); - const { - data: flowFilesData, - error: flowFilesError, - loading: isLoading, - refetch: refetchFiles, - } = useFlowFilesQuery({ - skip: !flowId, - variables: flowFilesVariables, + const canAcceptDrop = !!flowId && !upload.isUploading; + const { dragHandlers, isDragging } = useFilesDragAndDrop({ + canAcceptDrop, + onDrop: upload.uploadFiles, }); - const form = useForm>({ - defaultValues: { search: '' }, - resolver: zodResolver(searchFormSchema), - }); - - const searchValue = form.watch('search'); - - const debouncedUpdateSearch = useMemo( - () => - debounce((value: string) => { - setDebouncedSearch(value); - }, 300), - [], - ); - - useEffect(() => { - debouncedUpdateSearch(searchValue); - - return () => { - debouncedUpdateSearch.cancel(); - }; - }, [searchValue, debouncedUpdateSearch]); - - useEffect(() => { - form.reset({ search: '' }); - setDebouncedSearch(''); - debouncedUpdateSearch.cancel(); - }, [flowId, form, debouncedUpdateSearch]); - const isContainerRunning = flowStatus === StatusType.Running || flowStatus === StatusType.Waiting; + const isPullDisabled = !isContainerRunning || isLoading || upload.isUploading; - // Pause subscriptions until the initial query has loaded so that the - // `flowFiles` cache field exists before subscription-driven updates arrive. - const isSubscriptionPaused = !flowId || isLoading; + const handleCopyPath = useCallback(async (file: FileNode) => { + const wasCopied = await copyToClipboard(file.path); - useFlowFileAddedSubscription({ - skip: isSubscriptionPaused, - variables: flowFilesVariables, - }); - useFlowFileUpdatedSubscription({ - skip: isSubscriptionPaused, - variables: flowFilesVariables, - }); - useFlowFileDeletedSubscription({ - skip: isSubscriptionPaused, - variables: flowFilesVariables, - }); + if (wasCopied) { + toast.success('Path copied to clipboard'); - useEffect(() => { - if (flowFilesError) { - toast.error('Failed to load files', { - description: flowFilesError.message, - id: 'flow-files-error', - }); - } - }, [flowFilesError]); - - // ── upload ───────────────────────────────────────────────────────────────── - - 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 getDownloadHrefForFile = useCallback((file: FileNode) => buildDownloadHref(flowId, file), [flowId]); - - const fileManagerActions = useMemo( - () => [downloadAction(getDownloadHrefForFile), copyPathAction(handleCopyPath), deleteAction(setFileToDelete)], - [getDownloadHrefForFile, handleCopyPath], - ); - - const uploadFiles = useCallback( - async (selectedFiles: File[]) => { - if (!flowId || !selectedFiles.length) { - return; - } - - const formData = new FormData(); - - selectedFiles.forEach((file) => formData.append('files', file)); - - setIsUploading(true); - - try { - const response = await api.post(`/flows/${flowId}/files/`, formData, { - // 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 = unwrapApiResponse(response); - const uploadedCount = data.files?.length ?? selectedFiles.length; - - toast.success(uploadedCount === 1 ? 'File uploaded' : `${uploadedCount} files uploaded`, { - description: - uploadedCount === 1 - ? `Available at /work/uploads/${data.files?.[0]?.name ?? ''}` - : `${uploadedCount} files are now available under /work/uploads`, - }); - - await refetchFiles(); - } catch (error) { - 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); - } - }, - [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 api.delete(`/flows/${flowId}/files/`, { - params: { path: fileToDelete.path }, - }); - toast.success(fileToDelete.isDir ? 'Directory deleted' : 'File deleted'); - await refetchFiles(); - } catch (error) { - const description = getApiErrorMessage(error, 'Failed to delete file'); + toast.error('Failed to copy path'); + }, []); - toast.error('Delete failed', { description }); - } finally { - setFileToDelete(null); - } - }, [flowId, fileToDelete, refetchFiles]); - - 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 getDownloadHref = useCallback( + (file: FileNode): string => buildDownloadHref(flowId, file) ?? '', + [flowId], ); - const files = useMemo(() => flowFilesData?.flowFiles ?? [], [flowFilesData?.flowFiles]); - const fileNodes = useMemo(() => files.map(toFileNode), [files]); - const isInitialLoading = isLoading && fileNodes.length === 0; + const fileManagerActions = useMemo( + () => [downloadAction(getDownloadHref), copyPathAction(handleCopyPath), deleteAction(deletion.requestDelete)], + [getDownloadHref, handleCopyPath, deletion.requestDelete], + ); + + const handleOpenPullDialog = useCallback(() => setIsPullDialogOpen(true), []); + const handleClosePullDialog = useCallback(() => setIsPullDialogOpen(false), []); + const handleDeleteDialogOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + deletion.clearFileToDelete(); + } + }, + [deletion], + ); const noFilesState = ( @@ -499,28 +116,23 @@ const FlowFiles = () => { No matches - No files match {debouncedSearch.trim()}. Try a different query. + No files match {search.debouncedQuery.trim()}. Try a different query. ); - // ── render ───────────────────────────────────────────────────────────────── - return (
{isDragging && ( @@ -533,39 +145,37 @@ const FlowFiles = () => { )}
-
+
( - - - - - - - {field.value && ( - - { - form.reset({ search: '' }); - setDebouncedSearch(''); - debouncedUpdateSearch.cancel(); - }} - type="button" - > - - + + + + + - )} - - + + {field.value && ( + + + + + + )} + + + )} /> @@ -595,13 +205,13 @@ const FlowFiles = () => { @@ -612,8 +222,8 @@ const FlowFiles = () => {
); }; +const FlowFiles = () => { + const { flowId, flowStatus } = useFlow(); + + return ( + + ); +}; + export default FlowFiles; diff --git a/frontend/src/features/flows/files/use-files-drag-and-drop.ts b/frontend/src/features/flows/files/use-files-drag-and-drop.ts new file mode 100644 index 00000000..28475d86 --- /dev/null +++ b/frontend/src/features/flows/files/use-files-drag-and-drop.ts @@ -0,0 +1,108 @@ +import { useCallback, useRef, useState } from 'react'; + +interface DragHandlers { + onDragEnter: (event: React.DragEvent) => void; + onDragLeave: (event: React.DragEvent) => void; + onDragOver: (event: React.DragEvent) => void; + onDrop: (event: React.DragEvent) => void; +} + +interface UseFilesDragAndDropParams { + canAcceptDrop: boolean; + onDrop: (droppedFiles: File[]) => void; +} + +interface UseFilesDragAndDropResult { + dragHandlers: DragHandlers; + isDragging: boolean; +} + +const isFileDragEvent = (event: React.DragEvent): boolean => + event.dataTransfer.types?.includes('Files') ?? false; + +/** + * Encapsulates the drag-counter pattern (drag-enter / drag-leave fire for every nested + * element, so we increment/decrement a counter to know when the user actually leaves + * the drop zone). The mutable counter lives entirely inside this hook; the consumer + * sees only an immutable `isDragging` flag and four event handlers. + * + * The hook does not auto-reset on external identity changes: the consumer is expected + * to remount the subtree (via `key={flowId}` or similar) which discards both the + * counter and the `isDragging` state. + */ +export const useFilesDragAndDrop = ({ + canAcceptDrop, + onDrop, +}: UseFilesDragAndDropParams): UseFilesDragAndDropResult => { + const dragCounterRef = useRef(0); + const [isDragging, setIsDragging] = useState(false); + + const handleDragEnter = useCallback( + (event: React.DragEvent) => { + if (!canAcceptDrop || !isFileDragEvent(event)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + dragCounterRef.current += 1; + setIsDragging(true); + }, + [canAcceptDrop], + ); + + const handleDragOver = useCallback( + (event: React.DragEvent) => { + if (!canAcceptDrop || !isFileDragEvent(event)) { + 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; + } + + onDrop(droppedFiles); + }, + [canAcceptDrop, onDrop], + ); + + return { + dragHandlers: { + onDragEnter: handleDragEnter, + onDragLeave: handleDragLeave, + onDragOver: handleDragOver, + onDrop: handleDrop, + }, + isDragging, + }; +}; diff --git a/frontend/src/features/flows/files/use-flow-files-data.ts b/frontend/src/features/flows/files/use-flow-files-data.ts new file mode 100644 index 00000000..5aa6ef87 --- /dev/null +++ b/frontend/src/features/flows/files/use-flow-files-data.ts @@ -0,0 +1,66 @@ +import { useEffect, useMemo } from 'react'; +import { toast } from 'sonner'; + +import type { FileNode } from '@/components/file-manager'; + +import { useFlowFilesQuery } from '@/graphql/types'; + +import { toFileNode } from './flow-files-utils'; + +interface UseFlowFilesDataParams { + flowId: null | string; +} + +interface UseFlowFilesDataResult { + fileNodes: FileNode[]; + isInitialLoading: boolean; + isLoading: boolean; + refetchFiles: () => Promise; +} + +const FLOW_FILES_ERROR_TOAST_ID = 'flow-files-error'; + +/** + * Loads `flowFiles` for the current flow and converts them into `FileNode`s for the + * file manager. The `isInitialLoading` flag is derived from Apollo's response shape: + * it stays `true` only while the very first response is in flight (no cached data + * yet), so subsequent background `refetch` calls do not flash the skeleton. + */ +export const useFlowFilesData = ({ flowId }: UseFlowFilesDataParams): UseFlowFilesDataResult => { + const flowFilesVariables = useMemo(() => ({ flowId: flowId ?? '' }), [flowId]); + + const { + data: flowFilesData, + error: flowFilesError, + loading: isLoading, + refetch, + } = useFlowFilesQuery({ + skip: !flowId, + variables: flowFilesVariables, + }); + + useEffect(() => { + if (flowFilesError) { + toast.error('Failed to load files', { + description: flowFilesError.message, + id: FLOW_FILES_ERROR_TOAST_ID, + }); + } + }, [flowFilesError]); + + const fileNodes = useMemo( + () => (flowFilesData?.flowFiles ?? []).map(toFileNode), + [flowFilesData?.flowFiles], + ); + + const refetchFiles = useMemo(() => () => refetch(), [refetch]); + + const isInitialLoading = isLoading && flowFilesData === undefined; + + return { + fileNodes, + isInitialLoading, + isLoading, + refetchFiles, + }; +}; diff --git a/frontend/src/features/flows/files/use-flow-files-delete.ts b/frontend/src/features/flows/files/use-flow-files-delete.ts new file mode 100644 index 00000000..862a4473 --- /dev/null +++ b/frontend/src/features/flows/files/use-flow-files-delete.ts @@ -0,0 +1,106 @@ +import { useCallback, useState } from 'react'; +import { toast } from 'sonner'; + +import type { FileNode } from '@/components/file-manager'; + +import { api, getApiErrorMessage } from '@/lib/axios'; + +import { FLOW_FILES_API_PATH } from './flow-files-constants'; +import { type FlowFilesResponse, pluralizeItems } from './flow-files-utils'; + +interface UseFlowFilesDeleteParams { + flowId: null | string; + refetchFiles: () => Promise; +} + +interface UseFlowFilesDeleteResult { + clearFileToDelete: () => void; + confirmDelete: () => Promise; + deleteBulk: (filesToDelete: FileNode[]) => Promise; + fileToDelete: FileNode | null; + requestDelete: (file: FileNode) => void; +} + +const deleteFileRequest = (flowId: string, path: string) => + api.delete(FLOW_FILES_API_PATH(flowId), { params: { path } }); + +const reportBulkDeleteOutcome = (succeededCount: number, failedCount: number): void => { + if (failedCount === 0) { + toast.success(`${succeededCount} ${pluralizeItems(succeededCount)} deleted`); + + return; + } + + if (succeededCount === 0) { + toast.error('Bulk delete failed', { + description: `Failed to delete ${failedCount} ${pluralizeItems(failedCount)}`, + }); + + return; + } + + toast.warning(`${succeededCount} succeeded · ${failedCount} failed`); +}; + +/** + * Owns both the single-file and bulk-delete flows. The component drives the + * confirmation dialog state through the returned `fileToDelete`/`requestDelete`/ + * `clearFileToDelete` triple, while the hook hides every API call, toast and + * post-delete refetch. + */ +export const useFlowFilesDelete = ({ flowId, refetchFiles }: UseFlowFilesDeleteParams): UseFlowFilesDeleteResult => { + const [fileToDelete, setFileToDelete] = useState(null); + + const requestDelete = useCallback((file: FileNode) => { + setFileToDelete(file); + }, []); + + const clearFileToDelete = useCallback(() => { + setFileToDelete(null); + }, []); + + const confirmDelete = useCallback(async () => { + if (!flowId || !fileToDelete) { + return; + } + + try { + await deleteFileRequest(flowId, fileToDelete.path); + toast.success(fileToDelete.isDir ? 'Directory deleted' : 'File deleted'); + await refetchFiles(); + } catch (error) { + const description = getApiErrorMessage(error, 'Failed to delete file'); + + toast.error('Delete failed', { description }); + } finally { + setFileToDelete(null); + } + }, [flowId, fileToDelete, refetchFiles]); + + const deleteBulk = useCallback( + async (filesToDelete: FileNode[]) => { + if (!flowId || filesToDelete.length === 0) { + return; + } + + const results = await Promise.allSettled( + filesToDelete.map((file) => deleteFileRequest(flowId, file.path)), + ); + const succeededCount = results.filter((result) => result.status === 'fulfilled').length; + const failedCount = results.length - succeededCount; + + reportBulkDeleteOutcome(succeededCount, failedCount); + + await refetchFiles(); + }, + [flowId, refetchFiles], + ); + + return { + clearFileToDelete, + confirmDelete, + deleteBulk, + fileToDelete, + requestDelete, + }; +}; diff --git a/frontend/src/features/flows/files/use-flow-files-pull.ts b/frontend/src/features/flows/files/use-flow-files-pull.ts new file mode 100644 index 00000000..06a19d5d --- /dev/null +++ b/frontend/src/features/flows/files/use-flow-files-pull.ts @@ -0,0 +1,82 @@ +import { useCallback, useState } from 'react'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +import { api, getApiErrorMessage } from '@/lib/axios'; + +import { CONTAINER_TARGET_DIRECTORY, FLOW_FILES_PULL_API_PATH } from './flow-files-constants'; +import { type FlowFilesResponse } from './flow-files-utils'; + +export const flowFilesPullFormSchema = z.object({ + containerPath: z.string().trim().min(1, { message: 'Container path cannot be empty' }), + shouldOverwrite: z.boolean(), +}); + +export type FlowFilesPullFormValues = z.infer; + +interface UseFlowFilesPullParams { + flowId: null | string; + onSuccess: () => void; +} + +interface UseFlowFilesPullResult { + isPulling: boolean; + pull: (values: FlowFilesPullFormValues) => Promise; +} + +const PULL_OVERWRITE_HINT = 'Entry already exists — enable "Overwrite" to replace it'; + +/** + * Wraps the "pull from container" REST call with toast notifications and a loading flag. + * Returns a `pull(values)` callback that resolves to `true` on success, so the dialog + * can decide whether to close itself. + */ +export const useFlowFilesPull = ({ flowId, onSuccess }: UseFlowFilesPullParams): UseFlowFilesPullResult => { + const [isPulling, setIsPulling] = useState(false); + + const pull = useCallback( + async ({ containerPath, shouldOverwrite }: FlowFilesPullFormValues): Promise => { + if (!flowId) { + return false; + } + + setIsPulling(true); + + try { + await api.post( + FLOW_FILES_PULL_API_PATH(flowId), + { + force: shouldOverwrite, + path: containerPath, + }, + // 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_TARGET_DIRECTORY}`, + }); + onSuccess(); + + return true; + } catch (error) { + const description = getApiErrorMessage(error, 'Failed to pull from container', { + 409: PULL_OVERWRITE_HINT, + }); + + toast.error('Pull failed', { description }); + + return false; + } finally { + setIsPulling(false); + } + }, + [flowId, onSuccess], + ); + + return { + isPulling, + pull, + }; +}; diff --git a/frontend/src/features/flows/files/use-flow-files-realtime.ts b/frontend/src/features/flows/files/use-flow-files-realtime.ts new file mode 100644 index 00000000..3702c9cc --- /dev/null +++ b/frontend/src/features/flows/files/use-flow-files-realtime.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; + +import { + useFlowFileAddedSubscription, + useFlowFileDeletedSubscription, + useFlowFileUpdatedSubscription, +} from '@/graphql/types'; + +interface UseFlowFilesRealtimeParams { + flowId: null | string; + /** + * When `true`, every subscription is skipped. The flag is needed to delay subscription + * delivery until the initial query has populated the Apollo cache so that the cache + * field exists before the subscription updates arrive. + */ + isPaused: boolean; +} + +/** + * Wires up the three flow-file subscriptions (added / updated / deleted) under a single + * call so the parent component does not need to repeat the same `skip`/`variables` pair + * three times. + */ +export const useFlowFilesRealtime = ({ flowId, isPaused }: UseFlowFilesRealtimeParams): void => { + const variables = useMemo(() => ({ flowId: flowId ?? '' }), [flowId]); + const isSkipped = isPaused || !flowId; + + useFlowFileAddedSubscription({ skip: isSkipped, variables }); + useFlowFileUpdatedSubscription({ skip: isSkipped, variables }); + useFlowFileDeletedSubscription({ skip: isSkipped, variables }); +}; diff --git a/frontend/src/features/flows/files/use-flow-files-search.ts b/frontend/src/features/flows/files/use-flow-files-search.ts new file mode 100644 index 00000000..113f293d --- /dev/null +++ b/frontend/src/features/flows/files/use-flow-files-search.ts @@ -0,0 +1,49 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useCallback } from 'react'; +import { useForm, type UseFormReturn } from 'react-hook-form'; +import { z } from 'zod'; + +import { useDebouncedValue } from '@/hooks/use-debounced-value'; + +import { SEARCH_DEBOUNCE_MS } from './flow-files-constants'; + +const flowFilesSearchFormSchema = z.object({ + search: z.string(), +}); + +export type FlowFilesSearchFormValues = z.infer; + +interface UseFlowFilesSearchResult { + debouncedQuery: string; + form: UseFormReturn; + rawQuery: string; + resetSearch: () => void; +} + +/** + * Owns the search form and exposes a debounced version of the typed query. + * + * The hook intentionally has no `resetKey` parameter: the consumer is expected to + * remount the subtree (via `key={flowId}` on the surrounding component) when the + * flow changes, which clears form state without an imperative effect. + */ +export const useFlowFilesSearch = (): UseFlowFilesSearchResult => { + const form = useForm({ + defaultValues: { search: '' }, + resolver: zodResolver(flowFilesSearchFormSchema), + }); + + const rawQuery = form.watch('search'); + const debouncedQuery = useDebouncedValue(rawQuery, SEARCH_DEBOUNCE_MS); + + const resetSearch = useCallback(() => { + form.reset({ search: '' }); + }, [form]); + + return { + debouncedQuery, + form, + rawQuery, + resetSearch, + }; +}; diff --git a/frontend/src/features/flows/files/use-flow-files-upload.ts b/frontend/src/features/flows/files/use-flow-files-upload.ts new file mode 100644 index 00000000..cbe70da5 --- /dev/null +++ b/frontend/src/features/flows/files/use-flow-files-upload.ts @@ -0,0 +1,117 @@ +import { useCallback, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +import { api, getApiErrorMessage, unwrapApiResponse } from '@/lib/axios'; + +import { FLOW_FILES_API_PATH, UPLOADS_TARGET_DIRECTORY } from './flow-files-constants'; +import { type FlowFilesResponse } from './flow-files-utils'; + +interface UseFlowFilesUploadParams { + flowId: null | string; + refetchFiles: () => Promise; +} + +interface UseFlowFilesUploadResult { + fileInputKey: number; + fileInputProps: { + onChange: (event: React.ChangeEvent) => void; + ref: React.RefObject; + }; + isUploading: boolean; + openFilePicker: () => void; + uploadFiles: (selectedFiles: File[]) => Promise; +} + +const buildUploadSuccessMessage = (uploadedCount: number, firstFileName?: string) => { + if (uploadedCount === 1) { + return { + description: `Available at ${UPLOADS_TARGET_DIRECTORY}/${firstFileName ?? ''}`, + title: 'File uploaded', + }; + } + + return { + description: `${uploadedCount} files are now available under ${UPLOADS_TARGET_DIRECTORY}`, + title: `${uploadedCount} files uploaded`, + }; +}; + +/** + * Encapsulates the entire upload flow: + * * the hidden file input (consumer just spreads `fileInputProps` into the element), + * * the imperative `openFilePicker` action, + * * the actual `uploadFiles(File[])` call used by both the picker and drag-and-drop. + * + * The `key` value is bumped after every upload so React remounts the `` — + * this clears its native value declaratively without mutating the DOM directly. + */ +export const useFlowFilesUpload = ({ flowId, refetchFiles }: UseFlowFilesUploadParams): UseFlowFilesUploadResult => { + const inputRef = useRef(null); + const [isUploading, setIsUploading] = useState(false); + const [fileInputKey, setFileInputKey] = useState(0); + + const openFilePicker = useCallback(() => { + inputRef.current?.click(); + }, []); + + const uploadFiles = useCallback( + async (selectedFiles: File[]) => { + if (!flowId || selectedFiles.length === 0) { + return; + } + + const formData = new FormData(); + + selectedFiles.forEach((file) => formData.append('files', file)); + + setIsUploading(true); + + try { + const response = await api.post(FLOW_FILES_API_PATH(flowId), formData, { + // 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 = unwrapApiResponse(response); + const uploadedCount = data.files?.length ?? selectedFiles.length; + const successMessage = buildUploadSuccessMessage(uploadedCount, data.files?.[0]?.name); + + toast.success(successMessage.title, { description: successMessage.description }); + + await refetchFiles(); + } catch (error) { + const description = getApiErrorMessage(error, 'Failed to upload files'); + + toast.error('Upload failed', { description }); + } finally { + setIsUploading(false); + } + }, + [flowId, refetchFiles], + ); + + const handleFileSelection = useCallback( + async (event: React.ChangeEvent) => { + const selectedFiles = Array.from(event.target.files ?? []); + + try { + await uploadFiles(selectedFiles); + } finally { + setFileInputKey((previousKey) => previousKey + 1); + } + }, + [uploadFiles], + ); + + return { + fileInputKey, + fileInputProps: { + onChange: handleFileSelection, + ref: inputRef, + }, + isUploading, + openFilePicker, + uploadFiles, + }; +}; diff --git a/frontend/src/hooks/use-debounced-value.ts b/frontend/src/hooks/use-debounced-value.ts new file mode 100644 index 00000000..0d58edae --- /dev/null +++ b/frontend/src/hooks/use-debounced-value.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; + +/** + * Returns a debounced version of `value` that updates only after `delayMs` of inactivity. + * + * The hook owns its timer entirely: every change to `value` schedules a new update and + * cancels the previous one. When the component unmounts, the pending timer is cleared, + * so callers do not need to manage cancellation themselves. + */ +export const useDebouncedValue = (value: Value, delayMs: number): Value => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setDebouncedValue(value); + }, delayMs); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [value, delayMs]); + + return debouncedValue; +};