From efa1f11840d469ada62b6329396afb8cd8e139fc Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 12 Jun 2026 03:53:54 +0200 Subject: [PATCH] File refactor --- .gitignore | 1 + src/features/files/FilesPage.tsx | 975 ++---------------- .../components/FileManagerComponents.tsx | 343 ++++++ src/features/files/constants.ts | 6 + src/features/files/hooks/useFileDialogs.ts | 42 + .../files/hooks/useFileDragDropState.ts | 23 + src/features/files/hooks/useFileSelection.ts | 110 ++ src/features/files/hooks/useFileTreeState.ts | 87 ++ src/features/files/types.ts | 69 ++ src/features/files/utils/fileManagerUtils.ts | 303 ++++++ 10 files changed, 1083 insertions(+), 876 deletions(-) create mode 100644 src/features/files/components/FileManagerComponents.tsx create mode 100644 src/features/files/constants.ts create mode 100644 src/features/files/hooks/useFileDialogs.ts create mode 100644 src/features/files/hooks/useFileDragDropState.ts create mode 100644 src/features/files/hooks/useFileSelection.ts create mode 100644 src/features/files/hooks/useFileTreeState.ts create mode 100644 src/features/files/types.ts create mode 100644 src/features/files/utils/fileManagerUtils.ts diff --git a/.gitignore b/.gitignore index 57968ce..1739f90 100644 --- a/.gitignore +++ b/.gitignore @@ -423,4 +423,5 @@ bin-release/ .fuse_* multisealmail-*.zip +multi-seal-mail-*.zip multi-seal-mail-webui*.tar.gz \ No newline at end of file diff --git a/src/features/files/FilesPage.tsx b/src/features/files/FilesPage.tsx index 8890acd..54c494b 100644 --- a/src/features/files/FilesPage.tsx +++ b/src/features/files/FilesPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; import { ChevronRight, Copy, Download, File, Folder, FolderOpen, Home, MoveRight, Plus, Search, Trash2, UploadCloud } from "lucide-react"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; @@ -27,79 +27,52 @@ import { type ManagedFile, type RenameResponse } from "../../api/files"; - -type RenameMode = "prefix" | "suffix" | "replace"; -type SortColumn = "name" | "size" | "modified"; -type SortDirection = "asc" | "desc"; -type TransferMode = "move" | "copy"; -type DialogKind = "upload" | "create-folder" | "rename" | "single-rename" | "transfer" | null; -type EntrySelectionKey = `file:${string}` | `folder:${string}`; -type ContextMenuState = { - x: number; - y: number; - target: "empty" | "folder" | "file"; - entry?: ExplorerEntry; - source?: "list" | "tree"; - spaceId?: string; - folderPath?: string; -}; -type FileActionTarget = { spaceId: string; folderPath: string }; -type SelectionSets = { fileIds: Set; folderPaths: Set }; -type TransferDialogState = SelectionSets & { sourceSpaceId: string; targetSpaceId: string; targetFolder: string }; -type DragSelectionState = { sourceSpaceId: string; fileIds: string[]; folderPaths: string[] }; -type PendingUploadState = { files: File[]; target: FileActionTarget }; -type PendingTransferState = { mode: TransferMode; sourceSpaceId: string; sets: SelectionSets; target: FileActionTarget }; -type DeleteDialogState = SelectionSets & { spaceId: string; title: string; message: string }; -type FileConflictItem = { - id: string; - kind: "file" | "folder"; - label: string; - targetPath: string; - action: ConflictAction; - newPath: string; -}; -type ConflictDialogState = { - operation: "upload" | "transfer"; - title: string; - message: string; - items: FileConflictItem[]; - pendingUpload?: PendingUploadState; - pendingTransfer?: PendingTransferState; - review: boolean; -}; - -type FolderNode = { - name: string; - path: string; - children: FolderNode[]; - fileCount: number; - persisted: boolean; -}; - -type FolderEntry = { - kind: "folder"; - id: string; - name: string; - path: string; - fileCount: number; - folderCount: number; - totalSize: number; - updatedAt: string; - persisted: boolean; -}; - -type FileEntry = { - kind: "file"; - id: string; - file: ManagedFile; -}; - -type ExplorerEntry = FolderEntry | FileEntry; - -const EMPTY_SPACES: FileSpace[] = []; -const EMPTY_FILES: ManagedFile[] = []; -const EMPTY_FOLDERS: FileFolder[] = []; -const INTERNAL_DRAG_TYPE = "application/x-multi-seal-mail-file-selection"; +import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants"; +import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents"; +import type { + ConflictDialogState, + ContextMenuState, + DeleteDialogState, + DialogKind, + DragSelectionState, + EntrySelectionKey, + ExplorerEntry, + FolderEntry, + FileActionTarget, + FileConflictItem, + RenameMode, + SelectionSets, + SortColumn, + SortDirection, + TransferDialogState, + TransferMode +} from "./types"; +import { + buildExplorerEntries, + buildFolderEntryFromSources, + buildFolderTree, + candidateRenamedPath, + entrySelectionKey, + fileIdsForSelection, + folderAncestorPaths, + folderBreadcrumbs, + folderContentLabel, + formatBytes, + formatDate, + isFileInFolder, + isPathUnderOrSame, + joinFolder, + lastPathSegment, + normalizeFilePath, + normalizeFolder, + parseDragState, + sortExplorerEntries, + treeNodeKey +} from "./utils/fileManagerUtils"; +import { useFileSelection } from "./hooks/useFileSelection"; +import { useFileTreeState } from "./hooks/useFileTreeState"; +import { useFileDialogs } from "./hooks/useFileDialogs"; +import { useFileDragDropState } from "./hooks/useFileDragDropState"; export default function FilesPage({ settings }: { settings: ApiSettings }) { const [spaces, setSpaces] = useState(EMPTY_SPACES); @@ -110,8 +83,6 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) { const files = activeSpace ? filesBySpace[activeSpace.id] ?? EMPTY_FILES : EMPTY_FILES; const folders = activeSpace ? foldersBySpace[activeSpace.id] ?? EMPTY_FOLDERS : EMPTY_FOLDERS; - const [selectedFileIds, setSelectedFileIds] = useState>(() => new Set()); - const [selectedFolderPaths, setSelectedFolderPaths] = useState>(() => new Set()); const [currentFolder, setCurrentFolder] = useState(""); const [searchPattern, setSearchPattern] = useState(""); const [searchCaseSensitive, setSearchCaseSensitive] = useState(false); @@ -124,20 +95,27 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) { const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [error, setError] = useState(""); - const [dragActive, setDragActive] = useState(false); - const [internalDrag, setInternalDrag] = useState(null); - const [dropTargetKey, setDropTargetKey] = useState(""); - const [dialog, setDialog] = useState(null); - const [dialogTarget, setDialogTarget] = useState(null); - const [transferMode, setTransferMode] = useState("move"); - const [transferDialogState, setTransferDialogState] = useState(null); - const [contextMenu, setContextMenu] = useState(null); - const [conflictDialog, setConflictDialog] = useState(null); - const [deleteDialog, setDeleteDialog] = useState(null); - const [selectionAnchor, setSelectionAnchor] = useState(null); + const { dragActive, setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState(); + const { + dialog, + setDialog, + dialogTarget, + setDialogTarget, + transferMode, + setTransferMode, + transferDialogState, + setTransferDialogState, + contextMenu, + setContextMenu, + conflictDialog, + setConflictDialog, + deleteDialog, + setDeleteDialog, + openDialog: openRawDialog, + closeDialog: closeRawDialog + } = useFileDialogs(); const [newFolderName, setNewFolderName] = useState(""); const [newFolderError, setNewFolderError] = useState(""); - const [expandedTreeNodes, setExpandedTreeNodes] = useState>(() => new Set()); const [renamePreviewVisibleCount, setRenamePreviewVisibleCount] = useState(20); const [singleRenameName, setSingleRenameName] = useState(""); const [renameMode, setRenameMode] = useState("prefix"); @@ -148,24 +126,41 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) { const [renameRecursive, setRenameRecursive] = useState(false); const [renamePreview, setRenamePreview] = useState(null); const fileInputRef = useRef(null); - const dragExpandTimerRef = useRef(null); - const dragExpandKeyRef = useRef(null); - const suppressTreeAutoExpandKeyRef = useRef(null); const visibleFiles = searchActive && searchResults ? searchResults : files; - const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]); - const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]); const explorerEntries = useMemo(() => { const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive); return sortExplorerEntries(entries, sortColumn, sortDirection); }, [visibleFiles, folders, currentFolder, searchActive, sortColumn, sortDirection]); - const folderCrumbs = useMemo(() => folderBreadcrumbs(currentFolder), [currentFolder]); const visibleEntryKeys = useMemo(() => explorerEntries.map(entrySelectionKey), [explorerEntries]); + const { + selectedFileIds, + setSelectedFileIds, + selectedFolderPaths, + setSelectedFolderPaths, + selectedEntryCount, + hasSelection, + selectedSummary, + currentSelectionKeys, + setSelectionAnchor, + applySelectionKeys, + clearSelection, + currentSelectionSets, + setsForEntry, + isEntrySelected, + preventTextSelectionOnShift, + handleEntrySelection + } = useFileSelection(visibleEntryKeys, busy); + const { expandedTreeNodes, setExpandedTreeNodes, cancelTreeDragExpand, scheduleTreeDragExpand, toggleTreeFolder } = useFileTreeState({ + activeSpaceId, + currentFolder, + onOpenFolder: openFolder + }); + const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]); + const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]); + const folderCrumbs = useMemo(() => folderBreadcrumbs(currentFolder), [currentFolder]); const activeDialogTarget = dialogTarget ?? (activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null); const activeDialogSpace = activeDialogTarget ? spaces.find((space) => space.id === activeDialogTarget.spaceId) ?? null : null; - const selectedSummary = `${selectedFileIds.size} file(s), ${selectedFolderPaths.size} folder(s) selected`; - const selectedEntryCount = selectedFileIds.size + selectedFolderPaths.size; - const hasSelection = selectedEntryCount > 0; const downloadLabel = selectedDownloadFileIds.length > 1 ? "Download ZIP" : "Download"; async function loadSpaces() { @@ -245,26 +240,10 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) { setSelectedFolderPaths(new Set()); }, [activeSpaceId]); - useEffect(() => { - if (!activeSpaceId) return; - const ancestors = folderAncestorPaths(currentFolder, { includeSelf: true }); - if (ancestors.length === 0) return; - const suppressedKey = suppressTreeAutoExpandKeyRef.current; - suppressTreeAutoExpandKeyRef.current = null; - setExpandedTreeNodes((current) => { - const next = new Set(current); - ancestors.forEach((path) => { - const key = treeNodeKey(activeSpaceId, path); - if (key !== suppressedKey) next.add(key); - }); - return next; - }); - }, [activeSpaceId, currentFolder]); + function resetTransientState(options: { keepSearch?: boolean } = {}) { - setSelectedFileIds(new Set()); - setSelectedFolderPaths(new Set()); - setSelectionAnchor(null); + clearSelection(); if (!options.keepSearch) { setSearchActive(false); setSearchPattern(""); @@ -279,18 +258,15 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) { } function openDialog(kind: DialogKind, target: FileActionTarget | null = null) { - setDialogTarget(target); if (kind === "create-folder") { setNewFolderName(""); setNewFolderError(""); } - setDialog(kind); + openRawDialog(kind, target); } function closeDialog() { - setDialog(null); - setDialogTarget(null); - setTransferDialogState(null); + closeRawDialog(); setNewFolderError(""); } @@ -298,9 +274,7 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) { return spaces.find((space) => space.id === spaceId) ?? null; } - function currentSelectionSets(): SelectionSets { - return { fileIds: new Set(selectedFileIds), folderPaths: new Set(selectedFolderPaths) }; - } + async function handleFilesUpload(fileList: FileList | File[], options: { conflictStrategy?: ConflictStrategy; conflictResolutions?: ConflictResolution[]; bypassConflictDialog?: boolean; target?: FileActionTarget } = {}) { const target = options.target ?? currentActionTarget(); @@ -706,61 +680,8 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) { resetTransientState(); } - function currentSelectionKeys(): Set { - const keys = new Set(); - selectedFileIds.forEach((id) => keys.add(`file:${id}`)); - selectedFolderPaths.forEach((path) => keys.add(`folder:${path}`)); - return keys; - } - function applySelectionKeys(keys: Set) { - const nextFileIds = new Set(); - const nextFolderPaths = new Set(); - keys.forEach((key) => { - if (key.startsWith("file:")) nextFileIds.add(key.slice(5)); - if (key.startsWith("folder:")) nextFolderPaths.add(key.slice(7)); - }); - setSelectedFileIds(nextFileIds); - setSelectedFolderPaths(nextFolderPaths); - } - - function handleEntrySelection(entry: ExplorerEntry, event: ReactMouseEvent) { - if (busy) return; - const key = entrySelectionKey(entry); - const currentKeys = currentSelectionKeys(); - - if (event.shiftKey) event.preventDefault(); - - if (event.shiftKey && selectionAnchor) { - const startIndex = visibleEntryKeys.indexOf(selectionAnchor); - const endIndex = visibleEntryKeys.indexOf(key); - if (startIndex >= 0 && endIndex >= 0) { - const [from, to] = startIndex <= endIndex ? [startIndex, endIndex] : [endIndex, startIndex]; - const nextKeys = event.ctrlKey || event.metaKey ? currentKeys : new Set(); - visibleEntryKeys.slice(from, to + 1).forEach((rangeKey) => nextKeys.add(rangeKey)); - applySelectionKeys(nextKeys); - setSelectionAnchor(key); - return; - } - } - - if (event.ctrlKey || event.metaKey) { - if (currentKeys.has(key)) currentKeys.delete(key); - else currentKeys.add(key); - applySelectionKeys(currentKeys); - setSelectionAnchor(key); - return; - } - - if (currentKeys.size === 1 && currentKeys.has(key)) { - applySelectionKeys(new Set()); - } else { - applySelectionKeys(new Set([key])); - setSelectionAnchor(key); - } - } - - function handleEntryKeyDown(entry: ExplorerEntry, event: React.KeyboardEvent) { + function handleEntryKeyDown(entry: ExplorerEntry, event: ReactKeyboardEvent) { if (event.key === " " || event.key === "Spacebar") { event.preventDefault(); handleEntrySelection(entry, event as unknown as ReactMouseEvent); @@ -772,19 +693,6 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) { } } - function isEntrySelected(entry: ExplorerEntry): boolean { - return entry.kind === "folder" ? selectedFolderPaths.has(entry.path) : selectedFileIds.has(entry.id); - } - - function preventTextSelectionOnShift(event: ReactMouseEvent) { - if (event.shiftKey) event.preventDefault(); - } - - function setsForEntry(entry: ExplorerEntry): SelectionSets { - if (entry.kind === "folder") return { fileIds: new Set(), folderPaths: new Set([entry.path]) }; - return { fileIds: new Set([entry.id]), folderPaths: new Set() }; - } - function dragSetsForEntry(entry: ExplorerEntry): SelectionSets { const key = entrySelectionKey(entry); return currentSelectionKeys().has(key) ? currentSelectionSets() : setsForEntry(entry); @@ -850,55 +758,8 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) { } } - function cancelTreeDragExpand() { - if (dragExpandTimerRef.current !== null) { - window.clearTimeout(dragExpandTimerRef.current); - dragExpandTimerRef.current = null; - } - dragExpandKeyRef.current = null; - } - - function expandTreeNode(spaceId: string, folderPath: string) { - const key = treeNodeKey(spaceId, folderPath); - setExpandedTreeNodes((current) => { - if (current.has(key)) return current; - const next = new Set(current); - next.add(key); - return next; - }); - } - - function scheduleTreeDragExpand(spaceId: string, folderPath: string) { - const key = treeNodeKey(spaceId, folderPath); - if (expandedTreeNodes.has(key) || dragExpandKeyRef.current === key) return; - cancelTreeDragExpand(); - dragExpandKeyRef.current = key; - dragExpandTimerRef.current = window.setTimeout(() => { - expandTreeNode(spaceId, folderPath); - dragExpandTimerRef.current = null; - dragExpandKeyRef.current = null; - }, 750); - } - - function toggleTreeFolder(spaceId: string, folderPath: string) { - const normalizedPath = normalizeFolder(folderPath); - const key = treeNodeKey(spaceId, normalizedPath); - const isExpanded = expandedTreeNodes.has(key); - if (isExpanded && spaceId === activeSpaceId && isPathUnderOrSame(currentFolder, normalizedPath)) { - suppressTreeAutoExpandKeyRef.current = key; - openFolder(spaceId, normalizedPath); - } - setExpandedTreeNodes((current) => { - const next = new Set(current); - if (next.has(key)) next.delete(key); - else next.add(key); - return next; - }); - } - function clearDropState() { - setDropTargetKey(""); - setDragActive(false); + clearDragDropState(); cancelTreeDragExpand(); } @@ -1560,641 +1421,3 @@ export default function FilesPage({ settings }: { settings: ApiSettings }) { ); } - -function TransferFolderSelector({ - space, - nodes, - selectedFolder, - onSelect, - disabled -}: { - space: FileSpace | null; - nodes: FolderNode[]; - selectedFolder: string; - onSelect: (folderPath: string) => void; - disabled?: boolean; -}) { - return ( -
- - {nodes.length === 0 && No folders yet. Choose the root folder.} - -
- ); -} - -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) => ( -
- - -
- ))} -
- ); -} - -function FolderTree({ - nodes, - activeSpaceId, - spaceId, - currentFolder, - dropTargetKey, - expandedKeys, - onOpen, - onToggle, - onContextMenu, - onDragOverTarget, - onDropOnTarget, - onClearDropState, - onRequestDragExpand, - 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; - disabled?: boolean; - depth?: number; -}) { - if (nodes.length === 0) return null; - return ( -
- {nodes.map((node) => { - const isActive = activeSpaceId === spaceId && currentFolder === node.path; - const target = { spaceId, folderPath: node.path }; - const isDropTarget = dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}`; - const hasChildren = node.children.length > 0; - const isExpanded = expandedKeys.has(treeNodeKey(spaceId, node.path)); - return ( -
-
onContextMenu(event, spaceId, node.path)} - onDragOver={(event) => { - onDragOverTarget(event, target); - if (hasChildren && !isExpanded) onRequestDragExpand(spaceId, node.path); - }} - onDragLeave={onClearDropState} - onDrop={(event) => void onDropOnTarget(event, target)} - > - - -
- {hasChildren && isExpanded && ( - - )} -
- ); - })} -
- ); -} - -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 && ( - - )} - {shownItems.length > 20 && ( - - )} -
-
- ); -} - -function FileDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { - return ( -
{ if (event.target === event.currentTarget) onClose(); }}> -
-
-

{title}

- -
-
{children}
-
-
- ); -} - -function FileContextMenu({ - menu, - canDownload, - downloadLabel, - onCreateFolder, - onUpload, - onDownload, - onMove, - onCopy, - onDelete -}: { - menu: ContextMenuState; - canDownload: boolean; - downloadLabel: string; - onCreateFolder: () => void; - onUpload: () => void; - onDownload: () => void; - onMove: () => void; - onCopy: () => void; - onDelete: () => void; -}) { - const showNewFolder = true; - const showDelete = menu.target !== "empty"; - return ( -
event.stopPropagation()}> - {showNewFolder && } - - {canDownload && } - {canDownload && } - {canDownload && } - {showDelete && } -
- ); -} - -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) => ( -
-
- {item.label} - {item.kind === "folder" ? "Folder" : "File"} conflict at {item.targetPath} -
- - onUpdateItem(item.id, { newPath: event.target.value })} - disabled={busy || item.action !== "rename"} - aria-label={`New path for ${item.label}`} - /> -
- ))} -
- )} - {!state.review && ( -
- {state.items.slice(0, 8).map((item) => {item.targetPath})} - {state.items.length > 8 && … and {state.items.length - 8} more} -
- )} -
- - - - {!state.review && state.items.length > 1 && } - {state.review && } -
-
- ); -} - -function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] { - const byPath = new Map(); - const ensureNode = (path: string, persisted: boolean): FolderNode | null => { - const normalized = normalizeFolder(path); - if (!normalized) return null; - const existing = byPath.get(normalized); - if (existing) { - existing.persisted = existing.persisted || persisted; - return existing; - } - const parts = normalized.split("/"); - const node: FolderNode = { - name: parts[parts.length - 1], - path: normalized, - children: [], - fileCount: 0, - persisted - }; - byPath.set(normalized, node); - const parentPath = parts.slice(0, -1).join("/"); - const parent = ensureNode(parentPath, false); - if (parent) parent.children.push(node); - return node; - }; - for (const folder of folders) ensureNode(folder.path, true); - for (const file of files) { - const parts = normalizeFilePath(file.display_path).split("/").filter(Boolean); - for (let index = 0; index < parts.length - 1; index += 1) { - const node = ensureNode(parts.slice(0, index + 1).join("/"), false); - if (node) node.fileCount += 1; - } - } - const roots = Array.from(byPath.values()).filter((node) => !node.path.includes("/")); - sortFolderNodes(roots); - return roots; -} - -function sortFolderNodes(nodes: FolderNode[]) { - nodes.sort((a, b) => a.name.localeCompare(b.name)); - for (const node of nodes) sortFolderNodes(node.children); -} - -function treeNodeKey(spaceId: string, folderPath: string): string { - return `${spaceId}:${normalizeFolder(folderPath)}`; -} - -function folderAncestorPaths(folderPath: string, options: { includeSelf?: boolean } = {}): string[] { - const parts = normalizeFolder(folderPath).split("/").filter(Boolean); - const limit = options.includeSelf ? parts.length : Math.max(parts.length - 1, 0); - const result: string[] = []; - for (let index = 1; index <= limit; index += 1) { - result.push(parts.slice(0, index).join("/")); - } - return result; -} - -function isPathUnderOrSame(path: string, root: string): boolean { - const normalizedPath = normalizeFolder(path); - const normalizedRoot = normalizeFolder(root); - if (!normalizedRoot) return true; - return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`); -} - -function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[], currentFolder: string, searchActive: boolean): ExplorerEntry[] { - if (searchActive) return files.map((file) => ({ kind: "file", id: file.id, file })); - const folder = normalizeFolder(currentFolder); - const prefix = folder ? `${folder}/` : ""; - const folderMap = new Map(); - const directFiles: FileEntry[] = []; - - for (const persisted of folders) { - const path = normalizeFolder(persisted.path); - if (!path.startsWith(prefix) || path === folder) continue; - const relative = prefix ? path.slice(prefix.length) : path; - if (!relative || relative.includes("/")) continue; - folderMap.set(path, { - kind: "folder", - id: `folder:${path}`, - name: relative, - path, - fileCount: 0, - folderCount: 0, - totalSize: 0, - updatedAt: persisted.updated_at, - persisted: true - }); - } - - for (const file of files) { - const path = normalizeFilePath(file.display_path || file.filename); - if (prefix && !path.startsWith(prefix)) continue; - const relativePath = prefix ? path.slice(prefix.length) : path; - if (!relativePath) continue; - const slashIndex = relativePath.indexOf("/"); - if (slashIndex === -1) { - directFiles.push({ kind: "file", id: file.id, file }); - continue; - } - const folderName = relativePath.slice(0, slashIndex); - const folderPath = prefix ? `${folder}/${folderName}` : folderName; - const existing = folderMap.get(folderPath); - if (existing) { - existing.totalSize += file.size_bytes; - if (file.updated_at > existing.updatedAt) existing.updatedAt = file.updated_at; - } else { - folderMap.set(folderPath, { - kind: "folder", - id: `folder:${folderPath}`, - name: folderName, - path: folderPath, - fileCount: 0, - folderCount: 0, - totalSize: file.size_bytes, - updatedAt: file.updated_at, - persisted: false - }); - } - } - - for (const entry of folderMap.values()) { - const counts = directFolderCounts(files, folders, entry.path); - entry.fileCount = counts.files; - entry.folderCount = counts.folders; - } - - return [...Array.from(folderMap.values()), ...directFiles]; -} - -function sortExplorerEntries(entries: ExplorerEntry[], column: SortColumn, direction: SortDirection): ExplorerEntry[] { - const factor = direction === "asc" ? 1 : -1; - return [...entries].sort((a, b) => { - if (column === "name") { - if (a.kind !== b.kind) return a.kind === "folder" ? (direction === "asc" ? -1 : 1) : (direction === "asc" ? 1 : -1); - return factor * entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" }); - } - if (column === "size") { - const delta = entrySize(a) - entrySize(b); - if (delta !== 0) return factor * delta; - return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" }); - } - const timeA = entryModifiedTime(a); - const timeB = entryModifiedTime(b); - if (timeA !== timeB) return factor * (timeA - timeB); - return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" }); - }); -} - -function entryName(entry: ExplorerEntry): string { - return entry.kind === "folder" ? entry.name : entry.file.filename; -} - -function entrySize(entry: ExplorerEntry): number { - return entry.kind === "folder" ? entry.totalSize : entry.file.size_bytes; -} - -function entryModifiedTime(entry: ExplorerEntry): number { - const raw = entry.kind === "folder" ? entry.updatedAt : entry.file.updated_at; - const parsed = new Date(raw).getTime(); - return Number.isNaN(parsed) ? 0 : parsed; -} - -function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): { files: number; folders: number } { - const normalized = normalizeFolder(folderPath); - const prefix = normalized ? `${normalized}/` : ""; - const directFiles = files.filter((file) => parentFolderPath(file.display_path) === normalized).length; - const childFolders = new Set(); - for (const folder of folders) { - const path = normalizeFolder(folder.path); - if (!path.startsWith(prefix) || path === normalized) continue; - const relative = prefix ? path.slice(prefix.length) : path; - if (!relative) continue; - childFolders.add(relative.split("/")[0]); - } - for (const file of files) { - const path = normalizeFilePath(file.display_path); - if (!path.startsWith(prefix)) continue; - const relative = prefix ? path.slice(prefix.length) : path; - if (!relative || !relative.includes("/")) continue; - childFolders.add(relative.split("/")[0]); - } - return { files: directFiles, folders: childFolders.size }; -} - -function folderContentLabel(entry: FolderEntry): string { - if (entry.fileCount === 0 && entry.folderCount === 0) return "empty"; - const parts: string[] = []; - if (entry.fileCount > 0) parts.push(`${entry.fileCount} file(s)`); - if (entry.folderCount > 0) parts.push(`${entry.folderCount} folder(s)`); - return parts.join(", "); -} - -function parentFolderPath(path: string): string { - const parts = normalizeFilePath(path).split("/").filter(Boolean); - return normalizeFolder(parts.slice(0, -1).join("/")); -} - -function entrySelectionKey(entry: ExplorerEntry): EntrySelectionKey { - return entry.kind === "folder" ? `folder:${entry.path}` : `file:${entry.id}`; -} - -function buildFolderEntryFromSources(files: ManagedFile[], folders: FileFolder[], path: string): FolderEntry { - const normalizedPath = normalizeFolder(path); - const persistedFolder = folders.find((folder) => normalizeFolder(folder.path) === normalizedPath); - const childFiles = files.filter((file) => isFileInFolder(file, normalizedPath)); - const sortedDates = childFiles.map((file) => file.updated_at).sort(); - const latestFileDate = sortedDates.length > 0 ? sortedDates[sortedDates.length - 1] : undefined; - const updatedAt = latestFileDate || persistedFolder?.updated_at || new Date().toISOString(); - return { - kind: "folder", - id: `folder:${normalizedPath}`, - name: lastPathSegment(normalizedPath) || "Root", - path: normalizedPath, - fileCount: directFolderCounts(files, folders, normalizedPath).files, - folderCount: directFolderCounts(files, folders, normalizedPath).folders, - totalSize: childFiles.reduce((total, file) => total + file.size_bytes, 0), - updatedAt, - persisted: Boolean(persistedFolder) - }; -} - -function isFileInFolder(file: ManagedFile, folderPath: string): boolean { - const normalizedFolder = normalizeFolder(folderPath); - const filePath = normalizeFilePath(file.display_path); - if (!normalizedFolder) return true; - return filePath.startsWith(`${normalizedFolder}/`); -} - -function fileIdsForSelection(files: ManagedFile[], selectedFileIds: Set, selectedFolderPaths: Set): string[] { - const ids = new Set(selectedFileIds); - for (const folderPath of selectedFolderPaths) { - const normalizedFolder = normalizeFolder(folderPath); - for (const file of files) { - if (isFileInFolder(file, normalizedFolder)) ids.add(file.id); - } - } - return Array.from(ids); -} - -function folderBreadcrumbs(folder: string): { name: string; path: string }[] { - const parts = normalizeFolder(folder).split("/").filter(Boolean); - return parts.map((name, index) => ({ name, path: parts.slice(0, index + 1).join("/") })); -} - -function joinFolder(folder: string, name: string): string { - return normalizeFolder([folder, name].filter(Boolean).join("/")); -} - -function normalizeFolder(value: string): string { - return value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").replace(/\/+/g, "/").trim(); -} - -function normalizeFilePath(value: string): string { - return value.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/").trim(); -} - -function lastPathSegment(path: string): string { - const parts = normalizeFolder(path).split("/").filter(Boolean); - return parts.length > 0 ? parts[parts.length - 1] : ""; -} - -function candidateRenamedPath(path: string, counter: number): string { - const normalized = normalizeFilePath(path); - const parts = normalized.split("/").filter(Boolean); - const name = parts.pop() ?? normalized; - const lastDot = name.lastIndexOf("."); - const hasExtension = lastDot > 0; - const stem = hasExtension ? name.slice(0, lastDot) : name; - const ext = hasExtension ? name.slice(lastDot) : ""; - const suffix = counter === 1 ? " copy" : ` copy ${counter}`; - const nextName = `${stem}${suffix}${ext}`; - return normalizeFilePath([...parts, nextName].join("/")); -} - -function parseDragState(value: string): DragSelectionState | null { - if (!value) return null; - try { - const parsed = JSON.parse(value) as Partial; - if (!parsed.sourceSpaceId || !Array.isArray(parsed.fileIds) || !Array.isArray(parsed.folderPaths)) return null; - return { sourceSpaceId: parsed.sourceSpaceId, fileIds: parsed.fileIds, folderPaths: parsed.folderPaths }; - } catch { - return null; - } -} - -function formatBytes(value: number): string { - if (value < 1024) return `${value} B`; - const units = ["KB", "MB", "GB", "TB"]; - let size = value / 1024; - for (const unit of units) { - if (size < 1024) return `${size.toFixed(size >= 10 ? 0 : 1)} ${unit}`; - size /= 1024; - } - return `${size.toFixed(1)} PB`; -} - -function formatDate(value: string): string { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString(); -} diff --git a/src/features/files/components/FileManagerComponents.tsx b/src/features/files/components/FileManagerComponents.tsx new file mode 100644 index 0000000..2eee316 --- /dev/null +++ b/src/features/files/components/FileManagerComponents.tsx @@ -0,0 +1,343 @@ +import type { DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react"; +import { Copy, Download, Folder, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react"; +import Button from "../../../components/Button"; +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 ( +
+ + {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) => ( +
+ + +
+ ))} +
+ ); +} + +export function FolderTree({ + nodes, + activeSpaceId, + spaceId, + currentFolder, + dropTargetKey, + expandedKeys, + onOpen, + onToggle, + onContextMenu, + onDragOverTarget, + onDropOnTarget, + onClearDropState, + onRequestDragExpand, + 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; + disabled?: boolean; + depth?: number; +}) { + if (nodes.length === 0) return null; + return ( +
+ {nodes.map((node) => { + const isActive = activeSpaceId === spaceId && currentFolder === node.path; + const target = { spaceId, folderPath: node.path }; + const isDropTarget = dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}`; + const hasChildren = node.children.length > 0; + const isExpanded = expandedKeys.has(treeNodeKey(spaceId, node.path)); + return ( +
+
onContextMenu(event, spaceId, node.path)} + onDragOver={(event) => { + onDragOverTarget(event, target); + if (hasChildren && !isExpanded) onRequestDragExpand(spaceId, node.path); + }} + onDragLeave={onClearDropState} + onDrop={(event) => void onDropOnTarget(event, target)} + > + + +
+ {hasChildren && isExpanded && ( + + )} +
+ ); + })} +
+ ); +} + +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 && ( + + )} + {shownItems.length > 20 && ( + + )} +
+
+ ); +} + +export function FileDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { + return ( +
{ if (event.target === event.currentTarget) onClose(); }}> +
+
+

{title}

+ +
+
{children}
+
+
+ ); +} + +export function FileContextMenu({ + menu, + canDownload, + downloadLabel, + onCreateFolder, + onUpload, + onDownload, + onMove, + onCopy, + onDelete +}: { + menu: ContextMenuState; + canDownload: boolean; + downloadLabel: string; + onCreateFolder: () => void; + onUpload: () => void; + onDownload: () => void; + onMove: () => void; + onCopy: () => void; + onDelete: () => void; +}) { + const showNewFolder = true; + const showDelete = menu.target !== "empty"; + return ( +
event.stopPropagation()}> + {showNewFolder && } + + {canDownload && } + {canDownload && } + {canDownload && } + {showDelete && } +
+ ); +} + +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) => ( +
+
+ {item.label} + {item.kind === "folder" ? "Folder" : "File"} conflict at {item.targetPath} +
+ + onUpdateItem(item.id, { newPath: event.target.value })} + disabled={busy || item.action !== "rename"} + aria-label={`New path for ${item.label}`} + /> +
+ ))} +
+ )} + {!state.review && ( +
+ {state.items.slice(0, 8).map((item) => {item.targetPath})} + {state.items.length > 8 && … and {state.items.length - 8} more} +
+ )} +
+ + + + {!state.review && state.items.length > 1 && } + {state.review && } +
+
+ ); +} \ No newline at end of file diff --git a/src/features/files/constants.ts b/src/features/files/constants.ts new file mode 100644 index 0000000..4761379 --- /dev/null +++ b/src/features/files/constants.ts @@ -0,0 +1,6 @@ +import type { FileFolder, FileSpace, ManagedFile } from "../../api/files"; + +export const EMPTY_SPACES: FileSpace[] = []; +export const EMPTY_FILES: ManagedFile[] = []; +export const EMPTY_FOLDERS: FileFolder[] = []; +export const INTERNAL_DRAG_TYPE = "application/x-multi-seal-mail-file-selection"; diff --git a/src/features/files/hooks/useFileDialogs.ts b/src/features/files/hooks/useFileDialogs.ts new file mode 100644 index 0000000..c959fdc --- /dev/null +++ b/src/features/files/hooks/useFileDialogs.ts @@ -0,0 +1,42 @@ +import { useState } from "react"; +import type { ConflictDialogState, ContextMenuState, DeleteDialogState, DialogKind, FileActionTarget, TransferDialogState, TransferMode } from "../types"; + +export function useFileDialogs() { + const [dialog, setDialog] = useState(null); + const [dialogTarget, setDialogTarget] = useState(null); + const [transferMode, setTransferMode] = useState("move"); + const [transferDialogState, setTransferDialogState] = useState(null); + const [contextMenu, setContextMenu] = useState(null); + const [conflictDialog, setConflictDialog] = useState(null); + const [deleteDialog, setDeleteDialog] = useState(null); + + function openDialog(kind: DialogKind, target: FileActionTarget | null = null) { + setDialogTarget(target); + setDialog(kind); + } + + function closeDialog() { + setDialog(null); + setDialogTarget(null); + setTransferDialogState(null); + } + + return { + dialog, + setDialog, + dialogTarget, + setDialogTarget, + transferMode, + setTransferMode, + transferDialogState, + setTransferDialogState, + contextMenu, + setContextMenu, + conflictDialog, + setConflictDialog, + deleteDialog, + setDeleteDialog, + openDialog, + closeDialog + }; +} diff --git a/src/features/files/hooks/useFileDragDropState.ts b/src/features/files/hooks/useFileDragDropState.ts new file mode 100644 index 0000000..a952f1e --- /dev/null +++ b/src/features/files/hooks/useFileDragDropState.ts @@ -0,0 +1,23 @@ +import { useState } from "react"; +import type { DragSelectionState } from "../types"; + +export function useFileDragDropState() { + const [dragActive, setDragActive] = useState(false); + const [internalDrag, setInternalDrag] = useState(null); + const [dropTargetKey, setDropTargetKey] = useState(""); + + function clearDropState() { + setDropTargetKey(""); + setDragActive(false); + } + + return { + dragActive, + setDragActive, + internalDrag, + setInternalDrag, + dropTargetKey, + setDropTargetKey, + clearDropState + }; +} diff --git a/src/features/files/hooks/useFileSelection.ts b/src/features/files/hooks/useFileSelection.ts new file mode 100644 index 0000000..2dd8627 --- /dev/null +++ b/src/features/files/hooks/useFileSelection.ts @@ -0,0 +1,110 @@ +import { useMemo, useState, type MouseEvent as ReactMouseEvent } from "react"; +import type { ExplorerEntry, EntrySelectionKey, SelectionSets } from "../types"; +import { entrySelectionKey } from "../utils/fileManagerUtils"; + +export function useFileSelection(visibleEntryKeys: EntrySelectionKey[], busy: boolean) { + const [selectedFileIds, setSelectedFileIds] = useState>(() => new Set()); + const [selectedFolderPaths, setSelectedFolderPaths] = useState>(() => new Set()); + const [selectionAnchor, setSelectionAnchor] = useState(null); + + const selectedEntryCount = selectedFileIds.size + selectedFolderPaths.size; + const hasSelection = selectedEntryCount > 0; + const selectedSummary = `${selectedFileIds.size} file(s), ${selectedFolderPaths.size} folder(s) selected`; + + const currentSelectionKeySet = useMemo(() => { + const keys = new Set(); + selectedFileIds.forEach((id) => keys.add(`file:${id}`)); + selectedFolderPaths.forEach((path) => keys.add(`folder:${path}`)); + return keys; + }, [selectedFileIds, selectedFolderPaths]); + + function applySelectionKeys(keys: Set) { + const nextFileIds = new Set(); + const nextFolderPaths = new Set(); + keys.forEach((key) => { + if (key.startsWith("file:")) nextFileIds.add(key.slice(5)); + if (key.startsWith("folder:")) nextFolderPaths.add(key.slice(7)); + }); + setSelectedFileIds(nextFileIds); + setSelectedFolderPaths(nextFolderPaths); + } + + function clearSelection() { + setSelectedFileIds(new Set()); + setSelectedFolderPaths(new Set()); + setSelectionAnchor(null); + } + + function currentSelectionSets(): SelectionSets { + return { fileIds: new Set(selectedFileIds), folderPaths: new Set(selectedFolderPaths) }; + } + + function setsForEntry(entry: ExplorerEntry): SelectionSets { + if (entry.kind === "folder") return { fileIds: new Set(), folderPaths: new Set([entry.path]) }; + return { fileIds: new Set([entry.id]), folderPaths: new Set() }; + } + + function isEntrySelected(entry: ExplorerEntry): boolean { + return entry.kind === "folder" ? selectedFolderPaths.has(entry.path) : selectedFileIds.has(entry.id); + } + + function preventTextSelectionOnShift(event: ReactMouseEvent) { + if (event.shiftKey) event.preventDefault(); + } + + function handleEntrySelection(entry: ExplorerEntry, event: ReactMouseEvent) { + if (busy) return; + const key = entrySelectionKey(entry); + const currentKeys = new Set(currentSelectionKeySet); + + if (event.shiftKey) event.preventDefault(); + + if (event.shiftKey && selectionAnchor) { + const startIndex = visibleEntryKeys.indexOf(selectionAnchor); + const endIndex = visibleEntryKeys.indexOf(key); + if (startIndex >= 0 && endIndex >= 0) { + const [from, to] = startIndex <= endIndex ? [startIndex, endIndex] : [endIndex, startIndex]; + const nextKeys = event.ctrlKey || event.metaKey ? currentKeys : new Set(); + visibleEntryKeys.slice(from, to + 1).forEach((rangeKey) => nextKeys.add(rangeKey)); + applySelectionKeys(nextKeys); + setSelectionAnchor(key); + return; + } + } + + if (event.ctrlKey || event.metaKey) { + if (currentKeys.has(key)) currentKeys.delete(key); + else currentKeys.add(key); + applySelectionKeys(currentKeys); + setSelectionAnchor(key); + return; + } + + if (currentKeys.size === 1 && currentKeys.has(key)) { + applySelectionKeys(new Set()); + } else { + applySelectionKeys(new Set([key])); + setSelectionAnchor(key); + } + } + + return { + selectedFileIds, + setSelectedFileIds, + selectedFolderPaths, + setSelectedFolderPaths, + selectionAnchor, + setSelectionAnchor, + selectedEntryCount, + hasSelection, + selectedSummary, + currentSelectionKeys: () => currentSelectionKeySet, + applySelectionKeys, + clearSelection, + currentSelectionSets, + setsForEntry, + isEntrySelected, + preventTextSelectionOnShift, + handleEntrySelection + }; +} diff --git a/src/features/files/hooks/useFileTreeState.ts b/src/features/files/hooks/useFileTreeState.ts new file mode 100644 index 0000000..b295976 --- /dev/null +++ b/src/features/files/hooks/useFileTreeState.ts @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from "react"; +import { folderAncestorPaths, isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils"; + +export function useFileTreeState({ + activeSpaceId, + currentFolder, + onOpenFolder +}: { + activeSpaceId: string; + currentFolder: string; + onOpenFolder: (spaceId: string, path: string) => void; +}) { + const [expandedTreeNodes, setExpandedTreeNodes] = useState>(() => new Set()); + const dragExpandTimerRef = useRef(null); + const dragExpandKeyRef = useRef(null); + const suppressTreeAutoExpandKeyRef = useRef(null); + + useEffect(() => { + if (!activeSpaceId) return; + const ancestors = folderAncestorPaths(currentFolder, { includeSelf: true }); + if (ancestors.length === 0) return; + const suppressedKey = suppressTreeAutoExpandKeyRef.current; + suppressTreeAutoExpandKeyRef.current = null; + setExpandedTreeNodes((current) => { + const next = new Set(current); + ancestors.forEach((path) => { + const key = treeNodeKey(activeSpaceId, path); + if (key !== suppressedKey) next.add(key); + }); + return next; + }); + }, [activeSpaceId, currentFolder]); + + function cancelTreeDragExpand() { + if (dragExpandTimerRef.current !== null) { + window.clearTimeout(dragExpandTimerRef.current); + dragExpandTimerRef.current = null; + } + dragExpandKeyRef.current = null; + } + + function expandTreeNode(spaceId: string, folderPath: string) { + const key = treeNodeKey(spaceId, folderPath); + setExpandedTreeNodes((current) => { + if (current.has(key)) return current; + const next = new Set(current); + next.add(key); + return next; + }); + } + + function scheduleTreeDragExpand(spaceId: string, folderPath: string) { + const key = treeNodeKey(spaceId, folderPath); + if (expandedTreeNodes.has(key) || dragExpandKeyRef.current === key) return; + cancelTreeDragExpand(); + dragExpandKeyRef.current = key; + dragExpandTimerRef.current = window.setTimeout(() => { + expandTreeNode(spaceId, folderPath); + dragExpandTimerRef.current = null; + dragExpandKeyRef.current = null; + }, 750); + } + + function toggleTreeFolder(spaceId: string, folderPath: string) { + const normalizedPath = normalizeFolder(folderPath); + const key = treeNodeKey(spaceId, normalizedPath); + const isExpanded = expandedTreeNodes.has(key); + if (isExpanded && spaceId === activeSpaceId && isPathUnderOrSame(currentFolder, normalizedPath)) { + suppressTreeAutoExpandKeyRef.current = key; + onOpenFolder(spaceId, normalizedPath); + } + setExpandedTreeNodes((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + + return { + expandedTreeNodes, + setExpandedTreeNodes, + cancelTreeDragExpand, + scheduleTreeDragExpand, + toggleTreeFolder + }; +} diff --git a/src/features/files/types.ts b/src/features/files/types.ts new file mode 100644 index 0000000..f660b05 --- /dev/null +++ b/src/features/files/types.ts @@ -0,0 +1,69 @@ +import type { ConflictAction, ConflictStrategy, FileFolder, FileSpace, ManagedFile } from "../../api/files"; + +export type RenameMode = "prefix" | "suffix" | "replace"; +export type SortColumn = "name" | "size" | "modified"; +export type SortDirection = "asc" | "desc"; +export type TransferMode = "move" | "copy"; +export type DialogKind = "upload" | "create-folder" | "rename" | "single-rename" | "transfer" | null; +export type EntrySelectionKey = `file:${string}` | `folder:${string}`; +export type ContextMenuState = { + x: number; + y: number; + target: "empty" | "folder" | "file"; + entry?: ExplorerEntry; + source?: "list" | "tree"; + spaceId?: string; + folderPath?: string; +}; +export type FileActionTarget = { spaceId: string; folderPath: string }; +export type SelectionSets = { fileIds: Set; folderPaths: Set }; +export type TransferDialogState = SelectionSets & { sourceSpaceId: string; targetSpaceId: string; targetFolder: string }; +export type DragSelectionState = { sourceSpaceId: string; fileIds: string[]; folderPaths: string[] }; +export type PendingUploadState = { files: File[]; target: FileActionTarget }; +export type PendingTransferState = { mode: TransferMode; sourceSpaceId: string; sets: SelectionSets; target: FileActionTarget }; +export type DeleteDialogState = SelectionSets & { spaceId: string; title: string; message: string }; +export type FileConflictItem = { + id: string; + kind: "file" | "folder"; + label: string; + targetPath: string; + action: ConflictAction; + newPath: string; +}; +export type ConflictDialogState = { + operation: "upload" | "transfer"; + title: string; + message: string; + items: FileConflictItem[]; + pendingUpload?: PendingUploadState; + pendingTransfer?: PendingTransferState; + review: boolean; +}; + +export type FolderNode = { + name: string; + path: string; + children: FolderNode[]; + fileCount: number; + persisted: boolean; +}; + +export type FolderEntry = { + kind: "folder"; + id: string; + name: string; + path: string; + fileCount: number; + folderCount: number; + totalSize: number; + updatedAt: string; + persisted: boolean; +}; + +export type FileEntry = { + kind: "file"; + id: string; + file: ManagedFile; +}; + +export type ExplorerEntry = FolderEntry | FileEntry; diff --git a/src/features/files/utils/fileManagerUtils.ts b/src/features/files/utils/fileManagerUtils.ts new file mode 100644 index 0000000..13be27b --- /dev/null +++ b/src/features/files/utils/fileManagerUtils.ts @@ -0,0 +1,303 @@ +import type { FileFolder, ManagedFile } from "../../../api/files"; +import type { DragSelectionState, EntrySelectionKey, ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types"; + +export function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] { + const byPath = new Map(); + const ensureNode = (path: string, persisted: boolean): FolderNode | null => { + const normalized = normalizeFolder(path); + if (!normalized) return null; + const existing = byPath.get(normalized); + if (existing) { + existing.persisted = existing.persisted || persisted; + return existing; + } + const parts = normalized.split("/"); + const node: FolderNode = { + name: parts[parts.length - 1], + path: normalized, + children: [], + fileCount: 0, + persisted + }; + byPath.set(normalized, node); + const parentPath = parts.slice(0, -1).join("/"); + const parent = ensureNode(parentPath, false); + if (parent) parent.children.push(node); + return node; + }; + for (const folder of folders) ensureNode(folder.path, true); + for (const file of files) { + const parts = normalizeFilePath(file.display_path).split("/").filter(Boolean); + for (let index = 0; index < parts.length - 1; index += 1) { + const node = ensureNode(parts.slice(0, index + 1).join("/"), false); + if (node) node.fileCount += 1; + } + } + const roots = Array.from(byPath.values()).filter((node) => !node.path.includes("/")); + sortFolderNodes(roots); + return roots; +} + +export function sortFolderNodes(nodes: FolderNode[]) { + nodes.sort((a, b) => a.name.localeCompare(b.name)); + for (const node of nodes) sortFolderNodes(node.children); +} + +export function treeNodeKey(spaceId: string, folderPath: string): string { + return `${spaceId}:${normalizeFolder(folderPath)}`; +} + +export function folderAncestorPaths(folderPath: string, options: { includeSelf?: boolean } = {}): string[] { + const parts = normalizeFolder(folderPath).split("/").filter(Boolean); + const limit = options.includeSelf ? parts.length : Math.max(parts.length - 1, 0); + const result: string[] = []; + for (let index = 1; index <= limit; index += 1) { + result.push(parts.slice(0, index).join("/")); + } + return result; +} + +export function isPathUnderOrSame(path: string, root: string): boolean { + const normalizedPath = normalizeFolder(path); + const normalizedRoot = normalizeFolder(root); + if (!normalizedRoot) return true; + return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`); +} + +export function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[], currentFolder: string, searchActive: boolean): ExplorerEntry[] { + if (searchActive) return files.map((file) => ({ kind: "file", id: file.id, file })); + const folder = normalizeFolder(currentFolder); + const prefix = folder ? `${folder}/` : ""; + const folderMap = new Map(); + const directFiles: FileEntry[] = []; + + for (const persisted of folders) { + const path = normalizeFolder(persisted.path); + if (!path.startsWith(prefix) || path === folder) continue; + const relative = prefix ? path.slice(prefix.length) : path; + if (!relative || relative.includes("/")) continue; + folderMap.set(path, { + kind: "folder", + id: `folder:${path}`, + name: relative, + path, + fileCount: 0, + folderCount: 0, + totalSize: 0, + updatedAt: persisted.updated_at, + persisted: true + }); + } + + for (const file of files) { + const path = normalizeFilePath(file.display_path || file.filename); + if (prefix && !path.startsWith(prefix)) continue; + const relativePath = prefix ? path.slice(prefix.length) : path; + if (!relativePath) continue; + const slashIndex = relativePath.indexOf("/"); + if (slashIndex === -1) { + directFiles.push({ kind: "file", id: file.id, file }); + continue; + } + const folderName = relativePath.slice(0, slashIndex); + const folderPath = prefix ? `${folder}/${folderName}` : folderName; + const existing = folderMap.get(folderPath); + if (existing) { + existing.totalSize += file.size_bytes; + if (file.updated_at > existing.updatedAt) existing.updatedAt = file.updated_at; + } else { + folderMap.set(folderPath, { + kind: "folder", + id: `folder:${folderPath}`, + name: folderName, + path: folderPath, + fileCount: 0, + folderCount: 0, + totalSize: file.size_bytes, + updatedAt: file.updated_at, + persisted: false + }); + } + } + + for (const entry of folderMap.values()) { + const counts = directFolderCounts(files, folders, entry.path); + entry.fileCount = counts.files; + entry.folderCount = counts.folders; + } + + return [...Array.from(folderMap.values()), ...directFiles]; +} + +export function sortExplorerEntries(entries: ExplorerEntry[], column: SortColumn, direction: SortDirection): ExplorerEntry[] { + const factor = direction === "asc" ? 1 : -1; + return [...entries].sort((a, b) => { + if (column === "name") { + if (a.kind !== b.kind) return a.kind === "folder" ? (direction === "asc" ? -1 : 1) : (direction === "asc" ? 1 : -1); + return factor * entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" }); + } + if (column === "size") { + const delta = entrySize(a) - entrySize(b); + if (delta !== 0) return factor * delta; + return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" }); + } + const timeA = entryModifiedTime(a); + const timeB = entryModifiedTime(b); + if (timeA !== timeB) return factor * (timeA - timeB); + return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" }); + }); +} + +export function entryName(entry: ExplorerEntry): string { + return entry.kind === "folder" ? entry.name : entry.file.filename; +} + +export function entrySize(entry: ExplorerEntry): number { + return entry.kind === "folder" ? entry.totalSize : entry.file.size_bytes; +} + +export function entryModifiedTime(entry: ExplorerEntry): number { + const raw = entry.kind === "folder" ? entry.updatedAt : entry.file.updated_at; + const parsed = new Date(raw).getTime(); + return Number.isNaN(parsed) ? 0 : parsed; +} + +export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): { files: number; folders: number } { + const normalized = normalizeFolder(folderPath); + const prefix = normalized ? `${normalized}/` : ""; + const directFiles = files.filter((file) => parentFolderPath(file.display_path) === normalized).length; + const childFolders = new Set(); + for (const folder of folders) { + const path = normalizeFolder(folder.path); + if (!path.startsWith(prefix) || path === normalized) continue; + const relative = prefix ? path.slice(prefix.length) : path; + if (!relative) continue; + childFolders.add(relative.split("/")[0]); + } + for (const file of files) { + const path = normalizeFilePath(file.display_path); + if (!path.startsWith(prefix)) continue; + const relative = prefix ? path.slice(prefix.length) : path; + if (!relative || !relative.includes("/")) continue; + childFolders.add(relative.split("/")[0]); + } + return { files: directFiles, folders: childFolders.size }; +} + +export function folderContentLabel(entry: FolderEntry): string { + if (entry.fileCount === 0 && entry.folderCount === 0) return "empty"; + const parts: string[] = []; + if (entry.fileCount > 0) parts.push(`${entry.fileCount} file(s)`); + if (entry.folderCount > 0) parts.push(`${entry.folderCount} folder(s)`); + return parts.join(", "); +} + +export function parentFolderPath(path: string): string { + const parts = normalizeFilePath(path).split("/").filter(Boolean); + return normalizeFolder(parts.slice(0, -1).join("/")); +} + +export function entrySelectionKey(entry: ExplorerEntry): EntrySelectionKey { + return entry.kind === "folder" ? `folder:${entry.path}` : `file:${entry.id}`; +} + +export function buildFolderEntryFromSources(files: ManagedFile[], folders: FileFolder[], path: string): FolderEntry { + const normalizedPath = normalizeFolder(path); + const persistedFolder = folders.find((folder) => normalizeFolder(folder.path) === normalizedPath); + const childFiles = files.filter((file) => isFileInFolder(file, normalizedPath)); + const sortedDates = childFiles.map((file) => file.updated_at).sort(); + const latestFileDate = sortedDates.length > 0 ? sortedDates[sortedDates.length - 1] : undefined; + const updatedAt = latestFileDate || persistedFolder?.updated_at || new Date().toISOString(); + return { + kind: "folder", + id: `folder:${normalizedPath}`, + name: lastPathSegment(normalizedPath) || "Root", + path: normalizedPath, + fileCount: directFolderCounts(files, folders, normalizedPath).files, + folderCount: directFolderCounts(files, folders, normalizedPath).folders, + totalSize: childFiles.reduce((total, file) => total + file.size_bytes, 0), + updatedAt, + persisted: Boolean(persistedFolder) + }; +} + +export function isFileInFolder(file: ManagedFile, folderPath: string): boolean { + const normalizedFolder = normalizeFolder(folderPath); + const filePath = normalizeFilePath(file.display_path); + if (!normalizedFolder) return true; + return filePath.startsWith(`${normalizedFolder}/`); +} + +export function fileIdsForSelection(files: ManagedFile[], selectedFileIds: Set, selectedFolderPaths: Set): string[] { + const ids = new Set(selectedFileIds); + for (const folderPath of selectedFolderPaths) { + const normalizedFolder = normalizeFolder(folderPath); + for (const file of files) { + if (isFileInFolder(file, normalizedFolder)) ids.add(file.id); + } + } + return Array.from(ids); +} + +export function folderBreadcrumbs(folder: string): { name: string; path: string }[] { + const parts = normalizeFolder(folder).split("/").filter(Boolean); + return parts.map((name, index) => ({ name, path: parts.slice(0, index + 1).join("/") })); +} + +export function joinFolder(folder: string, name: string): string { + return normalizeFolder([folder, name].filter(Boolean).join("/")); +} + +export function normalizeFolder(value: string): string { + return value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").replace(/\/+/g, "/").trim(); +} + +export function normalizeFilePath(value: string): string { + return value.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/").trim(); +} + +export function lastPathSegment(path: string): string { + const parts = normalizeFolder(path).split("/").filter(Boolean); + return parts.length > 0 ? parts[parts.length - 1] : ""; +} + +export function candidateRenamedPath(path: string, counter: number): string { + const normalized = normalizeFilePath(path); + const parts = normalized.split("/").filter(Boolean); + const name = parts.pop() ?? normalized; + const lastDot = name.lastIndexOf("."); + const hasExtension = lastDot > 0; + const stem = hasExtension ? name.slice(0, lastDot) : name; + const ext = hasExtension ? name.slice(lastDot) : ""; + const suffix = counter === 1 ? " copy" : ` copy ${counter}`; + const nextName = `${stem}${suffix}${ext}`; + return normalizeFilePath([...parts, nextName].join("/")); +} + +export function parseDragState(value: string): DragSelectionState | null { + if (!value) return null; + try { + const parsed = JSON.parse(value) as Partial; + if (!parsed.sourceSpaceId || !Array.isArray(parsed.fileIds) || !Array.isArray(parsed.folderPaths)) return null; + return { sourceSpaceId: parsed.sourceSpaceId, fileIds: parsed.fileIds, folderPaths: parsed.folderPaths }; + } catch { + return null; + } +} + +export function formatBytes(value: number): string { + if (value < 1024) return `${value} B`; + const units = ["KB", "MB", "GB", "TB"]; + let size = value / 1024; + for (const unit of units) { + if (size < 1024) return `${size.toFixed(size >= 10 ? 0 : 1)} ${unit}`; + size /= 1024; + } + return `${size.toFixed(1)} PB`; +} + +export function formatDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +}