import { useState, useRef, useCallback, useMemo, useEffect, lazy, Suspense } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useQuery, useQueries, useMutation, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { FolderOpen, Loader2, Plus, Upload, Trash2, Download, ExternalLink, MoreVertical, ChevronRight, FolderPlus, FileBox, Clock, CalendarClock, HardDrive, File, MoveRight, CheckSquare, Square, Layers, LayoutGrid, List, Search, SortAsc, SortDesc, AlertTriangle, Filter, X, Link2, Unlink, Archive as ArchiveIcon, Briefcase, Cog, Play, Printer, Pencil, Image, User, Box, RefreshCw, Lock, FolderSymlink, Tag as TagIcon, FileText, FileSpreadsheet, Info, Globe, Users, StickyNote, Camera, Eye, Combine, Columns, ChevronRight as ChevronRightIcon, } from 'lucide-react'; import { api } from '../api/client'; import type { LibraryFolderTree, LibraryFileListItem, LibraryFolderCreate, LibraryFolderUpdate, ExternalFolderCreate, Archive, Permission, } from '../api/client'; import { Button } from '../components/Button'; import { ConfirmModal } from '../components/ConfirmModal'; import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu'; import { LibraryTagFilterRail } from '../components/LibraryTagFilterRail'; import { PrintModal } from '../components/PrintModal'; import { ModelViewerModal } from '../components/ModelViewerModal'; import { SliceModal } from '../components/SliceModal'; import { CombineFilesModal } from '../components/CombineFilesModal'; import { RunWithPipelineModal } from '../components/RunWithPipelineModal'; import { BulkTagsPickerModal } from '../components/BulkTagsPickerModal'; import { FileUploadModal } from '../components/FileUploadModal'; import { FolderReadmePanel } from '../components/FolderReadmePanel'; import { LibraryTagsModal } from '../components/LibraryTagsModal'; import { LibraryFileDetailsModal } from '../components/LibraryFileDetailsModal'; import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal'; import { useToast } from '../contexts/ToastContext'; import { usePageFileDrop } from '../hooks/usePageFileDrop'; import { useAuth } from '../contexts/AuthContext'; import { formatDuration, parseUTCDate, formatDate } from '../utils/date'; import { formatFileSize } from '../utils/file'; import { assignableProjects } from '../utils/projectTree'; import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer'; import { isSlicedLibraryFile, isSliceableLibraryFile } from '../utils/libraryFiles'; import { useUiSettings } from '../hooks/useUiSettings'; type SortField = 'name' | 'date' | 'size' | 'type' | 'prints'; type SortDirection = 'asc' | 'desc'; type TFunction = (key: string, options?: Record) => string; // Document previews (#2976) are code-split: pdf.js and the spreadsheet // parsers only load when a preview is actually opened. const PdfPreviewModal = lazy(() => import('../components/PdfPreviewModal').then((m) => ({ default: m.PdfPreviewModal })) ); const SpreadsheetPreviewModal = lazy(() => import('../components/SpreadsheetPreviewModal').then((m) => ({ default: m.SpreadsheetPreviewModal })) ); const ImagePreviewModal = lazy(() => import('../components/ImagePreviewModal').then((m) => ({ default: m.ImagePreviewModal })) ); function isSpreadsheetType(fileType: string): boolean { return fileType === 'csv' || fileType === 'xlsx' || fileType === 'ods'; } function isStepType(fileType: string): boolean { return fileType === 'step' || fileType === 'stp'; } // Mirrors IMAGE_EXTENSIONS in routes/library.py — the types the server both // stores and renders a thumbnail for, and so the ones that get an image icon. const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'tif']); // The subset ImagePreviewModal can actually show: it hands the bytes to an // , and outside Safari no browser decodes TIFF. Offering the preview // would download up to 50 MB only to report "cannot be previewed", so TIFF // keeps its server-rendered thumbnail and no preview (#2976). const PREVIEWABLE_IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']); function isImageType(fileType: string): boolean { return IMAGE_TYPES.has(fileType.toLowerCase()); } function isPreviewableImageType(fileType: string): boolean { return PREVIEWABLE_IMAGE_TYPES.has(fileType.toLowerCase()); } // The icon a file without a thumbnail shows. Shared by the grid card, the list // row and the columns view so the three never disagree about a type. function FileTypePlaceholderIcon({ fileType, className }: { fileType: string; className: string }) { const Icon = fileType === 'pdf' ? FileText : isSpreadsheetType(fileType) ? FileSpreadsheet : isImageType(fileType) ? Image : FileBox; return ; } /** * The columns view's per-level file queries, plus every file they hold — the * files a tick in an earlier column can point at (#3020). Module-level, so its * reference is stable and useQueries hands back the same value until a query * actually changes. */ function combineColumnFileQueries(results: UseQueryResult[]) { return { results, files: results.flatMap((result) => result.data ?? []) }; } // Which files have a preview at all: what a double-click opens, and what the // toolbar's Preview button appears for (#2976). Sliced files go to the // full-page gcode viewer, everything else to a modal. function isPreviewableLibraryFile(file: LibraryFileListItem): boolean { const type = file.file_type; return ( isSlicedLibraryFile(file) || type === '3mf' || type === 'stl' || isStepType(type) || type === 'pdf' || isSpreadsheetType(type) || isPreviewableImageType(type) ); } // Spread onto a card/row subtree that is not "the row": its own controls must // neither toggle the selection nor open the preview. `dblclick` is a separate // native event from `click`, so stopping the click alone still lets the second // click of a double-click reach the row's onDoubleClick (#2976). const stopRowActivation = { onClick: (e: React.MouseEvent) => e.stopPropagation(), onDoubleClick: (e: React.MouseEvent) => e.stopPropagation(), }; // Whether the preview is the 3D one, which has its own menu label. function isModelPreview(file: LibraryFileListItem): boolean { return isSlicedLibraryFile(file) || file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type); } function documentPreviewIcon(fileType: string) { if (fileType === 'pdf') return ; if (isSpreadsheetType(fileType)) return ; return ; } // Types the server renders a thumbnail for on request, STL through trimesh // and PDF through PDFium (#2976), so the per-file "Generate thumbnail" action // applies to them. Mirrors the file types batch_generate_stl_thumbnails in // routes/library.py selects. function hasServerThumbnail(fileType: string): boolean { return fileType === 'stl' || fileType === 'pdf'; } // Type badge colours shared by the list row and the columns view. Sliced // output shares the gcode blue so users see at a glance that the file is // already sliced and ready to print (#1543). function fileTypeBadgeClass(fileType: string): string { if (fileType === '3mf') return 'bg-bambu-green/20 text-bambu-green'; if (fileType === 'gcode' || fileType === 'gcode.3mf') return 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'; if (fileType === 'stl') return 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400'; if (isStepType(fileType)) return 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400'; if (fileType === 'pdf') return 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'; if (isSpreadsheetType(fileType)) return 'bg-teal-100 dark:bg-teal-500/20 text-teal-700 dark:text-teal-400'; return 'bg-bambu-gray/20 text-bambu-gray'; } // Search / type / user filtering plus the sort, shared by the main listing and // by each Miller column's file list so a column never sorts differently from // the pane beside it. `query` matches the filename and the embedded print name. function filterAndSortFiles( files: LibraryFileListItem[], opts: { query?: string; filterType: string; filterUsername: string; sortField: SortField; sortDirection: SortDirection; }, ): LibraryFileListItem[] { let result = [...files]; const query = opts.query?.trim().toLowerCase(); if (query) { result = result.filter( (f) => f.filename.toLowerCase().includes(query) || (f.print_name && f.print_name.toLowerCase().includes(query)) ); } if (opts.filterType !== 'all') { result = result.filter((f) => f.file_type === opts.filterType); } const userQuery = opts.filterUsername.trim().toLowerCase(); if (userQuery) { result = result.filter( (f) => f.created_by_username && f.created_by_username.toLowerCase().includes(userQuery) ); } result.sort((a, b) => { let comparison = 0; switch (opts.sortField) { case 'name': comparison = (a.print_name || a.filename).localeCompare(b.print_name || b.filename); break; case 'date': // #2680: sort by real on-disk mtime (matches `ls -t`), falling back to // the DB created_at for managed uploads that have no filesystem mtime. comparison = (parseUTCDate(a.fs_modified_at ?? a.created_at)?.getTime() ?? 0) - (parseUTCDate(b.fs_modified_at ?? b.created_at)?.getTime() ?? 0); break; case 'size': comparison = a.file_size - b.file_size; break; case 'type': comparison = a.file_type.localeCompare(b.file_type); break; case 'prints': comparison = a.print_count - b.print_count; break; } return opts.sortDirection === 'asc' ? comparison : -comparison; }); return result; } // Keep the filename useful when fixed columns consume the available width, // and give the header spacer and row checkbox one shared track so every later // column stays aligned. min-w-min lets each auth variant size itself to its // intrinsic grid width inside the overflow-x-auto wrapper (#3105). Tags get a // floor too: with a 0 minimum they took the squeeze the filename used to take, // hitting 0px right where the wrapper starts scrolling. const fileListGridColumns = (authEnabled: boolean) => authEnabled ? 'grid-cols-[24px_minmax(240px,1fr)_120px_100px_100px_100px_minmax(96px,200px)_220px]' : 'grid-cols-[24px_minmax(240px,1fr)_100px_100px_100px_minmax(96px,200px)_220px]'; const fileListGridMinWidth = 'min-w-min'; // New Folder Modal interface NewFolderModalProps { parentId: number | null; onClose: () => void; onSave: (data: LibraryFolderCreate) => void; isLoading: boolean; t: TFunction; } function NewFolderModal({ parentId, onClose, onSave, isLoading, t }: NewFolderModalProps) { const [name, setName] = useState(''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSave({ name: name.trim(), parent_id: parentId }); }; return (

{t('fileManager.newFolder')}

setName(e.target.value)} className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" placeholder={t('fileManager.folderNamePlaceholder')} autoFocus required />
); } // External Folder Modal interface ExternalFolderModalProps { onClose: () => void; onSave: (data: ExternalFolderCreate) => void; isLoading: boolean; t: TFunction; } function ExternalFolderModal({ onClose, onSave, isLoading, t }: ExternalFolderModalProps) { const [name, setName] = useState(''); const [path, setPath] = useState(''); const [readonly, setReadonly] = useState(true); const [showHidden, setShowHidden] = useState(false); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSave({ name: name.trim(), external_path: path.trim(), readonly, show_hidden: showHidden, }); }; return (

{t('fileManager.linkExternalFolder')}

{t('fileManager.linkExternalFolderDescription')}

setName(e.target.value)} className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" placeholder={t('fileManager.externalFolderNamePlaceholder')} autoFocus required />
setPath(e.target.value)} className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green font-mono text-sm" placeholder="/mnt/nas/3d-prints" required />

{t('fileManager.externalPathHelp')}

); } // FAT32/exFAT-illegal chars rejected by Bambu Studio (#1540). Mirrors the // backend validator in backend/app/utils/filename.py — keep in sync. const INVALID_FILENAME_CHARS = '<>:"/\\|?*'; function findInvalidFilenameChar(name: string): string | null { for (const ch of name) { if (INVALID_FILENAME_CHARS.includes(ch)) return ch; if (ch.charCodeAt(0) < 0x20) return ch; } return null; } // Rename Modal interface RenameModalProps { type: 'file' | 'folder'; currentName: string; onClose: () => void; onSave: (newName: string) => void; isLoading: boolean; t: TFunction; } function RenameModal({ type, currentName, onClose, onSave, isLoading, t }: RenameModalProps) { // For files, separate the extension so users can only edit the base name // Handle compound extensions like .gcode.3mf const fileExtension = type === 'file' ? (currentName.match(/(\.gcode\.3mf|\.3mf|\.gcode)$/i)?.[1] ?? '') : ''; const baseName = type === 'file' && fileExtension ? currentName.slice(0, -fileExtension.length) : currentName; const [name, setName] = useState(baseName); const invalidChar = type === 'file' ? findInvalidFilenameChar(name) : null; const filenameError = invalidChar ? t('fileManager.invalidFilenameChar', { char: invalidChar }) : null; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (filenameError) return; const fullName = type === 'file' ? name.trim() + fileExtension : name.trim(); if (name.trim() && fullName !== currentName) { onSave(fullName); } }; return (

{type === 'file' ? t('fileManager.renameFile') : t('fileManager.renameFolder')}

setName(e.target.value)} className="flex-1 bg-transparent px-3 py-2 text-white placeholder-bambu-gray focus:outline-none min-w-0" autoFocus required /> {fileExtension && ( {fileExtension} )}
{filenameError && (

{filenameError}

)}
); } // Move Files Modal interface MoveFilesModalProps { folders: LibraryFolderTree[]; selectedFiles: number[]; currentFolderId: number | null; onClose: () => void; onMove: (folderId: number | null) => void; isLoading: boolean; t: TFunction; } function MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMove, isLoading, t }: MoveFilesModalProps) { const [targetFolder, setTargetFolder] = useState(null); // can_write: a library:read_own user moves files only into their own and // shared folders (#3201); the rest stay listed so the tree keeps its shape. type MoveTarget = { id: number | null; name: string; depth: number; canWrite: boolean }; const flattenFolders = (items: LibraryFolderTree[], depth = 0): MoveTarget[] => { const result: MoveTarget[] = []; for (const item of items) { result.push({ id: item.id, name: item.name, depth, canWrite: item.can_write !== false }); if (item.children.length > 0) { result.push(...flattenFolders(item.children, depth + 1)); } } return result; }; const flatFolders: MoveTarget[] = [{ id: null, name: t('fileManager.rootNoFolder'), depth: 0, canWrite: true }, ...flattenFolders(folders)]; return (

{t('fileManager.moveFiles', { count: selectedFiles.length })}

{flatFolders.map((folder) => ( ))}
); } // Link Folder Modal interface LinkFolderModalProps { folder: LibraryFolderTree; onClose: () => void; onLink: (update: LibraryFolderUpdate) => void; isLoading: boolean; t: TFunction; } function LinkFolderModal({ folder, onClose, onLink, isLoading, t }: LinkFolderModalProps) { const [linkType, setLinkType] = useState<'project' | 'archive'>('project'); const [selectedId, setSelectedId] = useState( folder.project_id || folder.archive_id || null ); // Initialize linkType based on existing link useState(() => { if (folder.archive_id) setLinkType('archive'); }); // Archived projects are left out, bar the one this folder is already linked // to -- dropping that one would leave the folder looking unlinked while the // link is still there (#2888). const { data: projects } = useQuery({ queryKey: ['projects'], queryFn: () => api.getProjects(), select: (rows) => assignableProjects([...rows].sort((a, b) => a.name.localeCompare(b.name)), folder.project_id), }); const { data: archives } = useQuery({ queryKey: ['archives-for-link'], queryFn: () => api.getArchives(undefined, undefined, 100), }); const handleSave = () => { if (linkType === 'project') { onLink({ project_id: selectedId, archive_id: 0, // Unlink archive }); } else { onLink({ project_id: 0, // Unlink project archive_id: selectedId, }); } }; const handleUnlink = () => { onLink({ project_id: 0, archive_id: 0, }); }; const isLinked = folder.project_id || folder.archive_id; return (

{t('fileManager.linkFolder')}

{t('fileManager.linkFolderDescription', { name: folder.name })}

{/* Link type selector */}
{/* Selection list */}
{linkType === 'project' ? ( projects && projects.length > 0 ? ( projects.map((project) => ( )) ) : (

{t('fileManager.noProjectsFound')}

) ) : ( archives && archives.length > 0 ? ( archives.map((archive: Archive) => ( )) ) : (

{t('fileManager.noArchivesFound')}

) )}
{isLinked && ( )}
); } // Folder Tree Item interface FolderTreeItemProps { folder: LibraryFolderTree; selectedFolderId: number | null; onSelect: (id: number | null) => void; onDelete: (id: number) => void; onLink: (folder: LibraryFolderTree) => void; onRename: (folder: LibraryFolderTree) => void; onToggleShared?: (folder: LibraryFolderTree) => void; depth?: number; wrapNames?: boolean; defaultExpanded?: boolean; showModified?: boolean; hasPermission: (permission: Permission) => boolean; t: TFunction; } // Folder kebab: Rename / Link / Delete. Shared by the tree sidebar and the // columns view so both offer exactly the same entries and permission gates. // Rendered through `ContextMenu` (position: fixed, anchored to the button) // rather than an absolutely positioned dropdown, because both hosts scroll — // a dropdown inside an overflow-y-auto column gets clipped at its edge. interface FolderActionsMenuProps { folder: LibraryFolderTree; onDelete: (id: number) => void; onLink: (folder: LibraryFolderTree) => void; onRename: (folder: LibraryFolderTree) => void; // Share with every library:read_own user, or stop (#3201). library:update_all only. onToggleShared?: (folder: LibraryFolderTree) => void; hasPermission: (permission: Permission) => boolean; // Hide the kebab until its `group` row is hovered or focused — only for // pointers that can hover (#2865). The menu is a DOM descendant, so the // wrapper stays opaque while it is open: browsers that don't focus a button // on click (Safari) would otherwise fade the open menu out with the row. revealOnHover?: boolean; tabIndex?: number; t: TFunction; } function FolderActionsMenu({ folder, onDelete, onLink, onRename, onToggleShared, hasPermission, revealOnHover = false, tabIndex, t }: FolderActionsMenuProps) { const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number } | null>(null); const rootRef = useRef(null); const buttonRef = useRef(null); const closeMenu = () => { setMenuAnchor(null); // A menu entry activated by Enter unmounts with the menu; hand focus back // to the kebab rather than letting it drop to . Focus that already // moved elsewhere (click-outside, the columns pane) is left alone. if (rootRef.current?.contains(document.activeElement)) buttonRef.current?.focus(); }; const hasChildren = folder.children.length > 0; const isLinked = folder.project_id || folder.archive_id; const isExternal = folder.is_external; // The backend says what this user may do with the folder (#3201): delete // their own folder when everything in it is theirs, or an ownerless empty // one (#1781). The fallbacks cover a response without those fields. const canDeleteFolder = folder.can_delete ?? (hasPermission('library:delete_all') || (hasPermission('library:delete_own') && folder.file_count === 0 && !hasChildren && !isExternal && !isLinked)); const deleteDisabledTooltip = canDeleteFolder ? undefined : hasPermission('library:delete_own') && !isExternal && !isLinked ? t('fileManager.onlyOwnFoldersDeletable') : t('fileManager.noPermissionDeleteFolder'); const canRename = folder.can_rename ?? hasPermission('library:update_all'); const canLink = hasPermission('library:update_all'); const items: ContextMenuItem[] = [ { label: t('common.rename'), icon: , onClick: () => onRename(folder), disabled: !canRename, title: !canRename ? t('fileManager.noPermissionRenameFolder') : undefined, }, { label: isLinked ? t('fileManager.changeLink') : t('fileManager.linkTo'), icon: , onClick: () => onLink(folder), disabled: !canLink, title: !canLink ? t('fileManager.noPermissionLinkFolder') : undefined, }, ...(onToggleShared && canLink ? [ { label: folder.shared ? t('fileManager.unshareFolder') : t('fileManager.shareFolder'), icon: , onClick: () => onToggleShared(folder), title: t('fileManager.shareFolderHint'), }, ] : []), { label: t('common.delete'), icon: , onClick: () => onDelete(folder.id), danger: true, disabled: !canDeleteFolder, title: deleteDisabledTooltip, }, ]; return (
e.stopPropagation()} onKeyDown={(e) => { // The menu has no arrow-key navigation; Up/Down mean "leave it". if (menuAnchor && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) closeMenu(); }} > {menuAnchor && ( )}
); } function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, onToggleShared, depth = 0, wrapNames = false, defaultExpanded = true, showModified = false, hasPermission, t }: FolderTreeItemProps) { const [expanded, setExpanded] = useState(defaultExpanded); const hasChildren = folder.children.length > 0; const isLinked = folder.project_id || folder.archive_id; const isExternal = folder.is_external; return (
onSelect(folder.id)} > {hasChildren ? ( ) : (
)} {isExternal ? ( ) : ( )}
{folder.name} {/* #2680 follow-up: the same toolbar toggle that shows dates on file cards also shows them here. This is `latest_activity_at` — the newest timestamp among the folder itself, its files and its subfolders (the value "sort by recent activity" orders on) — not the folder's own on-disk mtime, hence the distinct label. */} {showModified && folder.latest_activity_at && ( {formatDate(folder.latest_activity_at)} )}
{/* Link indicator - clickable to change link */} {isLinked && ( )} {folder.shared && ( )} {/* Read-only indicator for external folders */} {isExternal && folder.external_readonly && ( )} {folder.file_count > 0 && ( {folder.file_count} )} {/* Quick link button - always visible for unlinked folders */} {!isLinked && !isExternal && ( )}
{hasChildren && expanded && (
{folder.children.map((child) => ( ))}
)}
); } // File Card // The mark on a file a ?highlight= link points at; the Archives page's colour. const HIGHLIGHT_CARD_STYLE = { outline: '4px solid #facc15', outlineOffset: '2px' } as const; const HIGHLIGHT_ROW_STYLE = { outline: '2px solid #facc15', outlineOffset: '-2px' } as const; interface FileCardProps { file: LibraryFileListItem; isSelected: boolean; /** The file a ?highlight= link points at, marked for a few seconds. */ isHighlighted?: boolean; onSelect: (id: number) => void; onDelete: (id: number) => void; onDownload: (id: number) => void; onPrint?: (file: LibraryFileListItem) => void; onSlice?: (file: LibraryFileListItem) => void; onOpenInSlicer?: (file: LibraryFileListItem) => void; onRunPipeline?: (file: LibraryFileListItem) => void; useSlicerApi?: boolean; // Which slicer the desktop handoff targets. Decides whether an STL or STEP // gets a Slice action at all: Bambu Studio's protocol handler takes 3MF only // (#3029), so offering one there would only ever fail. desktopSlicer: SlicerType; canSlice?: boolean; onPreview?: (file: LibraryFileListItem) => void; onRename?: (file: LibraryFileListItem) => void; onDetails?: (file: LibraryFileListItem) => void; onGenerateThumbnail?: (file: LibraryFileListItem) => void; onTagClick?: (tagId: number) => void; thumbnailVersion?: number; hasPermission: (permission: Permission) => boolean; canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean; authEnabled: boolean; showModified: boolean; t: TFunction; } function FileCard({ file, isSelected, isHighlighted, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) { // Viewport coordinates rather than a flag, because the menu is rendered by // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The // card it belongs to is only ~270px tall for a bare STL, which is shorter // than the seven-entry menu, so a menu positioned inside the card had its // top entry -- Slice -- cut off. The archive card menu works the same way. const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number } | null>(null); const canPreview3d = hasPermission('library:read'); const canRename = canModify('library', 'update', file.created_by_id); const canDelete = canModify('library', 'delete', file.created_by_id); const menuItems: ContextMenuItem[] = []; if (onPrint && isSlicedLibraryFile(file)) { menuItems.push({ label: t('common.print'), // The action stays visually distinct now that the menu component styles // its own labels; only the icon carries the accent. icon: , onClick: () => onPrint(file), disabled: !hasPermission('queue:create'), title: !hasPermission('queue:create') ? t('fileManager.noPermissionAddToQueue') : undefined, }); } if (isSliceableLibraryFile(file, !!useSlicerApi, desktopSlicer) && (useSlicerApi ? onSlice : onOpenInSlicer)) { menuItems.push({ label: t('slice.action'), icon: useSlicerApi ? : , onClick: () => { if (useSlicerApi) onSlice?.(file); else onOpenInSlicer?.(file); }, disabled: !canSlice, title: !canSlice ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined, }); } if (onRunPipeline && useSlicerApi && isSliceableLibraryFile(file, true, desktopSlicer)) { menuItems.push({ label: t('library.runWithPipeline.actionLabel'), icon: , onClick: () => onRunPipeline(file), disabled: !hasPermission('pipelines:run'), title: !hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined, }); } if (onPreview && isPreviewableLibraryFile(file)) { const modelPreview = isModelPreview(file); menuItems.push({ label: modelPreview ? t('fileManager.preview3d') : t('fileManager.preview.open'), icon: modelPreview ? : documentPreviewIcon(file.file_type), onClick: () => onPreview(file), disabled: !canPreview3d, title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined, }); } menuItems.push({ label: t('common.download'), icon: , onClick: () => onDownload(file.id), disabled: !hasPermission('library:read'), title: !hasPermission('library:read') ? t('fileManager.noPermissionDownload') : undefined, }); if (onRename) { menuItems.push({ label: t('common.rename'), icon: , onClick: () => onRename(file), disabled: !canRename, title: !canRename ? t('fileManager.noPermissionRenameFile') : undefined, }); } if (onDetails) { menuItems.push({ label: t('fileManager.details.title'), icon: , onClick: () => onDetails(file), disabled: !canPreview3d, title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined, }); } if (file.external_url) { menuItems.push({ label: t('fileManager.details.openLink'), icon: , // The URL is stored by whoever owns the file, so the opened page must // not get a handle on this window (#3077). onClick: () => window.open(file.external_url!, '_blank', 'noopener,noreferrer'), }); } if (onGenerateThumbnail && hasServerThumbnail(file.file_type)) { menuItems.push({ label: t('fileManager.generateThumbnail'), icon: , onClick: () => onGenerateThumbnail(file), disabled: !canRename, title: !canRename ? t('fileManager.noPermissionGenerateThumbnail') : undefined, }); } menuItems.push({ label: t('common.delete'), icon: , onClick: () => onDelete(file.id), danger: true, disabled: !canDelete, title: !canDelete ? t('fileManager.noPermissionDeleteFile') : undefined, }); return (
onSelect(file.id)} // Double-click opens the preview (#2976). The two clicks that precede it // toggle the selection twice, so the selection is left as it was. onDoubleClick={() => onPreview?.(file)} > {/* Thumbnail */}
{file.thumbnail_path ? ( {file.filename} ) : ( )} {/* File type badge */}
{file.file_type.toUpperCase()}
{/* Info */}

{file.print_name || file.filename}

{formatFileSize(file.file_size)} {file.print_time_seconds && ( {formatDuration(file.print_time_seconds)} )}
{file.sliced_for_model && (
{file.sliced_for_model}
)} {/* Counts the whole group, including members in other folders (#671 / #2570) — printing this file will offer all of them. */} {(file.variant_count ?? 0) > 1 && (
{t('fileManager.variants.badge', { count: file.variant_count })}
)} {file.print_count > 0 && (
{t('fileManager.printedCount', { count: file.print_count })}
)} {/* Metadata indicators (#3077): link, notes, photos. The link opens in a new tab like the archive card's globe; the others open Details. */} {(file.external_url || file.has_notes || (file.photo_count ?? 0) > 0) && (
{file.external_url && ( )} {file.has_notes && ( )} {(file.photo_count ?? 0) > 0 && ( )}
)} {authEnabled && file.created_by_username && (
{file.created_by_username}
)} {/* #2680: last-modified date, toggled from the toolbar. Uses the real on-disk mtime when known, else the DB created_at. */} {showModified && (
{formatDate(file.fs_modified_at ?? file.created_at)}
)} {(file.tags?.length ?? 0) > 0 && (
{file.tags!.map((tg) => ( ))}
)}
{/* Actions - hover-revealed with a mouse, always there without one (#2865) */}
{menuAnchor && ( setMenuAnchor(null)} /> )}
{/* Selection checkbox - hover-revealed with a mouse, always there without one (#2865) */}
{isSelected &&
}
); } // Per-file icon strip: Print, Slice, Run with pipeline, Preview, Download, // Details, Rename, Generate thumbnail, Delete. The list row and the columns view render // the same strip so the two stay identical in order, icons and gating; the // grid card packs the same actions into its kebab menu instead. interface FileActionStripProps { file: LibraryFileListItem; onPrint: (file: LibraryFileListItem) => void; onSlice: (file: LibraryFileListItem) => void; onOpenInSlicer: (file: LibraryFileListItem) => void; onRunPipeline: (file: LibraryFileListItem) => void; useSlicerApi: boolean; desktopSlicer: SlicerType; canSlice: boolean; onPreview: (file: LibraryFileListItem) => void; onDetails: (file: LibraryFileListItem) => void; onDownload: (id: number) => void; onRename: (file: LibraryFileListItem) => void; onGenerateThumbnail: (file: LibraryFileListItem) => void; thumbnailPending: boolean; onDelete: (id: number) => void; hasPermission: (permission: Permission) => boolean; canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean; // Roving tabindex for the columns view: only the focused row's buttons take // part in the Tab order, so Tab from the pane lands on that row's actions. tabIndex?: number; t: TFunction; } function FileActionStrip({ file, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview, onDetails, onDownload, onRename, onGenerateThumbnail, thumbnailPending, onDelete, hasPermission, canModify, tabIndex, t }: FileActionStripProps) { const canRename = canModify('library', 'update', file.created_by_id); const canDelete = canModify('library', 'delete', file.created_by_id); return (
{isSlicedLibraryFile(file) && ( )} {isSliceableLibraryFile(file, useSlicerApi, desktopSlicer) && ( )} {useSlicerApi && isSliceableLibraryFile(file, true, desktopSlicer) && ( )} {isModelPreview(file) && ( )} {!isModelPreview(file) && isPreviewableLibraryFile(file) && ( )} {hasServerThumbnail(file.file_type) && ( )}
); } // One row of a Miller column's file list. Extracted so every column renders // its files with the same markup, action strip and double-click behaviour the // selected folder's pane has always used. interface ColumnFileRowProps { file: LibraryFileListItem; isSelected: boolean; isHighlighted?: boolean; isFocused: boolean; showModified: boolean; thumbnailVersion?: number; onSelect: (file: LibraryFileListItem) => void; onOpen: (file: LibraryFileListItem) => void; actionProps: Omit; t: TFunction; } function ColumnFileRow({ file, isSelected, isHighlighted, isFocused, showModified, thumbnailVersion, onSelect, onOpen, actionProps, t }: ColumnFileRowProps) { const thumbnailUrl = api.getLibraryFileThumbnailUrl(file.id); return (
onSelect(file)} onDoubleClick={() => onOpen(file)} className={`group flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors ${ isSelected ? 'bg-bambu-green/10' : 'hover:bg-bambu-dark' } ${isFocused ? 'ring-1 ring-inset ring-bambu-green/60' : ''}`} title={file.print_name || file.filename} >
{isSelected &&
}
{file.thumbnail_path ? ( ) : ( )}
{file.print_name || file.filename}
{file.file_type.toUpperCase()} {formatFileSize(file.file_size)} {file.print_count > 0 && {file.print_count}x} {showModified && ( {formatDate(file.fs_modified_at ?? file.created_at)} )}
{/* Same strip as the list row. Hover-revealed with a mouse, always there without one (#2865) and on the focused / selected row so the keyboard can reach it. */}
); } export function FileManagerPage() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission, hasAnyPermission, canModify, authEnabled } = useAuth(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); // Read folder ID from URL query parameter const folderIdFromUrl = searchParams.get('folder'); const initialFolderId = folderIdFromUrl ? parseInt(folderIdFromUrl, 10) : null; // State const [selectedFolderId, setSelectedFolderId] = useState(initialFolderId); // Which top-level pseudo-view the sidebar shows when no specific folder is // selected: "internal" = files in Bambuddy's managed storage, "external" = // combined view across every linked external folder (#1621). Per-folder // selection bypasses this (selectedFolderId !== null disables the filter). const [topLevelView, setTopLevelView] = useState<'internal' | 'external'>('internal'); const [selectedFiles, setSelectedFiles] = useState([]); const [showNewFolderModal, setShowNewFolderModal] = useState(false); const [showExternalFolderModal, setShowExternalFolderModal] = useState(false); const [showMoveModal, setShowMoveModal] = useState(false); const [showUploadModal, setShowUploadModal] = useState(false); const [droppedFiles, setDroppedFiles] = useState([]); const [showPurgeModal, setShowPurgeModal] = useState(false); // Tag UI state (#1268). selectedTagIds is the AND-style filter applied to // the listing; setting it bypasses folder scoping on the server so // "every toy" works regardless of which folder is currently selected. const [showTagsModal, setShowTagsModal] = useState(false); const [showBulkTagsModal, setShowBulkTagsModal] = useState(false); const [selectedTagIds, setSelectedTagIds] = useState([]); const [linkFolder, setLinkFolder] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null); const [printFile, setPrintFile] = useState(null); const [sliceFile, setSliceFile] = useState(null); const [showCombineModal, setShowCombineModal] = useState(false); // A file just built by "Combine to 3MF", handed straight to the SliceModal. const [sliceCombined, setSliceCombined] = useState<{ id: number; filename: string } | null>(null); // Slicer Pipelines (#1425 PR B) — file gets "Run with pipeline" action. const [runPipelineFile, setRunPipelineFile] = useState(null); const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null); const [thumbnailVersions, setThumbnailVersions] = useState>({}); const [viewerFile, setViewerFile] = useState(null); const [pdfPreviewFile, setPdfPreviewFile] = useState(null); const [sheetPreviewFile, setSheetPreviewFile] = useState(null); const [detailsFile, setDetailsFile] = useState(null); const [imagePreviewFile, setImagePreviewFile] = useState(null); const [viewMode, setViewMode] = useState<'grid' | 'list' | 'columns'>(() => { return (localStorage.getItem('library-view-mode') as 'grid' | 'list' | 'columns') || 'grid'; }); // Miller-columns keyboard navigation (#3020): the pane itself is focusable // and arrow keys walk the hierarchy. The focused file is tracked separately // from the checkbox selection so Space can toggle a file without the arrow // keys hijacking multi-select. const columnsViewRef = useRef(null); const [columnsFocusedFileId, setColumnsFocusedFileId] = useState(null); const [wrapFolderNames, setWrapFolderNames] = useState(() => { return localStorage.getItem('library-wrap-folders') === 'true'; }); const [collapseFoldersByDefault, setCollapseFoldersByDefault] = useState(() => { return localStorage.getItem('library-collapse-folders') === 'true'; }); // Folder tree sort (#1770). 'name' = alphabetical (the prior behaviour); // 'activity' = most recent file activity inside the folder first. Persisted // independently from the file-side sort so each can be tuned to taste. const [folderSortField, setFolderSortField] = useState<'name' | 'activity'>(() => { const saved = localStorage.getItem('library-folder-sort-field'); return saved === 'activity' ? 'activity' : 'name'; }); const [folderSortDirection, setFolderSortDirection] = useState<'asc' | 'desc'>(() => { const saved = localStorage.getItem('library-folder-sort-direction'); return saved === 'desc' ? 'desc' : 'asc'; }); // Resizable sidebar state const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem('library-sidebar-width'); return saved ? parseInt(saved, 10) : 256; // Default w-64 = 256px }); const [isResizing, setIsResizing] = useState(false); const sidebarRef = useRef(null); // Handle sidebar resize useEffect(() => { if (!isResizing) return; // Prevent text selection during resize document.body.style.userSelect = 'none'; document.body.style.cursor = 'col-resize'; const handleMouseMove = (e: MouseEvent) => { if (!sidebarRef.current) return; const containerRect = sidebarRef.current.parentElement?.getBoundingClientRect(); if (!containerRect) return; // Calculate new width based on mouse position relative to container const newWidth = e.clientX - containerRect.left; // Clamp between 200px and 500px const clampedWidth = Math.min(500, Math.max(200, newWidth)); setSidebarWidth(clampedWidth); }; const handleMouseUp = () => { setIsResizing(false); document.body.style.userSelect = ''; document.body.style.cursor = ''; // Save to localStorage localStorage.setItem('library-sidebar-width', String(sidebarWidth)); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.body.style.userSelect = ''; document.body.style.cursor = ''; }; }, [isResizing, sidebarWidth]); // Filter and sort state (persist sort preferences to localStorage) const [searchQuery, setSearchQuery] = useState(''); const [filterType, setFilterType] = useState('all'); const [filterUsername, setFilterUsername] = useState(''); const [sortField, setSortField] = useState(() => { const saved = localStorage.getItem('library-sort-field'); return (saved as SortField) || 'name'; }); const [sortDirection, setSortDirection] = useState(() => { const saved = localStorage.getItem('library-sort-direction'); return (saved as SortDirection) || 'asc'; }); // Show/hide the last-modified date on each file card (#2680). Persisted. const [showModified, setShowModified] = useState( () => localStorage.getItem('library-show-modified') === 'true' ); // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page) useEffect(() => { const folderParam = searchParams.get('folder'); if (folderParam) { const newFolderId = parseInt(folderParam, 10); setSelectedFolderId(newFolderId); } }, [searchParams]); // ?highlight= points at one file (the queue card's "View in File // Manager"). The link carries only the id: the folder is looked up here, so // a file moved since it was queued is still found. The folder's listing is // refetched before the file is looked for in it, since a cached one may // predate the move. const highlightParam = searchParams.get('highlight'); const [pendingHighlight, setPendingHighlight] = useState<{ fileId: number; folderId: number | null; requestedAt: number } | null>(null); const [highlightedFileId, setHighlightedFileId] = useState(null); const selectedFolderIdRef = useRef(selectedFolderId); selectedFolderIdRef.current = selectedFolderId; useEffect(() => { const fileId = highlightParam ? parseInt(highlightParam, 10) : NaN; if (!Number.isFinite(fileId)) return; let cancelled = false; const folderAtRequest = selectedFolderIdRef.current; api.getLibraryFile(fileId).then( (file) => { // Someone who opened another folder meanwhile is not pulled back. if (cancelled || selectedFolderIdRef.current !== folderAtRequest) return; const requestedAt = Date.now(); queryClient.invalidateQueries({ queryKey: ['library-files'] }); setSelectedFolderId(file.folder_id); setPendingHighlight({ fileId, folderId: file.folder_id, requestedAt }); }, () => { if (!cancelled) showToast(t('fileManager.highlightedFileNotFound'), 'warning'); }, ); return () => { cancelled = true; }; }, [highlightParam, queryClient, showToast, t]); // Queries const { data: settings } = useUiSettings(); const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer); const handleOpenInSlicer = useCallback(async (file: LibraryFileListItem) => { try { const { token } = await api.createLibrarySlicerToken(file.id); const path = api.getLibrarySlicerDownloadUrl(file.id, token, file.filename); openInSlicer(`${window.location.origin}${path}`, preferredSlicer); } catch { // Fallback to direct URL (works when auth is disabled). With auth on the // slicer may then hit a 401, so surface the failure instead of making a // permission denial look identical to "no slicer installed". showToast(t('fileManager.toast.openInSlicerFailed'), 'error'); const path = api.getLibraryFileDownloadUrl(file.id); openInSlicer(`${window.location.origin}${path}`, preferredSlicer); } }, [preferredSlicer, showToast, t]); // Slice permission: API mode needs upload rights, the desktop handoff is a // download. Each mirrors what the backend enforces on the endpoint that // branch actually calls, so the UI never offers an action the server refuses. // // Deliberately NOT accepting the legacy `library:read` on the handoff branch. // It looks like the safe back-compat term to include, but the slicer-token // endpoint gates on require_ownership_permission(LIBRARY_READ_ALL, // LIBRARY_READ_OWN), and neither that dependency nor User.has_permission // expands the legacy name — so a group holding only `library:read` gets a 403 // there. It cannot reach this page to find out either: GET /library/folders // gates on the same pair. Accepting it here would only enable a menu item // that fails, and the `library:read` -> `library:read_own` migration in // core/database.py runs only over the groups named in DEFAULT_GROUPS, so a // custom role that still carries it is genuinely stuck rather than silently // upgraded. const canSlice = useCallback(() => { if (settings?.use_slicer_api) { return hasPermission('library:upload'); } return hasAnyPermission('library:read_all', 'library:read_own'); }, [settings?.use_slicer_api, hasPermission, hasAnyPermission]); const { data: folders, isLoading: foldersLoading } = useQuery({ queryKey: ['library-folders'], queryFn: () => api.getLibraryFolders(), }); // Recursive folder tree sort (#1770). Applies the same comparator to the // top-level list AND to each level of `children`, so sort order is uniform // at every depth of nesting. When sorting by activity, the comparator falls // back to a created-at fallback for folders with no files (`latest_activity_at` // is null) so they stay grouped at the end / start of the bucket instead of // randomly interspersed. const sortedFolders = useMemo(() => { if (!folders) return folders; const sortLevel = (items: LibraryFolderTree[]): LibraryFolderTree[] => { const sorted = [...items].sort((a, b) => { let comparison = 0; if (folderSortField === 'name') { comparison = a.name.localeCompare(b.name); } else { // activity: newest first on 'desc', oldest first on 'asc'. // Folders with no activity timestamp sort to the end regardless // of direction so an empty folder doesn't elbow a recently-used one. const aTs = a.latest_activity_at ? new Date(a.latest_activity_at).getTime() : null; const bTs = b.latest_activity_at ? new Date(b.latest_activity_at).getTime() : null; if (aTs === null && bTs === null) { comparison = a.name.localeCompare(b.name); } else if (aTs === null) { return 1; } else if (bTs === null) { return -1; } else { comparison = aTs - bTs; } } return folderSortDirection === 'asc' ? comparison : -comparison; }); return sorted.map((f) => ({ ...f, children: sortLevel(f.children) })); }; return sortLevel(folders); }, [folders, folderSortField, folderSortDirection]); // Trash count for the header badge (#1008). Empty/error are silently treated // as zero so a broken trash endpoint doesn't break the File Manager. const { data: trashCount } = useQuery({ queryKey: ['library-trash-count'], queryFn: async () => { try { const res = await api.listLibraryTrash(1, 0); return res.total; } catch { return 0; } }, staleTime: 30_000, }); // #1268: when a folder is selected and the user has typed a search query, // ask the server to expand the result to every descendant folder so the // client-side filter can match files in subfolders too. Without this the // listing is just the immediate children and "robot.3mf" two levels deep // is invisible from the parent. Only kicks in for folder-scoped views — // root and the internal/external pseudo-nodes already return the union. const searchExpandsSubfolders = selectedFolderId !== null && searchQuery.trim().length > 0; // The tag filter overrides folder scoping server-side (#1268 design call), // so the FE query key includes it as a peer of folder/topLevelView. Sorted // so the cache hits regardless of the order tags were toggled. const tagFilterKey = useMemo(() => [...selectedTagIds].sort((a, b) => a - b), [selectedTagIds]); // Tag catalog — needed to resolve names for the active-filter chip bar. // Cheap query, shared with LibraryTagsModal / BulkTagsPickerModal via the // same queryKey so they all invalidate together on tag CRUD. const { data: tagCatalog = [] } = useQuery({ queryKey: ['library-tags'], queryFn: api.getLibraryTags, }); const tagsById = useMemo(() => { const map = new Map(); for (const t of tagCatalog) map.set(t.id, t.name); return map; }, [tagCatalog]); // Prune the active filter when a tag is removed from the catalog so the // listing never stalls on a phantom id. Skipped while the catalog query is // still settling (empty array on first paint) — otherwise the user's filter // gets cleared the moment the page mounts. useEffect(() => { if (tagCatalog.length === 0) return; setSelectedTagIds((prev) => { const next = prev.filter((id) => tagsById.has(id)); return next.length === prev.length ? prev : next; }); }, [tagCatalog.length, tagsById]); const toggleTagFilter = useCallback((tagId: number) => { setSelectedTagIds((prev) => prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId], ); }, []); // While a search or tag filter is active the file list spans every matching // descendant folder, so per-level folder columns would lie about scope — // hide them and let the files pane take the full width. const columnsFilterActive = searchQuery.trim().length > 0 || selectedTagIds.length > 0; // In the columns view the pane beside the root column is that level's own // list, the files in no folder, like every other column's. Grid and list keep // "All Files", and so does a search or tag filter, whose results span every // folder. const paneRootOnly = viewMode === 'columns' && selectedFolderId === null && !columnsFilterActive; const { data: files, isLoading: filesLoading, dataUpdatedAt: filesUpdatedAt } = useQuery({ queryKey: ['library-files', selectedFolderId, topLevelView, searchExpandsSubfolders, tagFilterKey, paneRootOnly], // When a specific folder is selected we list its contents directly; when // no folder is selected the topLevelView pseudo-node decides whether the // server scopes the result to internal-managed-storage files or to the // union of every external folder (#1621). include_root is false outside // the columns view's root level, so "All Files" still descends into // subfolders (regression guard from #1499). queryFn: () => api.getLibraryFiles( selectedFolderId, paneRootOnly, undefined, selectedFolderId === null ? topLevelView : undefined, searchExpandsSubfolders, tagFilterKey, ), }); const { data: stats } = useQuery({ queryKey: ['library-stats'], queryFn: () => api.getLibraryStats(), }); // Get users for the username filter autocomplete -- names only (#1894) const { data: users } = useQuery({ queryKey: ['users', 'slim'], queryFn: () => api.getUsersSlim(), }); // Get unique file types for filter dropdown const fileTypes = useMemo(() => { if (!files) return []; const types = new Set(files.map((f) => f.file_type)); return Array.from(types).sort(); }, [files]); // Filter and sort files const filteredAndSortedFiles = useMemo( () => filterAndSortFiles(files ?? [], { query: searchQuery, filterType, filterUsername, sortField, sortDirection, }), [files, searchQuery, filterType, filterUsername, sortField, sortDirection], ); // Once the file's folder is listed afresh, mark the file; then scroll to it // and drop the mark after a few seconds, as the Archives page does. useEffect(() => { if (!pendingHighlight || filesLoading || !files || selectedFolderId !== pendingHighlight.folderId) return; if (filesUpdatedAt < pendingHighlight.requestedAt) return; if (filteredAndSortedFiles.some((f) => f.id === pendingHighlight.fileId)) { setHighlightedFileId(pendingHighlight.fileId); if (viewMode === 'columns') setColumnsFocusedFileId(pendingHighlight.fileId); } else { showToast(t('fileManager.highlightedFileNotFound'), 'warning'); } setPendingHighlight(null); }, [pendingHighlight, filesLoading, files, filesUpdatedAt, selectedFolderId, filteredAndSortedFiles, viewMode, showToast, t]); useEffect(() => { if (highlightedFileId === null) return; const scrollTimer = setTimeout(() => { document.querySelector(`[data-file-id="${highlightedFileId}"]`)?.scrollIntoView?.({ behavior: 'smooth', block: 'center' }); }, 100); const clearTimer = setTimeout(() => setHighlightedFileId(null), 5000); return () => { clearTimeout(scrollTimer); clearTimeout(clearTimer); }; }, [highlightedFileId]); // Check if disk space is low const isDiskSpaceLow = useMemo(() => { if (!stats || !settings) return false; const thresholdBytes = (settings.library_disk_warning_gb || 5) * 1024 * 1024 * 1024; return stats.disk_free_bytes < thresholdBytes; }, [stats, settings]); // Mutations const createFolderMutation = useMutation({ mutationFn: (data: LibraryFolderCreate) => api.createLibraryFolder(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['library-folders'] }); setShowNewFolderModal(false); showToast(t('fileManager.toast.folderCreated'), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const createExternalFolderMutation = useMutation({ mutationFn: async (data: ExternalFolderCreate) => { const folder = await api.createExternalFolder(data); // Auto-scan after creation await api.scanExternalFolder(folder.id); return folder; }, onSuccess: (folder) => { queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-stats'] }); setShowExternalFolderModal(false); setSelectedFolderId(folder.id); showToast(t('fileManager.toast.externalFolderLinked'), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const scanExternalFolderMutation = useMutation({ mutationFn: (folderId: number) => api.scanExternalFolder(folderId), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['library-stats'] }); showToast(t('fileManager.toast.folderScanned', { added: result.added, removed: result.removed }), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const deleteFolderMutation = useMutation({ mutationFn: (id: number) => api.deleteLibraryFolder(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-stats'] }); if (selectedFolderId === deleteConfirm?.id) { setSelectedFolderId(null); } setDeleteConfirm(null); showToast(t('fileManager.toast.folderDeleted'), 'success'); }, onError: (error: Error) => { setDeleteConfirm(null); showToast(error.message, 'error'); }, }); const deleteFileMutation = useMutation({ mutationFn: (id: number) => api.deleteLibraryFile(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['library-stats'] }); queryClient.invalidateQueries({ queryKey: ['library-trash-count'] }); setSelectedFiles((prev) => prev.filter((id) => id !== deleteConfirm?.id)); setDeleteConfirm(null); showToast(t('fileManager.toast.fileDeleted'), 'success'); }, onError: (error: Error) => { setDeleteConfirm(null); showToast(error.message, 'error'); }, }); // "These files are the same job for different printers" (#671 / #2570). // Durable, unlike the ad-hoc selection the Print button uses: once grouped, // printing any member offers the others without re-selecting them. const groupAsVersionsMutation = useMutation({ mutationFn: (fileIds: number[]) => api.createVariantGroup(fileIds.map((id) => ({ library_file_id: id }))), onSuccess: (group) => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); showToast(t('fileManager.variants.grouped', { count: group.members.length }), 'success'); setSelectedFiles([]); }, onError: (error: Error) => showToast(error.message, 'error'), }); const bulkDeleteMutation = useMutation({ mutationFn: (fileIds: number[]) => api.bulkDeleteLibrary(fileIds, []), onSuccess: (_, fileIds) => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['library-stats'] }); queryClient.invalidateQueries({ queryKey: ['library-trash-count'] }); showToast(t('fileManager.toast.filesDeleted', { count: fileIds.length }), 'success'); setSelectedFiles([]); setDeleteConfirm(null); }, onError: (error: Error) => { setDeleteConfirm(null); showToast(error.message, 'error'); }, }); const moveFilesMutation = useMutation({ mutationFn: ({ fileIds, folderId }: { fileIds: number[]; folderId: number | null }) => api.moveLibraryFiles(fileIds, folderId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-folders'] }); setSelectedFiles([]); setShowMoveModal(false); showToast(t('fileManager.toast.filesMoved'), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const updateFolderMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: LibraryFolderUpdate }) => api.updateLibraryFolder(id, data), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['library-folders'] }); // Invalidate project/archive folder queries so other pages see the update queryClient.invalidateQueries({ queryKey: ['project-folders'] }); queryClient.invalidateQueries({ queryKey: ['archive-folders'] }); setLinkFolder(null); const isUnlink = variables.data.project_id === 0 && variables.data.archive_id === 0; showToast(isUnlink ? t('fileManager.toast.folderUnlinked') : t('fileManager.toast.folderLinked'), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const renameFileMutation = useMutation({ mutationFn: ({ id, filename }: { id: number; filename: string }) => api.updateLibraryFile(id, { filename }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); setRenameItem(null); showToast(t('fileManager.toast.fileRenamed'), 'success'); }, onError: (error: Error) => { setRenameItem(null); showToast(error.message, 'error'); }, }); const renameFolderMutation = useMutation({ mutationFn: ({ id, name }: { id: number; name: string }) => api.updateLibraryFolder(id, { name }), onSuccess: () => { // Invalidate both folders and files - files may display folder info queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['library-files'] }); setRenameItem(null); showToast(t('fileManager.toast.folderRenamed'), 'success'); }, onError: (error: Error) => { setRenameItem(null); showToast(error.message, 'error'); }, }); const shareFolderMutation = useMutation({ mutationFn: ({ id, shared }: { id: number; shared: boolean }) => api.updateLibraryFolder(id, { shared }), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['project-folders'] }); queryClient.invalidateQueries({ queryKey: ['archive-folders'] }); showToast(variables.shared ? t('fileManager.toast.folderShared') : t('fileManager.toast.folderUnshared'), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const toggleFolderShared = (folder: LibraryFolderTree) => shareFolderMutation.mutate({ id: folder.id, shared: !folder.shared }); const batchThumbnailMutation = useMutation({ mutationFn: () => api.batchGenerateStlThumbnails({ all_missing: true }), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); // Update thumbnail versions for cache busting if (result.succeeded > 0) { const now = Date.now(); const newVersions: Record = {}; result.results.forEach((r) => { if (r.success) { newVersions[r.file_id] = now; } }); setThumbnailVersions((prev) => ({ ...prev, ...newVersions })); } if (result.succeeded > 0 && result.failed === 0) { showToast(t('fileManager.toast.thumbnailsGenerated', { count: result.succeeded }), 'success'); } else if (result.succeeded > 0 && result.failed > 0) { showToast(t('fileManager.toast.thumbnailsGeneratedPartial', { succeeded: result.succeeded, failed: result.failed }), 'success'); } else if (result.processed === 0) { showToast(t('fileManager.toast.noStlMissingThumbnails'), 'info'); } else { showToast(t('fileManager.toast.failedToGenerateThumbnails', { error: result.results[0]?.error || 'Unknown error' }), 'error'); } }, onError: (error: Error) => showToast(error.message, 'error'), }); const singleThumbnailMutation = useMutation({ mutationFn: (fileId: number) => api.batchGenerateStlThumbnails({ file_ids: [fileId] }), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); // Update thumbnail version for cache busting if (result.succeeded > 0) { const fileId = result.results[0]?.file_id; if (fileId) { setThumbnailVersions((prev) => ({ ...prev, [fileId]: Date.now() })); } showToast(t('fileManager.toast.thumbnailGenerated'), 'success'); } else { showToast(t('fileManager.toast.failedToGenerateThumbnail', { error: result.results[0]?.error || 'Unknown error' }), 'error'); } }, onError: (error: Error) => showToast(error.message, 'error'), }); // The one way into a preview (#2976): the kebab entry, the action-strip // icon, a double-click on the card or row, and the toolbar button all end // up here. Sliced files open the full-page gcode viewer the archive card // uses; everything else opens the modal for its type. A file with no // preview does nothing. const openPreview = useCallback((file: LibraryFileListItem) => { if (!hasPermission('library:read')) return; if (isSlicedLibraryFile(file)) { navigate(`/gcode-viewer?library_file=${file.id}`); } else if (file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type)) { setViewerFile(file); } else if (file.file_type === 'pdf') { setPdfPreviewFile(file); } else if (isSpreadsheetType(file.file_type)) { setSheetPreviewFile(file); } else if (isPreviewableImageType(file.file_type)) { setImagePreviewFile(file); } }, [hasPermission, navigate]); // Handlers const handleFileSelect = useCallback((id: number) => { // Always toggle selection (multi-select by default) setSelectedFiles((prev) => { return prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]; }); }, []); // In the columns view Select all adds the pane's files to what is ticked, so // a file ticked in an earlier column (still on screen) stays ticked (#3020). // Grid and list replace the selection: a file a search has hidden must not // stay ticked behind the user's back, or a bulk Delete would remove it. const handleSelectAll = useCallback(() => { if (filteredAndSortedFiles.length > 0) { const paneIds = filteredAndSortedFiles.map((f) => f.id); setSelectedFiles((prev) => (viewMode === 'columns' ? [...new Set([...prev, ...paneIds])] : paneIds)); } }, [filteredAndSortedFiles, viewMode]); // "Deselect all" once every file of the pane is ticked. The pane's own ids, // not a count: ticks in earlier columns count towards the selection too. const allPaneFilesSelected = filteredAndSortedFiles.length > 0 && filteredAndSortedFiles.every((f) => selectedFiles.includes(f.id)); const handleDeselectAll = useCallback(() => { setSelectedFiles([]); }, []); const handleUploadComplete = () => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-folders'] }); queryClient.invalidateQueries({ queryKey: ['library-stats'] }); }; // Page-wide drag-and-drop upload (#1510). Disabled when the user lacks // library:upload so a non-uploader can't accidentally show the overlay, // and also disabled while the upload modal itself is open so drags into // the modal's own drop zone don't bubble up and flash the page overlay // behind it. // Find the selected folder in the tree to check external status const selectedFolder = useMemo(() => { if (!selectedFolderId || !folders) return null; const findFolder = (items: LibraryFolderTree[]): LibraryFolderTree | null => { for (const item of items) { if (item.id === selectedFolderId) return item; const found = findFolder(item.children); if (found) return found; } return null; }; return findFolder(folders); }, [selectedFolderId, folders]); // A library:read_own user may add only to their own folders and shared // ones (#3201); a folder they merely pass through is read-only for them. const canWriteHere = selectedFolder?.can_write !== false; const canUpload = hasPermission('library:upload') && canWriteHere; const addDisabledTitle = !hasPermission('library:upload') ? undefined : !canWriteHere ? t('fileManager.folderNotWritable') : undefined; const { isDraggingOver, dragHandlers } = usePageFileDrop({ disabled: !canUpload || showUploadModal, onFiles: (files) => { setDroppedFiles(files); setShowUploadModal(true); }, }); // Returns the snapshot callback the preview components call with their // first render, or undefined when nothing should be persisted — the file // already has a thumbnail, or the user may not update it (#2976). const previewSnapshotHandler = useCallback( (file: LibraryFileListItem): ((blob: Blob) => void) | undefined => { if (file.thumbnail_path) return undefined; if (!canModify('library', 'update', file.created_by_id)) return undefined; return (blob: Blob) => { api .uploadLibraryPreviewThumbnail(file.id, blob) .then((res) => { if (res.updated) { setThumbnailVersions((prev) => ({ ...prev, [file.id]: (prev[file.id] || 0) + 1 })); queryClient.invalidateQueries({ queryKey: ['library-files'] }); } }) .catch(() => { // Thumbnail persistence is best-effort; the preview already rendered. }); }; }, [canModify, queryClient] ); const handleDownload = (id: number) => { api.downloadLibraryFile(id).catch((err) => { console.error('Library file download failed:', err); }); }; const handleDeleteConfirm = () => { if (!deleteConfirm) return; if (deleteConfirm.type === 'file') { deleteFileMutation.mutate(deleteConfirm.id); } else if (deleteConfirm.type === 'folder') { deleteFolderMutation.mutate(deleteConfirm.id); } else if (deleteConfirm.type === 'bulk') { bulkDeleteMutation.mutate(selectedFiles); } }; const isDeleting = deleteFolderMutation.isPending || deleteFileMutation.isPending || bulkDeleteMutation.isPending; const handleViewModeChange = (mode: 'grid' | 'list' | 'columns') => { setViewMode(mode); localStorage.setItem('library-view-mode', mode); }; // Shared by the list row and the columns view (see FileActionStrip). const fileActionProps = { onPrint: setPrintFile, onSlice: setSliceFile, onOpenInSlicer: handleOpenInSlicer, onRunPipeline: setRunPipelineFile, useSlicerApi: settings?.use_slicer_api ?? false, desktopSlicer: preferredSlicer, canSlice: canSlice(), onPreview: openPreview, onDetails: setDetailsFile, onDownload: handleDownload, onRename: (f: LibraryFileListItem) => setRenameItem({ type: 'file', id: f.id, name: f.filename }), onGenerateThumbnail: (f: LibraryFileListItem) => singleThumbnailMutation.mutate(f.id), thumbnailPending: singleThumbnailMutation.isPending, onDelete: (id: number) => setDeleteConfirm({ type: 'file', id }), hasPermission, canModify, t, }; const isLoading = foldersLoading || filesLoading; // The chain of folders from a top-level folder down to the selected one. // Selection is the single source of truth — clicking a folder anywhere just // moves selectedFolderId, and the path (and with it the path bar and the set // of visible columns) is re-derived from the sorted tree. const folderPath = useMemo(() => { if (!sortedFolders || selectedFolderId === null) return [] as LibraryFolderTree[]; const path: LibraryFolderTree[] = []; const walk = (items: LibraryFolderTree[]): boolean => { for (const item of items) { path.push(item); if (item.id === selectedFolderId) return true; if (walk(item.children)) return true; path.pop(); } return false; }; walk(sortedFolders); return path; }, [sortedFolders, selectedFolderId]); // Which half of the tree the current location lives in: the selected path's // bucket when there is one, otherwise the sidebar's top-level choice. Drives // both the path bar's root crumb and the columns view's first column. const currentBucketIsExternal = folderPath.length > 0 ? Boolean(folderPath[0].is_external) : topLevelView === 'external'; // One column per level: the top-level bucket, then the children of each // folder along the path. `folderId` is the folder the column is the inside // of (null = the library root), which is what its file list is keyed on. // Leaf folders contribute no column — the files pane to the right is their // content. const folderColumns = useMemo(() => { const rootItems = (sortedFolders ?? []).filter((f) => Boolean(f.is_external) === currentBucketIsExternal); const cols: { key: string; folderId: number | null; items: LibraryFolderTree[]; activeId: number | null }[] = [ { key: 'root', folderId: null, items: rootItems, activeId: folderPath[0]?.id ?? null }, ]; folderPath.forEach((node, i) => { if (node.children.length > 0) { cols.push({ key: `folder-${node.id}`, folderId: node.id, items: node.children, activeId: folderPath[i + 1]?.id ?? null, }); } }); return cols; }, [sortedFolders, folderPath, currentBucketIsExternal]); // Every column lists its own level's files, not just the rightmost pane: a // folder that holds files and no subfolders used to look empty until it was // the selection. One query per rendered level, keyed on that level's folder // id so walking back up a path is instant. Only levels the view actually // renders are fetched, and the level that IS the current selection is // skipped — the pane on the right already lists exactly those files. const columnFileLevels = useMemo(() => { if (viewMode !== 'columns' || columnsFilterActive) return []; return folderColumns .filter((col) => col.folderId !== selectedFolderId) .map((col) => ({ key: col.key, folderId: col.folderId, scope: col.folderId === null ? currentBucketIsExternal ? ('external' as const) : ('internal' as const) : undefined, })); }, [viewMode, columnsFilterActive, folderColumns, selectedFolderId, currentBucketIsExternal]); const columnFileQueries = useQueries({ combine: combineColumnFileQueries, queries: columnFileLevels.map((level) => ({ queryKey: ['library-files', 'column', level.folderId, level.scope ?? null], // include_root only means anything for the root column, where "this // level's files" are the ones that sit in no folder at all. Under a // folder id the server already answers with that folder's own files. queryFn: () => api.getLibraryFiles(level.folderId, level.folderId === null, undefined, level.scope, false, []), })), }); // Keyed by column so the render stays a lookup. Deliberately not memoised: // useQueries hands back a fresh array every render, so a memo would recompute // anyway. const columnFiles = new Map(); columnFileLevels.forEach((level, i) => { const query = columnFileQueries.results[i]; columnFiles.set(level.key, { files: filterAndSortFiles(query?.data ?? [], { filterType, filterUsername, sortField, sortDirection }), loading: Boolean(query?.isPending), }); }); // Every file a tick can point at: the pane plus the files of every column // the view has loaded, since a file can be ticked in an earlier column // (#3020). The toolbar's actions resolve the selection against this. const selectableFiles = useMemo(() => { const byId = new Map(); for (const file of [...(files ?? []), ...columnFileQueries.files]) byId.set(file.id, file); return byId; }, [files, columnFileQueries.files]); // The toolbar's Preview button acts on one file, so it is offered only for // a single previewable selection. const previewSelection = useMemo(() => { if (selectedFiles.length !== 1) return null; const file = selectableFiles.get(selectedFiles[0]); return file && isPreviewableLibraryFile(file) ? file : null; }, [selectableFiles, selectedFiles]); // The sliced files among the selection, in the order they were ticked. const selectedSlicedFiles = useMemo( () => selectedFiles .map((id) => selectableFiles.get(id)) .filter((f): f is LibraryFileListItem => !!f && isSlicedLibraryFile(f)), [selectableFiles, selectedFiles], ); // "Combine to 3MF" is offered only when every selected file is an STL, so // the action never silently drops part of the selection. Resolved like the // rest, so an STL ticked in an earlier column counts too. const selectedStlFiles = useMemo(() => { const stls = selectedFiles .map((id) => selectableFiles.get(id)) .filter((f): f is LibraryFileListItem => !!f && f.filename.toLowerCase().endsWith('.stl')); return stls.length === selectedFiles.length ? stls : []; }, [selectableFiles, selectedFiles]); // The clicked file's variant group, so printing one member offers the rest // without the user re-selecting them (#2570). const { data: printFileGroup } = useQuery({ queryKey: ['variant-group', printFile?.variant_group_id], queryFn: () => api.getVariantGroup(printFile!.variant_group_id!), enabled: !!printFile?.variant_group_id, }); // Candidates for a cross-model print (#671), or undefined for an ordinary one. // An explicit multi-selection wins over the group: the user just said, in this // action, which files they meant. const printVariantFiles = useMemo(() => { if (!printFile) return undefined; if (selectedSlicedFiles.length > 1) { return selectedSlicedFiles.map(f => ({ id: f.id, filename: f.filename, sliced_for_model: f.sliced_for_model, })); } if (printFileGroup && printFileGroup.members.length > 1) { return printFileGroup.members.map(m => ({ id: m.library_file_id, filename: m.filename, sliced_for_model: m.target_model, })); } return undefined; }, [printFile, selectedSlicedFiles, printFileGroup]); // The list the arrow keys walk: the one that actually holds the focused // file, which may be an intermediate column rather than the selected // folder's pane. const focusedFileColumnList = columnsFocusedFileId === null ? undefined : columnFileLevels .map((level) => columnFiles.get(level.key)?.files) .find((list) => list?.some((f) => f.id === columnsFocusedFileId)); const focusedFileList = focusedFileColumnList ?? filteredAndSortedFiles; // Focus the columns pane when the view opens so arrow keys work right away, // and drop the file focus whenever the folder (and with it the file list) // changes. A modal or the search box taking focus naturally mutes the // pane's key handling — no explicit guards needed. useEffect(() => { if (viewMode === 'columns') columnsViewRef.current?.focus({ preventScroll: true }); }, [viewMode]); useEffect(() => { setColumnsFocusedFileId(null); }, [selectedFolderId, viewMode]); // A selection belongs to the folder it was made in. Every column can tick a // file, so a selection that survived a folder change would leave Move and // Delete pointing at rows that are no longer anywhere on screen (#3020). useEffect(() => { setSelectedFiles([]); }, [selectedFolderId]); // Keep the keyboard-driven selection scrolled into view. Deliberately an // effect keyed on the ids, NOT per-render ref callbacks: those re-run on // every commit and would snap a manually scrolled column back to the // highlighted row whenever anything re-renders. scrollIntoView is guarded // because jsdom doesn't implement it. useEffect(() => { if (viewMode !== 'columns' || selectedFolderId === null) return; columnsViewRef.current?.querySelector(`[data-folder-id="${selectedFolderId}"]`)?.scrollIntoView?.({ block: 'nearest' }); }, [selectedFolderId, viewMode]); useEffect(() => { if (viewMode !== 'columns' || columnsFocusedFileId === null) return; columnsViewRef.current?.querySelector(`[data-file-id="${columnsFocusedFileId}"]`)?.scrollIntoView?.({ block: 'nearest' }); }, [columnsFocusedFileId, viewMode]); // Finder-style keys: Up/Down move within the current column, Right descends // (into the first child folder, then into the files), Left ascends, Enter // opens the focused file's preview, Space toggles its selection. // // Actions: the focused file's icon strip is the only one in the Tab order // (roving tabindex), so Tab from the pane lands on it; the context-menu // key (or Shift+F10) jumps there directly, or opens the selected folder's // kebab while no file is focused. Inside a strip Left/Right walk the // buttons, Enter/Space activate one natively and Escape returns to the pane. const focusColumnsActions = () => { const root = columnsViewRef.current; if (!root) return; if (columnsFocusedFileId !== null) { root .querySelector(`[data-file-id="${columnsFocusedFileId}"] [data-file-actions] button:not([disabled])`) ?.focus(); } else if (selectedFolderId !== null) { const kebab = root.querySelector(`[data-folder-id="${selectedFolderId}"] [data-folder-actions] button`); // Focus first so Tab continues into the menu that the click opens. kebab?.focus(); kebab?.click(); } }; const handleColumnsKeyDown = (e: React.KeyboardEvent) => { if (e.ctrlKey || e.metaKey || e.altKey) return; const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable) return; if (target.closest('[data-folder-actions]')) { // A folder kebab (and the menu it opens) handles its own keys, except // that Escape and Up/Down hand focus back to the pane — otherwise the // arrows are dead until the user clicks or Shift+Tabs out of the kebab. if (e.key === 'Escape') { e.preventDefault(); columnsViewRef.current?.focus({ preventScroll: true }); return; } if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; columnsViewRef.current?.focus({ preventScroll: true }); } const strip = target.closest('[data-file-actions]'); if (strip) { if (e.key === 'Enter' || e.key === ' ') return; if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); const buttons = Array.from(strip.querySelectorAll('button:not([disabled])')); buttons[buttons.indexOf(target as HTMLButtonElement) + (e.key === 'ArrowRight' ? 1 : -1)]?.focus(); return; } if (e.key === 'Escape') { e.preventDefault(); columnsViewRef.current?.focus({ preventScroll: true }); return; } // Up/Down move the row focus below; DOM focus must leave the old row's // strip with it, or the next Enter would fire that row's button. if (e.key === 'ArrowUp' || e.key === 'ArrowDown') columnsViewRef.current?.focus({ preventScroll: true }); } if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) { e.preventDefault(); focusColumnsActions(); return; } const inFiles = columnsFocusedFileId !== null; const selectedNode = folderPath[folderPath.length - 1]; switch (e.key) { case 'ArrowDown': case 'ArrowUp': { e.preventDefault(); const dir = e.key === 'ArrowDown' ? 1 : -1; if (inFiles || columnsFilterActive) { // findIndex yields -1 for a vanished focus id — ArrowDown then // lands on index 0, ArrowUp on -2 → no-op, both intended. const idx = focusedFileList.findIndex((f) => f.id === columnsFocusedFileId); const next = focusedFileList[idx + dir]; if (next) setColumnsFocusedFileId(next.id); else if (dir === -1 && idx === 0 && focusedFileColumnList) { // Off the top of a column's files: back onto that same column's // folders, which is where ArrowDown came from. setColumnsFocusedFileId(null); } } else if (selectedFolderId === null) { if (dir === 1 && folderColumns[0].items.length > 0) { setSelectedFolderId(folderColumns[0].items[0].id); } } else { const col = folderColumns[folderPath.length - 1]; const idx = col ? col.items.findIndex((f) => f.id === selectedFolderId) : -1; const next = idx === -1 ? undefined : col.items[idx + dir]; if (next) setSelectedFolderId(next.id); else if (dir === 1 && idx !== -1) { // Past the last folder the column's own files continue the list, // exactly as they read on screen: the only way a keyboard // reaches an intermediate column's rows at all. const colFiles = col ? columnFiles.get(col.key)?.files : undefined; if (colFiles?.length) setColumnsFocusedFileId(colFiles[0].id); } } break; } case 'ArrowRight': { e.preventDefault(); if (inFiles) break; if (!columnsFilterActive && selectedNode && selectedNode.children.length > 0) { setSelectedFolderId(selectedNode.children[0].id); } else if (filteredAndSortedFiles.length > 0) { setColumnsFocusedFileId(filteredAndSortedFiles[0].id); } break; } case 'ArrowLeft': { e.preventDefault(); if (inFiles) { setColumnsFocusedFileId(null); break; } // With a filter active the folder columns are hidden — don't move a // selection the user can't see. if (columnsFilterActive) break; if (folderPath.length > 1) { setSelectedFolderId(folderPath[folderPath.length - 2].id); } else if (folderPath.length === 1) { // Ascending past the top level: keep the bucket the user was // navigating — the tree can select a folder from either bucket // without touching topLevelView, and falling back to a stale one // would teleport the root column to the other folder set. setTopLevelView(folderPath[0].is_external ? 'external' : 'internal'); setSelectedFolderId(null); } else if (selectedFolderId !== null) { // Selection not in the tree (e.g. a deep link to a deleted // folder): give the keyboard an escape hatch back to the root. setSelectedFolderId(null); } break; } // Enter and Space preventDefault BEFORE the inFiles check: DOM focus // may still sit on the last-clicked folder button, and the browser's // default activation would re-click it — snapping the selection back // to a folder the arrows have long left (Space would scroll the page). case 'Enter': { e.preventDefault(); if (!inFiles) { // Enter means "open the selected thing": on a folder that is // descending, exactly like ArrowRight. if (!columnsFilterActive && selectedNode && selectedNode.children.length > 0) { setSelectedFolderId(selectedNode.children[0].id); } else if (filteredAndSortedFiles.length > 0) { setColumnsFocusedFileId(filteredAndSortedFiles[0].id); } break; } const file = focusedFileList.find((f) => f.id === columnsFocusedFileId); if (file) openPreview(file); break; } case ' ': { e.preventDefault(); if (!inFiles) break; if (columnsFocusedFileId !== null) handleFileSelect(columnsFocusedFileId); break; } } }; return (
{/* Drag & Drop Overlay — page-wide file upload (#1510) */} {isDraggingOver && (

{t('fileManager.dropFilesHere')}

{t('fileManager.releaseToUpload')}

)} {/* Header */}

{t('fileManager.title')}

{t('fileManager.subtitle')}

{/* Wraps: up to nine controls, which pushed the page sideways on a phone (#3295). Next to the title it takes only the room left over (flex-1 from zero), so the title is not squeezed to fit it. */}
{/* View mode toggle */}
{hasPermission('library:purge') && ( )} {(hasAnyPermission('library:delete_own', 'library:delete_all')) && ( {t('libraryTrash.headerButton')} {typeof trashCount === 'number' && trashCount > 0 && ( {trashCount} )} )}
{/* Disk space warning */} {isDiskSpaceLow && stats && settings && (

{t('fileManager.lowDiskSpaceWarning')}

{t('fileManager.lowDiskSpaceDetails', { free: formatFileSize(stats.disk_free_bytes), total: formatFileSize(stats.disk_total_bytes), threshold: settings.library_disk_warning_gb })}

)} {/* Stats bar */} {stats && (
{t('fileManager.files')}: {stats.total_files}
{t('fileManager.folders')}: {stats.total_folders}
{t('fileManager.size')}: {formatFileSize(stats.total_size_bytes)}
{t('fileManager.free')}: {formatFileSize(stats.disk_free_bytes)}
)} {/* Main content */}
{/* Mobile folder selector */}
{/* Folder sidebar - resizable, hidden on mobile */}
{/* Resize handle - drag to resize, double-click to reset */}
{ e.preventDefault(); setIsResizing(true); }} onDoubleClick={() => { setSidebarWidth(256); // Reset to default w-64 localStorage.setItem('library-sidebar-width', '256'); }} title={t('fileManager.dragToResizeTooltip')} > {/* Grip dots */}

{t('fileManager.folders')}

{/* Folder tree sort (#1770). Dropdown drives the comparator; direction button flips asc/desc. Both persist to localStorage on change so the choice survives reloads. */}
{/* All Files = the user's own uploaded / managed-storage files only. External folders are surfaced separately below to keep a linked NAS from drowning the user's own uploads (#1621). */}
{ setSelectedFolderId(null); setTopLevelView('internal'); }} > {t('fileManager.allFiles')}
{/* External (combined) — only shown when at least one external folder is linked. Single folder users don't need a combined view; clicking the individual folder is just as fast. */} {folders?.some((f) => f.is_external) && (
{ setSelectedFolderId(null); setTopLevelView('external'); }} > {t('fileManager.allExternal')}
)} {/* Folder tree — re-key on the collapse toggle so flipping it remounts every FolderTreeItem, which re-reads defaultExpanded and makes the preference take effect immediately. */} {sortedFolders?.map((folder) => ( setDeleteConfirm({ type: 'folder', id })} onLink={setLinkFolder} onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })} onToggleShared={toggleFolderShared} wrapNames={wrapFolderNames} defaultExpanded={!collapseFoldersByDefault} showModified={showModified} hasPermission={hasPermission} t={t} /> ))}
{/* Files area + README rail (#2520 item 2). On wide screens the README docks as a collapsible right-hand column (rendered after the files column, below) so it no longer steals vertical space from the file list; on narrow screens it stacks above the list via `order-first` and the page itself scrolls. */}
{/* Tag filter rail (#1268, #3411). Hidden entirely when the catalog is empty so brand-new installs don't see a stray rail. */} {tagCatalog.length > 0 && ( setSelectedTagIds([])} /> )} {/* External folder info bar */} {selectedFolder?.is_external && (
{t('fileManager.externalFolder')} {selectedFolder.external_readonly && ( {t('fileManager.readOnly')} )}

{selectedFolder.external_path}

)} {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */} {files && files.length > 0 && (
{/* Search */}
setSearchQuery(e.target.value)} className="w-full pl-9 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" /> {searchExpandsSubfolders && ( {t('fileManager.searchSubfoldersHint')} )}
{/* Type filter */}
{/* Username filter with autocomplete - only show when auth is enabled */} {authEnabled && (
setFilterUsername(e.target.value)} list="usernames-list" className={`w-32 sm:w-40 px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green ${filterUsername ? 'pr-7' : ''}`} style={filterUsername ? { WebkitAppearance: 'none', MozAppearance: 'textfield' } : undefined} /> {filterUsername && ( )} {users?.map((user) => (
)} {/* Sort */}
{/* Results count */} {(searchQuery || filterType !== 'all' || filterUsername) && ( {t('fileManager.resultsCount', { showing: filteredAndSortedFiles.length, total: files.length })} )}
)} {/* Selection toolbar - sticky on mobile below search bar. It stays while anything is selected, even with the selected folder's own pane empty: a file ticked in an intermediate column of the columns view still needs its count and actions (#3020). */} {(filteredAndSortedFiles.length > 0 || selectedFiles.length > 0) && (
{/* Select all / Deselect all */} {allPaneFilesSelected ? ( ) : ( )} {selectedFiles.length > 0 && ( <> {t('fileManager.selected', { count: selectedFiles.length })}
{previewSelection && ( )} {/* Print used to disappear the moment a second sliced file was selected. Selecting several is now how you say "same job, different printers" (#671) — one queue item, whichever machine frees up first. */} {selectedSlicedFiles.length >= 1 && ( )} {selectedSlicedFiles.length >= 2 && !selectedSlicedFiles.some(f => f.variant_group_id) && ( )} {selectedStlFiles.length >= 1 && ( )}
)}
)} {/* File grid/list. The columns view is exempt from the full-pane loading swap: every keyboard descent into an uncached folder flips filesLoading for one round-trip, and replacing the pane would strip its tabindex mid-keystroke — the focus-fixup rule then drops focus to and kills keyboard navigation. The pane stays mounted and only its files pane shows the spinner. */} {isLoading && viewMode !== 'columns' ? (

{t('fileManager.loadingFiles')}

) : viewMode === 'columns' ? ( /* Miller columns (macOS-Finder style): one column per folder level, the rightmost pane lists the selected folder's files. Unlike the grid/list branches this renders even for an "empty" folder — an empty files pane next to navigable folder columns is the whole point of the view. */
{!columnsFilterActive && folderColumns.map((col) => { const level = columnFiles.get(col.key); // A column carrying file rows needs the room the files pane // has: checkbox, thumbnail and the seven-icon action strip // leave nothing for the name at the folder-only width. A // column that only lists folders keeps that narrow width. const wide = Boolean(level && (level.loading || level.files.length > 0)); return (
{col.items.map((folder) => { const isSelectedFolder = selectedFolderId === folder.id; return ( /* The row is a div, not the button itself: the kebab is a button of its own and buttons don't nest. */
{/* Kebab: hover-revealed with a mouse, always there without one (#2865) and on the selected row. */} setDeleteConfirm({ type: 'folder', id })} onLink={setLinkFolder} onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })} onToggleShared={toggleFolderShared} hasPermission={hasPermission} revealOnHover={!isSelectedFolder} tabIndex={isSelectedFolder ? 0 : -1} t={t} />
); })} {level?.loading && ( )} {level && !level.loading && level.files.map((file) => ( { // Focusing a file in an intermediate column must not // move the folder selection out from under it. setColumnsFocusedFileId(f.id); handleFileSelect(f.id); }} onOpen={openPreview} actionProps={fileActionProps} t={t} /> ))}
); })} {/* Files pane — still the selected folder's contents, so a leaf folder's column layout is exactly what it was. */}
{filesLoading ? (
) : filteredAndSortedFiles.length === 0 ? (
{(files?.length ?? 0) > 0 ? t('fileManager.noMatchingFiles') : selectedFolderId !== null ? t('fileManager.folderIsEmpty') // At the root the pane lists only the files in no // folder, so with folders beside it "No files yet" // would be wrong (and an external bucket never has // unfiled files at all). : paneRootOnly && folderColumns[0].items.length > 0 ? t('fileManager.noFilesOutsideFolders') : topLevelView === 'external' ? t('fileManager.externalIsEmpty') : t('fileManager.noFilesYet')}
) : ( filteredAndSortedFiles.map((file) => ( { setColumnsFocusedFileId(f.id); handleFileSelect(f.id); }} onOpen={openPreview} actionProps={fileActionProps} t={t} /> )) )}
) : files?.length === 0 ? (

{selectedFolderId !== null ? t('fileManager.folderIsEmpty') : topLevelView === 'external' ? t('fileManager.externalIsEmpty') : t('fileManager.noFilesYet')}

{selectedFolderId !== null ? t('fileManager.folderEmptyDescription') : topLevelView === 'external' ? t('fileManager.externalEmptyDescription') : t('fileManager.noFilesDescription')}

) : filteredAndSortedFiles.length === 0 ? (

{t('fileManager.noMatchingFiles')}

{t('fileManager.noMatchingFilesDescription')}

) : viewMode === 'grid' ? (
{filteredAndSortedFiles.map((file) => ( setDeleteConfirm({ type: 'file', id })} onDownload={handleDownload} onPrint={setPrintFile} onSlice={setSliceFile} onOpenInSlicer={handleOpenInSlicer} desktopSlicer={preferredSlicer} onRunPipeline={setRunPipelineFile} useSlicerApi={settings?.use_slicer_api ?? false} canSlice={canSlice()} onPreview={openPreview} onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })} onDetails={setDetailsFile} onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)} onTagClick={toggleTagFilter} thumbnailVersion={thumbnailVersions[file.id]} hasPermission={hasPermission} canModify={canModify} authEnabled={authEnabled} showModified={showModified} /> ))}
) : (
{/* The wrapper has overflow-x-auto so a narrow viewport scrolls horizontally instead of clipping the actions column off the right edge. The previous `overflow-hidden` was there for the rounded corners but also swallowed any content the actions column couldn't fit (#1325 follow-up reported in chat). */}
{/* List header - hidden on mobile, show simplified on small screens. Trailing actions column is fixed at 220px (sliced 3MF = 7 icons ~220px). It used to be `min-content`, but header + body are sibling grids that compute `min-content` independently — the header's empty trailing div resolved to 0px, leaving body columns shifted left of their headers. Fixed width keeps header and body in lockstep. */}
{t('common.name')}
{authEnabled &&
{t('fileManager.uploadedBy', { defaultValue: 'Uploaded By' })}
}
{t('common.type')}
{t('fileManager.size')}
{t('fileManager.prints')}
{t('fileManager.tags.title')}
{/* List rows */} {filteredAndSortedFiles.map((file) => (
handleFileSelect(file.id)} // Double-click opens the preview (#2976), as in the grid. onDoubleClick={() => openPreview(file)} > {/* Checkbox */}
{selectedFiles.includes(file.id) &&
}
{/* Name with thumbnail */}
{file.thumbnail_path ? ( ) : (
)}
{/* Hover preview */} {file.thumbnail_path && (
{file.filename}
)}
{file.print_name || file.filename} {/* Metadata indicators (#3077), same set as the card. */} {file.external_url && ( )} {file.has_notes && ( )} {(file.photo_count ?? 0) > 0 && ( {file.photo_count} )}
{/* #2680: last-modified date under the name, toggled from the toolbar. Real on-disk mtime when known, else created_at. */} {showModified && (
{formatDate(file.fs_modified_at ?? file.created_at)}
)}
{/* Uploaded By - only show when auth is enabled */} {authEnabled && (
{file.created_by_username ? ( <> {file.created_by_username} ) : ( '-' )}
)} {/* Type */}
{file.file_type.toUpperCase()}
{/* Size */}
{formatFileSize(file.file_size)}
{/* Prints */}
{file.print_count > 0 ? `${file.print_count}x` : '-'}
{/* Tags (#1268) — clickable chips push into the active filter; minmax(96px,200px) on the column lets the cell shrink/wrap on narrow viewports, and the 96px floor keeps chips readable once the wrapper scrolls (#3105). */}
{!file.tags || file.tags.length === 0 ? ( - ) : (
{file.tags.map((tg) => ( ))}
)}
{/* Actions */}
))}
)}
{/* README rail — collapsible right column on lg+, stacks on top on mobile. See the files-area wrapper comment above (#2520). */} {selectedFolderId !== null && }
{/* Modals */} {showNewFolderModal && ( setShowNewFolderModal(false)} onSave={(data) => createFolderMutation.mutate(data)} isLoading={createFolderMutation.isPending} t={t} /> )} {showExternalFolderModal && ( setShowExternalFolderModal(false)} onSave={(data) => createExternalFolderMutation.mutate(data)} isLoading={createExternalFolderMutation.isPending} t={t} /> )} {showMoveModal && folders && ( setShowMoveModal(false)} onMove={(folderId) => moveFilesMutation.mutate({ fileIds: selectedFiles, folderId })} isLoading={moveFilesMutation.isPending} t={t} /> )} {showUploadModal && ( { setShowUploadModal(false); setDroppedFiles([]); }} onUploadComplete={handleUploadComplete} initialFiles={droppedFiles.length > 0 ? droppedFiles : undefined} /> )} {showPurgeModal && ( setShowPurgeModal(false)} /> )} setShowTagsModal(false)} onPickTag={(tagId) => { if (!selectedTagIds.includes(tagId)) { setSelectedTagIds((prev) => [...prev, tagId]); } }} /> setShowBulkTagsModal(false)} /> {linkFolder && ( setLinkFolder(null)} onLink={(data) => updateFolderMutation.mutate({ id: linkFolder.id, data })} isLoading={updateFolderMutation.isPending} t={t} /> )} {deleteConfirm && ( setDeleteConfirm(null)} /> )} {/* Held back until the variant group has loaded. The modal reads its candidate list once, on mount, so opening before the group arrives would show a single-file print for a file that has alternatives. */} {printFile && (!printFile.variant_group_id || printFileGroup !== undefined) && ( 1 ? `${printVariantFiles[0].filename} ${t('common.plusNMore', { count: printVariantFiles.length - 1 })}` : printFile.print_name || printFile.filename } onClose={() => setPrintFile(null)} onSuccess={() => { setPrintFile(null); setSelectedFiles([]); queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['queue'] }); queryClient.invalidateQueries({ queryKey: ['archives'] }); }} /> )} {sliceFile && ( setSliceFile(null)} /> )} {showCombineModal && selectedStlFiles.length > 0 && ( setShowCombineModal(false)} onCombined={(result, sliceNext) => { setShowCombineModal(false); setSelectedFiles([]); if (sliceNext) setSliceCombined({ id: result.id, filename: result.filename }); }} /> )} {sliceCombined && ( setSliceCombined(null)} defaultAutoArrange /> )} {runPipelineFile && ( setRunPipelineFile(null)} /> )} {viewerFile && ( setViewerFile(null)} // STEP has no server-side renderer; persist the first client render // as the grid thumbnail (#2976). onSnapshot={isStepType(viewerFile.file_type) ? previewSnapshotHandler(viewerFile) : undefined} onSliceWithBambuddy={ // Only offer in-app slicing on files the SliceModal can actually // handle (matches the file-row Cog visibility check at :2127). isSliceableLibraryFile(viewerFile, true, preferredSlicer) && hasPermission('library:upload') ? () => { const f = viewerFile; setViewerFile(null); setSliceFile(f); } : undefined } /> )} {(pdfPreviewFile || sheetPreviewFile || imagePreviewFile) && ( {pdfPreviewFile && ( setPdfPreviewFile(null)} onSnapshot={previewSnapshotHandler(pdfPreviewFile)} /> )} {sheetPreviewFile && ( setSheetPreviewFile(null)} onSnapshot={previewSnapshotHandler(sheetPreviewFile)} /> )} {imagePreviewFile && ( setImagePreviewFile(null)} /> )} )} {detailsFile && ( setDetailsFile(null)} /> )} {renameItem && ( setRenameItem(null)} onSave={(newName) => { if (renameItem.type === 'file') { renameFileMutation.mutate({ id: renameItem.id, filename: newName }); } else { renameFolderMutation.mutate({ id: renameItem.id, name: newName }); } }} isLoading={renameFileMutation.isPending || renameFolderMutation.isPending} t={t} /> )}
); }