File refactor
This commit is contained in:
File diff suppressed because it is too large
Load Diff
343
src/features/files/components/FileManagerComponents.tsx
Normal file
343
src/features/files/components/FileManagerComponents.tsx
Normal file
@@ -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 (
|
||||
<div className="file-folder-selector" role="tree" aria-label="Destination folder">
|
||||
<button
|
||||
type="button"
|
||||
className={`file-folder-selector-node ${selectedFolder === "" ? "is-selected" : ""}`}
|
||||
onClick={() => onSelect("")}
|
||||
disabled={disabled || !space}
|
||||
>
|
||||
<Home size={15} aria-hidden="true" />
|
||||
<span>{space?.label || "Root"}</span>
|
||||
</button>
|
||||
{nodes.length === 0 && <span className="muted small-text file-folder-selector-empty">No folders yet. Choose the root folder.</span>}
|
||||
<TransferFolderSelectorNodes nodes={nodes} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="file-folder-selector-children">
|
||||
{nodes.map((node) => (
|
||||
<div key={node.path}>
|
||||
<button
|
||||
type="button"
|
||||
className={`file-folder-selector-node ${selectedFolder === node.path ? "is-selected" : ""}`}
|
||||
style={{ paddingLeft: `${Math.min(depth * 16 + 10, 74)}px` }}
|
||||
onClick={() => onSelect(node.path)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
<span>{node.name}</span>
|
||||
</button>
|
||||
<TransferFolderSelectorNodes nodes={node.children} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} depth={depth + 1} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string>;
|
||||
onOpen: (spaceId: string, path: string) => void;
|
||||
onToggle: (spaceId: string, path: string) => void;
|
||||
onContextMenu: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
|
||||
onDragOverTarget: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;
|
||||
onDropOnTarget: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;
|
||||
onClearDropState: () => void;
|
||||
onRequestDragExpand: (spaceId: string, path: string) => void;
|
||||
disabled?: boolean;
|
||||
depth?: number;
|
||||
}) {
|
||||
if (nodes.length === 0) return null;
|
||||
return (
|
||||
<div className="file-tree-children">
|
||||
{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 (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={`file-tree-node-wrap ${isActive ? "is-active" : ""} ${isDropTarget ? "is-drop-target" : ""}`}
|
||||
style={{ paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
||||
onContextMenu={(event) => 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)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="file-tree-toggle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (hasChildren) onToggle(spaceId, node.path);
|
||||
}}
|
||||
disabled={disabled || !hasChildren}
|
||||
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${node.name}`}
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
>
|
||||
{isExpanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="file-tree-node"
|
||||
onClick={() => onOpen(spaceId, node.path)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && isExpanded && (
|
||||
<FolderTree
|
||||
nodes={node.children}
|
||||
activeSpaceId={activeSpaceId}
|
||||
spaceId={spaceId}
|
||||
currentFolder={currentFolder}
|
||||
dropTargetKey={dropTargetKey}
|
||||
expandedKeys={expandedKeys}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOverTarget={onDragOverTarget}
|
||||
onDropOnTarget={onDropOnTarget}
|
||||
onClearDropState={onClearDropState}
|
||||
onRequestDragExpand={onRequestDragExpand}
|
||||
disabled={disabled}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenamePreviewList({
|
||||
response,
|
||||
selectedFileIds,
|
||||
selectedFolderPaths,
|
||||
recursive,
|
||||
visibleCount,
|
||||
onShowMore,
|
||||
onShowFewer
|
||||
}: {
|
||||
response: RenameResponse;
|
||||
selectedFileIds: Set<string>;
|
||||
selectedFolderPaths: Set<string>;
|
||||
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 (
|
||||
<div className="rename-preview-panel">
|
||||
<div className="placeholder-stack rename-preview-list">
|
||||
{hiddenNonRecursiveItems > 0 && <span className="muted">{hiddenNonRecursiveItems} contained path(s) will move with the selected folder(s), but their own names will stay unchanged.</span>}
|
||||
{shownItems.map((item) => <span key={`${item.kind}:${item.id}:${item.old_path}`}><code>{item.old_path}</code> → <code>{item.new_path}</code></span>)}
|
||||
{remaining > 0 && (
|
||||
<button type="button" className="rename-preview-more" onClick={onShowMore}>… and {remaining} more — show next {Math.min(20, remaining)}</button>
|
||||
)}
|
||||
{shownItems.length > 20 && (
|
||||
<button type="button" className="rename-preview-more" onClick={onShowFewer}>Show fewer</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<div className="file-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
|
||||
<div className="file-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<div className="file-dialog-header">
|
||||
<h3>{title}</h3>
|
||||
<button type="button" onClick={onClose} aria-label="Close">×</button>
|
||||
</div>
|
||||
<div className="file-dialog-body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="file-context-menu" style={{ left: menu.x, top: menu.y }} role="menu" onClick={(event) => event.stopPropagation()}>
|
||||
{showNewFolder && <button type="button" role="menuitem" onClick={onCreateFolder}><Plus size={15} aria-hidden="true" /> New folder</button>}
|
||||
<button type="button" role="menuitem" onClick={onUpload}><UploadCloud size={15} aria-hidden="true" /> Upload</button>
|
||||
{canDownload && <button type="button" role="menuitem" onClick={onDownload}><Download size={15} aria-hidden="true" /> {downloadLabel}</button>}
|
||||
{canDownload && <button type="button" role="menuitem" onClick={onMove}><MoveRight size={15} aria-hidden="true" /> Move…</button>}
|
||||
{canDownload && <button type="button" role="menuitem" onClick={onCopy}><Copy size={15} aria-hidden="true" /> Copy…</button>}
|
||||
{showDelete && <button type="button" role="menuitem" className="danger" onClick={onDelete}><Trash2 size={15} aria-hidden="true" /> Delete</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<FileConflictItem>) => void;
|
||||
}) {
|
||||
return (
|
||||
<FileDialog title={state.title} onClose={onClose}>
|
||||
<p className="muted">{state.message}</p>
|
||||
<div className="file-conflict-summary">
|
||||
<strong>{state.items.length} conflict(s)</strong>
|
||||
<span>{state.items.length > 1 ? "Choose the same action for all conflicts or review the target names individually." : "Choose how to resolve this conflict."}</span>
|
||||
</div>
|
||||
{state.review && (
|
||||
<div className="file-conflict-list">
|
||||
{state.items.map((item) => (
|
||||
<div className="file-conflict-row" key={item.id}>
|
||||
<div>
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.kind === "folder" ? "Folder" : "File"} conflict at <code>{item.targetPath}</code></small>
|
||||
</div>
|
||||
<select value={item.action} onChange={(event) => onUpdateItem(item.id, { action: event.target.value as ConflictAction })} disabled={busy}>
|
||||
<option value="overwrite">Overwrite</option>
|
||||
<option value="rename">Rename</option>
|
||||
<option value="skip">Skip</option>
|
||||
</select>
|
||||
<input
|
||||
value={item.newPath}
|
||||
onChange={(event) => onUpdateItem(item.id, { newPath: event.target.value })}
|
||||
disabled={busy || item.action !== "rename"}
|
||||
aria-label={`New path for ${item.label}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!state.review && (
|
||||
<div className="placeholder-stack">
|
||||
{state.items.slice(0, 8).map((item) => <span key={item.id}><code>{item.targetPath}</code></span>)}
|
||||
{state.items.length > 8 && <span>… and {state.items.length - 8} more</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="button-row compact-actions align-end">
|
||||
<Button onClick={onCancel} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={onOverwrite} disabled={busy}>Overwrite all</Button>
|
||||
<Button onClick={onRename} disabled={busy}>Rename all</Button>
|
||||
{!state.review && state.items.length > 1 && <Button onClick={onReview} disabled={busy}>Review individually</Button>}
|
||||
{state.review && <Button variant="primary" onClick={onApplyReview} disabled={busy}>Apply reviewed choices</Button>}
|
||||
</div>
|
||||
</FileDialog>
|
||||
);
|
||||
}
|
||||
6
src/features/files/constants.ts
Normal file
6
src/features/files/constants.ts
Normal file
@@ -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";
|
||||
42
src/features/files/hooks/useFileDialogs.ts
Normal file
42
src/features/files/hooks/useFileDialogs.ts
Normal file
@@ -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<DialogKind>(null);
|
||||
const [dialogTarget, setDialogTarget] = useState<FileActionTarget | null>(null);
|
||||
const [transferMode, setTransferMode] = useState<TransferMode>("move");
|
||||
const [transferDialogState, setTransferDialogState] = useState<TransferDialogState | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [conflictDialog, setConflictDialog] = useState<ConflictDialogState | null>(null);
|
||||
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(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
|
||||
};
|
||||
}
|
||||
23
src/features/files/hooks/useFileDragDropState.ts
Normal file
23
src/features/files/hooks/useFileDragDropState.ts
Normal file
@@ -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<DragSelectionState | null>(null);
|
||||
const [dropTargetKey, setDropTargetKey] = useState("");
|
||||
|
||||
function clearDropState() {
|
||||
setDropTargetKey("");
|
||||
setDragActive(false);
|
||||
}
|
||||
|
||||
return {
|
||||
dragActive,
|
||||
setDragActive,
|
||||
internalDrag,
|
||||
setInternalDrag,
|
||||
dropTargetKey,
|
||||
setDropTargetKey,
|
||||
clearDropState
|
||||
};
|
||||
}
|
||||
110
src/features/files/hooks/useFileSelection.ts
Normal file
110
src/features/files/hooks/useFileSelection.ts
Normal file
@@ -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<Set<string>>(() => new Set());
|
||||
const [selectedFolderPaths, setSelectedFolderPaths] = useState<Set<string>>(() => new Set());
|
||||
const [selectionAnchor, setSelectionAnchor] = useState<EntrySelectionKey | null>(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<EntrySelectionKey>();
|
||||
selectedFileIds.forEach((id) => keys.add(`file:${id}`));
|
||||
selectedFolderPaths.forEach((path) => keys.add(`folder:${path}`));
|
||||
return keys;
|
||||
}, [selectedFileIds, selectedFolderPaths]);
|
||||
|
||||
function applySelectionKeys(keys: Set<EntrySelectionKey>) {
|
||||
const nextFileIds = new Set<string>();
|
||||
const nextFolderPaths = new Set<string>();
|
||||
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<HTMLElement>) {
|
||||
if (event.shiftKey) event.preventDefault();
|
||||
}
|
||||
|
||||
function handleEntrySelection(entry: ExplorerEntry, event: ReactMouseEvent<HTMLElement>) {
|
||||
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<EntrySelectionKey>();
|
||||
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<EntrySelectionKey>());
|
||||
} else {
|
||||
applySelectionKeys(new Set<EntrySelectionKey>([key]));
|
||||
setSelectionAnchor(key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectedFileIds,
|
||||
setSelectedFileIds,
|
||||
selectedFolderPaths,
|
||||
setSelectedFolderPaths,
|
||||
selectionAnchor,
|
||||
setSelectionAnchor,
|
||||
selectedEntryCount,
|
||||
hasSelection,
|
||||
selectedSummary,
|
||||
currentSelectionKeys: () => currentSelectionKeySet,
|
||||
applySelectionKeys,
|
||||
clearSelection,
|
||||
currentSelectionSets,
|
||||
setsForEntry,
|
||||
isEntrySelected,
|
||||
preventTextSelectionOnShift,
|
||||
handleEntrySelection
|
||||
};
|
||||
}
|
||||
87
src/features/files/hooks/useFileTreeState.ts
Normal file
87
src/features/files/hooks/useFileTreeState.ts
Normal file
@@ -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<Set<string>>(() => new Set());
|
||||
const dragExpandTimerRef = useRef<number | null>(null);
|
||||
const dragExpandKeyRef = useRef<string | null>(null);
|
||||
const suppressTreeAutoExpandKeyRef = useRef<string | null>(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
|
||||
};
|
||||
}
|
||||
69
src/features/files/types.ts
Normal file
69
src/features/files/types.ts
Normal file
@@ -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<string>; folderPaths: Set<string> };
|
||||
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;
|
||||
303
src/features/files/utils/fileManagerUtils.ts
Normal file
303
src/features/files/utils/fileManagerUtils.ts
Normal file
@@ -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<string, FolderNode>();
|
||||
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<string, FolderEntry>();
|
||||
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<string>();
|
||||
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<string>, selectedFolderPaths: Set<string>): 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<DragSelectionState>;
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user