File refactor

This commit is contained in:
2026-06-12 03:53:54 +02:00
parent 7b7ed05229
commit efa1f11840
10 changed files with 1083 additions and 876 deletions

1
.gitignore vendored
View File

@@ -423,4 +423,5 @@ bin-release/
.fuse_*
multisealmail-*.zip
multi-seal-mail-*.zip
multi-seal-mail-webui*.tar.gz

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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";

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View 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;

View 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();
}