/** * Folder Browser Page * * Full featured file browser with file operations and unified TopBar * Uses URL-based routing for modals and selection mode to support back gesture */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useSearchParams, useLocation } from 'react-router-dom'; import { IcNavForward, IcFolder, IcClose, IcCheck, IcFolderAdd, IcFilter, IcList, IcShare, IcMove, IcDelete, IcMoreCircle, IcClipboard } from '../res/icons'; import { FSNode } from '@/os/types'; import * as FileSystem from '@/os/FileSystemService'; import { useFileManagerStore, selectClipboardHasItems } from '../state'; import { getFileIcon, getFileIconColor, isPdfPreviewableFile, isTextPreviewableFile } from '../utils/fileUtils'; import { InputDialog, FileDetailsDialog, ConfirmDialog, ActionMenu } from '../components/Dialog'; import { TransferSheet } from '../components/TransferSheet'; import { Toast } from '@/os/components/Toast'; import { useFileManagerGestures } from '../hooks/useFileManagerGestures'; import { AsyncImage } from '../components/AsyncImage'; import { CollapsingToolbar, ToolbarIconButton, TOOLBAR_SPACER_HEIGHT } from '@/os/components/CollapsingToolbar'; import { strings } from '../res/strings'; import { stringsEn } from '../res/strings.en'; import { useAppStrings } from '@/os/useAppStrings'; import * as TimeService from '@/os/TimeService'; import { transferNodesToDirectory, shareNodesAsImages, type TransferOperation } from '../utils/fileOperations'; export const FolderPage: React.FC = () => { const [searchParams] = useSearchParams(); const location = useLocation(); const { go, back } = useFileManagerGestures(); const clipboardItems = useFileManagerStore(s => s.clipboardItems); const clipboardOperation = useFileManagerStore(s => s.clipboardOperation); const paste = useFileManagerStore(s => s.paste); const clearClipboard = useFileManagerStore(s => s.clearClipboard); const hasItems = useFileManagerStore(selectClipboardHasItems); const s = useAppStrings(strings, stringsEn); // Parse URL state const urlSearchParams = new URLSearchParams(location.search); const isSelecting = urlSearchParams.get('mode') === 'select'; const modal = urlSearchParams.get('modal') as 'delete' | 'more' | 'newFolder' | 'details' | 'rename' | 'transfer' | null; const modalItemId = urlSearchParams.get('itemId'); const transferOperation: TransferOperation = urlSearchParams.get('operation') === 'move' ? 'move' : 'copy'; const transferDialog = urlSearchParams.get('transferDialog'); // Selection state (local state) const [selectedIds, setSelectedIds] = useState>(new Set()); const isLongPress = useRef(false); const longPressTimer = useRef | null>(null); const clearLongPressTimer = () => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; } }; // Clear selection and reset long press flag when exiting select mode useEffect(() => { if (!isSelecting) { setSelectedIds(new Set()); isLongPress.current = false; } }, [isSelecting]); const toggleSelect = (itemId: string) => { setSelectedIds(prev => { const next = new Set(prev); if (next.has(itemId)) next.delete(itemId); else next.add(itemId); return next; }); }; const selectAll = (allIds: string[]) => setSelectedIds(new Set(allIds)); const clearSelection = () => setSelectedIds(new Set()); const enterSelectMode = (initialItemId?: string) => { if (isSelecting) return; isLongPress.current = true; if (initialItemId) setSelectedIds(new Set([initialItemId])); go('folder.select.enter', {}); }; const exitSelectMode = () => { isLongPress.current = false; back(); }; const openModal = (type: 'delete' | 'more' | 'newFolder' | 'details' | 'rename', itemId?: string) => { if (type === 'delete') go('folder.modal.delete.open', {}); else if (type === 'more') go('folder.modal.more.open', {}); else if (type === 'newFolder') go('folder.modal.newFolder.open', {}); else if (type === 'details') go('folder.modal.details.open', itemId ? { itemId } : {}); else if (type === 'rename') go('folder.modal.rename.open', itemId ? { itemId } : {}); }; const closeModal = () => back(); // Get path from URL const currentPath = searchParams.get('path') || '/sdcard'; const [items, setItems] = useState([]); const [scrollTop, setScrollTop] = useState(0); const [toast, setToast] = useState({ message: '', visible: false }); const [deleteTargetNodes, setDeleteTargetNodes] = useState([]); const showToast = (message: string) => { setToast({ message, visible: true }); setTimeout(() => setToast({ message: '', visible: false }), 2000); }; // Load directory contents const refreshItems = useCallback(() => { const contents = FileSystem.listDirectory(currentPath); setItems(contents); }, [currentPath]); useEffect(() => { refreshItems(); setScrollTop(0); }, [refreshItems]); const handleScroll = (e: React.UIEvent) => { setScrollTop(e.currentTarget.scrollTop); }; const handleItemClick = (item: FSNode) => { if (isLongPress.current) { isLongPress.current = false; return; } if (isSelecting) { toggleSelect(item.id); return; } if (item.type === 'directory') go('folder.open', { path: item.path }); else if (isTextPreviewableFile(item)) go('file.text.open', { path: item.path }); else if (isPdfPreviewableFile(item)) go('file.pdf.open', { path: item.path }); else if (item.mimeType?.startsWith('image/')) { window.__OS__?.startActivity?.({ action: 'ACTION_VIEW', type: item.mimeType, data: { stream: item.path }, }); } }; const handlePointerDown = (item: FSNode) => { isLongPress.current = false; clearLongPressTimer(); longPressTimer.current = setTimeout(() => { isLongPress.current = true; enterSelectMode(item.id); }, 500); }; const handlePointerUp = () => { clearLongPressTimer(); }; const getSelectedNodes = (): FSNode[] => items.filter(i => selectedIds.has(i.id)); const openTransferSheet = (operation: TransferOperation) => { if (selectedIds.size === 0) return; go('folder.modal.transfer.open', { operation }, { mode: modal === 'more' ? 'replace' : 'push' }); }; const openTransferCreateFolder = () => { go('folder.modal.transfer.newFolder.open'); }; const handlePaste = async () => { const success = await paste(currentPath); if (success) { showToast(s.toast_paste_success); refreshItems(); } else showToast(s.toast_paste_failed); }; const handleDeleteClick = () => { const nodes = getSelectedNodes(); if (nodes.length === 0) return; setDeleteTargetNodes(nodes); openModal('delete'); }; const doDelete = async () => { for (const node of deleteTargetNodes) await FileSystem.deleteNode(node.path); showToast(`${s.toast_deleted_prefix}${deleteTargetNodes.length}${s.toast_deleted_suffix}`); refreshItems(); closeModal(); exitSelectMode(); setDeleteTargetNodes([]); }; const deleteConfirmMessage = useMemo(() => { if (deleteTargetNodes.length === 0) return ''; const hasDirectory = deleteTargetNodes.some(n => n.type === 'directory'); return hasDirectory ? s.delete_confirm_folder : s.delete_confirm_file; }, [deleteTargetNodes, s.delete_confirm_folder, s.delete_confirm_file]); // Get target item for modals const modalTargetItem = useMemo(() => { if (modalItemId) { return items.find(i => i.id === modalItemId) || null; } if (selectedIds.size === 1) { const id = Array.from(selectedIds)[0]; return items.find(i => i.id === id) || null; } return null; }, [modalItemId, selectedIds, items]); const handleShowDetails = () => { const nodes = getSelectedNodes(); if (nodes.length !== 1) { showToast(s.select_single_item_required); return; } const id = nodes[0].id; // Back to base state first (2 steps: more menu + select mode), then open details back(2); setTimeout(() => go('folder.modal.details.open.fromBase', { itemId: id }), 50); }; const handleRenameClick = () => { const nodes = getSelectedNodes(); if (nodes.length !== 1) { showToast(s.select_single_item_required); return; } const id = nodes[0].id; // Back to base state first (2 steps: more menu + select mode), then open rename back(2); setTimeout(() => go('folder.modal.rename.open.fromBase', { itemId: id }), 50); }; const doRename = async (newName: string) => { if (!modalTargetItem) return; const parentPath = modalTargetItem.path.substring(0, modalTargetItem.path.lastIndexOf('/')); const result = await FileSystem.moveNode(modalTargetItem.path, `${parentPath}/${newName}`); if (result) { showToast(s.toast_rename_success); refreshItems(); } else showToast(s.toast_rename_failed); closeModal(); }; const handleTransferConfirm = async (destPath: string) => { const success = await transferNodesToDirectory(getSelectedNodes(), destPath, transferOperation); if (success) { showToast(transferOperation === 'copy' ? s.transfer_copy_success : s.transfer_move_success); closeModal(); exitSelectMode(); refreshItems(); } else { showToast(transferOperation === 'copy' ? s.transfer_copy_failed : s.transfer_move_failed); } }; const handleNewFolder = async (name: string) => { const folderPath = `${currentPath}/${name}`; if (FileSystem.exists(folderPath)) { showToast(s.toast_folder_exists); return; } await FileSystem.createDirectory(folderPath); showToast(s.toast_folder_created); closeModal(); refreshItems(); }; // Breadcrumbs logic const breadcrumbs = useMemo((): { name: string; path: string }[] => { const parts = currentPath.split('/').filter(Boolean); const result: { name: string; path: string }[] = [{ name: s.internal_storage_device, path: '/sdcard' }]; let path = ''; parts.forEach((part, index) => { if (part === 'sdcard') return; path += '/' + part; result.push({ name: part, path: (index === 0 ? '' : '/sdcard') + path }); }); return result; }, [currentPath, s.internal_storage_device]); const toggleSelectAll = () => { if (selectedIds.size === items.length) { clearSelection(); } else { selectAll(items.map(i => i.id)); } }; const isAllSelected = items.length > 0 && selectedIds.size === items.length; const folderName = useMemo(() => { const parts = currentPath.split('/').filter(Boolean); return parts[parts.length - 1] === 'sdcard' ? s.section_internal_storage : parts[parts.length - 1]; }, [currentPath]); const formatDisplayDate = useCallback((timestamp: number) => { const date = TimeService.fromTimestamp(timestamp); return `${date.getFullYear()}${s.date_year_suffix}${date.getMonth() + 1}${s.date_month_suffix}${date.getDate()}${s.date_day_suffix}`; }, [s.date_day_suffix, s.date_month_suffix, s.date_year_suffix]); const handleBack = useCallback(() => { back(); }, [back]); const selectionMoreOptions = [ { label: s.menu_copy, onClick: () => openTransferSheet('copy') }, { label: s.menu_set_private, onClick: () => { showToast(s.toast_set_private); closeModal(); } }, { label: s.menu_favorite, onClick: () => { showToast(s.toast_added_to_favorites); closeModal(); } }, { label: s.menu_rename, onClick: handleRenameClick }, { label: s.menu_compress, onClick: () => { showToast(s.toast_compressing); closeModal(); } }, { label: s.menu_add_to_widget, onClick: () => { showToast(s.toast_added); closeModal(); } }, { label: s.menu_open_with_other_app, onClick: () => { showToast(s.toast_searching_apps); closeModal(); } }, { label: s.menu_details, onClick: handleShowDetails }, ]; return (
0 ? `${s.selected_prefix}${selectedIds.size}${s.item_count_suffix}` : s.select_items_prompt) : ''} alwaysShowSmallTitle bgClass="bg-app-surface" showBack={!isSelecting} onBack={handleBack} leftContent={isSelecting ? ( ) : undefined} rightContent={isSelecting ? ( ) : ( <> openModal('newFolder')} label={s.toolbar_new_folder} /> )} />
{/* Breadcrumbs */}
{breadcrumbs.map((crumb, index) => ( {index > 0 && } ))}
{/* Clipboard indicator */} {hasItems && !isSelecting && (
{clipboardOperation === 'copy' ? s.clipboard_copied : s.clipboard_cut} {clipboardItems.length}{s.item_count_suffix}
)} {/* File list */}
{items.length === 0 ? (
{s.folder_empty}
) : (
{items.map(item => { const Icon = getFileIcon(item); const iconColor = getFileIconColor(item); const isSelected = selectedIds.has(item.id); const isCutting = clipboardOperation === 'cut' && clipboardItems.some(i => i.id === item.id); const isImage = item.mimeType?.startsWith('image/'); const triggerId = item.type === 'directory' ? 'folder.open' : isTextPreviewableFile(item) ? 'file.text.open' : isPdfPreviewableFile(item) ? 'file.pdf.open' : undefined; const actionAttrs = !triggerId && isImage ? { 'data-action': 'file.image.open', 'data-action-type': 'open', 'data-action-params': JSON.stringify({ path: item.path }), } : undefined; return ( ); })}
)}
{/* Bottom Action Bar */} {isSelecting && (() => { const hasSelection = selectedIds.size > 0; const actionBtn = `flex flex-col items-center gap-1.5 px-4 transition-opacity ${hasSelection ? 'active:opacity-60' : 'opacity-40'}`; return (
); })()} { closeModal(); setDeleteTargetNodes([]); }} title={s.delete_title} message={deleteConfirmMessage} onConfirm={doDelete} confirmText={s.delete_title} isDestructive={true} />
); }; export default FolderPage;