feat(flows): surface partial container listings in the Pull dialog

The container file endpoint now returns HTTP 200 with the readable entries
plus a Failures list when some entries can't be stat'd, instead of failing
the whole request. Plumb those failures through useFlowContainerFiles and
show a non-blocking warning banner above the readable files ("N entries
could not be read"), reserving the full error empty-state for a true
listing failure. When nothing is readable, the empty-state says so rather
than "Directory is empty".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-12 13:38:24 +07:00
co-authored by Claude Opus 4.8
parent 1300f60a2b
commit 56e8035b8a
3 changed files with 53 additions and 2 deletions
@@ -1,4 +1,4 @@
import { ArrowDownToLine, ArrowUp, FolderOpen, Loader2, RefreshCw } from 'lucide-react';
import { ArrowDownToLine, ArrowUp, FolderOpen, Loader2, RefreshCw, TriangleAlert } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import {
@@ -8,6 +8,7 @@ import {
type FileNode,
} from '@/components/shared/file-manager';
import { OverwriteButtons, OverwriteDialog, useOverwrite } from '@/components/shared/overwrite';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
Autocomplete,
AutocompleteContent,
@@ -133,6 +134,7 @@ function FlowFilesPullDialogForm({ cachedFiles, flowId, onClose, onSuccess }: Fl
const {
error: listingError,
failures: listingFailures,
files,
isLoading: isListingLoading,
refetch: refetchListing,
@@ -331,6 +333,18 @@ function FlowFilesPullDialogForm({ cachedFiles, flowId, onClose, onSuccess }: Fl
<EmptyDescription>{listingError.message}</EmptyDescription>
</EmptyHeader>
</Empty>
) : listingFailures.length > 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<TriangleAlert />
</EmptyMedia>
<EmptyTitle>Nothing readable here</EmptyTitle>
<EmptyDescription>
None of the entries in <code>{currentPath}</code> could be read.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<Empty>
<EmptyHeader>
@@ -424,6 +438,24 @@ function FlowFilesPullDialogForm({ cachedFiles, flowId, onClose, onSuccess }: Fl
</Tooltip>
</div>
{listingFailures.length > 0 && flatFiles.length > 0 && (
<Alert>
<TriangleAlert />
<AlertTitle>
{listingFailures.length} {listingFailures.length === 1 ? 'entry' : 'entries'} could not
be read
</AlertTitle>
<AlertDescription>
The readable entries are shown below. Skipped:{' '}
{listingFailures
.slice(0, 5)
.map((failure) => failure.name)
.join(', ')}
{listingFailures.length > 5 ? `, and ${listingFailures.length - 5} more` : ''}.
</AlertDescription>
</Alert>
)}
<FileManager
bulkActions={bulkActions}
className="h-[360px]"
@@ -10,7 +10,14 @@ import { CONTAINER_PATH_PREFIX, RESOURCES_PATH_PREFIX, UPLOADS_PATH_PREFIX } fro
* Wire shape of `models.ContainerFiles`. `path` echoes back the queried path
* when exactly one was requested — empty string for multi-path queries.
*/
export interface ContainerFileFailure {
message: string;
name: string;
path: string;
}
export interface ContainerFilesResponse {
failures?: ContainerFileFailure[];
files: RestContainerFile[];
path: string;
total: number;
@@ -6,7 +6,7 @@ import { buildPathsQuery } from '@/features/resources/resources-utils';
import { api, getApiErrorMessage, unwrapApiResponse } from '@/lib/axios';
import { FLOW_FILES_CONTAINER_API_PATH } from './flow-files-constants';
import { type ContainerFilesResponse, containerFileToFileNode } from './flow-files-utils';
import { type ContainerFileFailure, type ContainerFilesResponse, containerFileToFileNode } from './flow-files-utils';
interface UseFlowContainerFilesParams {
flowId: null | string;
@@ -21,6 +21,13 @@ interface UseFlowContainerFilesParams {
interface UseFlowContainerFilesResult {
error: Error | null;
/**
* Entries the backend could not stat (dangling symlink, a file removed
* mid-listing, a transient /proc pid). The listing still succeeds with the
* readable entries in `files`; these are surfaced so the UI can warn that
* the directory is shown partially.
*/
failures: ContainerFileFailure[];
files: FileNode[];
isLoading: boolean;
/**
@@ -46,6 +53,7 @@ interface UseFlowContainerFilesResult {
*/
export function useFlowContainerFiles({ flowId, paths }: UseFlowContainerFilesParams): UseFlowContainerFilesResult {
const [files, setFiles] = useState<FileNode[]>([]);
const [failures, setFailures] = useState<ContainerFileFailure[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
@@ -65,6 +73,7 @@ export function useFlowContainerFiles({ flowId, paths }: UseFlowContainerFilesPa
if (!flowId || paths.length === 0) {
currentTokenRef.current += 1;
setFiles([]);
setFailures([]);
setIsLoading(false);
setError(null);
@@ -89,6 +98,7 @@ export function useFlowContainerFiles({ flowId, paths }: UseFlowContainerFilesPa
}
setFiles(data.files.map(containerFileToFileNode));
setFailures(data.failures ?? []);
} catch (caught) {
if (token !== currentTokenRef.current) {
return;
@@ -96,6 +106,7 @@ export function useFlowContainerFiles({ flowId, paths }: UseFlowContainerFilesPa
setError(new Error(getApiErrorMessage(caught, 'Failed to load container files')));
setFiles([]);
setFailures([]);
} finally {
if (token === currentTokenRef.current) {
setIsLoading(false);
@@ -116,6 +127,7 @@ export function useFlowContainerFiles({ flowId, paths }: UseFlowContainerFilesPa
return {
error,
failures,
files,
isLoading,
refetch: fetchListing,