refactor(file-manager): chevron drill-in, responsive bulk bar and tri-state toggle fix

- File manager core:
  - Chevron click on folder rows now drills in via `onOpenDirectory`
    (when set), matching the existing double-click / Enter semantics.
    Consumers that don't wire the prop keep the legacy expand/collapse
    behaviour, so flow-files / resources trees stay unchanged.
  - Bulk actions bar wraps and collapses to icon-only buttons (Cancel
    included) on small viewports so the bar stays usable on mobile.
  - Fix tri-state subtree toggle requiring two clicks when the
    directory's own path was never in the selection (e.g. user ticked
    children one-by-one). \`toggleSubtreeOnSet\` now accepts an optional
    \`rootPath\` and ignores it for the "all selected?" check, mirroring
    what \`computeDirSelectionState\` shows on the visible checkbox.
    Covered by new regression tests in \`file-manager-utils.test.ts\`.
  - Built-in icons swapped: copy-path uses \`ClipboardCopy\` instead of
    \`Copy\`; bulk \"Save as resources\" uses \`FolderOutput\` instead of
    \`BookmarkPlus\`.

- Pull dialog: flatten the container listing (\`name\` as \`path\`,
  absolute path in \`id\`) so the chevron / double-click drill into
  real subfolders instead of toggling a synthetic \`work/\` wrapper that
  has no meaningful navigation target. Selection is mapped back to
  absolute paths via a name → absolute lookup before pulling.

- Resources page: drop the focus-derived \`currentDir\` plumbing —
  toolbar mkdir / upload always target the library root, row context
  menu loses \"Upload files here\" and renames \"New folder here\" to
  \"New folder\". Page wraps in \`h-[calc(100dvh-3rem)]\` so the bulk
  bar stays inside the viewport. Tooltips simplified accordingly.

- Flow files toolbar: replace the standalone Info icon with rich
  per-button tooltips that explain where each gesture lands
  (/work/uploads, /work/resources, separate Container snapshot area);
  upload button uses the standard \`Upload\` glyph.

- Attach resources dialog: switch the ad-hoc footer to a proper
  \`DialogFooter\` with responsive layout, tighten the dialog title.

- \`Dialog\` / \`Sheet\` footers always apply the inter-button gap,
  not only at the \`sm+\` breakpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sergey Kozyrenko
2026-05-08 17:55:37 +07:00
co-authored by Cursor
parent d743bf5105
commit 012cb741ad
12 changed files with 299 additions and 169 deletions
@@ -1,4 +1,4 @@
import { BookmarkPlus, ClipboardCopy, Copy, Download, FileSymlink, Trash2 } from 'lucide-react';
import { ClipboardCopy, Copy, Download, FileSymlink, FolderOutput, Trash2 } from 'lucide-react';
import type { FileManagerAction, FileManagerBulkAction, FileNode } from './file-manager-types';
@@ -29,7 +29,7 @@ export const downloadAction = (
/** Built-in copy-path action. */
export const copyPathAction = (onCopyPath: (file: FileNode) => void): FileManagerAction => ({
appliesToDirs: true,
icon: Copy,
icon: ClipboardCopy,
id: '__builtin_copy_path',
label: 'Copy path',
onSelect: onCopyPath,
@@ -148,7 +148,7 @@ export const bulkPromoteAction = (
onPromote: (files: FileNode[]) => void,
options: { label?: string; overflow?: boolean } = {},
): FileManagerBulkAction => ({
icon: BookmarkPlus,
icon: FolderOutput,
id: '__builtin_bulk_promote',
label: options.label ?? 'Save as resources',
onSelect: onPromote,
@@ -1,4 +1,4 @@
import { MoreHorizontal } from 'lucide-react';
import { MoreHorizontal, X } from 'lucide-react';
import { type ComponentType, useCallback, useMemo, useState } from 'react';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
@@ -136,15 +136,18 @@ export const FileManagerBulkActionsBar = ({
return (
<>
<div className="bg-background flex items-center justify-between gap-2 border-t px-3 py-2">
<div className="bg-background flex flex-wrap items-center gap-2 border-t px-3 py-2">
<span className="text-muted-foreground text-sm">{selectedText}</span>
<div className="flex items-center gap-2">
<div className="ml-auto flex items-center gap-2">
<Button
aria-label={cancelText}
className="max-sm:size-8 max-sm:px-0"
onClick={onClearSelection}
size="sm"
variant="ghost"
>
{cancelText}
<X className="sm:hidden" />
<span className="hidden sm:inline">{cancelText}</span>
</Button>
{inlineActions.map(({ action, isDisabled }) => (
@@ -221,12 +224,13 @@ const BulkActionButton = ({ action, isDisabled, onClick }: BulkActionButtonProps
const Icon = action.icon as ComponentType<{ className?: string }> | undefined;
const button = (
<Button
className={cn(action.icon && 'max-sm:size-8 max-sm:px-0')}
disabled={isDisabled}
onClick={() => onClick(action)}
size="sm"
variant={action.variant === 'destructive' ? 'destructive' : 'outline'}
>
{Icon ? <Icon className="size-4" /> : null}
{Icon ? <Icon /> : null}
<span className={cn(action.icon ? 'hidden sm:inline' : undefined)}>{action.label}</span>
</Button>
);
@@ -153,9 +153,7 @@ const buildVisibleActions = (
actions: readonly FileManagerAction[],
file: FileManagerInternalNode,
): FileManagerAction[] =>
actions.filter((action) =>
file.isDir ? action.appliesToDirs === true : action.appliesToFiles !== false,
);
actions.filter((action) => (file.isDir ? action.appliesToDirs === true : action.appliesToFiles !== false));
const FileManagerRowImpl = ({
actions,
@@ -425,7 +423,18 @@ const FileManagerRowImpl = ({
<span
aria-hidden="true"
className="text-muted-foreground hover:bg-muted -mx-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded transition-colors"
onClick={() => onToggleExpand(file.path, isExpanded)}
onClick={() => {
// Mirror the double-click / Enter semantics here so the
// chevron stays consistent with the row-level "open"
// gesture: navigation-style consumers (e.g. the remote
// container browser) drill into the folder instead of
// toggling expansion that has no children to show.
if (onOpenDirectory) {
onOpenDirectory(file);
} else {
onToggleExpand(file.path, isExpanded);
}
}}
{...skipRowClickProps}
>
<ChevronRight className={cn('size-3.5 transition-transform', isExpanded && 'rotate-90')} />
@@ -13,8 +13,8 @@ export interface FileManagerAction {
appliesToDirs?: boolean;
/**
* When true (default), the action is shown for file rows. Set to `false` for
* actions that only make sense on directory rows (e.g. "Upload files here",
* "New folder here") combined with `appliesToDirs: true`.
* actions that only make sense on directory rows (e.g. "Upload files",
* "New folder") combined with `appliesToDirs: true`.
*/
appliesToFiles?: boolean;
/**
@@ -242,15 +242,12 @@ export interface FileManagerProps {
*/
onOpen?: (file: FileNode) => void;
/**
* Fired when the user "opens" a *directory* row via double-click or `Enter`.
* When provided, **replaces** the default expand/collapse gesture — useful
* for navigation-style file browsers (e.g. drilling into a remote directory
* by replacing the listing instead of expanding inline). When omitted,
* directories keep the default expand/collapse behaviour.
*
* The chevron icon on the row's left edge always toggles expand/collapse
* regardless of this prop, so the user still has access to inline
* exploration when it makes sense.
* Fired when the user "opens" a *directory* row via double-click, `Enter`,
* or a click on the row's chevron. When provided, **replaces** the default
* expand/collapse gesture — useful for navigation-style file browsers
* (e.g. drilling into a remote directory by replacing the listing instead
* of expanding inline). When omitted, directories keep the default
* expand/collapse behaviour for all three gestures.
*/
onOpenDirectory?: (dir: FileNode) => void;
/**
@@ -513,6 +513,58 @@ describe('toggleSubtreeOnSet', () => {
expect(next).not.toBe(prev);
expect([...prev]).toEqual(['x']);
});
it('REGRESSION: with `rootPath`, clears the branch when every descendant is selected even though the dir itself is missing from prev', () => {
// User scenario: inside an expanded folder, select every file via the
// row checkboxes one by one (or via the header "select all" while the
// folder is the only visible group). The folder's own path is NEVER
// added to `selectedPaths` — the tri-state still renders "checked"
// because `computeDirSelectionState` only counts descendants. A single
// click on the folder's checkbox must clear the branch; without
// `rootPath` the strict `isEverySelected` check would treat the dir's
// missing path as "not all selected" and ADD it instead, requiring a
// second click to actually deselect.
expect([...toggleSubtreeOnSet(new Set(['dir/a', 'dir/b']), ['dir', 'dir/a', 'dir/b'], 'dir')]).toEqual([]);
});
it('with `rootPath`, still adds the missing pieces when only some descendants are selected', () => {
expect([...toggleSubtreeOnSet(new Set(['dir/a']), ['dir', 'dir/a', 'dir/b'], 'dir')].sort()).toEqual([
'dir',
'dir/a',
'dir/b',
]);
});
it('with `rootPath`, treats a fully selected branch (dir + descendants) the same as descendants-only', () => {
// Both shapes ("dir + every descendant" and "every descendant, no dir")
// render the checkbox as fully checked, so a single click must clear
// the entire branch in either case.
expect([...toggleSubtreeOnSet(new Set(['dir', 'dir/a', 'dir/b']), ['dir', 'dir/a', 'dir/b'], 'dir')]).toEqual(
[],
);
});
it('with `rootPath` on an empty folder (paths === [folder]), falls back to a simple binary toggle of the folder itself', () => {
// Empty folder: `paths.length === 1`, the descendant-only short-circuit
// would treat "no descendants" as vacuously all-selected, which would
// make every click on an unselected empty folder remove its own path
// (a no-op). Falling through to the strict path keeps the binary
// semantics that match `computeDirSelectionState` for empty folders.
expect([...toggleSubtreeOnSet(new Set(), ['empty'], 'empty')]).toEqual(['empty']);
expect([...toggleSubtreeOnSet(new Set(['empty']), ['empty'], 'empty')]).toEqual([]);
});
it('with `rootPath`, preserves unrelated entries on both add and remove', () => {
expect([...toggleSubtreeOnSet(new Set(['dir/a', 'x']), ['dir', 'dir/a', 'dir/b'], 'dir')].sort()).toEqual([
'dir',
'dir/a',
'dir/b',
'x',
]);
expect([...toggleSubtreeOnSet(new Set(['dir/a', 'dir/b', 'x']), ['dir', 'dir/a', 'dir/b'], 'dir')]).toEqual([
'x',
]);
});
});
describe('computeRowClickSelection — single modifier', () => {
@@ -612,6 +664,26 @@ describe('computeRowClickSelection — toggle modifier', () => {
expect([...result.next]).toEqual(['unrelated']);
expect(result.nextAnchor).toBe('dir');
});
it('REGRESSION: cmd-click on a folder whose descendants are all selected (dir itself missing) clears the branch in one gesture', () => {
// Mirror of the checkbox-toggle regression — `Cmd`/`Ctrl`+click on a
// folder row goes through the same `toggleSubtreeOnSet` path. If the
// user single-clicked each child to fill the branch (so `dir`'s own
// path was never added), a follow-up cmd-click on the folder row must
// strip everything in one gesture, not silently add `dir` and require
// a second click.
const result = computeRowClickSelection({
anchor: null,
flatVisible: ['dir', 'dir/a', 'dir/b'],
modifier: 'toggle',
path: 'dir',
prev: new Set(['dir/a', 'dir/b', 'unrelated']),
subtreePaths: ['dir', 'dir/a', 'dir/b'],
});
expect([...result.next]).toEqual(['unrelated']);
expect(result.nextAnchor).toBe('dir');
});
});
describe('computeRowClickSelection — range modifier', () => {
@@ -1345,6 +1417,24 @@ describe('computeToggleSelection (Space / row checkbox)', () => {
it('treats empty subtreePaths as "single path" (defensive)', () => {
expect([...computeToggleSelection({ path: 'a', prev: new Set(), subtreePaths: [] })]).toEqual(['a']);
});
it('REGRESSION: clears the branch in one click when every descendant is selected but the dir itself was never added', () => {
// Real-world: user opens `dir`, ticks each child checkbox (or the
// header's "select all" with `dir` being the only visible group). The
// folder's tri-state shows "checked" because every descendant is in
// the selection — but `dir` itself isn't. A single click on the
// folder checkbox must DESELECT the whole branch; the previous code
// required two clicks (the first one silently added `dir`'s own path,
// the second one finally removed everything because by then the
// strict "every path including dir" check passed).
expect([
...computeToggleSelection({
path: 'dir',
prev: new Set(['dir/a', 'dir/b']),
subtreePaths: ['dir', 'dir/a', 'dir/b'],
}),
]).toEqual([]);
});
});
describe('computeToggleSelectAll', () => {
@@ -515,13 +515,47 @@ export const isEverySelected = (paths: Iterable<string>, selected: ReadonlySet<s
* if every path is already selected, the whole batch is removed; otherwise
* the missing ones are added. Mirrors the directory-checkbox semantics.
*
* When `rootPath` is provided (the directory's own path inside `paths`), the
* "is every path selected?" check is computed over the *descendants only* —
* i.e. it ignores whether `rootPath` itself is in `prev`. This matches what
* `computeDirSelectionState` shows on the visible tri-state checkbox, so a
* single click reliably toggles a folder whose descendants are all selected
* even though the folder's own path was never added to the selection (which
* happens whenever the user fills the branch one file at a time, e.g. via
* the row checkboxes or the header "select all" inside an expanded folder).
* Without `rootPath`, the legacy strict contract is used (every path,
* including the dir's own, must be present to qualify as "all selected").
*
* Always returns a freshly cloned Set so React's reference-equality bail-out
* still works for downstream memoized consumers.
*/
export const toggleSubtreeOnSet = (prev: ReadonlySet<string>, paths: readonly string[]): Set<string> => {
export const toggleSubtreeOnSet = (
prev: ReadonlySet<string>,
paths: readonly string[],
rootPath?: string,
): Set<string> => {
const next = new Set(prev);
if (isEverySelected(paths, next)) {
let allSelected: boolean;
if (rootPath !== undefined && paths.length > 1) {
allSelected = true;
for (const p of paths) {
if (p === rootPath) {
continue;
}
if (!next.has(p)) {
allSelected = false;
break;
}
}
} else {
allSelected = isEverySelected(paths, next);
}
if (allSelected) {
removeAllFromSet(next, paths);
} else {
addAllToSet(next, paths);
@@ -620,7 +654,7 @@ export const computeRowClickSelection = ({
if (modifier === 'toggle') {
if (hasSubtree && subtreePaths) {
return { next: toggleSubtreeOnSet(prev, subtreePaths), nextAnchor: path };
return { next: toggleSubtreeOnSet(prev, subtreePaths, path), nextAnchor: path };
}
const next = new Set(prev);
@@ -695,7 +729,7 @@ interface ComputeToggleSelectionArgs {
*/
export const computeToggleSelection = ({ path, prev, subtreePaths }: ComputeToggleSelectionArgs): Set<string> => {
if (subtreePaths && subtreePaths.length > 0) {
return toggleSubtreeOnSet(prev, subtreePaths);
return toggleSubtreeOnSet(prev, subtreePaths, path);
}
const next = new Set(prev);
+1 -1
View File
@@ -61,7 +61,7 @@ DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2', className)}
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
);
+1 -1
View File
@@ -83,7 +83,7 @@ SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2', className)}
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
);
@@ -6,7 +6,14 @@ import { OverwriteConfirmDialog } from '@/components/shared/overwrite-confirm-di
import { OverwriteCtaButtons } from '@/components/shared/overwrite-cta-buttons';
import { useOverwriteAction } from '@/components/shared/use-overwrite-action';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group';
import { toFileNode } from '@/features/resources/resources-utils';
@@ -174,11 +181,11 @@ const FlowFilesAttachResourcesDialogBody = ({
return (
<>
<DialogContent className="flex max-h-[85vh] max-w-3xl flex-col gap-4">
<DialogHeader>
<DialogContent className="flex max-h-[85vh] min-h-[min(85vh,580px)] flex-col gap-4 sm:max-w-3xl">
<DialogHeader className="text-left">
<DialogTitle className="flex items-center gap-2">
<FolderInput className="size-4" />
Attach resources from library
Attach resources
</DialogTitle>
<DialogDescription>
Pick files and/or folders from your global library they will be copied into{' '}
@@ -218,7 +225,7 @@ const FlowFilesAttachResourcesDialogBody = ({
</div>
) : (
<FileManager
className="min-h-[280px] flex-1"
className="min-h-0 flex-1"
emptyState={emptyState}
enableSelection
files={files}
@@ -229,15 +236,15 @@ const FlowFilesAttachResourcesDialogBody = ({
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground text-xs">
<DialogFooter className="flex-wrap gap-4 sm:items-center">
<span className="text-muted-foreground order-last mr-auto text-xs sm:order-first">
{selectedCount > 0
? `${selectedCount} selected`
: hasResources
? 'Select one or more items'
: ''}
</span>
<div className="flex flex-wrap justify-end gap-2">
<div className="flex flex-col-reverse gap-2 sm:ml-auto sm:flex-row sm:justify-end">
<Button
disabled={isAttaching}
onClick={onClose}
@@ -256,7 +263,7 @@ const FlowFilesAttachResourcesDialogBody = ({
primaryLabel={primaryLabel}
/>
</div>
</div>
</DialogFooter>
</DialogContent>
<OverwriteConfirmDialog
@@ -109,6 +109,43 @@ const FlowFilesPullDialogForm = ({ cachedFiles, flowId, onClose, onSuccess }: Fl
refetch: refetchListing,
} = useFlowContainerFiles({ flowId, paths: listingPaths });
/**
* Feed the FileManager a flat single-level view of the current directory.
*
* The container endpoint returns absolute paths (`/work/foo.txt`) and
* passing those straight in would have `buildFileManagerTree` synthesise
* placeholder parent folders for every leading segment (e.g. a collapsed
* `work/` wrapper around the actual entries). That wrapper makes the
* navigation-style chevron / double-click drill-in feel broken the
* chevron of the synthetic root just toggles a wrapper that has no
* meaningful navigation target ("we are already there").
*
* Workaround: expose `name` as `path` (so every entry is a top-level
* sibling) and stash the absolute container path inside `id`. The dialog
* uses `id` for navigation / pull, the FileManager uses `path` for
* selection / row keys / focus management the two stay in sync because
* directory listings always have unique entry names.
*/
const flatFiles = useMemo<FileNode[]>(
() => files.map((file) => ({ ...file, id: file.path, path: file.name })),
[files],
);
/**
* Reverse lookup `name → absolute container path`, used to map the
* FileManager's name-keyed selection back to the absolute paths the
* backend's pull endpoint expects.
*/
const nameToAbsolutePath = useMemo(() => {
const map = new Map<string, string>();
for (const file of files) {
map.set(file.name, file.path);
}
return map;
}, [files]);
const { isPulling, pull } = useFlowFilesPull({
flowId,
// Refresh the listing after a successful pull so newly available entries
@@ -146,7 +183,11 @@ const FlowFilesPullDialogForm = ({ cachedFiles, flowId, onClose, onSuccess }: Fl
const handleOpenDirectory = useCallback(
(dir: FileNode) => {
navigateTo(dir.path);
// `dir.id` is the absolute container path (we flattened the
// listing into `flatFiles` for the FileManager). `dir.path` is
// just the entry's name in this dialog and would normalise to
// a wrong absolute path (`/${name}`) if we used it directly.
navigateTo(dir.id);
},
[navigateTo],
);
@@ -170,15 +211,28 @@ const FlowFilesPullDialogForm = ({ cachedFiles, flowId, onClose, onSuccess }: Fl
}, [refetchListing]);
// Final list of paths to pull. Empty selection → fall back to the directory
// the user is currently browsing. Non-empty selection wins and is deduped
// so a folder + one of its descendants don't double-process.
// the user is currently browsing. Non-empty selection wins and is mapped
// back from the FileManager's name-keyed selection to absolute container
// paths, then deduped so a folder + one of its descendants don't
// double-process. (Dedup is mostly defensive in this dialog because the
// listing is single-level, so descendants aren't visible.)
const pullTargets = useMemo<readonly string[]>(() => {
if (selectedPaths.size > 0) {
return dedupeOverlappingPaths(selectedPaths);
if (selectedPaths.size === 0) {
return [currentPath];
}
return [currentPath];
}, [currentPath, selectedPaths]);
const absolutePaths: string[] = [];
for (const name of selectedPaths) {
const absolute = nameToAbsolutePath.get(name);
if (absolute) {
absolutePaths.push(absolute);
}
}
return dedupeOverlappingPaths(absolutePaths);
}, [currentPath, nameToAbsolutePath, selectedPaths]);
const isUpDisabled = currentPath === '/' || isListingLoading || isPulling;
const isPullDisabled = isListingLoading || pullTargets.length === 0 || !flowId;
@@ -237,7 +291,7 @@ const FlowFilesPullDialogForm = ({ cachedFiles, flowId, onClose, onSuccess }: Fl
</DialogTitle>
<DialogDescription>
Browse the running container and select files or directories to sync into the local cache under{' '}
<code>container/</code>. Double-click a folder to drill in.
<code>container/</code>. Click the arrow on a folder row or double-click the row to drill in.
</DialogDescription>
</DialogHeader>
@@ -302,8 +356,8 @@ const FlowFilesPullDialogForm = ({ cachedFiles, flowId, onClose, onSuccess }: Fl
className="h-[360px]"
emptyState={emptyState}
enableSelection
files={files}
isLoading={isListingLoading && files.length === 0}
files={flatFiles}
isLoading={isListingLoading && flatFiles.length === 0}
onOpenDirectory={handleOpenDirectory}
onSelectionChange={setSelectedPaths}
/>
@@ -343,13 +397,7 @@ const FlowFilesPullDialogForm = ({ cachedFiles, flowId, onClose, onSuccess }: Fl
);
};
export const FlowFilesPullDialog = ({
cachedFiles,
flowId,
isOpen,
onClose,
onSuccess,
}: FlowFilesPullDialogProps) => {
export const FlowFilesPullDialog = ({ cachedFiles, flowId, isOpen, onClose, onSuccess }: FlowFilesPullDialogProps) => {
const handleDialogOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
onClose();
@@ -1,4 +1,4 @@
import { ArrowDownToLine, FolderInput, FolderOutput, FolderUp, Info, Loader2, Search, X } from 'lucide-react';
import { ArrowDownToLine, FolderInput, FolderOutput, FolderUp, Loader2, Search, Upload, X } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner';
@@ -249,28 +249,6 @@ const FlowFiles = () => {
)}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
type="button"
variant="ghost"
>
<Info className="text-muted-foreground size-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-64 text-center text-xs">
<p>
<strong>Uploads</strong> are pushed to <code>/work/uploads</code> and are
immediately accessible inside the container.
</p>
<p className="mt-1">
<strong>Container</strong> files are snapshots pulled via Pull and stored
separately.
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
@@ -281,11 +259,16 @@ const FlowFiles = () => {
type="button"
variant="outline"
>
{upload.isUploading ? <Loader2 className="animate-spin" /> : <FolderUp />}
{upload.isUploading ? <Loader2 className="animate-spin" /> : <Upload />}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Upload files</TooltipContent>
<TooltipContent className="max-w-64 text-center text-xs">
<p className="font-medium">Upload files</p>
<p className="mt-1">
Pushed to <code>/work/uploads</code> immediately accessible inside the container.
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -302,7 +285,13 @@ const FlowFiles = () => {
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Attach resources from library</TooltipContent>
<TooltipContent className="max-w-64 text-center text-xs">
<p className="font-medium">Attach resources</p>
<p className="mt-1">
Copied from the library to <code>/work/resources</code> immediately accessible
inside the container.
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -319,10 +308,17 @@ const FlowFiles = () => {
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{isContainerRunning
? 'Pull file or directory from container'
: 'Container is not running'}
<TooltipContent className="max-w-64 text-center text-xs">
{isContainerRunning ? (
<>
<p className="font-medium">Pull file or directory from container</p>
<p className="mt-1">
Snapshots are stored separately under <strong>Container</strong>.
</p>
</>
) : (
<p className="font-medium">Container is not running</p>
)}
</TooltipContent>
</Tooltip>
</div>
+29 -84
View File
@@ -1,4 +1,4 @@
import { Copy, FileSymlink, Folder, FolderPlus, FolderUp, Loader2, Search, X } from 'lucide-react';
import { Copy, FileSymlink, Folder, FolderPlus, FolderUp, Loader2, Search, Upload, X } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner';
@@ -49,84 +49,38 @@ const Resources = () => {
const [isMkdirOpen, setIsMkdirOpen] = useState(false);
// When the user invokes "New folder here" from a directory row's menu, we
// need the dialog to seed itself with that *specific* directory rather
// than the focus-derived `currentDir`. Cleared whenever the dialog closes
// so the toolbar mkdir falls back to the focus default again.
// need the dialog to seed itself with that *specific* directory. Cleared
// whenever the dialog closes so the toolbar mkdir falls back to the
// library root again.
const [mkdirParentOverride, setMkdirParentOverride] = useState<null | string>(null);
// Both dialogs accept an array now: a row-action click pushes a single-element
// array, the bulk bar pushes the full deduped selection. Empty / null array
// closes the dialog.
const [filesToMove, setFilesToMove] = useState<FileNode[] | null>(null);
const [filesToCopy, setFilesToCopy] = useState<FileNode[] | null>(null);
// The path of the row currently focused in the FileManager (roving tabindex).
// `null` until the user has actually clicked / Tab-ed into a row, so that we
// can distinguish "user explicitly chose a directory" from the auto-fallback
// the FileManager uses for its keyboard handler.
const [activeRowPath, setActiveRowPath] = useState<null | string>(null);
// Index resources by virtual path so deriving `currentDir` (and other
// path-based lookups) stays O(1) regardless of library size.
const resourcesByPath = useMemo(() => {
const map = new Map<string, (typeof resources)[number]>();
for (const resource of resources) {
map.set(resource.path, resource);
}
return map;
}, [resources]);
/**
* Virtual directory the next upload / mkdir / drop should target. Resolved
* from `activeRowPath`:
* - directory row the directory's own path (upload lands inside it)
* - file row the file's parent directory (sibling upload)
* - no focused row '' (library root)
* - stale path '' (focused row was deleted by a subscription)
*
* Mirrors the same resolution rule that the FileManager's internal
* drag-and-drop uses (`resolveDropTargetDir` in `use-file-manager-dnd`),
* so context-aware uploads land where users would expect a file dropped
* onto the same row to land.
*/
const currentDir = useMemo(() => {
if (!activeRowPath) {
return '';
}
const resource = resourcesByPath.get(activeRowPath);
if (!resource) {
return '';
}
if (resource.isDir) {
return resource.path;
}
const idx = resource.path.lastIndexOf('/');
return idx === -1 ? '' : resource.path.slice(0, idx);
}, [activeRowPath, resourcesByPath]);
const upload = useResourcesUpload({ defaultDir: currentDir });
// Toolbar / empty-area mkdir + upload always target the library root —
// row-level "Upload here" / "New folder here" handlers carry their own
// explicit path, and DnD passes the destination per drop, so these
// entry points don't need a focus-derived fallback directory.
const upload = useResourcesUpload();
const deletion = useResourcesDelete();
const { move } = useResourcesMove();
const canAcceptDrop = !upload.isUploading;
const { dragHandlers, isDragging } = useFilesDragAndDrop({
canAcceptDrop,
// `upload.uploadFiles` is reference-stable: it reads the latest
// `defaultDir` through a ref, so changing the focused row does not
// invalidate the drag handlers below.
// Page-level drop falls back to the hook's defaults — i.e. the
// library root, since `useResourcesUpload` is invoked without a
// `defaultDir`.
onDrop: upload.uploadFiles,
});
// Per-row external file drop (OS desktop → folder row): forward the
// dropped files together with the resolved directory so the upload lands
// exactly where the user released, not in the focus-derived `currentDir`.
// FileManager already stops propagation on the row, so this never
// double-fires alongside the page-level `dragHandlers` above.
// exactly where the user released, not in the library root that the
// page-level `dragHandlers` above default to. FileManager already stops
// propagation on the row, so this never double-fires.
const handleExternalFileDrop = useCallback(
async (droppedFiles: File[], destinationDir: string): Promise<void> => {
await upload.uploadFiles(droppedFiles, { dir: destinationDir });
@@ -270,19 +224,19 @@ const Resources = () => {
{
appliesToDirs: true,
appliesToFiles: false,
icon: FolderUp,
id: 'resources-upload-here',
label: 'Upload files here',
onSelect: handleUploadHere,
icon: FolderPlus,
id: 'resources-mkdir-here',
label: 'New folder',
onSelect: handleMkdirHere,
separatorBefore: true,
},
{
appliesToDirs: true,
appliesToFiles: false,
icon: FolderPlus,
id: 'resources-mkdir-here',
label: 'New folder here',
onSelect: handleMkdirHere,
icon: Upload,
id: 'resources-upload-here',
label: 'Upload files',
onSelect: handleUploadHere,
},
{
appliesToDirs: true,
@@ -320,10 +274,7 @@ const Resources = () => {
// Right-click anywhere outside a row in the tree → mirror the toolbar
// gestures so users have a closer-to-pointer entry point. Both items
// resolve through the same focus-derived `currentDir` as the toolbar
// buttons (via `defaultDir` / `mkdirParentOverride === null`), so the
// outcome — and the toolbar tooltip telling the user *where* it lands —
// stays identical no matter which surface the user invokes.
// target the library root, identical to the toolbar buttons.
const fileManagerEmptyAreaActions = useMemo<FileManagerEmptyAreaAction[]>(
() => [
{
@@ -385,11 +336,6 @@ const Resources = () => {
/>
);
// Human-readable target for the toolbar tooltips: matches the same wording
// used in the upload success toast so users see the same "to /reports/2025"
// / "to your library" phrasing both before and after the action.
const uploadTargetLabel = currentDir ? `/${currentDir}` : 'library root';
const noMatchesState = (
<Empty>
<EmptyHeader>
@@ -408,7 +354,7 @@ const Resources = () => {
<>
{pageHeader}
<div
className="relative flex flex-1 flex-col gap-4 p-4"
className="relative flex h-[calc(100dvh-3rem)] flex-col gap-4 p-4"
{...dragHandlers}
>
<input
@@ -476,7 +422,7 @@ const Resources = () => {
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Create directory in {uploadTargetLabel}</TooltipContent>
<TooltipContent>Create new folder</TooltipContent>
</Tooltip>
<Tooltip>
@@ -489,11 +435,11 @@ const Resources = () => {
type="button"
variant="outline"
>
{upload.isUploading ? <Loader2 className="animate-spin" /> : <FolderUp />}
{upload.isUploading ? <Loader2 className="animate-spin" /> : <Upload />}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Upload files to {uploadTargetLabel}</TooltipContent>
<TooltipContent>Upload files</TooltipContent>
</Tooltip>
</div>
</Form>
@@ -506,7 +452,6 @@ const Resources = () => {
emptyState={noResourcesState}
files={fileNodes}
isLoading={isInitialLoading}
onActiveRowChange={setActiveRowPath}
onExternalFileDrop={handleExternalFileDrop}
onMoveItems={handleMoveItems}
onOpen={handleOpenFile}
@@ -514,7 +459,7 @@ const Resources = () => {
/>
<ResourcesMkdirDialog
defaultParentPath={mkdirParentOverride ?? currentDir}
defaultParentPath={mkdirParentOverride ?? ''}
isOpen={isMkdirOpen}
onClose={closeMkdirDialog}
/>