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 { 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). Lists every catalog tag as a togglable chip — active chips are filled green and show an X, inactive chips are outlined and toggle ON when clicked. Clicking an active chip removes it from the filter. Hidden entirely when the catalog is empty so brand-new installs don't see a stray rail. */} {tagCatalog.length > 0 && (
{t('fileManager.tags.filterLabel')} {tagCatalog.map((tg) => { const active = selectedTagIds.includes(tg.id); return ( ); })} {selectedTagIds.length > 0 && ( )}
)} {/* 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} /> )}
); }