refactor(flow-files): naming

This commit is contained in:
Sergey Kozyrenko
2026-05-01 04:34:42 +07:00
parent ca747b867a
commit 0c412cedf9
12 changed files with 912 additions and 497 deletions
@@ -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/';
@@ -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<FlowFilesPullFormValues>({
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 (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ArrowDownToLine className="size-4" />
Pull from container
</DialogTitle>
<DialogDescription>
Enter a path inside the running container. The file or directory will be synced to the local cache
under <code>container/</code>.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit}
>
<FormField
control={form.control}
name="containerPath"
render={({ field }) => (
<FormItem>
<FormLabel>Container path</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="off"
autoFocus
disabled={isPulling}
placeholder="/etc/nginx/conf"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="shouldOverwrite"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-2">
<FormControl>
<Switch
checked={field.value}
disabled={isPulling}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="cursor-pointer font-normal">
Overwrite if already cached
</FormLabel>
<FormDescription className="sr-only">
Replace the cached entry when it already exists.
</FormDescription>
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
disabled={isPulling}
onClick={onClose}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
disabled={isSubmitDisabled}
type="submit"
>
{isPulling ? <Loader2 className="animate-spin" /> : <ArrowDownToLine />}
Pull
</Button>
</div>
</form>
</Form>
</DialogContent>
);
};
export const FlowFilesPullDialog = ({ flowId, isOpen, onClose, onSuccess }: FlowFilesPullDialogProps) => {
const handleDialogOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
onClose();
}
};
return (
<Dialog
onOpenChange={handleDialogOpenChange}
open={isOpen}
>
{isOpen && (
<FlowFilesPullDialogForm
flowId={flowId}
onClose={onClose}
onSuccess={onSuccess}
/>
)}
</Dialog>
);
};
@@ -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<FlowFile>;
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');
+115 -497
View File
@@ -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<FlowFile>;
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 `<FlowFiles />`
* 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<FlowFilesResponse>(
`/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 (
<Dialog
onOpenChange={(isOpen) => {
if (!isOpen && !isPulling) {
onClose();
}
}}
open={open}
>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ArrowDownToLine className="size-4" />
Pull from container
</DialogTitle>
<DialogDescription>
Enter a path inside the running container. The file or directory will be synced to the local
cache under <code>container/</code>.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="container-path">Container path</Label>
<Input
autoComplete="off"
autoFocus
disabled={isPulling}
id="container-path"
onChange={(event) => setContainerPath(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && containerPath.trim() && !isPulling) {
void handlePull();
}
}}
placeholder="/etc/nginx/conf"
value={containerPath}
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={shouldOverwrite}
disabled={isPulling}
id="force-pull"
onCheckedChange={setShouldOverwrite}
/>
<Label
className="cursor-pointer font-normal"
htmlFor="force-pull"
>
Overwrite if already cached
</Label>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
disabled={isPulling}
onClick={onClose}
variant="outline"
>
Cancel
</Button>
<Button
disabled={!containerPath.trim() || isPulling}
onClick={() => void handlePull()}
>
{isPulling ? <Loader2 className="animate-spin" /> : <ArrowDownToLine />}
Pull
</Button>
</div>
</DialogContent>
</Dialog>
);
};
// ── main component ─────────────────────────────────────────────────────────────
const FlowFiles = () => {
const { flowId, flowStatus } = useFlow();
const inputRef = useRef<HTMLInputElement | null>(null);
const dragCounterRef = useRef(0);
const [isUploading, setIsUploading] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [fileToDelete, setFileToDelete] = useState<FileNode | null>(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<z.infer<typeof searchFormSchema>>({
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<FileManagerAction[]>(
() => [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<FlowFilesResponse, FormData>(`/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<HTMLInputElement>) => {
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<HTMLDivElement>) => {
if (!canAcceptDrop || !event.dataTransfer.types?.includes('Files')) {
return;
}
event.preventDefault();
event.stopPropagation();
dragCounterRef.current += 1;
setIsDragging(true);
},
[canAcceptDrop],
);
const handleDragOver = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
if (!canAcceptDrop || !event.dataTransfer.types?.includes('Files')) {
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'copy';
},
[canAcceptDrop],
);
const handleDragLeave = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragCounterRef.current = Math.max(dragCounterRef.current - 1, 0);
if (dragCounterRef.current === 0) {
setIsDragging(false);
}
}, []);
const handleDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
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<FlowFilesResponse>(`/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<FlowFilesResponse>(`/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<FileNode[]>(() => files.map(toFileNode), [files]);
const isInitialLoading = isLoading && fileNodes.length === 0;
const fileManagerActions = useMemo<FileManagerAction[]>(
() => [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 = (
<Empty>
@@ -499,28 +116,23 @@ const FlowFiles = () => {
</EmptyMedia>
<EmptyTitle>No matches</EmptyTitle>
<EmptyDescription>
No files match <code>{debouncedSearch.trim()}</code>. Try a different query.
No files match <code>{search.debouncedQuery.trim()}</code>. Try a different query.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
// ── render ─────────────────────────────────────────────────────────────────
return (
<div
className="relative flex h-full flex-col"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
{...dragHandlers}
>
<input
className="hidden"
key={upload.fileInputKey}
multiple
onChange={handleFileSelection}
ref={inputRef}
type="file"
{...upload.fileInputProps}
/>
{isDragging && (
@@ -533,39 +145,37 @@ const FlowFiles = () => {
)}
<div className="bg-background sticky top-0 z-10 pb-4">
<Form {...form}>
<Form {...search.form}>
<div className="flex gap-2 p-px">
<FormField
control={form.control}
control={search.form.control}
name="search"
render={({ field }) => (
<FormControl>
<InputGroup className="flex-1">
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
{...field}
autoComplete="off"
placeholder="Search files..."
type="text"
/>
{field.value && (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={() => {
form.reset({ search: '' });
setDebouncedSearch('');
debouncedUpdateSearch.cancel();
}}
type="button"
>
<X />
</InputGroupButton>
<FormItem className="flex-1">
<FormControl>
<InputGroup>
<InputGroupAddon>
<Search />
</InputGroupAddon>
)}
</InputGroup>
</FormControl>
<InputGroupInput
{...field}
autoComplete="off"
placeholder="Search files..."
type="text"
/>
{field.value && (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={search.resetSearch}
type="button"
>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
</FormControl>
</FormItem>
)}
/>
@@ -595,13 +205,13 @@ const FlowFiles = () => {
<TooltipTrigger asChild>
<span>
<Button
disabled={isUploading || isLoading}
onClick={() => inputRef.current?.click()}
disabled={upload.isUploading || isLoading}
onClick={upload.openFilePicker}
size="icon-sm"
type="button"
variant="outline"
>
{isUploading ? <Loader2 className="animate-spin" /> : <FolderUp />}
{upload.isUploading ? <Loader2 className="animate-spin" /> : <FolderUp />}
</Button>
</span>
</TooltipTrigger>
@@ -612,8 +222,8 @@ const FlowFiles = () => {
<TooltipTrigger asChild>
<span>
<Button
disabled={!isContainerRunning || isLoading || isUploading}
onClick={() => setShowPullDialog(true)}
disabled={isPullDisabled}
onClick={handleOpenPullDialog}
size="icon-sm"
type="button"
variant="outline"
@@ -638,33 +248,41 @@ const FlowFiles = () => {
emptyState={noFilesState}
files={fileNodes}
isLoading={isInitialLoading}
onBulkDelete={handleBulkDelete}
onBulkDelete={deletion.deleteBulk}
rootGroups={ROOT_GROUPS}
search={{ emptyState: noMatchesState, query: debouncedSearch }}
search={{ emptyState: noMatchesState, query: search.debouncedQuery }}
/>
<PullDialog
<FlowFilesPullDialog
flowId={flowId}
onClose={() => setShowPullDialog(false)}
onSuccess={() => void refetchFiles()}
open={showPullDialog}
isOpen={isPullDialogOpen}
onClose={handleClosePullDialog}
onSuccess={refetchFiles}
/>
<ConfirmationDialog
confirmText="Delete"
handleConfirm={handleDeleteFile}
handleOpenChange={(isOpen) => {
if (!isOpen) {
setFileToDelete(null);
}
}}
isOpen={!!fileToDelete}
itemName={fileToDelete?.name}
itemType={fileToDelete?.isDir ? 'directory' : 'file'}
title={fileToDelete?.isDir ? 'Delete Directory' : 'Delete File'}
handleConfirm={deletion.confirmDelete}
handleOpenChange={handleDeleteDialogOpenChange}
isOpen={!!deletion.fileToDelete}
itemName={deletion.fileToDelete?.name}
itemType={deletion.fileToDelete?.isDir ? 'directory' : 'file'}
title={deletion.fileToDelete?.isDir ? 'Delete Directory' : 'Delete File'}
/>
</div>
);
};
const FlowFiles = () => {
const { flowId, flowStatus } = useFlow();
return (
<FlowFilesContent
flowId={flowId}
flowStatus={flowStatus}
key={flowId ?? 'no-flow'}
/>
);
};
export default FlowFiles;
@@ -0,0 +1,108 @@
import { useCallback, useRef, useState } from 'react';
interface DragHandlers {
onDragEnter: (event: React.DragEvent<HTMLDivElement>) => void;
onDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
onDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
}
interface UseFilesDragAndDropParams {
canAcceptDrop: boolean;
onDrop: (droppedFiles: File[]) => void;
}
interface UseFilesDragAndDropResult {
dragHandlers: DragHandlers;
isDragging: boolean;
}
const isFileDragEvent = (event: React.DragEvent<HTMLDivElement>): 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<HTMLDivElement>) => {
if (!canAcceptDrop || !isFileDragEvent(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
dragCounterRef.current += 1;
setIsDragging(true);
},
[canAcceptDrop],
);
const handleDragOver = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
if (!canAcceptDrop || !isFileDragEvent(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'copy';
},
[canAcceptDrop],
);
const handleDragLeave = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
dragCounterRef.current = Math.max(dragCounterRef.current - 1, 0);
if (dragCounterRef.current === 0) {
setIsDragging(false);
}
}, []);
const handleDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
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,
};
};
@@ -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<unknown>;
}
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<FileNode[]>(
() => (flowFilesData?.flowFiles ?? []).map(toFileNode),
[flowFilesData?.flowFiles],
);
const refetchFiles = useMemo(() => () => refetch(), [refetch]);
const isInitialLoading = isLoading && flowFilesData === undefined;
return {
fileNodes,
isInitialLoading,
isLoading,
refetchFiles,
};
};
@@ -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<unknown>;
}
interface UseFlowFilesDeleteResult {
clearFileToDelete: () => void;
confirmDelete: () => Promise<void>;
deleteBulk: (filesToDelete: FileNode[]) => Promise<void>;
fileToDelete: FileNode | null;
requestDelete: (file: FileNode) => void;
}
const deleteFileRequest = (flowId: string, path: string) =>
api.delete<FlowFilesResponse>(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<FileNode | null>(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,
};
};
@@ -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<typeof flowFilesPullFormSchema>;
interface UseFlowFilesPullParams {
flowId: null | string;
onSuccess: () => void;
}
interface UseFlowFilesPullResult {
isPulling: boolean;
pull: (values: FlowFilesPullFormValues) => Promise<boolean>;
}
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<boolean> => {
if (!flowId) {
return false;
}
setIsPulling(true);
try {
await api.post<FlowFilesResponse>(
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,
};
};
@@ -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 });
};
@@ -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<typeof flowFilesSearchFormSchema>;
interface UseFlowFilesSearchResult {
debouncedQuery: string;
form: UseFormReturn<FlowFilesSearchFormValues>;
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<FlowFilesSearchFormValues>({
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,
};
};
@@ -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<unknown>;
}
interface UseFlowFilesUploadResult {
fileInputKey: number;
fileInputProps: {
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
ref: React.RefObject<HTMLInputElement | null>;
};
isUploading: boolean;
openFilePicker: () => void;
uploadFiles: (selectedFiles: File[]) => Promise<void>;
}
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 `<input>`
* this clears its native value declaratively without mutating the DOM directly.
*/
export const useFlowFilesUpload = ({ flowId, refetchFiles }: UseFlowFilesUploadParams): UseFlowFilesUploadResult => {
const inputRef = useRef<HTMLInputElement | null>(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<FlowFilesResponse, FormData>(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<HTMLInputElement>) => {
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,
};
};
+24
View File
@@ -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: Value, delayMs: number): Value => {
const [debouncedValue, setDebouncedValue] = useState<Value>(value);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
return () => {
window.clearTimeout(timeoutId);
};
}, [value, delayMs]);
return debouncedValue;
};