Files
webmail/components/files/file-browser.tsx
T

1505 lines
67 KiB
TypeScript

"use client";
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import {
Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info,
FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen,
RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput,
} from "@/components/icons";
import { useIsDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useRegisterCommands } from "@/lib/command-registry";
import { formatShortcutCombo } from "@/lib/shortcut-keys";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { FolderPickerDialog } from "@/components/files/folder-picker-dialog";
import { fileNameProblem } from "@/lib/file-name-rules";
import { EmptyFolder, NoSearchResults } from "@/components/files/file-empty-state";
import { EmptyState } from "@/components/ui/empty-state";
import { isAudioFile, isImageFile, isPdfFile, isTextFile, isVideoFile } from "@/components/files/file-icons";
import { useFilesSettings } from "@/components/files/files-settings";
import { FilesSidebar, type CreateKind } from "@/components/files/files-sidebar";
import { FilesHeader, type Crumb, type SortDir, type SortKey, type ViewMode } from "@/components/files/files-header";
import { FileGridView, FileListView, type FileItemHandlers } from "@/components/files/file-views";
import { FileDetailsPanel } from "@/components/files/file-details-panel";
import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import { resourceToView, useFileStore, isSharedFilesPage, sharedFilePages, type FileResource } from "@/stores/file-store";
import { TRASH_LOCATION, TRASH_PATH, isTrashLocation } from "@/lib/files-trash";
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
import { toast } from "@/stores/toast-store";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNodeRights } from "@/lib/jmap/types";
import type { FileResourceView } from "@/lib/plugin-types";
interface ClipboardState {
mode: "cut" | "copy";
ids: string[];
names: string[];
sourceParentId: string | null;
}
export interface AccountFolderEntry {
accountId: string;
label: string;
email: string;
avatarColor: string;
}
interface FileBrowserProps {
currentPath: string;
resources: FileResource[];
isLoading: boolean;
error: string | null;
selectedResources: Set<string>;
uploadProgress: { name: string; loaded: number; total: number; current: number; totalFiles: number } | null;
clipboard: ClipboardState | null;
onNavigate: (path: string, resourceId?: string | null) => void;
onCreateFolder: (name: string) => Promise<void>;
onUploadFiles: (files: File[]) => Promise<void>;
onUploadFolder: (files: File[]) => Promise<void>;
onCancelUpload: () => void;
/** Delete: into the trash, or for good when in the trash or a shared drive. */
onDelete: (name: string) => Promise<void>;
onBatchDelete: (names: string[]) => Promise<void>;
/** In the trash: put items back where they came from. */
onRestore?: (names: string[]) => Promise<void>;
/** In the trash: delete everything in it for good. */
onEmptyTrash?: () => void;
onRename: (oldName: string, newName: string) => Promise<void>;
onDownload: (name: string) => Promise<void>;
onBatchDownload: (names: string[]) => Promise<void>;
/** Download items by id as one zip, e.g. a folder. */
onDownloadZip?: (ids: string[], zipName: string) => Promise<void>;
onRefresh: () => Promise<void>;
onToggleSelect: (name: string) => void;
onClearSelection: () => void;
onSetSelection: (names: Set<string>) => void;
onCut: (names: string[]) => void;
onCopy: (names: string[]) => void;
onPaste: () => Promise<void>;
/** Move listed items into a folder by id (null = the root). */
onMoveTo: (names: string[], targetId: string | null) => Promise<void>;
/** Move a folder of the tree, which need not be listed, into a folder. */
onMoveNode?: (id: string, targetId: string | null) => Promise<void>;
onPreviewImage: (name: string) => void;
onPreviewFile: (name: string) => void;
/** WOPI document editing (#425): whether a file can open in the document editor. */
isOfficeEditable?: (name: string) => boolean;
onEditFile?: (name: string) => void;
onCreateTextFile: (name: string) => Promise<void>;
onDuplicate: (name: string) => Promise<void>;
getImageUrl: (name: string) => Promise<string>;
favorites: string[];
recentFiles: { name: string; id: string; timestamp: number }[];
onToggleFavorite: (path: string) => void;
/** Open a recently used file wherever it now lives. */
onOpenRecent: (id: string) => void;
/** Sidebar actions on items that need not be in the open folder. */
onRenameNode: (id: string, newName: string) => Promise<void>;
onDeleteNode: (id: string, name: string) => Promise<void>;
onDownloadNode: (id: string) => Promise<void>;
onRevealNode: (id: string) => Promise<void>;
onRemoveRecent: (id: string) => void;
/** Pro shell only: all connected accounts surfaced as top-level folders at the root. */
accountFolders?: AccountFolderEntry[];
onSelectAccount?: (accountId: string) => void;
/** Pro shell only: when true, the root is a pure account picker - hide the file tools and don't render a regular listing. */
accountPickerMode?: boolean;
/** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after the root. */
accountLabel?: string | null;
/** JMAP client for the browsing account, used by the share dialog to list principals. */
client?: IJMAPClient | null;
/** Files account id of the browsing account; used to exclude self from the share picker. */
ownAccountId?: string | null;
/** True when the server supports JMAP Sharing (principals); gates the Share action. */
sharingEnabled?: boolean;
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
}
function isPreviewable(name: string): boolean {
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
}
type MenuData = { kind: "item"; resource: FileResource } | { kind: "empty" } | { kind: "new" };
const SIDEBAR_DEFAULT_WIDTH = 256;
/** A folder's path ("/Documents/Taxes") from the folder list. */
function pathOfFolder(folders: FileResource[], folder: FileResource): string {
const names = [folder.name];
const byId = new Map(folders.map((f) => [f.id, f]));
for (let cur = folder.parentId ? byId.get(folder.parentId) : undefined; cur && names.length < 64; cur = cur.parentId ? byId.get(cur.parentId) : undefined) {
names.unshift(cur.name);
}
return "/" + names.join("/");
}
function isTypingTarget(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el) return false;
return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable;
}
export function FileBrowser({
currentPath,
resources,
isLoading,
error,
selectedResources,
uploadProgress,
clipboard,
onNavigate,
onCreateFolder,
onUploadFiles,
onUploadFolder,
onCancelUpload,
onDelete,
onBatchDelete,
onRestore,
onEmptyTrash,
onRename,
onDownload,
onBatchDownload,
onDownloadZip,
onRefresh,
onToggleSelect,
onClearSelection,
onSetSelection,
onCut,
onCopy,
onPaste,
onMoveTo,
onMoveNode,
onPreviewImage,
onPreviewFile,
isOfficeEditable,
onEditFile,
onCreateTextFile,
onDuplicate,
getImageUrl,
favorites,
recentFiles,
onToggleFavorite,
onOpenRecent,
onRenameNode,
onDeleteNode,
onDownloadNode,
onRevealNode,
onRemoveRecent,
accountFolders,
onSelectAccount,
accountPickerMode,
accountLabel,
client,
ownAccountId,
sharingEnabled,
onShare,
}: FileBrowserProps) {
const t = useTranslations("files");
const fileNameRules = useMemo(() => client?.getFileNameRules?.() ?? null, [client]);
const validateFileName = useCallback((name: string) => {
const problem = fileNameProblem(name, fileNameRules);
if (!problem) return null;
switch (problem.kind) {
case "chars": return t("name_forbidden_chars", { chars: problem.chars });
case "control": return t("name_control_chars");
case "tooLong": return t("name_too_long");
case "reserved": return t("name_reserved");
}
}, [fileNameRules, t]);
// ---- Store-derived state ---------------------------------------------------
const storeClient = useFileStore((s) => s.client);
const sharedRoots = useFileStore((s) => s.sharedRoots);
const loadSharedRoots = useFileStore((s) => s.loadSharedRoots);
const allFolders = useFileStore((s) => s.allFolders);
const hasListed = useFileStore((s) => s.hasListed);
const currentParentId = useFileStore((s) => s.currentParentId);
const pathStack = useFileStore((s) => s.pathStack);
const trashCount = useFileStore((s) => s.trashCount);
// The trash lists deleted items: they can be restored, previewed or
// deleted for good, nothing else.
const inTrash = isTrashLocation(currentParentId);
useEffect(() => {
if (storeClient) void loadSharedRoots();
}, [storeClient, loadSharedRoots]);
// ---- Settings --------------------------------------------------------------
const [settings, updateSettings] = useFilesSettings();
const [sortKey, setSortKey] = useState<SortKey>(() => settings.defaultSortKey);
const [sortDir, setSortDir] = useState<SortDir>(() => settings.defaultSortDir);
// A new default order (Settings > Files, another device) applies at once.
useEffect(() => {
setSortKey(settings.defaultSortKey);
setSortDir(settings.defaultSortDir);
}, [settings.defaultSortKey, settings.defaultSortDir]);
const viewMode: ViewMode = settings.defaultViewMode;
// The toggle and Settings > Files edit the same preference.
const handleViewModeChange = useCallback((mode: ViewMode) => updateSettings({ defaultViewMode: mode }), [updateSettings]);
const iconOptions = useMemo(() => ({
showIcons: settings.showIcons,
coloredIcons: settings.coloredIcons,
showThumbnails: settings.showThumbnails,
}), [settings.showIcons, settings.coloredIcons, settings.showThumbnails]);
// ---- Layout ----------------------------------------------------------------
const isDesktop = useIsDesktop();
const isNarrow = !isDesktop;
const isMobile = useIsMobile();
const touch = useMediaQuery("(pointer: coarse)") && isMobile;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
useEffect(() => { setNarrowSidebarOpen(false); }, [currentPath]);
// Anything picked in the drawer closes it (R7).
const fromDrawer = <A extends unknown[], R>(action: (...args: A) => R) => (...args: A): R => {
setNarrowSidebarOpen(false);
return action(...args);
};
const [sidebarWidth, setSidebarWidth] = useState(() => {
if (typeof window !== "undefined") {
const saved = Number(localStorage.getItem("files-sidebar-width"));
if (saved) return Math.max(180, Math.min(400, saved));
}
return SIDEBAR_DEFAULT_WIDTH;
});
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(SIDEBAR_DEFAULT_WIDTH);
const [showDetails, setShowDetails] = useState(() => {
try { return localStorage.getItem("files-show-details") === "1"; } catch { return false; }
});
const toggleDetails = useCallback((open?: boolean) => {
setShowDetails((v) => {
const next = open ?? !v;
try { localStorage.setItem("files-show-details", next ? "1" : "0"); } catch { /* ignore */ }
return next;
});
}, []);
// ---- Dialogs and transient UI ---------------------------------------------
const [showNewFolder, setShowNewFolder] = useState(false);
const [showNewTextFile, setShowNewTextFile] = useState(false);
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [renameFolder, setRenameFolder] = useState<FileResource | null>(null);
const [shareTargetId, setShareTargetId] = useState<string | null>(null);
// "Move to…": listed items by name, or a folder of the tree by id.
const [moveTarget, setMoveTarget] = useState<{ names: string[] } | { folder: FileResource } | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [focusedName, setFocusedName] = useState<string | null>(null);
// The cursor outline follows a click or the arrow keys, but goes away once
// the clicked item is deselected; while the keyboard is in use it stays.
const [keyboardNav, setKeyboardNav] = useState(false);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
const dragDepth = useRef(0);
const anchorName = useRef<string | null>(null);
const menu = useContextMenu<MenuData>();
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSearchQuery("");
setFocusedName(null);
anchorName.current = null;
if (scrollAreaRef.current) scrollAreaRef.current.scrollTop = 0;
}, [currentPath]);
// ---- Upload progress, shown as a toast like every other notice ----------------
const uploadToastId = useRef<string | null>(null);
const cancelUploadRef = useRef(onCancelUpload);
cancelUploadRef.current = onCancelUpload;
useEffect(() => {
if (!uploadProgress) {
if (uploadToastId.current) toast.dismiss(uploadToastId.current);
uploadToastId.current = null;
return;
}
const progress = uploadProgress.total > 0 ? uploadProgress.loaded / uploadProgress.total : null;
const title = uploadProgress.totalFiles > 1
? t("uploading_count", { current: uploadProgress.current, total: uploadProgress.totalFiles })
: t("uploading");
const message = progress === null ? uploadProgress.name : `${uploadProgress.name} · ${Math.round(progress * 100)}%`;
if (uploadToastId.current) {
// Closing the toast only hides it; the upload carries on.
toast.update(uploadToastId.current, { title, message, progress });
} else {
uploadToastId.current = toast.progress(title, {
message,
progress,
action: { label: t("cancel"), onClick: () => cancelUploadRef.current() },
});
}
}, [uploadProgress, t]);
useEffect(() => () => {
if (uploadToastId.current) toast.dismiss(uploadToastId.current);
}, []);
// ---- Sharing ---------------------------------------------------------------
// A node is shareable when the server supports JMAP Sharing, the viewer owns
// it (not a shared-with-me node), and holds the mayShare right (owned nodes
// report full rights; treat missing myRights as allowed).
const canShare = useCallback((r: FileResource | null | undefined): boolean =>
!!(sharingEnabled && onShare && client && r && !r.isShared && !r.isSymlink && (r.myRights?.mayShare ?? true)),
[sharingEnabled, onShare, client]);
// Files have content to preview, open, download or duplicate. Folders and
// symlinks don't: a symlink row only supports rename, move and delete.
const hasContent = useCallback((name: string): boolean => {
const r = resources.find((res) => res.name === name);
return !!r && !r.isDirectory && !r.isSymlink;
}, [resources]);
// Looked up by id so the dialog shows the shares as they change. A folder
// shared from the tree is usually not in the listing, so fall back to it.
const shareTarget = shareTargetId
? resources.find((r) => r.id === shareTargetId) ?? allFolders.find((r) => r.id === shareTargetId) ?? null
: null;
// Whether the user may add items to the folder on screen. Nothing can be
// put on a page listing files shared on their own.
const currentFolder = currentParentId ? allFolders.find((f) => f.id === currentParentId) : null;
const canCreate = !accountPickerMode && !isSharedFilesPage(currentParentId) && !inTrash
&& (currentFolder?.myRights?.mayAddChildren ?? true);
// ---- Listing ---------------------------------------------------------------
const displayResources = useMemo(() => {
// In the trash the date column is when an item was deleted.
let list = inTrash
? resources.map((r) => (r.trash?.deletedAt ? { ...r, lastModified: r.trash.deletedAt } : r))
: resources;
if (!settings.showHiddenFiles) list = list.filter((r) => !r.name.startsWith("."));
if (searchQuery) {
const q = searchQuery.toLowerCase();
list = list.filter((r) => r.name.toLowerCase().includes(q));
}
return [...list].sort((a, b) => {
// Folders always first
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
let cmp = 0;
switch (sortKey) {
case "name":
cmp = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
break;
case "size":
cmp = a.contentLength - b.contentLength;
break;
case "modified":
cmp = new Date(a.lastModified || 0).getTime() - new Date(b.lastModified || 0).getTime();
break;
}
if (cmp === 0) cmp = a.name.localeCompare(b.name);
return sortDir === "asc" ? cmp : -cmp;
});
}, [resources, inTrash, settings.showHiddenFiles, searchQuery, sortKey, sortDir]);
// Folders other principals shared with the user, listed at the root next to
// the user's own (#1181).
const sharedAtRoot = useMemo(() => {
if (currentPath !== "/" || accountPickerMode) return [];
const q = searchQuery.toLowerCase();
return sharedRoots.filter((r) => r.isDirectory && (!q || r.name.toLowerCase().includes(q)));
}, [currentPath, accountPickerMode, sharedRoots, searchQuery]);
// Files shared on their own are listed on one page per owner.
const sharedPagesAtRoot = useMemo(() => {
if (currentPath !== "/" || accountPickerMode) return [];
const q = searchQuery.toLowerCase();
return sharedFilePages(sharedRoots)
.map((page) => ({ ...page, name: t("shared_files_from", { name: page.ownerName }) }))
.filter((page) => !q || page.name.toLowerCase().includes(q));
}, [currentPath, accountPickerMode, sharedRoots, searchQuery, t]);
const pathOf = useCallback((r: FileResource) => (currentPath === "/" ? `/${r.name}` : `${currentPath}/${r.name}`), [currentPath]);
const starredPaths = useMemo(() => new Set(favorites), [favorites]);
const cutNames = useMemo(() => new Set(clipboard?.mode === "cut" ? clipboard.names : []), [clipboard]);
const selectedList = useMemo(() => displayResources.filter((r) => selectedResources.has(r.name)), [displayResources, selectedResources]);
const singleSelected = selectedList.length === 1 ? selectedList[0] : null;
// What plugins see of the selection (file-actions, file-preview-section).
const selectedViews = useMemo(() => selectedList.map((r) => resourceToView(r, currentPath)), [selectedList, currentPath]);
// ---- Breadcrumbs -----------------------------------------------------------
const crumbs: Crumb[] = useMemo(() => {
const segments = currentPath.split("/").filter(Boolean);
// Inside a folder someone shared with the user, the trail starts there.
const insideShared = !!pathStack[1]?.id?.includes(":");
const list: Crumb[] = [{ id: null, name: insideShared ? t("shared_with_me") : t("my_files"), path: "/" }];
if (inTrash) return [...list, { id: TRASH_LOCATION, name: t("trash"), path: TRASH_PATH }];
segments.forEach((name, i) => {
list.push({ id: pathStack[i + 1]?.id ?? null, name, path: "/" + segments.slice(0, i + 1).join("/") });
});
return list;
}, [currentPath, pathStack, inTrash, t]);
const folderName = crumbs[crumbs.length - 1].name;
const folderNameRef = useRef(folderName);
folderNameRef.current = folderName;
const navigateCrumb = useCallback((crumb: Crumb) => {
if (crumb.path === "/") {
// Pro shell: the root detaches the account and returns to the picker.
onNavigate("/");
return;
}
if (crumb.id) onNavigate(crumb.path, crumb.id);
else onNavigate(crumb.path);
}, [onNavigate]);
const navigateUp = useCallback(() => {
if (crumbs.length < 2) return;
const parent = crumbs[crumbs.length - 2];
// Pro shell: going up from a top folder lands on the account's root, not
// back on the account picker.
if (parent.path === "/" && accountLabel) onNavigate("/", "__account_root__");
else if (parent.path === "/") onNavigate("/", null);
else navigateCrumb(parent);
}, [crumbs, accountLabel, onNavigate, navigateCrumb]);
// ---- Opening ---------------------------------------------------------------
const openResource = useCallback((resource: FileResource) => {
// A symlink has nothing to open (Stalwart 1.0).
if (resource.isSymlink) return;
// A deleted folder is restored before it is opened; a deleted file can be looked at.
if (resource.trash && (resource.isDirectory || !isPreviewable(resource.name))) return;
if (resource.isDirectory) {
onNavigate(pathOf(resource), resource.id);
} else if (isOfficeEditable?.(resource.name) && onEditFile) {
onEditFile(resource.name);
} else if (isImageFile(resource.name)) {
onPreviewImage(resource.name);
} else if (isPreviewable(resource.name)) {
onPreviewFile(resource.name);
} else {
void onDownload(resource.name);
}
}, [onNavigate, pathOf, isOfficeEditable, onEditFile, onPreviewImage, onPreviewFile, onDownload]);
const previewResource = useCallback((resource: FileResource) => {
if (isImageFile(resource.name)) onPreviewImage(resource.name);
else onPreviewFile(resource.name);
}, [onPreviewImage, onPreviewFile]);
// Where a deleted item came from, as its row's tooltip in the trash.
const trashNote = useCallback((r: FileResource): string | undefined => {
if (!r.trash) return undefined;
const from = r.trash.fromParentId;
const folder = from ? allFolders.find((f) => f.id === from) : null;
const path = from === null ? t("my_files") : folder ? pathOfFolder(allFolders, folder) : null;
return path ? t("deleted_from", { folder: path }) : undefined;
}, [allFolders, t]);
// ---- Selection -------------------------------------------------------------
const selectRange = useCallback((from: string, to: string, additive: boolean) => {
const names = displayResources.map((r) => r.name);
const a = names.indexOf(from);
const b = names.indexOf(to);
if (a === -1 || b === -1) return;
const next = additive ? new Set(selectedResources) : new Set<string>();
for (let i = Math.min(a, b); i <= Math.max(a, b); i++) next.add(names[i]);
onSetSelection(next);
}, [displayResources, selectedResources, onSetSelection]);
const handleSelectClick = useCallback((resource: FileResource, e: React.MouseEvent) => {
setFocusedName(resource.name);
setKeyboardNav(false);
if (e.shiftKey && anchorName.current) {
selectRange(anchorName.current, resource.name, e.ctrlKey || e.metaKey);
return;
}
anchorName.current = resource.name;
if (e.ctrlKey || e.metaKey) {
onToggleSelect(resource.name);
} else {
onSetSelection(new Set([resource.name]));
}
}, [selectRange, onToggleSelect, onSetSelection]);
const handleToggleSelect = useCallback((resource: FileResource) => {
anchorName.current = resource.name;
setFocusedName(resource.name);
setKeyboardNav(false);
onToggleSelect(resource.name);
}, [onToggleSelect]);
const selectAllVisible = useCallback(() => {
onSetSelection(new Set(displayResources.map((r) => r.name)));
}, [displayResources, onSetSelection]);
const allSelected = displayResources.length > 0 && selectedList.length === displayResources.length;
const someSelected = selectedList.length > 0;
// ---- Deleting --------------------------------------------------------------
const deleteNames = useCallback((names: string[]) => {
if (names.length === 1) void onDelete(names[0]);
else if (names.length > 1) void onBatchDelete(names);
}, [onDelete, onBatchDelete]);
const downloadNames = useCallback((names: string[]) => {
const items = names.map((n) => resources.find((r) => r.name === n)).filter((r): r is FileResource => !!r && !r.isSymlink);
// Folders come as one zip, with any files picked next to them.
if (onDownloadZip && items.some((r) => r.isDirectory)) {
const zipName = items.length === 1 ? items[0].name : folderNameRef.current;
void onDownloadZip(items.map((r) => r.id), `${zipName}.zip`);
return;
}
const files = names.filter(hasContent);
if (files.length === 1) void onDownload(files[0]);
else if (files.length > 1) void onBatchDownload(files);
}, [resources, hasContent, onDownload, onBatchDownload, onDownloadZip]);
// ---- Drag and drop ---------------------------------------------------------
const moveNames = useCallback((names: string[], targetId: string | null) => {
void onMoveTo(names, targetId);
}, [onMoveTo]);
const itemDragProps = useCallback((resource: FileResource) => ({
draggable: !touch && !inTrash,
onDragStart: (e: React.DragEvent) => {
const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name];
e.dataTransfer.setData(INTERNAL_DRAG_TYPE, JSON.stringify(names));
e.dataTransfer.effectAllowed = "move";
},
onDragOver: (e: React.DragEvent) => {
if (!resource.isDirectory || !isInternalDrag(e)) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
setDropTarget(resource.name);
},
onDragLeave: () => setDropTarget((cur) => (cur === resource.name ? null : cur)),
onDrop: (e: React.DragEvent) => {
if (!resource.isDirectory || !isInternalDrag(e)) return;
e.preventDefault();
e.stopPropagation();
setDropTarget(null);
const names = readDraggedNames(e).filter((n) => n !== resource.name);
if (names.length > 0) moveNames(names, resource.id);
},
}), [touch, inTrash, selectedResources, moveNames]);
const runUpload = useCallback(async (files: File[], asFolder: boolean) => {
if (files.length === 0) return;
if (asFolder) await onUploadFolder(files);
else await onUploadFiles(files);
}, [onUploadFiles, onUploadFolder]);
const handleContentDragEnter = useCallback((e: React.DragEvent) => {
if (!e.dataTransfer.types.includes("Files") || !canCreate) return;
e.preventDefault();
dragDepth.current += 1;
setIsDraggingFiles(true);
}, [canCreate]);
const handleContentDragOver = useCallback((e: React.DragEvent) => {
if (!e.dataTransfer.types.includes("Files") || !canCreate) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}, [canCreate]);
const handleContentDragLeave = useCallback((e: React.DragEvent) => {
if (!e.dataTransfer.types.includes("Files")) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setIsDraggingFiles(false);
}, []);
const handleContentDrop = useCallback(async (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes("Files") || !canCreate) return;
e.preventDefault();
dragDepth.current = 0;
setIsDraggingFiles(false);
const { files, hasDirectories } = await getDroppedFilesAndFolders(e.dataTransfer);
await runUpload(files, hasDirectories);
}, [canCreate, runUpload]);
const handleInputChange = (asFolder: boolean) => async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
e.target.value = "";
await runUpload(files, asFolder);
};
// ---- Marquee (rubber-band) selection ----------------------------------------
const [marquee, setMarquee] = useState<{ startX: number; startY: number; currentX: number; currentY: number } | null>(null);
const marqueeInfo = useRef<{ initialSelection: Set<string> } | null>(null);
const handleMarqueeMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0 || touch) return;
const target = e.target as HTMLElement;
if (target.closest("[data-resource], button, input, [role=columnheader], a")) return;
const area = scrollAreaRef.current;
if (!area) return;
const rect = area.getBoundingClientRect();
const x = e.clientX - rect.left + area.scrollLeft;
const y = e.clientY - rect.top + area.scrollTop;
const additive = e.ctrlKey || e.metaKey;
marqueeInfo.current = { initialSelection: additive ? new Set(selectedResources) : new Set() };
setMarquee({ startX: x, startY: y, currentX: x, currentY: y });
if (!additive) onClearSelection();
e.preventDefault();
}, [touch, selectedResources, onClearSelection]);
useEffect(() => {
if (!marquee) return;
const handleMove = (e: MouseEvent) => {
const area = scrollAreaRef.current;
const info = marqueeInfo.current;
if (!area || !info) return;
const rect = area.getBoundingClientRect();
const x = e.clientX - rect.left + area.scrollLeft;
const y = e.clientY - rect.top + area.scrollTop;
setMarquee((prev) => (prev ? { ...prev, currentX: x, currentY: y } : null));
const mx = Math.min(marquee.startX, x);
const my = Math.min(marquee.startY, y);
const mw = Math.abs(x - marquee.startX);
const mh = Math.abs(y - marquee.startY);
if (mw < 4 && mh < 4) return;
const next = new Set(info.initialSelection);
area.querySelectorAll("[data-resource]").forEach((el) => {
const name = el.getAttribute("data-resource");
if (!name) return;
const r = el.getBoundingClientRect();
const ex = r.left - rect.left + area.scrollLeft;
const ey = r.top - rect.top + area.scrollTop;
if (mx < ex + r.width && mx + mw > ex && my < ey + r.height && my + mh > ey) next.add(name);
});
onSetSelection(next);
};
const handleUp = () => {
setMarquee(null);
marqueeInfo.current = null;
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
}, [marquee, onSetSelection]);
// ---- Keyboard ----------------------------------------------------------------
const moveFocus = useCallback((delta: number, extend: boolean) => {
const names = displayResources.map((r) => r.name);
if (names.length === 0) return;
const current = focusedName ? names.indexOf(focusedName) : -1;
const base = current === -1 ? (delta > 0 ? -1 : names.length) : current;
const nextIndex = Math.max(0, Math.min(names.length - 1, base + delta));
const name = names[nextIndex];
setFocusedName(name);
setKeyboardNav(true);
if (extend && anchorName.current) {
selectRange(anchorName.current, name, false);
} else {
anchorName.current = name;
onSetSelection(new Set([name]));
}
scrollAreaRef.current?.querySelector(`[data-resource="${CSS.escape(name)}"]`)?.scrollIntoView?.({ block: "nearest" });
}, [displayResources, focusedName, selectRange, onSetSelection]);
// Columns in the grid's file section, for Up/Down.
const gridColumns = useCallback((): number => {
const area = scrollAreaRef.current;
if (!area || !focusedName) return 1;
const el = area.querySelector(`[data-resource="${CSS.escape(focusedName)}"]`);
const row = el?.parentElement;
if (!row) return 1;
const tops = Array.from(row.children).map((c) => (c as HTMLElement).offsetTop);
return Math.max(1, tops.filter((top) => top === tops[0]).length);
}, [focusedName]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (isTypingTarget(e.target)) return;
if (showNewFolder || showNewTextFile || renameTarget || renameFolder || shareTargetId || moveTarget) return;
// A preview, editor or confirmation is open on top of the list.
if (document.querySelector('[role="dialog"], [role="alertdialog"]')) return;
if (accountPickerMode) return;
const mod = e.ctrlKey || e.metaKey;
const selected = selectedList.map((r) => r.name);
// "/" as in Mail and Contacts; Ctrl/⌘+F as in a file manager.
if ((mod && e.key.toLowerCase() === "f") || (e.key === "/" && !mod && !e.altKey)) {
e.preventDefault();
searchInputRef.current?.focus();
return;
}
if (mod && e.key.toLowerCase() === "a") {
e.preventDefault();
selectAllVisible();
return;
}
if (inTrash && (mod || e.key === "F2") && ["c", "x", "v", "f2"].includes(e.key.toLowerCase())) return;
if (mod && e.key.toLowerCase() === "c" && selected.length > 0) {
if (window.getSelection()?.toString()) return;
e.preventDefault();
onCopy(selected);
return;
}
if (mod && e.key.toLowerCase() === "x" && selected.length > 0) {
e.preventDefault();
onCut(selected);
return;
}
if (mod && e.key.toLowerCase() === "v" && clipboard) {
e.preventDefault();
void onPaste();
return;
}
if (e.key === "Escape") {
if (searchQuery) setSearchQuery("");
else if (selected.length > 0) onClearSelection();
return;
}
// Backspace deletes too, as in Mail (⌘⌫ on a Mac); Alt+↑ goes up.
if ((e.key === "Delete" || e.key === "Backspace") && selected.length > 0) {
e.preventDefault();
deleteNames(selected);
return;
}
if (e.key === "F2" && singleSelected) {
e.preventDefault();
setRenameTarget(singleSelected.name);
return;
}
if (e.key === "Enter" && singleSelected) {
e.preventDefault();
openResource(singleSelected);
return;
}
if (e.key === " " && focusedName) {
e.preventDefault();
onToggleSelect(focusedName);
return;
}
if (e.altKey && e.key === "ArrowUp" && currentPath !== "/") {
e.preventDefault();
navigateUp();
return;
}
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const step = viewMode === "grid" ? gridColumns() : 1;
moveFocus(e.key === "ArrowDown" ? step : -step, e.shiftKey);
return;
}
if (viewMode === "grid" && (e.key === "ArrowRight" || e.key === "ArrowLeft")) {
e.preventDefault();
const rtl = document.dir === "rtl";
moveFocus((e.key === "ArrowRight") !== rtl ? 1 : -1, e.shiftKey);
return;
}
if (e.key === "Home" || e.key === "End") {
e.preventDefault();
moveFocus(e.key === "Home" ? -Infinity : Infinity, e.shiftKey);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, moveTarget, accountPickerMode, selectedList, singleSelected, inTrash,
selectAllVisible, onCopy, onCut, onPaste, clipboard, searchQuery, onClearSelection, deleteNames, openResource,
focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus,
]);
// ---- Context menu ----------------------------------------------------------
const openMenu = menu.openContextMenu;
const openItemMenu = useCallback((e: React.MouseEvent, resource: FileResource) => {
// Acting on an item outside the selection makes it the selection, like a
// desktop file manager.
if (!selectedResources.has(resource.name)) {
onSetSelection(new Set([resource.name]));
anchorName.current = resource.name;
}
setFocusedName(resource.name);
setKeyboardNav(false);
const target = e.currentTarget as HTMLElement | null;
// The "..." button opens the menu under itself; a right-click at the pointer.
if (e.type === "click" && target) openMenu(anchorEvent(target, "end"), { kind: "item", resource });
else openMenu(e, { kind: "item", resource });
}, [selectedResources, onSetSelection, openMenu]);
const closeMenu = menu.closeContextMenu;
const menuData = menu.contextMenu.data;
const menuSelection = menuData?.kind === "item" && selectedResources.has(menuData.resource.name) && selectedResources.size > 1
? selectedList
: menuData?.kind === "item" ? [menuData.resource] : [];
// Items move within the own drive; a shared drive's folders are not in the picker.
const canMove = !inTrash && !accountPickerMode && !currentParentId?.includes(":");
const renderItemMenu = (resource: FileResource) => {
if (inTrash) {
const names = menuSelection.map((r) => r.name);
const one = menuSelection.length === 1 ? menuSelection[0] : null;
return (
<>
{onRestore && (
<ContextMenuItem icon={RotateCcw} label={t("restore")} onClick={() => { closeMenu(); void onRestore(names); }} />
)}
{one && !one.isDirectory && !one.isSymlink && isPreviewable(one.name) && (
<ContextMenuItem icon={Eye} label={t("preview")} onClick={() => { closeMenu(); previewResource(one); }} />
)}
{one && !one.isDirectory && !one.isSymlink && (
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(one.name); }} />
)}
<ContextMenuSeparator />
<ContextMenuItem icon={Trash2} label={t("delete_forever")} destructive onClick={() => { closeMenu(); deleteNames(names); }} />
</>
);
}
if (menuSelection.length > 1) {
const names = menuSelection.map((r) => r.name);
const fileCount = menuSelection.filter((r) => !r.isDirectory && !r.isSymlink).length;
const withFolders = !!onDownloadZip && menuSelection.some((r) => r.isDirectory);
return (
<>
{(fileCount > 0 || withFolders) && (
<ContextMenuItem
icon={Download}
label={withFolders ? t("download_zip") : t("download_count", { count: fileCount })}
onClick={() => { closeMenu(); downloadNames(names); }}
/>
)}
{canMove && (
<ContextMenuItem icon={FolderInput} label={t("move_to")} onClick={() => { closeMenu(); setMoveTarget({ names }); }} />
)}
<ContextMenuItem icon={Scissors} label={t("cut")} shortcut={formatShortcutCombo("Mod + X")} onClick={() => { closeMenu(); onCut(names); }} />
{menuSelection.some((r) => !r.isSymlink) && (
<ContextMenuItem icon={Copy} label={t("copy")} shortcut={formatShortcutCombo("Mod + C")} onClick={() => { closeMenu(); onCopy(names); }} />
)}
<ContextMenuSeparator />
<ContextMenuItem icon={Trash2} label={t("delete_count", { count: names.length })} destructive onClick={() => { closeMenu(); deleteNames(names); }} />
<FilePluginMenuEntries files={menuSelection.map((r) => resourceToView(r, currentPath))} onRan={closeMenu} />
</>
);
}
const path = pathOf(resource);
const starred = starredPaths.has(path);
return (
<>
{resource.isDirectory ? (
<>
<ContextMenuItem icon={FolderOpen} label={t("open")} onClick={() => { closeMenu(); openResource(resource); }} />
{onDownloadZip && (
<ContextMenuItem icon={Download} label={t("download_zip")} onClick={() => { closeMenu(); downloadNames([resource.name]); }} />
)}
</>
) : resource.isSymlink ? null : (
<>
{isOfficeEditable?.(resource.name) && onEditFile && (
<ContextMenuItem icon={SquarePen} label={t("office_edit")} onClick={() => { closeMenu(); onEditFile(resource.name); }} />
)}
{isPreviewable(resource.name) && (
<ContextMenuItem icon={Eye} label={t("preview")} onClick={() => { closeMenu(); previewResource(resource); }} />
)}
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(resource.name); }} />
</>
)}
{(canShare(resource) || (resource.isDirectory && !resource.isShared)) && <ContextMenuSeparator />}
{canShare(resource) && (
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => { closeMenu(); setShareTargetId(resource.id); }} />
)}
{resource.isDirectory && !resource.isShared && (
<ContextMenuItem
icon={starred ? StarOff : Star}
label={starred ? t("unstar") : t("star")}
onClick={() => { closeMenu(); onToggleFavorite(path); }}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem icon={Pencil} label={t("rename")} shortcut="F2" onClick={() => { closeMenu(); setRenameTarget(resource.name); }} />
{!resource.isDirectory && !resource.isSymlink && (
<ContextMenuItem icon={CopyPlus} label={t("duplicate")} onClick={() => { closeMenu(); void onDuplicate(resource.name); }} />
)}
{canMove && (
<ContextMenuItem icon={FolderInput} label={t("move_to")} onClick={() => { closeMenu(); setMoveTarget({ names: [resource.name] }); }} />
)}
<ContextMenuItem icon={Scissors} label={t("cut")} shortcut={formatShortcutCombo("Mod + X")} onClick={() => { closeMenu(); onCut([resource.name]); }} />
{!resource.isSymlink && (
<ContextMenuItem icon={Copy} label={t("copy")} shortcut={formatShortcutCombo("Mod + C")} onClick={() => { closeMenu(); onCopy([resource.name]); }} />
)}
<ContextMenuItem icon={Info} label={t("details")} onClick={() => { closeMenu(); toggleDetails(true); }} />
<ContextMenuSeparator />
<ContextMenuItem icon={Trash2} label={t("delete")} shortcut="Del" destructive onClick={() => { closeMenu(); deleteNames([resource.name]); }} />
<FilePluginMenuEntries
files={[resourceToView(resource, currentPath)]}
mimeType={resource.isDirectory ? "" : resource.contentType}
onRan={closeMenu}
/>
</>
);
};
const newItems = (
<>
<ContextMenuItem icon={Upload} label={t("upload_files")} onClick={() => { closeMenu(); fileInputRef.current?.click(); }} />
<ContextMenuItem icon={FolderUp} label={t("upload_folder")} onClick={() => { closeMenu(); folderInputRef.current?.click(); }} />
<ContextMenuSeparator />
<ContextMenuItem icon={FolderPlus} label={t("new_folder")} onClick={() => { closeMenu(); setShowNewFolder(true); }} />
<ContextMenuItem icon={FilePlus} label={t("new_text_file")} onClick={() => { closeMenu(); setShowNewTextFile(true); }} />
</>
);
const handlers: FileItemHandlers = useMemo(() => ({
onSelectClick: handleSelectClick,
onToggleSelect: handleToggleSelect,
onOpen: openResource,
onContextMenu: openItemMenu,
onDownload: (r) => void onDownload(r.name),
onShare: (r) => setShareTargetId(r.id),
canShare,
dragProps: itemDragProps,
}), [handleSelectClick, handleToggleSelect, openResource, openItemMenu, onDownload, canShare, itemDragProps]);
const handleSort = useCallback((key: SortKey) => {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortKey(key);
setSortDir(key === "name" ? "asc" : "desc");
}
}, [sortKey]);
// A sidebar entry's "New folder" / "Upload" acts on that folder: open it first.
const createIn = useCallback((path: string, id: string | null, kind: CreateKind) => {
if (id !== currentParentId) onNavigate(path, id);
if (kind === "folder") setShowNewFolder(true);
else if (kind === "text") setShowNewTextFile(true);
else if (kind === "files") fileInputRef.current?.click();
else folderInputRef.current?.click();
}, [currentParentId, onNavigate]);
// ---- Command palette ---------------------------------------------------------
const tSidebar = useTranslations("sidebar");
useRegisterCommands(tSidebar("files"), [
{ id: "upload-files", label: t("upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
{ id: "upload-folder", label: t("upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
{ id: "new-folder", label: t("new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate },
{ id: "new-text-file", label: t("new_text_file"), icon: FilePlus, run: () => setShowNewTextFile(true), when: () => canCreate },
{ id: "trash", label: t("trash"), icon: Trash2, run: () => onNavigate(TRASH_PATH, TRASH_LOCATION), when: () => !inTrash && !accountPickerMode },
{ id: "empty-trash", label: t("empty_trash"), icon: Trash2, run: () => onEmptyTrash?.(), when: () => inTrash && trashCount > 0 && !!onEmptyTrash },
{ id: "refresh", label: t("refresh"), icon: RefreshCw, run: () => void onRefresh() },
]);
// ---- Rendering -------------------------------------------------------------
const sidebar = !accountPickerMode && (
<FilesSidebar
currentParentId={currentParentId}
currentPath={currentPath}
allFolders={allFolders}
sharedRoots={sharedRoots}
favorites={favorites}
recentFiles={recentFiles}
canCreate={canCreate}
onNavigate={onNavigate}
onOpenRecent={onOpenRecent}
onUploadFiles={fromDrawer(() => fileInputRef.current?.click())}
onUploadFolder={fromDrawer(() => folderInputRef.current?.click())}
onNewFolder={fromDrawer(() => setShowNewFolder(true))}
onNewTextFile={fromDrawer(() => setShowNewTextFile(true))}
onToggleFavorite={onToggleFavorite}
onDropItems={moveNames}
canShare={canShare}
onShare={fromDrawer((r: FileResource) => setShareTargetId(r.id))}
onCreateIn={fromDrawer(createIn)}
onRenameFolder={fromDrawer(setRenameFolder)}
onDownloadFolder={onDownloadZip ? (folder: FileResource) => void onDownloadZip([folder.id], `${folder.name}.zip`) : undefined}
onMoveFolder={onMoveNode ? fromDrawer((folder: FileResource) => setMoveTarget({ folder })) : undefined}
onDeleteFolder={fromDrawer((folder: FileResource) => void onDeleteNode(folder.id, folder.name))}
onRevealRecent={fromDrawer((id: string) => void onRevealNode(id))}
onDownloadRecent={fromDrawer((id: string) => void onDownloadNode(id))}
onRemoveRecent={onRemoveRecent}
trashCount={trashCount}
onOpenTrash={fromDrawer(() => onNavigate(TRASH_PATH, TRASH_LOCATION))}
onDropToTrash={(names) => deleteNames(names)}
/>
);
const showEmpty = !isLoading && !error && displayResources.length === 0 && sharedAtRoot.length === 0 && sharedPagesAtRoot.length === 0;
const listing = (() => {
if (accountPickerMode) {
return accountFolders && accountFolders.length > 0 && onSelectAccount ? (
<div className="p-4">
<h3 className="text-xs font-medium text-muted-foreground mb-2 px-0.5">{t("choose_account")}</h3>
<div className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
{accountFolders.map((acc) => (
<button
key={`__account__:${acc.accountId}`}
onClick={() => onSelectAccount(acc.accountId)}
title={acc.email}
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/60 transition-colors text-start min-w-0"
>
<Avatar name={acc.label} email={acc.email} size="md" fallbackColor={acc.avatarColor} className="shrink-0" />
<div className="min-w-0 flex flex-col">
<span className="truncate text-sm font-medium">{acc.label || acc.email}</span>
{acc.label && acc.label !== acc.email && (
<span className="truncate text-xs text-muted-foreground">{acc.email}</span>
)}
</div>
</button>
))}
</div>
</div>
) : (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("no_accounts")}</p>
</div>
);
}
// Until the first listing is in, an empty list means "not loaded yet".
if ((isLoading || !hasListed) && resources.length === 0) return <ListSkeleton />;
if (showEmpty && searchQuery) return <NoSearchResults query={searchQuery} onClear={() => setSearchQuery("")} />;
if (showEmpty && inTrash) {
return <EmptyState icon={Trash2} title={t("trash_empty_title")} description={t("trash_empty_hint")} />;
}
if (showEmpty) {
return (
<EmptyFolder
isRoot={currentPath === "/"}
canCreate={canCreate}
onUpload={() => fileInputRef.current?.click()}
onCreateFolder={() => setShowNewFolder(true)}
/>
);
}
const viewProps = {
resources: displayResources,
selected: selectedResources,
focusedName: focusedName && (keyboardNav || selectedResources.has(focusedName)) ? focusedName : null,
dropTarget,
cutNames,
starredPaths,
pathOf,
touch,
iconOptions,
getImageUrl,
handlers,
itemNote: inTrash ? trashNote : undefined,
};
return (
<>
{displayResources.length > 0 && (viewMode === "grid" ? (
<FileGridView {...viewProps} />
) : (
<FileListView
{...viewProps}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
allSelected={allSelected}
someSelected={someSelected}
onToggleAll={() => (allSelected ? onClearSelection() : selectAllVisible())}
dateLabel={inTrash ? t("deleted") : undefined}
/>
))}
{(sharedAtRoot.length > 0 || sharedPagesAtRoot.length > 0) && (
<SharedWithMe
folders={sharedAtRoot}
pages={sharedPagesAtRoot}
onOpen={(r) => onNavigate(`/${r.name}`, r.id)}
iconOptions={iconOptions}
/>
)}
</>
);
})();
const detailsPanel = showDetails && !accountPickerMode && (
<FileDetailsPanel
resource={singleSelected}
selection={selectedList}
folderName={folderName}
folderItems={displayResources}
path={singleSelected ? pathOf(singleSelected) : currentPath}
isStarred={singleSelected ? starredPaths.has(pathOf(singleSelected)) : false}
canShare={canShare(singleSelected)}
canPreview={!!singleSelected && !singleSelected.isSymlink && isPreviewable(singleSelected.name)}
canEdit={!!singleSelected && !singleSelected.isSymlink && !!onEditFile && !!isOfficeEditable?.(singleSelected.name)}
pluginFile={singleSelected ? selectedViews[0] : null}
iconOptions={iconOptions}
getImageUrl={getImageUrl}
onClose={() => toggleDetails(false)}
onOpen={() => singleSelected && (singleSelected.isDirectory ? openResource(singleSelected) : previewResource(singleSelected))}
onEdit={() => singleSelected && onEditFile?.(singleSelected.name)}
onDownload={() => singleSelected && void onDownload(singleSelected.name)}
onShare={() => singleSelected && setShareTargetId(singleSelected.id)}
onToggleStar={() => singleSelected && onToggleFavorite(pathOf(singleSelected))}
className={cn(
"flex-shrink-0",
isNarrow ? "absolute inset-y-0 end-0 z-30 w-80 max-w-[85%] shadow-xl" : "w-72",
)}
/>
);
const selectionActions = selectedList.length > 0 && inTrash ? {
count: selectedList.length,
fileCount: 0,
canOpen: false,
canRename: false,
canShare: false,
trash: true,
onRestore: onRestore ? () => void onRestore(selectedList.map((r) => r.name)) : undefined,
onOpen: () => {},
onDownload: () => {},
onShare: () => {},
onRename: () => {},
onCut: () => {},
onCopy: () => {},
onDelete: () => deleteNames(selectedList.map((r) => r.name)),
onClear: onClearSelection,
pluginFiles: [],
} : selectedList.length > 0 ? {
count: selectedList.length,
// Folders download as a zip.
fileCount: selectedList.filter((r) => !r.isSymlink && (!r.isDirectory || !!onDownloadZip)).length,
canOpen: !!singleSelected && !singleSelected.isSymlink
&& (singleSelected.isDirectory || isPreviewable(singleSelected.name) || !!isOfficeEditable?.(singleSelected.name)),
canRename: !!singleSelected,
canShare: !!singleSelected && canShare(singleSelected),
onOpen: () => singleSelected && openResource(singleSelected),
onDownload: () => downloadNames(selectedList.map((r) => r.name)),
onShare: () => singleSelected && setShareTargetId(singleSelected.id),
onRename: () => singleSelected && setRenameTarget(singleSelected.name),
onMoveTo: canMove ? () => setMoveTarget({ names: selectedList.map((r) => r.name) }) : undefined,
onCut: () => onCut(selectedList.map((r) => r.name)),
onCopy: () => onCopy(selectedList.map((r) => r.name)),
onDelete: () => deleteNames(selectedList.map((r) => r.name)),
onClear: onClearSelection,
pluginFiles: selectedViews,
pluginMimeType: singleSelected ? (singleSelected.isDirectory ? "" : singleSelected.contentType) : undefined,
} : null;
return (
// Clips the closed drawer, so it never sits over the nav rail.
<div className="relative flex flex-1 min-h-0 overflow-hidden">
{/* Folder sidebar: in flow on desktop, a drawer on narrow panes */}
{sidebar && isNarrow && narrowSidebarOpen && (
<div className="absolute inset-0 bg-black/50 z-40" onClick={() => setNarrowSidebarOpen(false)} />
)}
{sidebar && (isNarrow ? (
<div
data-testid="files-sidebar-drawer"
className={cn(
"absolute inset-y-0 start-0 z-50 w-72 border-e border-border",
"transform transition-[transform,visibility] duration-300 ease-in-out",
!narrowSidebarOpen && "invisible -translate-x-full rtl:translate-x-full",
)}
// Closed, the drawer is out of reach: no clicks, no Tab stops.
inert={!narrowSidebarOpen}
>
{sidebar}
</div>
) : (
<>
<div
className={cn("border-e border-border flex-shrink-0", !isResizing && "transition-[width] duration-300")}
style={{ width: `${sidebarWidth}px` }}
>
{sidebar}
</div>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("files-sidebar-width", String(sidebarWidth));
}}
onDoubleClick={() => { setSidebarWidth(SIDEBAR_DEFAULT_WIDTH); localStorage.setItem("files-sidebar-width", String(SIDEBAR_DEFAULT_WIDTH)); }}
/>
</>
))}
<div className="flex flex-col flex-1 min-w-0">
<FilesHeader
crumbs={crumbs}
accountLabel={accountLabel}
onNavigateCrumb={navigateCrumb}
onNavigateAccountRoot={() => onNavigate("/", "__account_root__")}
onDropOnCrumb={moveNames}
onMenuClick={isNarrow && sidebar ? () => setNarrowSidebarOpen((v) => !v) : undefined}
menuOpen={narrowSidebarOpen}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
searchInputRef={searchInputRef}
sortKey={sortKey}
sortDir={sortDir}
onSortChange={(key, dir) => { setSortKey(key); setSortDir(dir); }}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
showDetails={showDetails}
onToggleDetails={() => toggleDetails()}
clipboardCount={clipboard?.names.length ?? 0}
onPaste={() => void onPaste()}
onRefresh={() => void onRefresh()}
isLoading={isLoading}
compact={isMobile}
toolsHidden={accountPickerMode}
selection={selectionActions}
emptyTrash={inTrash && onEmptyTrash ? { onEmpty: onEmptyTrash, disabled: trashCount === 0 } : undefined}
/>
{error && (
<div role="alert" className="mx-4 mt-3 px-3 py-2 rounded-md bg-destructive/10 text-destructive text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span className="flex-1">{error}</span>
<Button variant="ghost" size="xs" className="text-destructive hover:text-destructive shrink-0" onClick={() => void onRefresh()}>
<RefreshCw className="w-3.5 h-3.5 me-1" />
{t("retry")}
</Button>
</div>
)}
<div className="relative flex flex-1 min-h-0">
<div
ref={scrollAreaRef}
className="relative flex-1 min-w-0 overflow-y-auto"
onMouseDown={handleMarqueeMouseDown}
onContextMenu={(e) => {
if (accountPickerMode || (e.target as HTMLElement).closest("[data-resource]")) return;
menu.openContextMenu(e, { kind: "empty" });
}}
onClick={(e) => {
// With a mouse, pressing on empty space already cleared the
// selection, and the click that ends a drag-select lands here
// too: clearing again would drop what was just selected.
if (touch && e.target === e.currentTarget && selectedResources.size > 0) onClearSelection();
}}
onDragEnter={handleContentDragEnter}
onDragOver={handleContentDragOver}
onDragLeave={handleContentDragLeave}
onDrop={handleContentDrop}
>
{listing}
{marquee && (
<div
className="absolute border border-primary/50 bg-primary/10 pointer-events-none z-20 rounded-sm"
style={{
left: Math.min(marquee.startX, marquee.currentX),
top: Math.min(marquee.startY, marquee.currentY),
width: Math.abs(marquee.currentX - marquee.startX),
height: Math.abs(marquee.currentY - marquee.startY),
}}
/>
)}
</div>
{isDraggingFiles && (
<div className="absolute inset-2 z-30 rounded-xl border-2 border-dashed border-primary bg-primary/5 flex items-center justify-center pointer-events-none">
<div className="text-center bg-background/90 rounded-lg px-5 py-4 shadow-sm">
<Upload className="w-8 h-8 text-primary mx-auto mb-2" />
<p className="text-sm font-medium">{t("drop_to_upload", { folder: folderName })}</p>
</div>
</div>
)}
{detailsPanel}
{isNarrow && canCreate && !selectionActions && (
<Button
size="icon-lg"
className="absolute bottom-4 end-4 z-20 rounded-full shadow-lg"
aria-label={t("new")}
onClick={(e) => menu.openContextMenu(anchorEvent(e.currentTarget, "end"), { kind: "new" })}
>
<Plus className="w-5 h-5" />
</Button>
)}
</div>
</div>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleInputChange(false)} />
<input
ref={folderInputRef}
type="file"
className="hidden"
onChange={handleInputChange(true)}
{...({ webkitdirectory: "", directory: "" } as React.InputHTMLAttributes<HTMLInputElement>)}
/>
<ContextMenu ref={menu.menuRef} isOpen={menu.contextMenu.isOpen} position={menu.contextMenu.position} onClose={closeMenu}>
{menuData?.kind === "item" && renderItemMenu(menuData.resource)}
{menuData?.kind === "new" && newItems}
{menuData?.kind === "empty" && (
<>
{canCreate && newItems}
{clipboard && canCreate && (
<>
<ContextMenuSeparator />
<ContextMenuItem icon={Clipboard} label={t("paste")} shortcut={formatShortcutCombo("Mod + V")} onClick={() => { closeMenu(); void onPaste(); }} />
</>
)}
<ContextMenuSeparator />
{displayResources.length > 0 && (
<ContextMenuItem icon={CheckSquare} label={t("select_all")} shortcut={formatShortcutCombo("Mod + A")} onClick={() => { closeMenu(); selectAllVisible(); }} />
)}
<ContextMenuItem icon={RefreshCw} label={t("refresh")} onClick={() => { closeMenu(); void onRefresh(); }} />
</>
)}
</ContextMenu>
<PromptDialog
isOpen={showNewFolder}
onClose={() => setShowNewFolder(false)}
title={t("new_folder")}
placeholder={t("new_folder_name")}
confirmText={t("create")}
cancelText={t("cancel")}
validate={validateFileName}
onSubmit={(name) => void onCreateFolder(name)}
/>
<PromptDialog
isOpen={showNewTextFile}
onClose={() => setShowNewTextFile(false)}
title={t("new_text_file")}
placeholder={t("file_name")}
defaultValue="new-file.txt"
selectBaseName
confirmText={t("create")}
cancelText={t("cancel")}
validate={validateFileName}
onSubmit={(name) => void onCreateTextFile(name)}
/>
<PromptDialog
isOpen={renameTarget !== null}
onClose={() => setRenameTarget(null)}
title={t("rename_title")}
placeholder={t("new_name")}
defaultValue={renameTarget ?? ""}
// A file keeps its extension unless the user means to change it.
selectBaseName={!resources.find((r) => r.name === renameTarget)?.isDirectory}
confirmText={t("save")}
cancelText={t("cancel")}
validate={validateFileName}
onSubmit={(newName) => {
if (renameTarget !== null && newName !== renameTarget) void onRename(renameTarget, newName);
}}
/>
<PromptDialog
isOpen={renameFolder !== null}
onClose={() => setRenameFolder(null)}
title={t("rename_title")}
placeholder={t("new_name")}
defaultValue={renameFolder?.name ?? ""}
confirmText={t("save")}
cancelText={t("cancel")}
validate={validateFileName}
onSubmit={(newName) => {
if (renameFolder && newName !== renameFolder.name) void onRenameNode(renameFolder.id, newName);
}}
/>
<FolderPickerDialog
isOpen={moveTarget !== null}
onClose={() => setMoveTarget(null)}
title={t("move_to_title")}
confirmText={t("move_here")}
folders={allFolders.filter((f) => !f.isShared && !f.id.includes(":"))}
excludeIds={new Set(moveTarget && "folder" in moveTarget
? [moveTarget.folder.id]
: (moveTarget?.names ?? []).map((n) => resources.find((r) => r.name === n)).filter((r) => r?.isDirectory).map((r) => r!.id))}
currentId={moveTarget && "folder" in moveTarget ? moveTarget.folder.parentId ?? null : currentParentId}
onPick={(targetId) => {
if (!moveTarget) return;
if ("folder" in moveTarget) void onMoveNode?.(moveTarget.folder.id, targetId);
else void onMoveTo(moveTarget.names, targetId);
}}
/>
{shareTarget && client && onShare && (
<ShareCollectionDialog
client={client}
kind="file"
collectionName={shareTarget.name}
shareWith={shareTarget.shareWith}
ownAccountId={ownAccountId || ""}
onShare={async (principalId, rights) => {
await onShare(shareTarget.id, principalId, rights as FileNodeRights | null);
}}
onClose={() => setShareTargetId(null)}
/>
)}
</div>
);
}
function SharedWithMe({ folders, pages, onOpen, iconOptions }: {
folders: FileResource[];
/** Pages of files shared on their own, one per owner. */
pages: { id: string; name: string; ownerName: string }[];
onOpen: (r: { id: string; name: string }) => void;
iconOptions: { showIcons: boolean; coloredIcons: boolean };
}) {
const t = useTranslations("files");
return (
<section aria-label={t("shared_with_me")} className="px-4 pt-5 pb-4">
<h3 className="text-xs font-medium text-muted-foreground mb-2 px-0.5 flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{t("shared_with_me")}
</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-2">
{folders.map((folder) => (
<button
key={folder.id}
type="button"
onClick={() => onOpen(folder)}
title={folder.ownerName ? t("shared_by", { name: folder.ownerName }) : folder.name}
className="flex items-center gap-2.5 h-12 px-3 rounded-lg border border-border hover:bg-muted/60 transition-colors text-start min-w-0"
>
{iconOptions.showIcons && <FolderOpen className={cn("w-5 h-5 shrink-0", iconOptions.coloredIcons ? "text-primary" : "text-muted-foreground")} />}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{folder.name}</span>
{folder.ownerName && <span className="block truncate text-xs text-muted-foreground">{folder.ownerName}</span>}
</span>
</button>
))}
{pages.map((page) => (
<button
key={page.id}
type="button"
onClick={() => onOpen(page)}
title={t("shared_by", { name: page.ownerName })}
className="flex items-center gap-2.5 h-12 px-3 rounded-lg border border-border hover:bg-muted/60 transition-colors text-start min-w-0"
>
{iconOptions.showIcons && <File className={cn("w-5 h-5 shrink-0", iconOptions.coloredIcons ? "text-primary" : "text-muted-foreground")} />}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{page.name}</span>
<span className="block truncate text-xs text-muted-foreground">{page.ownerName}</span>
</span>
</button>
))}
</div>
</section>
);
}
function ListSkeleton() {
return (
<div className="px-4" aria-hidden>
<div className="h-9 border-b border-border" />
{Array.from({ length: 7 }, (_, i) => (
<div key={i} className="h-10 flex items-center gap-3 border-b border-border/60">
<div className="w-4 h-4 rounded bg-muted animate-pulse" />
<div className="w-5 h-5 rounded bg-muted animate-pulse" />
<div className="h-3.5 rounded bg-muted animate-pulse" style={{ width: `${30 + ((i * 37) % 35)}%` }} />
</div>
))}
</div>
);
}
/** Plugin entries at the end of a file's menu, after a separator when there are any. */
function FilePluginMenuEntries({ files, mimeType, onRan }: { files: FileResourceView[]; mimeType?: string; onRan: () => void }) {
const hasActions = usePluginActions("file-actions", { mimeType }).length > 0;
if (!hasActions) return null;
return (
<>
<ContextMenuSeparator />
<PluginActions surface="file-actions" variant="menu" context={{ files }} mimeType={mimeType} onRan={onRan} />
</>
);
}