mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-23 11:36:29 +00:00
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:
co-authored by
Cursor
parent
76cb4c1283
commit
3540407aa6
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user