File refactor
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -423,4 +423,5 @@ bin-release/
|
|||||||
.fuse_*
|
.fuse_*
|
||||||
|
|
||||||
multisealmail-*.zip
|
multisealmail-*.zip
|
||||||
|
multi-seal-mail-*.zip
|
||||||
multi-seal-mail-webui*.tar.gz
|
multi-seal-mail-webui*.tar.gz
|
||||||
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