diff --git a/frontend/src/components/shared/file-manager/file-manager-row.tsx b/frontend/src/components/shared/file-manager/file-manager-row.tsx index 68230f44..6192e0f1 100644 --- a/frontend/src/components/shared/file-manager/file-manager-row.tsx +++ b/frontend/src/components/shared/file-manager/file-manager-row.tsx @@ -1,5 +1,13 @@ import { ChevronRight, MoreVertical } from 'lucide-react'; -import { type CSSProperties, memo, type MouseEvent as ReactMouseEvent, type ReactNode, useMemo } from 'react'; +import { + type CSSProperties, + memo, + type FocusEvent as ReactFocusEvent, + type MouseEvent as ReactMouseEvent, + type ReactNode, + type SyntheticEvent, + useMemo, +} from 'react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; @@ -115,10 +123,39 @@ interface FileManagerRowProps { const isClickInsideSkipZone = (target: EventTarget | null): boolean => target instanceof Element && !!target.closest(`[${SKIP_ROW_CLICK_ATTR}]`); +/** + * Returns `true` when the synthetic event originated from a portal-mounted + * descendant of this row in the React tree — e.g. a dropdown / context-menu + * item rendered to `document.body` by Radix. + * + * React synthetic events bubble through the **component tree**, not the DOM + * tree, so a click on a portaled `` still bubbles up to + * the row's `onClick` even though its DOM lives outside the row. The + * `data-fm-skip-row-click` opt-out doesn't help here either — `closest()` + * walks DOM ancestors, and the portal isn't a DOM child of the row. + * + * The fix is structural: if the event's actual DOM target is not contained + * within the row's DOM subtree (the row is `event.currentTarget`), the event + * came from a portal — ignore it for selection / focus / open gestures. + */ +const isEventFromOutsideRowDom = (event: SyntheticEvent): boolean => { + const { currentTarget, target } = event; + + return currentTarget instanceof Node && target instanceof Node && !currentTarget.contains(target); +}; + +// Filter rules: +// - directory row → keep only actions with `appliesToDirs: true` +// - file row → keep only actions with `appliesToFiles !== false` (default true) +// So the legacy `appliesToDirs` semantics (omit → files-only, true → both) still hold, +// and `appliesToFiles: false` carves out the directory-only subset. const buildVisibleActions = ( actions: readonly FileManagerAction[], file: FileManagerInternalNode, -): FileManagerAction[] => actions.filter((action) => action.appliesToDirs || !file.isDir); +): FileManagerAction[] => + actions.filter((action) => + file.isDir ? action.appliesToDirs === true : action.appliesToFiles !== false, + ); const FileManagerRowImpl = ({ actions, @@ -156,6 +193,15 @@ const FileManagerRowImpl = ({ const visibleActions = useMemo(() => buildVisibleActions(actions, file), [actions, file]); const handleRowClick = (event: ReactMouseEvent) => { + // Drop events bubbling up from portaled menu content (Radix dropdown / + // context menu items rendered to document.body) — they reach this + // handler through React's component-tree bubbling, not DOM bubbling, + // and would otherwise reset / mutate the multi-selection on every + // action invocation. + if (isEventFromOutsideRowDom(event)) { + return; + } + if (isClickInsideSkipZone(event.target)) { return; } @@ -177,6 +223,13 @@ const FileManagerRowImpl = ({ // chevron icon on the row's left edge always toggles expand/collapse for // directories, regardless of `onOpenDirectory`. const handleRowDoubleClick = (event: ReactMouseEvent) => { + // Same React-tree-bubbling guard as `handleRowClick` — a double-click + // on a portaled menu item must not be treated as a row "open" gesture + // (which would, for files, kick off a download via `onOpen`). + if (isEventFromOutsideRowDom(event)) { + return; + } + if (isClickInsideSkipZone(event.target)) { return; } @@ -256,6 +309,7 @@ const FileManagerRowImpl = ({ const dropdownItems = hasActions ? renderActionItems('dropdown') : []; const contextItems = renderActionItems('context'); + const hasOwnContextMenu = contextItems.length > 0; const isActiveRow = activeRowPath === file.path; const rowStyle = { @@ -263,7 +317,10 @@ const FileManagerRowImpl = ({ gridTemplateColumns: gridTemplate, } as CSSProperties & Record<'--fm-depth', number>; - const isDraggable = !!dnd && !file.isGroupRoot; + // Bind handlers may be present even when intra-tree move is off (external + // file-drop only) — in that case `dnd.canDrag` is false and the row should + // not advertise itself as grabbable. + const isDraggable = !!dnd && dnd.canDrag && !file.isGroupRoot; const isDropTarget = dnd?.isDropTarget ?? false; const isBeingDragged = dnd?.isBeingDragged ?? false; @@ -296,6 +353,16 @@ const FileManagerRowImpl = ({ data-path={file.path} draggable={isDraggable} onClick={handleRowClick} + // Stop the contextmenu event from bubbling to the FileManager's + // empty-area context menu when the row has its own. `composeEventHandlers` + // (used by Radix's `asChild` Slot) only stops on `defaultPrevented`, + // not `propagationStopped`, so the row's own ContextMenuTrigger + // still fires after our handler — both behaviors compose cleanly. + // For rows without their own items we leave the event alone so it + // falls through to the outer empty-area menu (a sensible fallback). + onContextMenu={ + hasOwnContextMenu ? (event: ReactMouseEvent) => event.stopPropagation() : undefined + } onDoubleClick={handleRowDoubleClick} onDragEnd={dnd?.onDragEnd} onDragEnter={dnd?.onDragEnter} @@ -303,7 +370,19 @@ const FileManagerRowImpl = ({ onDragOver={dnd?.onDragOver} onDragStart={dnd?.onDragStart} onDrop={dnd?.onDrop} - onFocus={() => onFocusRow(file.path)} + // `focusin` (which React's `onFocus` listens to) bubbles through + // both DOM and React trees, so focusing a portaled menu item — or + // navigating between them with arrow keys — would otherwise fire + // the row's focus handler and silently change `activeRowPath` / + // the focus-derived "current dir". Same containment check as the + // click handlers gates this off. + onFocus={(event: ReactFocusEvent) => { + if (isEventFromOutsideRowDom(event)) { + return; + } + + onFocusRow(file.path); + }} role="treeitem" style={rowStyle} tabIndex={isActiveRow ? 0 : -1} diff --git a/frontend/src/components/shared/file-manager/file-manager-types.ts b/frontend/src/components/shared/file-manager/file-manager-types.ts index f5237d1c..bab3b625 100644 --- a/frontend/src/components/shared/file-manager/file-manager-types.ts +++ b/frontend/src/components/shared/file-manager/file-manager-types.ts @@ -1,8 +1,22 @@ import type { ComponentType, ReactNode } from 'react'; export interface FileManagerAction { - /** When true, action is shown for both files and directories. Defaults to false (files only). */ + /** + * When true, the action is shown for directory rows. Defaults to false. + * + * Combine with {@link FileManagerAction.appliesToFiles} to scope the action: + * - `appliesToDirs: false, appliesToFiles: true` (default) → files only + * - `appliesToDirs: true, appliesToFiles: true` (default) → files + directories + * - `appliesToDirs: true, appliesToFiles: false` → directories only + * - `appliesToDirs: false, appliesToFiles: false` → never (filtered out) + */ 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`. + */ + appliesToFiles?: boolean; /** * If provided, the action is rendered as an `` link instead of a button. * Useful for native browser downloads. @@ -81,6 +95,28 @@ export interface FileManagerColumnsConfig { isSizeVisible?: boolean; } +/** + * Single entry in the right-click context menu surfaced over the empty area + * of the tree (i.e. anywhere outside a row). Distinct from {@link FileManagerAction} + * because it cannot reference a `FileNode` — the user clicked between rows, + * not on one. Typical use: "Upload files", "New folder" at the tree's root. + * + * The menu only renders when the host supplies a non-empty + * `emptyAreaActions` array. When a row has its own context items, the + * row-level menu wins (right-clicks on rows do not propagate to the empty-area + * menu — see `file-manager-row.tsx`). + */ +export interface FileManagerEmptyAreaAction { + icon?: ComponentType<{ className?: string }>; + /** Stable identifier — used as React `key`. */ + id: string; + label: string; + onSelect: () => void; + /** When true, separator is rendered before this item. */ + separatorBefore?: boolean; + variant?: 'default' | 'destructive'; +} + export interface FileManagerInternalNode extends FileNode { children: FileManagerInternalNode[]; depth: number; @@ -140,6 +176,13 @@ export interface FileManagerProps { className?: string; /** Per-column visibility flags. Defaults: `{ isSizeVisible: true, isModifiedVisible: true }`. */ columns?: FileManagerColumnsConfig; + /** + * Items rendered in the right-click context menu over the tree's empty + * area (anywhere outside a row). When omitted / empty, no empty-area + * menu is registered and the browser's native context menu is shown. + * See {@link FileManagerEmptyAreaAction} for the item shape. + */ + emptyAreaActions?: readonly FileManagerEmptyAreaAction[]; /** Empty state node (rendered when files.length === 0 and not loading). */ emptyState?: ReactNode; /** @@ -153,6 +196,35 @@ export interface FileManagerProps { isLoading?: boolean; /** Localizable user-facing strings. */ labels?: FileManagerLabels; + /** + * Fires whenever the focused row changes (roving tabindex). The path is `null` + * until the user actually focuses a row via click or keyboard navigation — + * it does NOT auto-fall back to the first visible row, so callers can + * distinguish "user picked something" from "tree just rendered". + * + * Use it to implement context-aware actions (e.g. "Upload here" defaulting + * to the focused directory, or its parent for files). The supplied callback + * is read through a ref, so it does not need to be memoized. + * + * Emitted values may reference paths that no longer exist in `files` (e.g. + * the focused row was deleted by an external mutation); consumers should + * validate against their own data before using the path. + */ + onActiveRowChange?: (path: null | string) => void; + /** + * Optional handler for files dragged in from outside the page (e.g. the + * desktop / OS file explorer). When provided, dropping files onto a + * directory row — or onto any file row whose parent is a real directory — + * fires this callback with the dropped `File[]` and the resolved + * destination directory path. Drops on the empty area outside any row, + * on synthetic group-root headers and on top-level files fall through + * to whatever drag handler the host attaches around `FileManager` + * (e.g. a page-level DnD upload zone). + * + * The callback is independent of {@link onMoveItems}: external-file drop + * support can be enabled without intra-tree move support, and vice versa. + */ + onExternalFileDrop?: (files: File[], destinationDir: string) => Promise | void; /** * Enables internal drag-and-drop: rows become draggable and directories accept drops. * Invoked with the dragged item(s) and the destination directory path (`''` for root). diff --git a/frontend/src/components/shared/file-manager/file-manager.tsx b/frontend/src/components/shared/file-manager/file-manager.tsx index 788f90be..1c1f94bb 100644 --- a/frontend/src/components/shared/file-manager/file-manager.tsx +++ b/frontend/src/components/shared/file-manager/file-manager.tsx @@ -1,10 +1,31 @@ -import { type MouseEvent as ReactMouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + type MouseEvent as ReactMouseEvent, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Checkbox } from '@/components/ui/checkbox'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; import { cn } from '@/lib/utils'; import type { FileManagerRowDisplay, FileManagerRowHandlers } from './file-manager-row'; -import type { FileManagerAction, FileManagerBulkAction, FileManagerProps, FileNode } from './file-manager-types'; +import type { + FileManagerAction, + FileManagerBulkAction, + FileManagerEmptyAreaAction, + FileManagerProps, + FileNode, +} from './file-manager-types'; import { FileManagerBulkActionsBar } from './file-manager-bulk-actions-bar'; import { FileManagerSkeleton } from './file-manager-skeleton'; @@ -25,17 +46,54 @@ import { useFileManagerSelection } from './use-file-manager-selection'; const EMPTY_ACTIONS: readonly FileManagerAction[] = Object.freeze([]); const EMPTY_BULK_ACTIONS: readonly FileManagerBulkAction[] = Object.freeze([]); +const EMPTY_AREA_ACTIONS: readonly FileManagerEmptyAreaAction[] = Object.freeze([]); + +/** + * Renders the right-click context menu items for the empty area of the tree. + * Kept as a plain helper so the same JSX can be reused inside the + * `` regardless of where it lives in the render tree. + */ +const renderEmptyAreaItems = (items: readonly FileManagerEmptyAreaAction[]): ReactNode[] => { + const nodes: ReactNode[] = []; + + for (const action of items) { + if (action.separatorBefore && nodes.length > 0) { + nodes.push(); + } + + const ActionIcon = action.icon; + + nodes.push( + action.onSelect()} + > + {ActionIcon ? : null} + {action.label} + , + ); + } + + return nodes; +}; export const FileManager = ({ actions, bulkActions, className, columns, + emptyAreaActions, emptyState, enableSelection, files, isLoading, labels, + onActiveRowChange, + onExternalFileDrop, onMoveItems, onOpen, onOpenDirectory, @@ -175,6 +233,22 @@ export const FileManager = ({ return flatVisible[0] ?? null; }, [activeRowPath, flatVisible]); + // Mirror the `onSelectionChange` plumbing for the focused row: stash the + // callback in a ref so the effect only re-fires on actual `activeRowPath` + // changes, not on every parent re-render passing a fresh function. We + // emit the raw `activeRowPath` (not `resolvedActiveRow`) so consumers can + // distinguish "user picked something" from the auto-fallback to the first + // visible row that the roving tabindex uses internally. + const onActiveRowChangeRef = useRef(onActiveRowChange); + + useEffect(() => { + onActiveRowChangeRef.current = onActiveRowChange; + }, [onActiveRowChange]); + + useEffect(() => { + onActiveRowChangeRef.current?.(activeRowPath); + }, [activeRowPath]); + const focusRow = useCallback((path: null | string) => { if (!path) { return; @@ -210,6 +284,7 @@ export const FileManager = ({ const dnd = useFileManagerDnd({ findNode: useCallback((path: string) => findNodeByPath(fullTree, path), [fullTree]), onClearSelection: clearSelection, + onExternalFileDrop, onMoveItems, selectedPaths, }); @@ -284,6 +359,63 @@ export const FileManager = ({ return
{search?.emptyState ?? emptyState}
; } + const effectiveEmptyAreaActions = emptyAreaActions ?? EMPTY_AREA_ACTIONS; + const hasEmptyAreaActions = effectiveEmptyAreaActions.length > 0; + + // The scrollable tree element. Wrapped in a Radix `` below + // when the host registered empty-area items — right-clicks on rows still + // open the row-level menu because rows stop the contextmenu event there + // (see `file-manager-row.tsx`), so the outer trigger only fires for + // clicks outside any row, which is the entire point. + const treeBody = ( + + ); + + const tree = hasEmptyAreaActions ? ( + + {treeBody} + {renderEmptyAreaItems(effectiveEmptyAreaActions)} + + ) : ( + treeBody + ); + return (
- + {tree} {hasBulkActions && ( void; + /** + * Optional handler for external (OS-side) file drops onto a directory row. + * When provided, dropping files from the desktop / file explorer onto any + * folder row (including a file row whose parent is a real folder, mirroring + * the intra-tree resolution) lands them in that folder instead of bubbling + * up to a page-level handler. When omitted, external drops fall through + * to the parent's drag handlers as before. + * + * The handler receives the dropped `File` list and the resolved destination + * directory (never the synthetic root sentinel — top-level rows that + * resolve to root still pass through, see {@link isRootPassthrough}). + */ + onExternalFileDrop?: (files: File[], destinationDir: string) => Promise | void; /** When undefined, DnD is fully disabled. */ onMoveItems?: (sources: FileNode[], destinationDir: string) => Promise | void; /** @@ -105,6 +126,18 @@ const isValidMove = (sources: FileManagerInternalNode[], destDir: string): boole const isFmDragEvent = (event: ReactDragEvent): boolean => event.dataTransfer.types?.includes(FM_DND_MIME) ?? false; +/** + * `true` when the drag carries OS-side files (i.e. an external drag from the + * desktop / file explorer rather than an intra-tree row drag). Used to route + * the row-level handlers into the upload code path instead of the move one. + * + * Browsers report these drags via the `'Files'` entry in + * `dataTransfer.types`; we deliberately don't read `dataTransfer.files` until + * `drop` because most browsers gate it for security on `dragenter` / `dragover`. + */ +const isExternalFileDragEvent = (event: ReactDragEvent): boolean => + event.dataTransfer.types?.includes('Files') ?? false; + /** * Top-level files (no parent directory) act as a pass-through to the container's * root-drop logic: instead of treating the row as its own drop target, we let the @@ -177,10 +210,21 @@ const resolveDropTargetDir = ( export const useFileManagerDnd = ({ findNode, onClearSelection, + onExternalFileDrop, onMoveItems, selectedPaths, }: UseFileManagerDndParams): UseFileManagerDndResult => { + // `isEnabled` keeps its legacy meaning ("internal move DnD is on") so + // existing consumers (e.g. row `draggable` flag, container root-drop + // wiring) keep working unchanged. External-file drops live on a separate + // flag and only contribute row-level handlers; they never make rows + // draggable or wire the container. const isEnabled = !!onMoveItems; + const isExternalDropEnabled = !!onExternalFileDrop; + // Some node-level branches need to know whether the row should react to + // *any* drag event (move OR external) — this combined flag avoids + // repeating the OR at every entry. + const reactsToDrags = isEnabled || isExternalDropEnabled; // Stash via ref so the dragstart handler doesn't re-create on every selection // change (which would invalidate `bindNodeDnd` and re-render every row through @@ -297,12 +341,30 @@ export const useFileManagerDnd = ({ const handleNodeDragEnter = useCallback( (node: FileManagerInternalNode, event: ReactDragEvent): void => { - if (!isEnabled || !isFmDragEvent(event)) { + if (!reactsToDrags) { + return; + } + + const isFm = isFmDragEvent(event); + // External file drags are only honoured when the host registered an + // `onExternalFileDrop` callback — otherwise the event must bubble + // out so a page-level handler can pick it up. + const isExternal = !isFm && isExternalDropEnabled && isExternalFileDragEvent(event); + + if (!isFm && !isExternal) { + return; + } + + // The "internal" branches need a registered move callback to do + // anything useful — gate accordingly so an external-only setup + // doesn't accidentally claim FM-mime drags it can't complete. + if (isFm && !isEnabled) { return; } // Top-level files behave as part of the root drop area — let the event - // bubble so the container handler shows the root highlight. + // bubble so the container handler (for FM-mime drags) or the page- + // level external drop handler can pick it up. if (isRootPassthrough(node)) { return; } @@ -319,8 +381,12 @@ export const useFileManagerDnd = ({ // automatically because every row compares `dropTargetPath` to its own // `node.path`, and we set the parent's path here. const targetDir = resolveDropTargetDir(node, findNode); + // External drags accept any directory; move drags additionally need + // a valid source/destination pairing (no self-into-self / parent etc.). + const isAcceptable = + targetDir !== null && (isExternal || isValidMove(dragSourcesRef.current, targetDir)); - if (targetDir === null || !isValidMove(dragSourcesRef.current, targetDir)) { + if (!isAcceptable) { // Cursor is now over a non-droppable row — make sure the previously // shown root highlight (if any) gets cleared. Container `dragleave` // gates clearing on `relatedTarget` to avoid flicker, so the row @@ -341,17 +407,29 @@ export const useFileManagerDnd = ({ setDropTargetPath(targetDir); } }, - [findNode, isEnabled], + [findNode, isEnabled, isExternalDropEnabled, reactsToDrags], ); const handleNodeDragLeave = useCallback( (node: FileManagerInternalNode, event: ReactDragEvent): void => { - if (!isEnabled || !isFmDragEvent(event)) { + if (!reactsToDrags) { + return; + } + + const isFm = isFmDragEvent(event); + const isExternal = !isFm && isExternalDropEnabled && isExternalFileDragEvent(event); + + if (!isFm && !isExternal) { + return; + } + + if (isFm && !isEnabled) { return; } // Mirrors `handleNodeDragEnter`: pass-through rows must let `dragleave` - // bubble too, so the container's enter/leave counter stays balanced. + // bubble too, so the container's / page-level enter/leave counter + // stays balanced. if (isRootPassthrough(node)) { return; } @@ -382,16 +460,28 @@ export const useFileManagerDnd = ({ counters.set(targetDir, current - 1); } }, - [findNode, isEnabled], + [findNode, isEnabled, isExternalDropEnabled, reactsToDrags], ); const handleNodeDragOver = useCallback( (node: FileManagerInternalNode, event: ReactDragEvent): void => { - if (!isEnabled || !isFmDragEvent(event)) { + if (!reactsToDrags) { return; } - // Pass-through rows defer drop-acceptance to the container (= root drop). + const isFm = isFmDragEvent(event); + const isExternal = !isFm && isExternalDropEnabled && isExternalFileDragEvent(event); + + if (!isFm && !isExternal) { + return; + } + + if (isFm && !isEnabled) { + return; + } + + // Pass-through rows defer drop-acceptance to the container (= root drop) + // for FM-mime drags, and to the page-level handler for external drags. if (isRootPassthrough(node)) { return; } @@ -403,59 +493,105 @@ export const useFileManagerDnd = ({ const targetDir = resolveDropTargetDir(node, findNode); - if (targetDir === null || !isValidMove(dragSourcesRef.current, targetDir)) { + if (targetDir === null) { + return; + } + + if (isFm && !isValidMove(dragSourcesRef.current, targetDir)) { return; } event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; + event.dataTransfer.dropEffect = isFm ? 'move' : 'copy'; }, - [findNode, isEnabled], + [findNode, isEnabled, isExternalDropEnabled, reactsToDrags], ); const handleNodeDrop = useCallback( (node: FileManagerInternalNode, event: ReactDragEvent): void => { - if (!isEnabled || !isFmDragEvent(event)) { + if (!reactsToDrags) { return; } - // Pass-through rows let the container handle the actual drop (= root move). + const isFm = isFmDragEvent(event); + const isExternal = !isFm && isExternalDropEnabled && isExternalFileDragEvent(event); + + if (!isFm && !isExternal) { + return; + } + + if (isFm && !isEnabled) { + return; + } + + // Pass-through rows let the container handle the FM-mime drop (= root move) + // or, for external drags, bubble out to the page-level handler. if (isRootPassthrough(node)) { return; } - // Otherwise stop propagation so a drop on a row never bubbles to the - // container, which would otherwise treat the drop as a root move (the source - // bug behind "drag a file, return it back" → unintended API call). + // Stop propagation so the drop never bubbles to the container — both + // for FM-mime drags (otherwise the row position would be treated as + // a root move) and for external drags (otherwise a page-level + // listener would also process the same files and double-upload). event.stopPropagation(); - const sources = dragSourcesRef.current; const targetDir = resolveDropTargetDir(node, findNode); - if (targetDir === null || !isValidMove(sources, targetDir)) { + if (targetDir === null) { resetDragState(); return; } + if (isFm) { + const sources = dragSourcesRef.current; + + if (!isValidMove(sources, targetDir)) { + resetDragState(); + + return; + } + + event.preventDefault(); + resetDragState(); + // Selection paths reference the OLD locations, which the move call is + // about to invalidate. Clear them so the bulk-actions bar / "select-all" + // checkbox don't show stale state. + onClearSelection?.(); + void onMoveItems?.(sources, targetDir); + + return; + } + + // External-file branch — `dataTransfer.files` is finally readable on + // `drop` (most browsers gate access during enter/over for security). + const droppedFiles = Array.from(event.dataTransfer.files ?? []); + event.preventDefault(); resetDragState(); - // Selection paths reference the OLD locations, which the move call is - // about to invalidate. Clear them so the bulk-actions bar / "select-all" - // checkbox don't show stale state. - onClearSelection?.(); - void onMoveItems?.(sources, targetDir); + + if (droppedFiles.length === 0) { + return; + } + + void onExternalFileDrop?.(droppedFiles, targetDir); }, - [findNode, isEnabled, onClearSelection, onMoveItems, resetDragState], + [findNode, isEnabled, isExternalDropEnabled, onClearSelection, onExternalFileDrop, onMoveItems, reactsToDrags, resetDragState], ); const bindNodeDnd = useCallback( (node: FileManagerInternalNode): FileManagerNodeDndHandlers | null => { - if (!isEnabled) { + // Bind handlers whenever ANY drag interaction is enabled (move OR + // external file drop). Rows still gate their own `draggable` flag + // on `isEnabled` via `file-manager-row.tsx`, so external-only + // setups don't accidentally make rows look grabbable. + if (!reactsToDrags) { return null; } return { + canDrag: isEnabled, isBeingDragged: draggingPaths.has(node.path), isDropTarget: dropTargetPath === node.path, onDragEnd: resetDragState, @@ -475,6 +611,7 @@ export const useFileManagerDnd = ({ handleNodeDragStart, handleNodeDrop, isEnabled, + reactsToDrags, resetDragState, ], ); diff --git a/frontend/src/components/shared/use-overwrite-action.ts b/frontend/src/components/shared/use-overwrite-action.ts index a6c1abc4..06a59520 100644 --- a/frontend/src/components/shared/use-overwrite-action.ts +++ b/frontend/src/components/shared/use-overwrite-action.ts @@ -95,7 +95,7 @@ interface UseOverwriteActionResult { * to wrap them in `useCallback`. This keeps the hook ergonomic at the call * site without sacrificing reference stability for the returned actions. */ -export const useOverwriteAction = ( +export const useOverwriteAction = ( options: UseOverwriteActionOptions, ): UseOverwriteActionResult => { const [conflicts, setConflicts] = useState([]); diff --git a/frontend/src/features/flows/files/flow-files-promote-dialog.tsx b/frontend/src/features/flows/files/flow-files-promote-dialog.tsx index 8df00671..f6f6e3b8 100644 --- a/frontend/src/features/flows/files/flow-files-promote-dialog.tsx +++ b/frontend/src/features/flows/files/flow-files-promote-dialog.tsx @@ -5,7 +5,6 @@ import { useForm } from 'react-hook-form'; import type { FileNode } from '@/components/shared/file-manager'; import type { OverwriteConflict } from '@/components/shared/overwrite-confirm-dialog'; -import type { OverwriteOutcome } from '@/components/shared/use-overwrite-action'; import { OverwriteConfirmDialog } from '@/components/shared/overwrite-confirm-dialog'; import { OverwriteCtaButtons } from '@/components/shared/overwrite-cta-buttons'; @@ -44,12 +43,12 @@ interface FlowFilesPromoteDialogProps { } interface PromotePlan { - /** Final virtual path inside the user's library. */ + /** Destination string sent to the backend (exact path or base directory). */ destination: string; - /** Display name extracted from the destination for the conflict dialog. */ - destinationName: string; - /** Source path inside the flow cache (e.g. `uploads/result.md`). */ - source: string; + /** Source paths inside the flow cache (sent as `sources[]`). */ + sources: readonly string[]; + /** Pre-computed `(destination, destinationName)` pairs for client-side preflight + 409 fallback. */ + targets: OverwriteConflict[]; } const buildSingleDefaultDestination = (file: FileNode): string => stripFlowRootPrefix(file.path) || file.name; @@ -74,39 +73,40 @@ const computeMultiDefaultDestination = (files: readonly [FileNode, ...FileNode[] }; /** - * Build the final set of `(source, destination)` promotion plans from the form - * value and the picked files. For a single file the user types the full path; - * for a batch the input is a directory and every entry keeps its name. + * Pre-compute the per-file destinations the backend will write to. Mirrors the + * server's resolution rules: + * - 1 source → destination is the exact target path + * - 2+ sources → destination is a base directory; each source + * lands at `/`. */ -const buildPromotePlans = ( - files: readonly [FileNode, ...FileNode[]], - values: FlowFilesPromoteFormValues, -): PromotePlan[] => { +const computeTargets = (files: readonly [FileNode, ...FileNode[]], destination: string): OverwriteConflict[] => { + const trimmed = destination.trim(); + if (files.length > 1) { - const targetDir = values.destination.trim().replace(/\/+$/, ''); + const baseDir = trimmed.replace(/\/+$/, ''); - return files.map((file) => ({ - destination: targetDir ? `${targetDir}/${file.name}` : file.name, - destinationName: file.name, - source: file.path, - })); + return files.map((file) => { + const dest = baseDir ? `${baseDir}/${file.name}` : file.name; + + return { destination: dest, destinationName: file.name }; + }); } - const [single] = files; - const destination = values.destination.trim(); - return [ { - destination, - destinationName: destination.split('/').pop() ?? destination, - source: single.path, + destination: trimmed, + destinationName: trimmed.split('/').pop() ?? trimmed, }, ]; }; -const planToConflict = ({ destination, destinationName }: PromotePlan): OverwriteConflict => ({ - destination, - destinationName, +const buildPromotePlan = ( + files: readonly [FileNode, ...FileNode[]], + values: FlowFilesPromoteFormValues, +): PromotePlan => ({ + destination: values.destination.trim(), + sources: files.map((file) => file.path), + targets: computeTargets(files, values.destination), }); const FlowFilesPromoteDialogForm = ({ files, flowId, onClose }: FlowFilesPromoteDialogFormProps) => { @@ -136,52 +136,26 @@ const FlowFilesPromoteDialogForm = ({ files, flowId, onClose }: FlowFilesPromote /** * Drive the canonical "Save / Save with overwrite / Replace all" workflow - * from the shared hook. The plan is the array of promote operations - * derived from the form on submit; per-file outcomes are aggregated into - * a single `OverwriteOutcome` so the hook can branch uniformly. + * with a single atomic batch request. Backend handles `sources[]` in one + * DB transaction (all-or-nothing) — no per-source aggregation needed here. */ - const overwriteAction = useOverwriteAction({ - execute: async (plans, force): Promise => { - const outcomes = await Promise.all( - plans.map((plan) => promote(plan.source, { destination: plan.destination }, force)), - ); - - // Surface per-file conflict descriptors so the dialog names exactly - // which destinations are taken — far more useful for multi-promote - // than a count-based fallback. - const conflicts: OverwriteConflict[] = []; - - outcomes.forEach((outcome, index) => { - if (outcome.kind === 'conflict') { - const plan = plans[index]; - - if (plan) { - conflicts.push(planToConflict(plan)); - } - } - }); - - if (conflicts.length > 0) { - return { conflicts, kind: 'conflict' }; - } - - if (outcomes.some((outcome) => outcome.kind === 'error')) { - return { kind: 'error' }; - } - - return { kind: 'ok' }; - }, - findConflicts: (plans) => - plans.filter((plan) => resourcePaths.has(plan.destination)).map(planToConflict), + const overwriteAction = useOverwriteAction({ + execute: (plan, force) => promote(plan.sources, plan.destination, force), + // Local preflight against the resource library snapshot — flags the + // exact destinations already taken so the dialog can name them. + findConflicts: (plan) => plan.targets.filter((t) => resourcePaths.has(t.destination)), onSuccess: onClose, + // Race-fallback: backend doesn't return per-path conflict descriptors + // on a 409, so we synthesize them from the plan we just submitted. + synthesizeFallbackConflicts: (plan) => plan.targets, }); const handleSave = form.handleSubmit(async (values) => { - await overwriteAction.primaryExecute(buildPromotePlans(files, values)); + await overwriteAction.primaryExecute(buildPromotePlan(files, values)); }); const handleSaveWithOverwrite = form.handleSubmit(async (values) => { - await overwriteAction.forceExecute(buildPromotePlans(files, values)); + await overwriteAction.forceExecute(buildPromotePlan(files, values)); }); const isSubmitDisabled = !form.formState.isValid; diff --git a/frontend/src/features/flows/files/use-flow-files-promote.ts b/frontend/src/features/flows/files/use-flow-files-promote.ts index b40f2e07..6145e346 100644 --- a/frontend/src/features/flows/files/use-flow-files-promote.ts +++ b/frontend/src/features/flows/files/use-flow-files-promote.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import type { OverwriteOutcome } from '@/components/shared/use-overwrite-action'; import type { RestResourceList } from '@/features/resources/resources-rest'; +import { pluralizeItems } from '@/features/resources/resources-utils'; import { api, getApiErrorMessage, getApiErrorStatusCode } from '@/lib/axios'; import { FLOW_FILES_PROMOTE_API_PATH } from './flow-files-constants'; @@ -23,7 +24,7 @@ export type FlowFilesPromoteFormValues = z.infer Promise; + /** + * Issue a batch promote in a single atomic request and return a discriminated outcome: + * - `ok` — every flow file/dir was promoted (success toast already fired), + * - `conflict` — at least one resource path is occupied (no toast, caller + * resolves via the shared overwrite workflow), + * - `error` — anything else (failure toast already fired). + * + * Backend semantics: with one source, `destination` is the exact target + * path; with multiple sources, `destination` is a base directory and each + * source lands at `destination/`. The Apollo cache stays in sync + * via `resourceAdded` / `resourceUpdated` GraphQL subscriptions. + */ + promote: (sources: readonly string[], destination: string, force: boolean) => Promise; } /** * Wraps the "promote flow file → user resource" REST call (`POST /files/to-resources`) - * with toast notifications and a loading flag. Returns a discriminated outcome - * so callers can branch between success, a 409 conflict that warrants a user - * prompt, and any other failure (already toasted). - * - * The endpoint accepts both single files and directories — the backend always returns - * a `ResourceList` covering every entry it created or updated. The response payload - * itself is discarded: the resource library Apollo cache is kept in sync via the - * `resourceAdded` / `resourceUpdated` GraphQL subscriptions. + * with toast notifications and a loading flag. */ export const useFlowFilesPromote = ({ flowId }: UseFlowFilesPromoteParams): UseFlowFilesPromoteResult => { const [isPromoting, setIsPromoting] = useState(false); const promote = useCallback( - async ( - source: string, - { destination }: FlowFilesPromoteFormValues, - force: boolean, - ): Promise => { - if (!flowId) { + async (sources: readonly string[], destination: string, force: boolean): Promise => { + if (!flowId || sources.length === 0) { return { kind: 'error' }; } @@ -71,14 +69,17 @@ export const useFlowFilesPromote = ({ flowId }: UseFlowFilesPromoteParams): UseF { destination: destination.trim(), force, - source, + sources, }, { timeout: 0 }, ); - toast.success('Saved to resources', { - description: `Stored at ${destination.trim()} in your resource library`, - }); + const description = + sources.length === 1 + ? `Stored at ${destination.trim()} in your resource library` + : `Stored ${sources.length} ${pluralizeItems(sources.length)} under ${destination.trim()} in your resource library`; + + toast.success('Saved to resources', { description }); return { kind: 'ok' }; } catch (error) { diff --git a/frontend/src/features/resources/resources-conflict-dialog.tsx b/frontend/src/features/resources/resources-conflict-dialog.tsx deleted file mode 100644 index 3f3b7205..00000000 --- a/frontend/src/features/resources/resources-conflict-dialog.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { - OverwriteConfirmDialog, - type OverwriteConflict, -} from '@/components/shared/overwrite-confirm-dialog'; - -interface ResourcesConflictDialogProps { - /** - * Conflicts collected from a batch operation. Empty array keeps the dialog hidden. - * For a single conflict the message names the conflicting item; for many it falls - * back to a count-based summary (Finder-style "Apply to all"). - */ - conflicts: OverwriteConflict[]; - onCancel: () => void; - onReplaceAll: () => Promise | unknown; -} - -/** - * Backwards-compatible thin wrapper over the shared {@link OverwriteConfirmDialog}. - * Move / copy hooks already consume `ConflictItem` shaped values that mirror - * `OverwriteConflict` 1:1, so this is a pure rename / re-export today. - * - * New call sites should depend on `OverwriteConfirmDialog` directly — this file - * only exists to keep the existing imports in `resources-move-dialog.tsx` and - * `resources-copy-dialog.tsx` working without churn. - */ -export const ResourcesConflictDialog = ({ conflicts, onCancel, onReplaceAll }: ResourcesConflictDialogProps) => ( - -); diff --git a/frontend/src/features/resources/resources-copy-dialog.tsx b/frontend/src/features/resources/resources-copy-dialog.tsx index 1d018873..34898a55 100644 --- a/frontend/src/features/resources/resources-copy-dialog.tsx +++ b/frontend/src/features/resources/resources-copy-dialog.tsx @@ -4,16 +4,28 @@ import { useEffect, useMemo } from 'react'; import { useForm } from 'react-hook-form'; import type { FileNode } from '@/components/shared/file-manager'; +import type { OverwriteConflict } from '@/components/shared/overwrite-confirm-dialog'; +import { OverwriteConfirmDialog } from '@/components/shared/overwrite-confirm-dialog'; 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 { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; +import { useResources } from '@/providers/resources-provider'; -import { ResourcesConflictDialog } from './resources-conflict-dialog'; import { resourcesCopyFormSchema, type ResourcesCopyFormValues, useResourcesCopy } from './use-resources-copy'; +interface CopyPlan { + /** Final destination string sent to the backend (exact path or base directory). */ + destination: string; + /** Resource paths being copied (sent as `sources[]`). */ + sources: readonly string[]; + /** Pre-computed `(destination, destinationName)` pairs for client-side preflight + 409 fallback. */ + targets: OverwriteConflict[]; +} + /** Guaranteed non-empty by `ResourcesCopyDialog` (which gates rendering on `files.length > 0`). */ interface ResourcesCopyDialogFormProps { files: readonly [FileNode, ...FileNode[]]; @@ -40,6 +52,8 @@ const getParentDir = (path: string): string => { return idx === -1 ? '' : path.slice(0, idx); }; +const splitName = (path: string): string => path.split('/').pop() ?? path; + /** * Build the single-file copy default destination. Inserts a `-copy` suffix * before the extension so the user can submit immediately without manual @@ -69,26 +83,37 @@ const computeCommonParent = (files: readonly [FileNode, ...FileNode[]]): string }; /** - * Resolve the per-file destination from the form value: - * - single copy → use the typed path verbatim, - * - multi-file batch → derive `/`, root when empty. + * Pre-compute the per-file destinations the backend will write to. Mirrors the + * server's resolution rules so the client can preflight and so the conflict + * dialog can name the exact items at risk. See {@link computeTargets} in the + * move dialog for the full rule table. */ -const resolveDestination = ( - file: FileNode, - values: ResourcesCopyFormValues, - isMulti: boolean, -): string => { - if (!isMulti) { - return values.destination; +const computeTargets = (files: readonly [FileNode, ...FileNode[]], destination: string): OverwriteConflict[] => { + const trimmed = destination.trim(); + const treatAsDir = files.length > 1 || (trimmed.length > 1 && trimmed.endsWith('/')); + + if (treatAsDir) { + const baseDir = trimmed.replace(/\/+$/, ''); + + return files.map((file) => { + const dest = baseDir ? `${baseDir}/${file.name}` : file.name; + + return { destination: dest, destinationName: file.name }; + }); } - const targetDir = values.destination.trim().replace(/\/+$/, ''); - - return targetDir ? `${targetDir}/${file.name}` : file.name; + return [{ destination: trimmed, destinationName: splitName(trimmed) }]; }; +const buildCopyPlan = (files: readonly [FileNode, ...FileNode[]], values: ResourcesCopyFormValues): CopyPlan => ({ + destination: values.destination.trim(), + sources: files.map((file) => file.path), + targets: computeTargets(files, values.destination), +}); + const ResourcesCopyDialogForm = ({ files, onClose }: ResourcesCopyDialogFormProps) => { - const { cancelConflicts, copy, isCopying, pendingConflicts, resolveConflicts } = useResourcesCopy(); + const { copy, isCopying } = useResourcesCopy(); + const { resources } = useResources(); const isMulti = files.length > 1; const defaultDestination = useMemo(() => { @@ -109,132 +134,121 @@ const ResourcesCopyDialogForm = ({ files, onClose }: ResourcesCopyDialogFormProp form.reset({ destination: defaultDestination }); }, [defaultDestination, form]); - /** - * Run every copy in parallel with the given `force` flag. Returns `true` - * when every operation either succeeded or surfaced as a 409 conflict - * (the latter feeds into `pendingConflicts` for the aggregated dialog). - * `false` means at least one entry hit a non-conflict error and the form - * should stay open so the user can read the toast and retry. - */ - const submitAll = async (values: ResourcesCopyFormValues, force: boolean): Promise => { - const results = await Promise.all( - files.map((file) => copy(file.path, { destination: resolveDestination(file, values, isMulti) }, force)), - ); + const resourcePaths = useMemo(() => new Set(resources.map((resource) => resource.path)), [resources]); - return results.every(Boolean); - }; + /** + * Drive the canonical "Copy / Copy with overwrite / Replace all" workflow + * with a single atomic batch request. Backend handles `sources[]` in one + * DB transaction (all-or-nothing). + */ + const overwriteAction = useOverwriteAction({ + execute: (plan, force) => copy(plan.sources, plan.destination, force), + // Copy never deletes the sources, so collisions with sources are real + // conflicts (unlike move). Just intersect targets with existing paths. + findConflicts: (plan) => plan.targets.filter((t) => resourcePaths.has(t.destination)), + onSuccess: onClose, + synthesizeFallbackConflicts: (plan) => plan.targets, + }); const handleSave = form.handleSubmit(async (values) => { - const ok = await submitAll(values, false); - - if (ok) { - onClose(); - } + await overwriteAction.primaryExecute(buildCopyPlan(files, values)); }); const handleSaveWithOverwrite = form.handleSubmit(async (values) => { - const ok = await submitAll(values, true); - - if (ok) { - onClose(); - } + await overwriteAction.forceExecute(buildCopyPlan(files, values)); }); - // After the user picks "Replace" in the conflict dialog the hook retries every - // failed copy with `force = true`. Close the form so it doesn't leave a stale - // modal — the resolveConflicts promise resolves once every retry has settled. - const handleResolveConflicts = async () => { - await resolveConflicts(); - onClose(); - }; - const isSubmitDisabled = !form.formState.isValid; const titleText = isMulti ? `Copy ${files.length} items` : files[0].isDir ? 'Copy directory' : 'Copy resource'; const overwriteCtaLabel = isMulti ? `Copy ${files.length} with overwrite` : 'Copy with overwrite'; return ( - - - - - {titleText} - - - {isMulti ? ( - <>Duplicate every selected item into the destination directory. - ) : ( - <> - Duplicate {files[0].path} to a new path. - - )} - - - -
- - ( - - {isMulti ? 'Destination directory' : 'Destination path'} - - - - - {isMulti ? ( - <> - Relative directory inside your library. Leave empty for the root. Each item - keeps its current filename. - - ) : ( - <>Relative path inside your library. - )} - - - + <> + + + + + {titleText} + + + {isMulti ? ( + <>Duplicate every selected item into the destination directory. + ) : ( + <> + Duplicate {files[0].path} to a new path. + )} - /> + + -
- - { - void handleSaveWithOverwrite(); - }} - overwriteLabel={overwriteCtaLabel} - primaryIcon={Copy} - primaryLabel="Copy" - primaryType="submit" + + + ( + + {isMulti ? 'Destination directory' : 'Destination path'} + + + + + {isMulti ? ( + <> + Relative directory inside your library. Leave empty for the root. Each + item keeps its current filename. + + ) : ( + <>Relative path inside your library. + )} + + + + )} /> -
- - - + + { + void handleSaveWithOverwrite(); + }} + overwriteLabel={overwriteCtaLabel} + primaryIcon={Copy} + primaryLabel="Copy" + primaryType="submit" + /> +
+ + + + + - + ); }; diff --git a/frontend/src/features/resources/resources-move-dialog.tsx b/frontend/src/features/resources/resources-move-dialog.tsx index 3f9eda88..16ebe857 100644 --- a/frontend/src/features/resources/resources-move-dialog.tsx +++ b/frontend/src/features/resources/resources-move-dialog.tsx @@ -4,16 +4,28 @@ import { useEffect, useMemo } from 'react'; import { useForm } from 'react-hook-form'; import type { FileNode } from '@/components/shared/file-manager'; +import type { OverwriteConflict } from '@/components/shared/overwrite-confirm-dialog'; +import { OverwriteConfirmDialog } from '@/components/shared/overwrite-confirm-dialog'; 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 { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; +import { useResources } from '@/providers/resources-provider'; -import { ResourcesConflictDialog } from './resources-conflict-dialog'; import { resourcesMoveFormSchema, type ResourcesMoveFormValues, useResourcesMove } from './use-resources-move'; +interface MovePlan { + /** Final destination string sent to the backend (exact path or base directory). */ + destination: string; + /** Resource paths being moved (sent as `sources[]`). */ + sources: readonly string[]; + /** Pre-computed `(destination, destinationName)` pairs for client-side preflight + 409 fallback. */ + targets: OverwriteConflict[]; +} + /** Guaranteed non-empty by `ResourcesMoveDialog` (which gates rendering on `files.length > 0`). */ interface ResourcesMoveDialogFormProps { files: readonly [FileNode, ...FileNode[]]; @@ -51,27 +63,45 @@ const computeCommonParent = (files: readonly [FileNode, ...FileNode[]]): string return files.every((file) => getParentDir(file.path) === first) ? first : ''; }; +const splitName = (path: string): string => path.split('/').pop() ?? path; + /** - * Resolve the per-file destination from the form value: - * - single rename / move → use the typed path verbatim, - * - multi-file batch → derive `/`, root when empty. + * Pre-compute the per-file destinations the backend will write to. This mirrors + * the server's resolution rules so the client can preflight against the local + * snapshot and so the conflict dialog can name the exact items at risk: + * + * - 1 source, no trailing `/` → destination is the exact target path + * - 1 source, trailing `/` → backend treats destination as a dir; + * target = `/` + * - 2+ sources → destination is always a base directory; + * each source lands at `/`. */ -const resolveDestination = ( - file: FileNode, - values: ResourcesMoveFormValues, - isMulti: boolean, -): string => { - if (!isMulti) { - return values.destination; +const computeTargets = (files: readonly [FileNode, ...FileNode[]], destination: string): OverwriteConflict[] => { + const trimmed = destination.trim(); + const treatAsDir = files.length > 1 || (trimmed.length > 1 && trimmed.endsWith('/')); + + if (treatAsDir) { + const baseDir = trimmed.replace(/\/+$/, ''); + + return files.map((file) => { + const dest = baseDir ? `${baseDir}/${file.name}` : file.name; + + return { destination: dest, destinationName: file.name }; + }); } - const targetDir = values.destination.trim().replace(/\/+$/, ''); - - return targetDir ? `${targetDir}/${file.name}` : file.name; + return [{ destination: trimmed, destinationName: splitName(trimmed) }]; }; +const buildMovePlan = (files: readonly [FileNode, ...FileNode[]], values: ResourcesMoveFormValues): MovePlan => ({ + destination: values.destination.trim(), + sources: files.map((file) => file.path), + targets: computeTargets(files, values.destination), +}); + const ResourcesMoveDialogForm = ({ files, onClose }: ResourcesMoveDialogFormProps) => { - const { cancelConflicts, isMoving, move, pendingConflicts, resolveConflicts } = useResourcesMove(); + const { isMoving, move } = useResourcesMove(); + const { resources } = useResources(); const isMulti = files.length > 1; // Default destination differs by mode: single-file rename keeps the existing @@ -95,45 +125,37 @@ const ResourcesMoveDialogForm = ({ files, onClose }: ResourcesMoveDialogFormProp form.reset({ destination: defaultDestination }); }, [defaultDestination, form]); - /** - * Run every move in parallel with the given `force` flag. Returns `true` - * when every operation either succeeded or surfaced as a 409 conflict - * (the latter feeds into `pendingConflicts` for the aggregated dialog). - * `false` means at least one entry hit a non-conflict error and the form - * should stay open so the user can read the toast and retry. - */ - const submitAll = async (values: ResourcesMoveFormValues, force: boolean): Promise => { - const results = await Promise.all( - files.map((file) => move(file.path, { destination: resolveDestination(file, values, isMulti) }, force)), - ); + // Lazy snapshot of every existing resource path. Recomputed only when the + // library changes; reused by `findConflicts` for the local preflight. + const resourcePaths = useMemo(() => new Set(resources.map((resource) => resource.path)), [resources]); + const sourcePaths = useMemo(() => new Set(files.map((file) => file.path)), [files]); - return results.every(Boolean); - }; + /** + * Drive the canonical "Move / Move with overwrite / Replace all" workflow + * with a single atomic batch request. Backend handles `sources[]` in one + * DB transaction (all-or-nothing) — no per-source aggregation needed here. + */ + const overwriteAction = useOverwriteAction({ + execute: (plan, force) => move(plan.sources, plan.destination, force), + // Local preflight: filter out targets that match an item we're moving + // (those are no-ops, not conflicts) and keep the ones already taken + // by some other resource. + findConflicts: (plan) => + plan.targets.filter((t) => !sourcePaths.has(t.destination) && resourcePaths.has(t.destination)), + onSuccess: onClose, + // Race-fallback: backend doesn't return per-path conflict descriptors + // on a 409, so we synthesize them from the plan we just submitted. + synthesizeFallbackConflicts: (plan) => plan.targets, + }); const handleSave = form.handleSubmit(async (values) => { - const ok = await submitAll(values, false); - - if (ok) { - onClose(); - } + await overwriteAction.primaryExecute(buildMovePlan(files, values)); }); const handleSaveWithOverwrite = form.handleSubmit(async (values) => { - const ok = await submitAll(values, true); - - if (ok) { - onClose(); - } + await overwriteAction.forceExecute(buildMovePlan(files, values)); }); - // After the user picks "Replace" in the conflict dialog the hook retries every - // failed move with `force = true`. Close the form so it doesn't leave a stale - // modal — the resolveConflicts promise resolves once every retry has settled. - const handleResolveConflicts = async () => { - await resolveConflicts(); - onClose(); - }; - const isSubmitDisabled = !form.formState.isValid; const titleText = isMulti ? `Move ${files.length} items` @@ -143,91 +165,95 @@ const ResourcesMoveDialogForm = ({ files, onClose }: ResourcesMoveDialogFormProp const overwriteCtaLabel = isMulti ? `Move ${files.length} with overwrite` : 'Move with overwrite'; return ( - - - - - {titleText} - - - {isMulti ? ( - <>Move every selected item into the destination directory. - ) : ( - <> - Update the path of {files[0].path}. - - )} - - - -
- - ( - - {isMulti ? 'Destination directory' : 'New path'} - - - - - {isMulti ? ( - <> - Relative directory inside your library. Leave empty for the root. Each item - keeps its current filename. - - ) : ( - <> - Relative path inside your library. End with / to drop the entry - into that directory. - - )} - - - + <> + + + + + {titleText} + + + {isMulti ? ( + <>Move every selected item into the destination directory. + ) : ( + <> + Update the path of {files[0].path}. + )} - /> + + -
- - { - void handleSaveWithOverwrite(); - }} - overwriteLabel={overwriteCtaLabel} - primaryIcon={FolderInput} - primaryLabel="Move" - primaryType="submit" + + + ( + + {isMulti ? 'Destination directory' : 'New path'} + + + + + {isMulti ? ( + <> + Relative directory inside your library. Leave empty for the root. Each + item keeps its current filename. + + ) : ( + <> + Relative path inside your library. End with / to drop the + entry into that directory. + + )} + + + + )} /> -
- - - + + { + void handleSaveWithOverwrite(); + }} + overwriteLabel={overwriteCtaLabel} + primaryIcon={FolderInput} + primaryLabel="Move" + primaryType="submit" + /> + + + +
+ + -
+ ); }; diff --git a/frontend/src/features/resources/use-resources-copy.ts b/frontend/src/features/resources/use-resources-copy.ts index b4a7053d..f72b768d 100644 --- a/frontend/src/features/resources/use-resources-copy.ts +++ b/frontend/src/features/resources/use-resources-copy.ts @@ -2,9 +2,12 @@ import { useCallback, useState } from 'react'; import { toast } from 'sonner'; import { z } from 'zod'; +import type { OverwriteOutcome } from '@/components/shared/use-overwrite-action'; + import { api, getApiErrorMessage, getApiErrorStatusCode } from '@/lib/axios'; import { RESOURCES_COPY_API_PATH } from './resources-constants'; +import { pluralizeItems } from './resources-utils'; export const resourcesCopyFormSchema = z.object({ destination: z @@ -15,81 +18,68 @@ export const resourcesCopyFormSchema = z.object({ .refine((value) => !value.split('/').includes('..'), { message: 'Destination must not contain ".."' }), }); -export interface ResourcesCopyConflict { - destination: string; - /** Display name extracted from `destination` for the confirm dialog. */ - destinationName: string; - sourcePath: string; -} - export type ResourcesCopyFormValues = z.infer; interface CopyRequestBody { destination: string; force: boolean; - source: string; + sources: readonly string[]; } interface UseResourcesCopyResult { - /** Drop the pending conflicts without retrying. */ - cancelConflicts: () => void; /** - * Issue a single copy. `force=false` collects 409s into `pendingConflicts` - * for an aggregated dialog; `force=true` skips that branch and toasts on - * any failure (used by the "Copy with overwrite" CTA path). + * Issue a batch copy in a single atomic request and return a discriminated outcome: + * - `ok` — every source was copied (success toast already fired), + * - `conflict` — at least one destination is occupied (no toast, caller + * resolves via the shared overwrite workflow), + * - `error` — anything else (failure toast already fired). + * + * Backend semantics: with one source, `destination` is the exact target + * path; with multiple sources, `destination` is a base directory and each + * source lands at `destination/`. Either way, the whole batch + * executes inside one DB transaction. */ - copy: (sourcePath: string, values: ResourcesCopyFormValues, force: boolean) => Promise; + copy: (sources: readonly string[], destination: string, force: boolean) => Promise; isCopying: boolean; - /** - * 409 conflicts collected across one or more parallel `copy()` calls. The consumer - * shows a single "Replace?" dialog summarising the count; clicking Replace retries - * every entry with `force = true`. - */ - pendingConflicts: ResourcesCopyConflict[]; - /** Retry every pending conflict with `force = true`. Promise resolves when all settle. */ - resolveConflicts: () => Promise; } -const extractName = (path: string): string => path.split('/').pop() ?? path; - -/** Wraps `POST /resources/copy`. */ +/** Wraps `POST /resources/copy` for single and batch copy operations. */ export const useResourcesCopy = (): UseResourcesCopyResult => { const [isCopying, setIsCopying] = useState(false); - const [pendingConflicts, setPendingConflicts] = useState([]); - const performCopy = useCallback( - async (sourcePath: string, destination: string, force: boolean): Promise => { + const copy = useCallback( + async (sources: readonly string[], destination: string, force: boolean): Promise => { + if (sources.length === 0) { + return { kind: 'error' }; + } + setIsCopying(true); try { await api.post(RESOURCES_COPY_API_PATH, { destination, force, - source: sourcePath, + sources, }); - toast.success('Resource copied', { description: `Copied to /${destination}` }); + const description = + sources.length === 1 + ? `Copied to /${destination}` + : `Copied ${sources.length} ${pluralizeItems(sources.length)} into /${destination}`; - return true; + toast.success('Resource copied', { description }); + + return { kind: 'ok' }; } catch (error) { - if (getApiErrorStatusCode(error) === 409 && !force) { - setPendingConflicts((prev) => [ - ...prev, - { - destination, - destinationName: extractName(destination), - sourcePath, - }, - ]); - - return false; + if (!force && getApiErrorStatusCode(error) === 409) { + return { kind: 'conflict' }; } const description = getApiErrorMessage(error, 'Failed to copy resource'); toast.error('Copy failed', { description }); - return false; + return { kind: 'error' }; } finally { setIsCopying(false); } @@ -97,35 +87,8 @@ export const useResourcesCopy = (): UseResourcesCopyResult => { [], ); - const copy = useCallback( - (sourcePath: string, { destination }: ResourcesCopyFormValues, force: boolean): Promise => - performCopy(sourcePath, destination.trim(), force), - [performCopy], - ); - - const resolveConflicts = useCallback(async (): Promise => { - if (pendingConflicts.length === 0) { - return; - } - - const conflicts = pendingConflicts; - - setPendingConflicts([]); - - await Promise.allSettled( - conflicts.map((conflict) => performCopy(conflict.sourcePath, conflict.destination, true)), - ); - }, [pendingConflicts, performCopy]); - - const cancelConflicts = useCallback(() => { - setPendingConflicts([]); - }, []); - return { - cancelConflicts, copy, isCopying, - pendingConflicts, - resolveConflicts, }; }; diff --git a/frontend/src/features/resources/use-resources-move.ts b/frontend/src/features/resources/use-resources-move.ts index ff80b528..fcddc711 100644 --- a/frontend/src/features/resources/use-resources-move.ts +++ b/frontend/src/features/resources/use-resources-move.ts @@ -2,9 +2,12 @@ import { useCallback, useState } from 'react'; import { toast } from 'sonner'; import { z } from 'zod'; +import type { OverwriteOutcome } from '@/components/shared/use-overwrite-action'; + import { api, getApiErrorMessage, getApiErrorStatusCode } from '@/lib/axios'; import { RESOURCES_MOVE_API_PATH } from './resources-constants'; +import { pluralizeItems } from './resources-utils'; export const resourcesMoveFormSchema = z.object({ destination: z @@ -15,86 +18,72 @@ export const resourcesMoveFormSchema = z.object({ .refine((value) => !value.split('/').includes('..'), { message: 'Destination must not contain ".."' }), }); -export interface ResourcesMoveConflict { - destination: string; - /** Display name extracted from `destination` for the confirm dialog. */ - destinationName: string; - sourcePath: string; -} - export type ResourcesMoveFormValues = z.infer; interface MoveRequestBody { destination: string; force: boolean; - source: string; + sources: readonly string[]; } interface UseResourcesMoveResult { - /** Drop the pending conflicts without retrying. */ - cancelConflicts: () => void; isMoving: boolean; /** - * Issue a single move. `force=false` collects 409s into `pendingConflicts` - * for an aggregated dialog; `force=true` skips that branch and toasts on - * any failure (used by the "Move with overwrite" CTA path). + * Issue a batch move in a single atomic request and return a discriminated outcome: + * - `ok` — every source was moved (success toast already fired), + * - `conflict` — at least one destination is occupied (no toast, caller + * resolves via the shared overwrite workflow), + * - `error` — anything else (failure toast already fired). + * + * Backend semantics: with one source, `destination` is the exact target + * path; with multiple sources, `destination` is treated as a base directory + * and each source lands at `destination/`. Either way, the whole + * batch executes inside one DB transaction — partial state is impossible. */ - move: (sourcePath: string, values: ResourcesMoveFormValues, force: boolean) => Promise; - /** - * 409 conflicts collected across one or more parallel `move()` calls. The consumer - * shows a single "Replace?" dialog summarising the count; clicking Replace retries - * every entry with `force = true`. - */ - pendingConflicts: ResourcesMoveConflict[]; - /** Retry every pending conflict with `force = true`. Promise resolves when all settle. */ - resolveConflicts: () => Promise; + move: (sources: readonly string[], destination: string, force: boolean) => Promise; } -const extractName = (path: string): string => path.split('/').pop() ?? path; - -/** Wraps `PUT /resources/move` for rename / move operations. */ +/** Wraps `PUT /resources/move` for rename / move / batch-move operations. */ export const useResourcesMove = (): UseResourcesMoveResult => { const [isMoving, setIsMoving] = useState(false); - const [pendingConflicts, setPendingConflicts] = useState([]); - const performMove = useCallback( - async (sourcePath: string, destination: string, force: boolean): Promise => { + const move = useCallback( + async (sources: readonly string[], destination: string, force: boolean): Promise => { + if (sources.length === 0) { + return { kind: 'error' }; + } + setIsMoving(true); try { await api.put(RESOURCES_MOVE_API_PATH, { destination, force, - source: sourcePath, + sources, }); - toast.success('Resource moved', { description: `Moved to /${destination}` }); + const description = + sources.length === 1 + ? `Moved to /${destination}` + : `Moved ${sources.length} ${pluralizeItems(sources.length)} into /${destination}`; - return true; + toast.success('Resource moved', { description }); + + return { kind: 'ok' }; } catch (error) { - // 409 = destination already exists. Push to the conflict array so the - // consumer can render a single aggregated "Replace?" dialog covering - // every parallel `move()` call. `force = true` skips this branch (the - // overwrite was already pre-confirmed), so retries land in the toast - // path on any unexpected re-conflict. - if (getApiErrorStatusCode(error) === 409 && !force) { - setPendingConflicts((prev) => [ - ...prev, - { - destination, - destinationName: extractName(destination), - sourcePath, - }, - ]); - - return false; + // 409 = at least one destination already exists. Surface as + // `conflict` so the shared overwrite workflow can prompt the + // user; success / error toasts stay aligned with the outcome + // they pick. + if (!force && getApiErrorStatusCode(error) === 409) { + return { kind: 'conflict' }; } const description = getApiErrorMessage(error, 'Failed to move resource'); toast.error('Move failed', { description }); - return false; + return { kind: 'error' }; } finally { setIsMoving(false); } @@ -102,35 +91,8 @@ export const useResourcesMove = (): UseResourcesMoveResult => { [], ); - const move = useCallback( - (sourcePath: string, { destination }: ResourcesMoveFormValues, force: boolean): Promise => - performMove(sourcePath, destination.trim(), force), - [performMove], - ); - - const resolveConflicts = useCallback(async (): Promise => { - if (pendingConflicts.length === 0) { - return; - } - - const conflicts = pendingConflicts; - - setPendingConflicts([]); - - await Promise.allSettled( - conflicts.map((conflict) => performMove(conflict.sourcePath, conflict.destination, true)), - ); - }, [pendingConflicts, performMove]); - - const cancelConflicts = useCallback(() => { - setPendingConflicts([]); - }, []); - return { - cancelConflicts, isMoving, move, - pendingConflicts, - resolveConflicts, }; }; diff --git a/frontend/src/features/resources/use-resources-upload.ts b/frontend/src/features/resources/use-resources-upload.ts index bf29e4a3..14ac4812 100644 --- a/frontend/src/features/resources/use-resources-upload.ts +++ b/frontend/src/features/resources/use-resources-upload.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import type { UserResourceFragmentFragment } from '@/graphql/types'; @@ -25,6 +25,15 @@ interface UploadResponse { } interface UseResourcesUploadParams { + /** + * Default virtual directory used when `uploadFiles` is called without an explicit + * `options.dir`. Drives the toolbar/file-picker / external-DnD flows that don't + * have a per-call target — empty string keeps uploads at the library root. + * + * Stored in a ref internally so changes do NOT invalidate `uploadFiles`, + * keeping memoized consumers (e.g. `useFilesDragAndDrop`) reference-stable. + */ + defaultDir?: string; onSuccess?: (uploaded: UploadResponse) => void; } @@ -35,7 +44,20 @@ interface UseResourcesUploadResult { ref: React.RefObject; }; isUploading: boolean; + /** + * Opens the native file picker. The next picked batch uploads to the + * hook's `defaultDir` (typically the focused folder in the manager, or + * the library root if nothing is focused). Wired to toolbar buttons, + * the empty-state CTA, the sidebar quick-upload and external DnD. + */ openFilePicker: () => void; + /** + * Same picker, but the next picked batch is force-targeted at the + * supplied directory regardless of `defaultDir`. Drives row-level + * "Upload here" actions / context-menu items where the user explicitly + * names the destination folder. + */ + openFilePickerForDir: (targetDir: string) => void; uploadFiles: (selectedFiles: File[], options?: UploadOptions) => Promise; } @@ -92,12 +114,39 @@ const buildUploadSuccessMessage = (uploadedCount: number, dir?: string) => { * each upload so the consumer can remount the hidden `` and clear it * declaratively. */ -export const useResourcesUpload = ({ onSuccess }: UseResourcesUploadParams = {}): UseResourcesUploadResult => { +export const useResourcesUpload = ({ + defaultDir, + onSuccess, +}: UseResourcesUploadParams = {}): UseResourcesUploadResult => { const inputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const [fileInputKey, setFileInputKey] = useState(0); + // Stash the default target directory in a ref so every call to `uploadFiles` + // sees the latest value without invalidating its memoization. Without this, + // wrapping consumers (e.g. `useFilesDragAndDrop`) would re-create their + // handlers on every focus change in the file tree. + const defaultDirRef = useRef(defaultDir); + + useEffect(() => { + defaultDirRef.current = defaultDir; + }, [defaultDir]); + + // One-shot directory override consumed on the next file-picker selection. + // Lets row-level "Upload here" actions target a specific directory without + // mutating the hook's default. Both picker entry points reset this ref + // before opening the dialog, so a stashed override can never leak into a + // subsequent toolbar / sidebar invocation — even if the user cancels the + // picker (`change` only fires on actual selection). + const pendingDirRef = useRef(undefined); + const openFilePicker = useCallback(() => { + pendingDirRef.current = undefined; + inputRef.current?.click(); + }, []); + + const openFilePickerForDir = useCallback((targetDir: string) => { + pendingDirRef.current = targetDir; inputRef.current?.click(); }, []); @@ -119,12 +168,17 @@ export const useResourcesUpload = ({ onSuccess }: UseResourcesUploadParams = {}) selectedFiles.forEach((file) => formData.append('files', file)); + // Per-call `options.dir` wins over the hook-level default so callers + // can target a specific directory ad-hoc (e.g. row-level "Upload here" + // actions) without mutating the global default. + const targetDir = options?.dir ?? defaultDirRef.current; + setIsUploading(true); try { const response = await api.post(RESOURCES_API_PATH, formData, { headers: { 'Content-Type': undefined }, - params: options?.dir ? { dir: options.dir } : undefined, + params: targetDir ? { dir: targetDir } : undefined, timeout: 0, }); // Backend sends `models.ResourceList` (snake_case, numeric IDs). @@ -138,7 +192,7 @@ export const useResourcesUpload = ({ onSuccess }: UseResourcesUploadParams = {}) total: raw.total ?? 0, }; const uploadedCount = data.items.length; - const message = buildUploadSuccessMessage(uploadedCount, options?.dir); + const message = buildUploadSuccessMessage(uploadedCount, targetDir); toast.success(message.title, { description: message.description }); @@ -163,9 +217,19 @@ export const useResourcesUpload = ({ onSuccess }: UseResourcesUploadParams = {}) const handleFileSelection = useCallback( async (event: React.ChangeEvent) => { const selectedFiles = Array.from(event.target.files ?? []); + // Drain the one-shot override so a subsequent toolbar pick (no arg) + // resolves through `defaultDirRef` again. Forwarding `undefined` + // would override `defaultDir` with `undefined`, sending uploads to + // the root instead of the currently focused folder. + const pendingDir = pendingDirRef.current; + + pendingDirRef.current = undefined; try { - await uploadFiles(selectedFiles); + await uploadFiles( + selectedFiles, + pendingDir !== undefined ? { dir: pendingDir } : undefined, + ); } finally { setFileInputKey((previousKey) => previousKey + 1); } @@ -181,6 +245,7 @@ export const useResourcesUpload = ({ onSuccess }: UseResourcesUploadParams = {}) }, isUploading, openFilePicker, + openFilePickerForDir, uploadFiles, }; }; diff --git a/frontend/src/hooks/use-files-drag-and-drop.ts b/frontend/src/hooks/use-files-drag-and-drop.ts index f52e4a05..7e37bad2 100644 --- a/frontend/src/hooks/use-files-drag-and-drop.ts +++ b/frontend/src/hooks/use-files-drag-and-drop.ts @@ -5,6 +5,15 @@ interface DragHandlers { onDragLeave: (event: React.DragEvent) => void; onDragOver: (event: React.DragEvent) => void; onDrop: (event: React.DragEvent) => void; + /** + * Capture-phase drop listener: resets the internal counter and the + * `isDragging` flag *before* any nested handler can call + * `event.stopPropagation()`. Necessary so a child component (e.g. the + * `FileManager`'s row-level external-file drop) can claim the drop and + * stop the bubble — the bubble-phase `onDrop` below would otherwise + * never fire and `isDragging` would stay stuck on `true`. + */ + onDropCapture: (event: React.DragEvent) => void; } interface UseFilesDragAndDropParams { @@ -74,10 +83,23 @@ export const useFilesDragAndDrop = ({ } }, []); + // Capture-phase: ALWAYS reset the local state, even if a descendant claims + // the drop and stops bubble propagation. Without this, a child handler + // (e.g. row-level upload-into-folder) leaves the page-level overlay + // visually stuck on "Drop files to upload" because no `dragleave` fires + // after a drop and the bubble-phase reset never runs. + const handleDropCapture = useCallback(() => { + dragCounterRef.current = 0; + setIsDragging(false); + }, []); + const handleDrop = useCallback( (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); + // Capture-phase already cleared these — keep the lines as a defensive + // belt-and-braces in case the capture handler is ever omitted by a + // consumer that forgets to spread the full handler bundle. dragCounterRef.current = 0; setIsDragging(false); @@ -102,6 +124,7 @@ export const useFilesDragAndDrop = ({ onDragLeave: handleDragLeave, onDragOver: handleDragOver, onDrop: handleDrop, + onDropCapture: handleDropCapture, }, isDragging, }; diff --git a/frontend/src/pages/resources/resources.tsx b/frontend/src/pages/resources/resources.tsx index 7d5577f2..925995f6 100644 --- a/frontend/src/pages/resources/resources.tsx +++ b/frontend/src/pages/resources/resources.tsx @@ -2,6 +2,8 @@ import { Copy, FileSymlink, Folder, FolderPlus, FolderUp, Loader2, Search, X } f import { useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; +import type { OverwriteConflict } from '@/components/shared/overwrite-confirm-dialog'; + import ConfirmationDialog from '@/components/shared/confirmation-dialog'; import { bulkCopyAction, @@ -15,8 +17,11 @@ import { FileManager, type FileManagerAction, type FileManagerBulkAction, + type FileManagerEmptyAreaAction, type FileNode, } from '@/components/shared/file-manager'; +import { OverwriteConfirmDialog } from '@/components/shared/overwrite-confirm-dialog'; +import { useOverwriteAction } from '@/components/shared/use-overwrite-action'; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; import { Button } from '@/components/ui/button'; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'; @@ -26,7 +31,6 @@ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from ' import { Separator } from '@/components/ui/separator'; import { SidebarTrigger } from '@/components/ui/sidebar'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { ResourcesConflictDialog } from '@/features/resources/resources-conflict-dialog'; import { ResourcesCopyDialog } from '@/features/resources/resources-copy-dialog'; import { ResourcesMkdirDialog } from '@/features/resources/resources-mkdir-dialog'; import { ResourcesMoveDialog } from '@/features/resources/resources-move-dialog'; @@ -44,42 +48,140 @@ const Resources = () => { const search = useResourcesSearch(); 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. + const [mkdirParentOverride, setMkdirParentOverride] = useState(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(null); const [filesToCopy, setFilesToCopy] = useState(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); - const upload = useResourcesUpload(); + // 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(); + + 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 }); const deletion = useResourcesDelete(); - const moveAction = useResourcesMove(); + 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. 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. + const handleExternalFileDrop = useCallback( + async (droppedFiles: File[], destinationDir: string): Promise => { + await upload.uploadFiles(droppedFiles, { dir: destinationDir }); + }, + [upload], + ); + const fileNodes = useMemo(() => resources.map(toFileNode), [resources]); + // Snapshot of every existing path in the library — drives the local + // preflight for the drag-and-drop move workflow. + const resourcePaths = useMemo(() => new Set(resources.map((resource) => resource.path)), [resources]); + /** - * Drag-and-drop entry point: move every dragged item into `destinationDir` by issuing - * one `PUT /resources/move` per source. We don't pre-check or batch — each request - * goes out independently so a partial failure still moves the rest. Conflicts feed - * into `pendingConflicts` and surface through the shared overwrite confirm dialog; - * `force=false` is therefore safe even for repeated drags onto the same target. + * Drag-and-drop move: ship every dragged item to `destinationDir` in a + * single atomic batch (`PUT /resources/move` with `sources[]`). The + * backend handles dedup + transactional writes; the shared overwrite + * workflow drives the local preflight and the conflict dialog. */ + interface DndMovePlan { + destination: string; + sources: readonly string[]; + targets: OverwriteConflict[]; + } + + const dndMoveAction = useOverwriteAction({ + execute: (plan, force) => move(plan.sources, plan.destination, force), + findConflicts: (plan) => { + const movedPaths = new Set(plan.sources); + + // Targets that match an item being moved are no-ops, not conflicts. + return plan.targets.filter((t) => !movedPaths.has(t.destination) && resourcePaths.has(t.destination)); + }, + synthesizeFallbackConflicts: (plan) => plan.targets, + }); + const handleMoveItems = useCallback( async (sources: FileNode[], destinationDir: string) => { - await Promise.allSettled( - sources.map((source) => { - const destination = destinationDir ? `${destinationDir}/${source.name}` : source.name; + if (sources.length === 0) { + return; + } - return moveAction.move(source.path, { destination }, false); - }), - ); + const baseDir = destinationDir.replace(/\/+$/, ''); + const targets: OverwriteConflict[] = sources.map((source) => ({ + destination: baseDir ? `${baseDir}/${source.name}` : source.name, + destinationName: source.name, + })); + + await dndMoveAction.primaryExecute({ + destination: baseDir, + sources: sources.map((source) => source.path), + targets, + }); }, - [moveAction], + [dndMoveAction], ); const handleCopyPath = useCallback(async (file: FileNode) => { @@ -134,6 +236,26 @@ const Resources = () => { anchor.remove(); }, []); + // Row-level "Upload here" pre-targets the picker at the chosen directory's + // path, regardless of `currentDir` (the dropdown trigger doesn't move + // keyboard focus, so `activeRowPath` may still point at a sibling row). + const handleUploadHere = useCallback( + (file: FileNode) => { + upload.openFilePickerForDir(file.path); + }, + [upload], + ); + + const handleMkdirHere = useCallback((file: FileNode) => { + setMkdirParentOverride(file.path); + setIsMkdirOpen(true); + }, []); + + const closeMkdirDialog = useCallback(() => { + setIsMkdirOpen(false); + setMkdirParentOverride(null); + }, []); + const fileManagerActions = useMemo( () => [ // Row download is the single-file specialisation of the bulk download: @@ -141,12 +263,34 @@ const Resources = () => { // contract (`?paths[]=`) is used everywhere. downloadAction((file) => buildResourcesDownloadHref([file])), copyPathAction(handleCopyPath), + // Directory-only actions — surfaced both in the row dropdown and + // the right-click context menu (the manager renders both menus + // from the same `actions` array). `appliesToFiles: false` keeps + // them off file rows. + { + appliesToDirs: true, + appliesToFiles: false, + icon: FolderUp, + id: 'resources-upload-here', + label: 'Upload files here', + onSelect: handleUploadHere, + separatorBefore: true, + }, + { + appliesToDirs: true, + appliesToFiles: false, + icon: FolderPlus, + id: 'resources-mkdir-here', + label: 'New folder here', + onSelect: handleMkdirHere, + }, { appliesToDirs: true, icon: FileSymlink, id: 'resources-rename', label: 'Rename or move', onSelect: (file) => setFilesToMove([file]), + separatorBefore: true, }, { appliesToDirs: true, @@ -157,7 +301,7 @@ const Resources = () => { }, deleteAction(deletion.requestDelete), ], - [deletion.requestDelete, handleCopyPath], + [deletion.requestDelete, handleCopyPath, handleMkdirHere, handleUploadHere], ); // Bulk-action set, rendered in the bulk-actions bar when at least one row @@ -174,6 +318,30 @@ const Resources = () => { [deletion.deleteFiles, handleBulkCopyPaths], ); + // 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. + const fileManagerEmptyAreaActions = useMemo( + () => [ + { + icon: FolderUp, + id: 'resources-empty-upload', + label: 'Upload files', + onSelect: upload.openFilePicker, + }, + { + icon: FolderPlus, + id: 'resources-empty-mkdir', + label: 'New folder', + onSelect: () => setIsMkdirOpen(true), + }, + ], + [upload.openFilePicker], + ); + const handleDeleteDialogOpenChange = useCallback( (nextOpen: boolean) => { if (!nextOpen) { @@ -217,6 +385,11 @@ 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 = ( @@ -303,7 +476,7 @@ const Resources = () => { - Create directory + Create directory in {uploadTargetLabel} @@ -320,7 +493,7 @@ const Resources = () => { - Upload files + Upload files to {uploadTargetLabel} @@ -329,17 +502,21 @@ const Resources = () => { actions={fileManagerActions} bulkActions={fileManagerBulkActions} className="min-h-0 flex-1" + emptyAreaActions={fileManagerEmptyAreaActions} emptyState={noResourcesState} files={fileNodes} isLoading={isInitialLoading} + onActiveRowChange={setActiveRowPath} + onExternalFileDrop={handleExternalFileDrop} onMoveItems={handleMoveItems} onOpen={handleOpenFile} search={{ emptyState: noMatchesState, query: search.debouncedQuery }} /> setIsMkdirOpen(false)} + onClose={closeMkdirDialog} /> { onClose={() => setFilesToCopy(null)} /> -