feat(frontend): atomic batch resource ops and context-aware uploads

- Send `sources[]` (was single `source`) to /resources/copy,
  /resources/move and /files/to-resources so multi-select operations
  execute in one DB transaction; replace the per-feature 409 aggregation
  state and the now-unused `resources-conflict-dialog` with the shared
  `useOverwriteAction` workflow returning `OverwriteOutcome`.
- Extend `FileManager` with `emptyAreaActions` (right-click context menu
  over the tree's empty area), `appliesToFiles` filter (companion to
  `appliesToDirs`), `onActiveRowChange` focus reporting and row-level
  external-file drop (`onExternalFileDrop`).
- `useFilesDragAndDrop`: capture-phase `onDropCapture` resets the
  internal counter / `isDragging` flag before any descendant claims the
  drop with `stopPropagation`, fixing the page-level upload overlay
  staying stuck after a row-level drop.
- `useResourcesUpload` gains `defaultDir` (read via ref so `uploadFiles`
  stays reference-stable) and `openFilePickerForDir` so toolbar /
  sidebar / CTA pickers upload into the focused folder by default and
  per-row "Upload here" actions can target a specific directory.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sergey Kozyrenko
2026-05-08 17:55:37 +07:00
co-authored by Cursor
parent 76cb4c1283
commit 3540407aa6
16 changed files with 1173 additions and 615 deletions
@@ -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 `<DropdownMenuItem>` 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<HTMLDivElement>) => 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<HTMLDivElement>) => {
if (isEventFromOutsideRowDom(event)) {
return;
}
onFocusRow(file.path);
}}
role="treeitem"
style={rowStyle}
tabIndex={isActiveRow ? 0 : -1}
@@ -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 `<a href>` 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> | 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).
@@ -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
* `<ContextMenuContent>` 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(<ContextMenuSeparator key={`separator-${action.id}`} />);
}
const ActionIcon = action.icon;
nodes.push(
<ContextMenuItem
className={cn(
action.variant === 'destructive' &&
'text-destructive focus:bg-destructive/10 focus:text-destructive',
)}
key={action.id}
onSelect={() => action.onSelect()}
>
{ActionIcon ? <ActionIcon className="size-4" /> : null}
{action.label}
</ContextMenuItem>,
);
}
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 <div className={className}>{search?.emptyState ?? emptyState}</div>;
}
const effectiveEmptyAreaActions = emptyAreaActions ?? EMPTY_AREA_ACTIONS;
const hasEmptyAreaActions = effectiveEmptyAreaActions.length > 0;
// The scrollable tree element. Wrapped in a Radix `<ContextMenu>` 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 = (
<div
aria-label="File tree"
aria-multiselectable={isCheckboxVisible || undefined}
className={cn(
'flex flex-1 flex-col overflow-y-auto py-1 transition-colors',
// Highlight the whole tree only when the cursor is actually hovering
// the empty area outside any row — that's the only place a "drop to
// root" will be accepted. `border-radius: inherit` makes the inset
// ring follow the outer container's rounded corners (top corners are
// hidden behind the header, so only the bottom is visually affected).
dnd.container.isRootDropTarget &&
'bg-primary/10 ring-primary [border-radius:inherit] ring-1 ring-inset',
)}
onDragEnter={dnd.isEnabled ? dnd.container.onDragEnter : undefined}
onDragLeave={dnd.isEnabled ? dnd.container.onDragLeave : undefined}
onDragOver={dnd.isEnabled ? dnd.container.onDragOver : undefined}
onDrop={dnd.isEnabled ? dnd.container.onDrop : undefined}
role="tree"
>
{visibleTree.map((node, index) => (
<FileManagerTreeNode
actions={effectiveActions}
activeRowPath={resolvedActiveRow}
bindNodeDnd={dnd.bindNodeDnd}
dirSelectionStates={dirSelectionStates}
dirSubtreePaths={dirSubtreePaths}
display={display}
expandedPaths={expandedPaths}
handlers={handlers}
key={node.id}
node={node}
posInSet={index + 1}
selectedPaths={selectedPaths}
setSize={visibleTree.length}
/>
))}
</div>
);
const tree = hasEmptyAreaActions ? (
<ContextMenu>
<ContextMenuTrigger asChild>{treeBody}</ContextMenuTrigger>
<ContextMenuContent>{renderEmptyAreaItems(effectiveEmptyAreaActions)}</ContextMenuContent>
</ContextMenu>
) : (
treeBody
);
return (
<div
className={cn('bg-card flex flex-col overflow-hidden rounded-lg border', className)}
@@ -318,43 +450,7 @@ export const FileManager = ({
)}
</div>
<div
aria-label="File tree"
aria-multiselectable={isCheckboxVisible || undefined}
className={cn(
'flex flex-1 flex-col overflow-y-auto py-1 transition-colors',
// Highlight the whole tree only when the cursor is actually hovering
// the empty area outside any row — that's the only place a "drop to
// root" will be accepted. `border-radius: inherit` makes the inset
// ring follow the outer container's rounded corners (top corners are
// hidden behind the header, so only the bottom is visually affected).
dnd.container.isRootDropTarget &&
'bg-primary/10 ring-primary [border-radius:inherit] ring-1 ring-inset',
)}
onDragEnter={dnd.isEnabled ? dnd.container.onDragEnter : undefined}
onDragLeave={dnd.isEnabled ? dnd.container.onDragLeave : undefined}
onDragOver={dnd.isEnabled ? dnd.container.onDragOver : undefined}
onDrop={dnd.isEnabled ? dnd.container.onDrop : undefined}
role="tree"
>
{visibleTree.map((node, index) => (
<FileManagerTreeNode
actions={effectiveActions}
activeRowPath={resolvedActiveRow}
bindNodeDnd={dnd.bindNodeDnd}
dirSelectionStates={dirSelectionStates}
dirSubtreePaths={dirSubtreePaths}
display={display}
expandedPaths={expandedPaths}
handlers={handlers}
key={node.id}
node={node}
posInSet={index + 1}
selectedPaths={selectedPaths}
setSize={visibleTree.length}
/>
))}
</div>
{tree}
{hasBulkActions && (
<FileManagerBulkActionsBar
@@ -16,6 +16,7 @@ export type {
FileManagerAction,
FileManagerBulkAction,
FileManagerBulkActionConfirm,
FileManagerEmptyAreaAction,
FileManagerLabels,
FileManagerProps,
FileManagerRootGroup,
@@ -18,6 +18,14 @@ export interface FileManagerContainerDndHandlers {
}
export interface FileManagerNodeDndHandlers {
/**
* `true` when intra-tree move DnD is on (i.e. the row should set
* `draggable={true}` so the user can grab it). When only external-file
* drops are enabled, rows still bind drop handlers (so the highlight /
* counter logic works) but stay non-draggable, since there's no move
* destination contract for them.
*/
canDrag: boolean;
/**
* `true` when this row is part of the in-flight drag operation. Drives the
* "ghosted" appearance for every selected row when the user drags one of them,
@@ -44,6 +52,19 @@ interface UseFileManagerDndParams {
* the items live elsewhere). Optional — when omitted, selection isn't touched.
*/
onClearSelection?: () => 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> | void;
/** When undefined, DnD is fully disabled. */
onMoveItems?: (sources: FileNode[], destinationDir: string) => Promise<void> | void;
/**
@@ -105,6 +126,18 @@ const isValidMove = (sources: FileManagerInternalNode[], destDir: string): boole
const isFmDragEvent = (event: ReactDragEvent<HTMLDivElement>): 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<HTMLDivElement>): 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<HTMLDivElement>): 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<HTMLDivElement>): 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<HTMLDivElement>): 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<HTMLDivElement>): 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,
],
);
@@ -95,7 +95,7 @@ interface UseOverwriteActionResult<TPlan> {
* 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 = <TPlan,>(
export const useOverwriteAction = <TPlan>(
options: UseOverwriteActionOptions<TPlan>,
): UseOverwriteActionResult<TPlan> => {
const [conflicts, setConflicts] = useState<OverwriteConflict[]>([]);
@@ -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 `<dir>/<file.name>`.
*/
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<readonly PromotePlan[]>({
execute: async (plans, force): Promise<OverwriteOutcome> => {
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<PromotePlan>({
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;
@@ -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<typeof flowFilesPromoteFormSche
interface PromoteRequestBody {
destination: string;
force: boolean;
source: string;
sources: readonly string[];
}
interface UseFlowFilesPromoteParams {
@@ -32,34 +33,31 @@ interface UseFlowFilesPromoteParams {
interface UseFlowFilesPromoteResult {
isPromoting: boolean;
promote: (
source: string,
values: FlowFilesPromoteFormValues,
force: boolean,
) => Promise<OverwriteOutcome>;
/**
* 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/<basename>`. The Apollo cache stays in sync
* via `resourceAdded` / `resourceUpdated` GraphQL subscriptions.
*/
promote: (sources: readonly string[], destination: string, force: boolean) => Promise<OverwriteOutcome>;
}
/**
* 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<OverwriteOutcome> => {
if (!flowId) {
async (sources: readonly string[], destination: string, force: boolean): Promise<OverwriteOutcome> => {
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) {
@@ -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> | 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) => (
<OverwriteConfirmDialog
conflicts={conflicts}
onCancel={onCancel}
onReplaceAll={onReplaceAll}
/>
);
@@ -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 `<targetDir>/<file.name>`, 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<boolean> => {
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<CopyPlan>({
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 (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Copy className="size-4" />
{titleText}
</DialogTitle>
<DialogDescription>
{isMulti ? (
<>Duplicate every selected item into the destination directory.</>
) : (
<>
Duplicate <code>{files[0].path}</code> to a new path.
</>
)}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
className="flex flex-col gap-4"
onSubmit={handleSave}
>
<FormField
control={form.control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>{isMulti ? 'Destination directory' : 'Destination path'}</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="off"
autoFocus
disabled={isCopying}
placeholder={isMulti ? 'Leave empty to copy into the library root' : undefined}
/>
</FormControl>
<FormDescription>
{isMulti ? (
<>
Relative directory inside your library. Leave empty for the root. Each item
keeps its current filename.
</>
) : (
<>Relative path inside your library.</>
)}
</FormDescription>
<FormMessage />
</FormItem>
<>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Copy className="size-4" />
{titleText}
</DialogTitle>
<DialogDescription>
{isMulti ? (
<>Duplicate every selected item into the destination directory.</>
) : (
<>
Duplicate <code>{files[0].path}</code> to a new path.
</>
)}
/>
</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap justify-end gap-2">
<Button
disabled={isCopying}
onClick={onClose}
type="button"
variant="outline"
>
Cancel
</Button>
<OverwriteCtaButtons
isDisabled={isSubmitDisabled}
isProcessing={isCopying}
onOverwrite={() => {
void handleSaveWithOverwrite();
}}
overwriteLabel={overwriteCtaLabel}
primaryIcon={Copy}
primaryLabel="Copy"
primaryType="submit"
<Form {...form}>
<form
className="flex flex-col gap-4"
onSubmit={handleSave}
>
<FormField
control={form.control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>{isMulti ? 'Destination directory' : 'Destination path'}</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="off"
autoFocus
disabled={isCopying}
placeholder={
isMulti ? 'Leave empty to copy into the library root' : undefined
}
/>
</FormControl>
<FormDescription>
{isMulti ? (
<>
Relative directory inside your library. Leave empty for the root. Each
item keeps its current filename.
</>
) : (
<>Relative path inside your library.</>
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
<ResourcesConflictDialog
conflicts={pendingConflicts}
onCancel={cancelConflicts}
onReplaceAll={handleResolveConflicts}
<div className="flex flex-wrap justify-end gap-2">
<Button
disabled={isCopying}
onClick={onClose}
type="button"
variant="outline"
>
Cancel
</Button>
<OverwriteCtaButtons
isDisabled={isSubmitDisabled}
isProcessing={isCopying}
onOverwrite={() => {
void handleSaveWithOverwrite();
}}
overwriteLabel={overwriteCtaLabel}
primaryIcon={Copy}
primaryLabel="Copy"
primaryType="submit"
/>
</div>
</form>
</Form>
</DialogContent>
<OverwriteConfirmDialog
conflicts={overwriteAction.conflicts}
onCancel={overwriteAction.resetConflicts}
onReplaceAll={overwriteAction.handleReplaceAll}
/>
</DialogContent>
</>
);
};
@@ -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 `<targetDir>/<file.name>`, 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 = `<dir>/<file.name>`
* - 2+ sources → destination is always a base directory;
* each source lands at `<dir>/<file.name>`.
*/
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<boolean> => {
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<MovePlan>({
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 (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderInput className="size-4" />
{titleText}
</DialogTitle>
<DialogDescription>
{isMulti ? (
<>Move every selected item into the destination directory.</>
) : (
<>
Update the path of <code>{files[0].path}</code>.
</>
)}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
className="flex flex-col gap-4"
onSubmit={handleSave}
>
<FormField
control={form.control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>{isMulti ? 'Destination directory' : 'New path'}</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="off"
autoFocus
disabled={isMoving}
placeholder={isMulti ? 'Leave empty to move into the library root' : undefined}
/>
</FormControl>
<FormDescription>
{isMulti ? (
<>
Relative directory inside your library. Leave empty for the root. Each item
keeps its current filename.
</>
) : (
<>
Relative path inside your library. End with <code>/</code> to drop the entry
into that directory.
</>
)}
</FormDescription>
<FormMessage />
</FormItem>
<>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderInput className="size-4" />
{titleText}
</DialogTitle>
<DialogDescription>
{isMulti ? (
<>Move every selected item into the destination directory.</>
) : (
<>
Update the path of <code>{files[0].path}</code>.
</>
)}
/>
</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap justify-end gap-2">
<Button
disabled={isMoving}
onClick={onClose}
type="button"
variant="outline"
>
Cancel
</Button>
<OverwriteCtaButtons
isDisabled={isSubmitDisabled}
isProcessing={isMoving}
onOverwrite={() => {
void handleSaveWithOverwrite();
}}
overwriteLabel={overwriteCtaLabel}
primaryIcon={FolderInput}
primaryLabel="Move"
primaryType="submit"
<Form {...form}>
<form
className="flex flex-col gap-4"
onSubmit={handleSave}
>
<FormField
control={form.control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>{isMulti ? 'Destination directory' : 'New path'}</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="off"
autoFocus
disabled={isMoving}
placeholder={
isMulti ? 'Leave empty to move into the library root' : undefined
}
/>
</FormControl>
<FormDescription>
{isMulti ? (
<>
Relative directory inside your library. Leave empty for the root. Each
item keeps its current filename.
</>
) : (
<>
Relative path inside your library. End with <code>/</code> to drop the
entry into that directory.
</>
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
<ResourcesConflictDialog
conflicts={pendingConflicts}
onCancel={cancelConflicts}
onReplaceAll={handleResolveConflicts}
<div className="flex flex-wrap justify-end gap-2">
<Button
disabled={isMoving}
onClick={onClose}
type="button"
variant="outline"
>
Cancel
</Button>
<OverwriteCtaButtons
isDisabled={isSubmitDisabled}
isProcessing={isMoving}
onOverwrite={() => {
void handleSaveWithOverwrite();
}}
overwriteLabel={overwriteCtaLabel}
primaryIcon={FolderInput}
primaryLabel="Move"
primaryType="submit"
/>
</div>
</form>
</Form>
</DialogContent>
<OverwriteConfirmDialog
conflicts={overwriteAction.conflicts}
onCancel={overwriteAction.resetConflicts}
onReplaceAll={overwriteAction.handleReplaceAll}
/>
</DialogContent>
</>
);
};
@@ -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<typeof resourcesCopyFormSchema>;
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/<basename>`. Either way, the whole batch
* executes inside one DB transaction.
*/
copy: (sourcePath: string, values: ResourcesCopyFormValues, force: boolean) => Promise<boolean>;
copy: (sources: readonly string[], destination: string, force: boolean) => Promise<OverwriteOutcome>;
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<void>;
}
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<ResourcesCopyConflict[]>([]);
const performCopy = useCallback(
async (sourcePath: string, destination: string, force: boolean): Promise<boolean> => {
const copy = useCallback(
async (sources: readonly string[], destination: string, force: boolean): Promise<OverwriteOutcome> => {
if (sources.length === 0) {
return { kind: 'error' };
}
setIsCopying(true);
try {
await api.post<void, CopyRequestBody>(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<boolean> =>
performCopy(sourcePath, destination.trim(), force),
[performCopy],
);
const resolveConflicts = useCallback(async (): Promise<void> => {
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,
};
};
@@ -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<typeof resourcesMoveFormSchema>;
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/<basename>`. Either way, the whole
* batch executes inside one DB transaction — partial state is impossible.
*/
move: (sourcePath: string, values: ResourcesMoveFormValues, force: boolean) => Promise<boolean>;
/**
* 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<void>;
move: (sources: readonly string[], destination: string, force: boolean) => Promise<OverwriteOutcome>;
}
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<ResourcesMoveConflict[]>([]);
const performMove = useCallback(
async (sourcePath: string, destination: string, force: boolean): Promise<boolean> => {
const move = useCallback(
async (sources: readonly string[], destination: string, force: boolean): Promise<OverwriteOutcome> => {
if (sources.length === 0) {
return { kind: 'error' };
}
setIsMoving(true);
try {
await api.put<void, MoveRequestBody>(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<boolean> =>
performMove(sourcePath, destination.trim(), force),
[performMove],
);
const resolveConflicts = useCallback(async (): Promise<void> => {
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,
};
};
@@ -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<HTMLInputElement | null>;
};
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<null | UploadResponse>;
}
@@ -92,12 +114,39 @@ const buildUploadSuccessMessage = (uploadedCount: number, dir?: string) => {
* each upload so the consumer can remount the hidden `<input>` and clear it
* declaratively.
*/
export const useResourcesUpload = ({ onSuccess }: UseResourcesUploadParams = {}): UseResourcesUploadResult => {
export const useResourcesUpload = ({
defaultDir,
onSuccess,
}: UseResourcesUploadParams = {}): UseResourcesUploadResult => {
const inputRef = useRef<HTMLInputElement | null>(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<string | undefined>(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<RestResourceList, FormData>(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<HTMLInputElement>) => {
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,
};
};
@@ -5,6 +5,15 @@ interface DragHandlers {
onDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
onDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
onDrop: (event: React.DragEvent<HTMLDivElement>) => 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<HTMLDivElement>) => 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<HTMLDivElement>) => {
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,
};
+200 -23
View File
@@ -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 | 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);
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<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 });
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<void> => {
await upload.uploadFiles(droppedFiles, { dir: destinationDir });
},
[upload],
);
const fileNodes = useMemo<FileNode[]>(() => 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<DndMovePlan>({
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<FileManagerAction[]>(
() => [
// 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<FileManagerEmptyAreaAction[]>(
() => [
{
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 = (
<Empty>
<EmptyHeader>
@@ -303,7 +476,7 @@ const Resources = () => {
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Create directory</TooltipContent>
<TooltipContent>Create directory in {uploadTargetLabel}</TooltipContent>
</Tooltip>
<Tooltip>
@@ -320,7 +493,7 @@ const Resources = () => {
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Upload files</TooltipContent>
<TooltipContent>Upload files to {uploadTargetLabel}</TooltipContent>
</Tooltip>
</div>
</Form>
@@ -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 }}
/>
<ResourcesMkdirDialog
defaultParentPath={mkdirParentOverride ?? currentDir}
isOpen={isMkdirOpen}
onClose={() => setIsMkdirOpen(false)}
onClose={closeMkdirDialog}
/>
<ResourcesMoveDialog
@@ -352,10 +529,10 @@ const Resources = () => {
onClose={() => setFilesToCopy(null)}
/>
<ResourcesConflictDialog
conflicts={moveAction.pendingConflicts}
onCancel={moveAction.cancelConflicts}
onReplaceAll={moveAction.resolveConflicts}
<OverwriteConfirmDialog
conflicts={dndMoveAction.conflicts}
onCancel={dndMoveAction.resetConflicts}
onReplaceAll={dndMoveAction.handleReplaceAll}
/>
<ConfirmationDialog