mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-25 12:36:30 +00:00
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:
co-authored by
Cursor
parent
d743bf5105
commit
012cb741ad
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user