mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-28 14:06:36 +00:00
refactor(flow-files): naming
This commit is contained in:
@@ -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');
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user