import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import { Copy, Download, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
import { Button, Dialog, ExplorerTree, type ExplorerTreeNodeContext } from "@govoplan/core-webui";
import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files";
import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types";
import { isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
export function TransferFolderSelector({
space,
nodes,
selectedFolder,
onSelect,
disabled
}: {
space: FileSpace | null;
nodes: FolderNode[];
selectedFolder: string;
onSelect: (folderPath: string) => void;
disabled?: boolean;
}) {
return (
onSelect("")}
disabled={disabled || !space}
>
{space?.label || "Root"}
{nodes.length === 0 && No folders yet. Choose the root folder. }
);
}
export function TransferFolderSelectorNodes({
nodes,
selectedFolder,
onSelect,
disabled,
depth = 1
}: {
nodes: FolderNode[];
selectedFolder: string;
onSelect: (folderPath: string) => void;
disabled?: boolean;
depth?: number;
}) {
if (nodes.length === 0) return null;
return (
{nodes.map((node) => (
onSelect(node.path)}
disabled={disabled}
>
{node.name}
))}
);
}
export function FolderTree({
nodes,
activeSpaceId,
spaceId,
currentFolder,
dropTargetKey = "",
expandedKeys,
onOpen,
onToggle,
onContextMenu,
onDragOverTarget,
onDropOnTarget,
onClearDropState,
onRequestDragExpand,
onDragStartFolder,
onDragEndFolder,
dragDropEnabled = true,
contextMenuEnabled = true,
disabled,
depth = 1
}: {
nodes: FolderNode[];
activeSpaceId: string;
spaceId: string;
currentFolder: string;
dropTargetKey?: string;
expandedKeys: Set;
onOpen: (spaceId: string, path: string) => void;
onToggle: (spaceId: string, path: string) => void;
onContextMenu?: (event: ReactMouseEvent, spaceId: string, path: string) => void;
onDragOverTarget?: (event: ReactDragEvent, target: FileActionTarget) => void;
onDropOnTarget?: (event: ReactDragEvent, target: FileActionTarget) => Promise;
onClearDropState?: () => void;
onRequestDragExpand?: (spaceId: string, path: string) => void;
onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent) => void;
onDragEndFolder?: () => void;
dragDropEnabled?: boolean;
contextMenuEnabled?: boolean;
disabled?: boolean;
depth?: number;
}) {
return (
treeNodeKey(spaceId, node.path)}
getNodeLabel={(node) => node.name}
getNodeChildren={(node) => node.children}
activeId={activeSpaceId === spaceId ? treeNodeKey(spaceId, currentFolder) : ""}
expandedIds={expandedKeys}
onOpen={(node) => onOpen(spaceId, node.path)}
onToggle={(node) => onToggle(spaceId, node.path)}
disabled={disabled}
depth={depth}
getNodeWrapClassName={(node) => {
const target = { spaceId, folderPath: node.path };
return dragDropEnabled && dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}` ? "is-drop-target" : undefined;
}}
getNodeWrapStyle={(_node, context: ExplorerTreeNodeContext) => ({ paddingLeft: `${Math.min(context.depth * 14, 56)}px` })}
getNodeDraggable={() => dragDropEnabled && !disabled}
onContextMenu={contextMenuEnabled && onContextMenu ? (event, node) => onContextMenu(event, spaceId, node.path) : undefined}
onDragStart={dragDropEnabled && onDragStartFolder ? (event, node) => onDragStartFolder(spaceId, node.path, event) : undefined}
onDragEnd={dragDropEnabled && onDragEndFolder ? () => onDragEndFolder() : undefined}
onDragOver={dragDropEnabled && onDragOverTarget ? (event, node, context) => {
const target = { spaceId, folderPath: node.path };
onDragOverTarget(event, target);
if (context.hasChildren && !context.expanded) onRequestDragExpand?.(spaceId, node.path);
} : undefined}
onDragLeave={dragDropEnabled && onClearDropState ? () => onClearDropState() : undefined}
onDrop={dragDropEnabled && onDropOnTarget ? (event, node) => void onDropOnTarget(event, { spaceId, folderPath: node.path }) : undefined}
/>
);
}
export function RenamePreviewList({
response,
selectedFileIds,
selectedFolderPaths,
recursive,
visibleCount,
onShowMore,
onShowFewer
}: {
response: RenameResponse;
selectedFileIds: Set;
selectedFolderPaths: Set;
recursive: boolean;
visibleCount: number;
onShowMore: () => void;
onShowFewer: () => void;
}) {
const selectedFolderRoots = Array.from(selectedFolderPaths).map(normalizeFolder);
const hiddenNonRecursiveItems = !recursive
? response.items.filter((item) => {
if (item.kind === "file" && selectedFileIds.has(item.id)) return false;
if (item.kind === "folder" && selectedFolderRoots.includes(normalizeFolder(item.old_path))) return false;
return selectedFolderRoots.some((folder) => isPathUnderOrSame(item.old_path, folder));
}).length
: 0;
const visibleItems = response.items.filter((item) => {
if (recursive) return true;
if (item.kind === "file") {
if (selectedFileIds.has(item.id)) return true;
return !selectedFolderRoots.some((folder) => isPathUnderOrSame(item.old_path, folder));
}
return selectedFolderRoots.includes(normalizeFolder(item.old_path));
});
const safeVisibleCount = Math.max(20, visibleCount);
const shownItems = visibleItems.slice(0, safeVisibleCount);
const remaining = visibleItems.length - shownItems.length;
return (
{hiddenNonRecursiveItems > 0 && {hiddenNonRecursiveItems} contained path(s) will move with the selected folder(s), but their own names will stay unchanged. }
{shownItems.map((item) => {item.old_path} → {item.new_path} )}
{remaining > 0 && (
… and {remaining} more — show next {Math.min(20, remaining)}
)}
{shownItems.length > 20 && (
Show fewer
)}
);
}
export function FileDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
return (
{children}
);
}
export function FileContextMenu({
menu,
hasSelection,
canCreateFolder,
canUpload,
canDownload,
canOrganize,
canDelete,
downloadLabel,
onCreateFolder,
onUpload,
onDownload,
onMove,
onCopy,
onDelete
}: {
menu: ContextMenuState;
hasSelection: boolean;
canCreateFolder: boolean;
canUpload: boolean;
canDownload: boolean;
canOrganize: boolean;
canDelete: boolean;
downloadLabel: string;
onCreateFolder: () => void;
onUpload: () => void;
onDownload: () => void;
onMove: () => void;
onCopy: () => void;
onDelete: () => void;
}) {
const showNewFolder = true;
const showDelete = menu.target !== "empty";
const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth;
const viewportHeight = typeof window === "undefined" ? 768 : window.innerHeight;
const estimatedWidth = 220;
const estimatedHeight = 260;
const left = Math.max(8, Math.min(menu.x, viewportWidth - estimatedWidth - 8));
const openUp = menu.y + estimatedHeight > viewportHeight;
const style: CSSProperties = openUp
? { left, bottom: Math.max(8, viewportHeight - menu.y) }
: { left, top: Math.max(8, menu.y) };
return (
event.stopPropagation()}>
{showNewFolder &&
New folder}
Upload
{downloadLabel}
Move…
Copy…
{showDelete &&
Delete}
);
}
export function FileConflictDialog({
state,
busy,
onClose,
onCancel,
onOverwrite,
onRename,
onReview,
onApplyReview,
onUpdateItem
}: {
state: ConflictDialogState;
busy: boolean;
onClose: () => void;
onCancel: () => void;
onOverwrite: () => void;
onRename: () => void;
onReview: () => void;
onApplyReview: () => void;
onUpdateItem: (id: string, patch: Partial) => void;
}) {
return (
{state.message}
{state.items.length} conflict(s)
{state.items.length > 1 ? "Choose the same action for all conflicts or review the target names individually." : "Choose how to resolve this conflict."}
{state.review && (
{state.items.map((item) => (
))}
)}
{!state.review && (
{state.items.slice(0, 8).map((item) => {item.targetPath} )}
{state.items.length > 8 && … and {state.items.length - 8} more }
)}
Cancel
Overwrite all
Rename all
{!state.review && state.items.length > 1 && Review individually }
{state.review && Apply reviewed choices }
);
}