From c21661ac7a0728b84df59ad757fed59cbb720d1e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 8 May 2026 00:57:51 +0200 Subject: [PATCH] feat: finish Files tab with folder nav, grid view, and refresh --- src/screens/FilesScreen.tsx | 481 ++++++++++++++++++++++++++---------- 1 file changed, 352 insertions(+), 129 deletions(-) diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index 39b488b5..4c442a75 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -2,20 +2,29 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, FlatList, + Pressable, + RefreshControl, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; import { - FileText, Folder, HardDrive, FileImage, FileVideo, FileAudio, - FileArchive, FileSpreadsheet, FileCode2, + ChevronLeft, FileText, Folder, HardDrive, FileImage, FileVideo, FileAudio, + FileArchive, FileSpreadsheet, FileCode2, LayoutGrid, List as ListIcon, } from 'lucide-react-native'; import { getFileNodes } from '../api/files'; import type { FileNode } from '../api/types'; -import { spacing, typography, type ThemePalette } from '../theme/tokens'; +import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; -import { useSettingsStore } from '../stores/settings-store'; +import { useSettingsStore, type FilesViewMode } from '../stores/settings-store'; + +interface FolderFrame { + id: string | null; + name: string; +} + +const ROOT_FRAME: FolderFrame = { id: null, name: 'Files' }; function pickFileIcon(name: string) { const ext = name.split('.').pop()?.toLowerCase() ?? ''; @@ -48,14 +57,35 @@ function formatFileSize(bytes?: number): string { if (bytes == null) return ''; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +function formatModified(iso?: string): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const now = new Date(); + const sameDay = + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate(); + if (sameDay) { + return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + } + if (d.getFullYear() === now.getFullYear()) { + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } export default function FilesScreen() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const [stack, setStack] = useState([ROOT_FRAME]); const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const showIcons = useSettingsStore((s) => s.filesShowIcons); @@ -63,19 +93,37 @@ export default function FilesScreen() { const showHiddenFiles = useSettingsStore((s) => s.filesShowHiddenFiles); const sortKey = useSettingsStore((s) => s.filesDefaultSortKey); const sortDir = useSettingsStore((s) => s.filesDefaultSortDir); + const defaultViewMode = useSettingsStore((s) => s.filesDefaultViewMode); + const setSetting = useSettingsStore((s) => s.updateSetting); - const loadFiles = useCallback(async () => { - setLoading(true); - setError(null); - try { - const nodes = await getFileNodes(null); - setFiles(nodes); - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to load files'); - } finally { - setLoading(false); - } - }, []); + // Local override so the user can flip view from the toolbar without + // changing their persisted default. Falls back to the setting. + const [viewOverride, setViewOverride] = useState(null); + const viewMode: FilesViewMode = viewOverride ?? defaultViewMode; + + const current = stack[stack.length - 1]; + + const loadFiles = useCallback( + async (parentId: string | null, mode: 'initial' | 'refresh' = 'initial') => { + if (mode === 'refresh') setRefreshing(true); + else setLoading(true); + setError(null); + try { + const nodes = await getFileNodes(parentId); + setFiles(nodes); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load files'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, + [], + ); + + useEffect(() => { + void loadFiles(current.id); + }, [loadFiles, current.id]); const visibleFiles = useMemo(() => { let out = files; @@ -102,141 +150,316 @@ export default function FilesScreen() { }); }, [files, showHiddenFiles, sortKey, sortDir]); - useEffect(() => { - void loadFiles(); - }, [loadFiles]); + const openNode = useCallback((node: FileNode) => { + if (node.type === 'directory') { + setStack((s) => [...s, { id: node.id, name: node.name }]); + } + // Files are no-ops for now — opening a blob would need a download flow + // wired to the JMAP download endpoint. + }, []); - if (loading) { + const goBack = useCallback(() => { + setStack((s) => (s.length > 1 ? s.slice(0, -1) : s)); + }, []); + + const toggleView = useCallback(() => { + const next: FilesViewMode = viewMode === 'list' ? 'grid' : 'list'; + setViewOverride(next); + setSetting('filesDefaultViewMode', next); + }, [viewMode, setSetting]); + + const onRefresh = useCallback(() => { + void loadFiles(current.id, 'refresh'); + }, [loadFiles, current.id]); + + const renderHeader = () => { + const canBack = stack.length > 1; + const ToggleIcon = viewMode === 'list' ? LayoutGrid : ListIcon; return ( + + + + {canBack ? ( + + + + ) : null} + + {current.name} + + + + + + + {canBack ? ( + + {stack.map((f) => f.name).join(' / ')} + + ) : null} + + ); + }; + + const renderRow = (item: FileNode) => { + const Icon = item.type === 'directory' ? Folder : pickFileIcon(item.name); + const tint = item.type === 'directory' + ? (coloredIcons ? c.calendar.blue : c.textMuted) + : (coloredIcons ? fileIconColor(item.name, c) : c.textMuted); + const tappable = item.type === 'directory'; + return ( + openNode(item)} + disabled={!tappable} + style={({ pressed }) => [ + styles.fileRow, + pressed && tappable && { backgroundColor: c.surfaceHover }, + ]} + > + {showIcons && } + + + {item.name} + + + {item.size != null && item.type !== 'directory' ? ( + {formatFileSize(item.size)} + ) : null} + {item.updated ? ( + {formatModified(item.updated)} + ) : null} + + + + ); + }; + + const renderGridCell = (item: FileNode) => { + const Icon = item.type === 'directory' ? Folder : pickFileIcon(item.name); + const tint = item.type === 'directory' + ? (coloredIcons ? c.calendar.blue : c.textMuted) + : (coloredIcons ? fileIconColor(item.name, c) : c.textMuted); + const tappable = item.type === 'directory'; + return ( + openNode(item)} + disabled={!tappable} + style={({ pressed }) => [ + styles.gridItem, + pressed && tappable && { backgroundColor: c.surfaceHover }, + ]} + > + {showIcons ? ( + + ) : ( + + )} + + {item.name} + + {item.size != null && item.type !== 'directory' ? ( + + {formatFileSize(item.size)} + + ) : null} + + ); + }; + + let body: React.ReactNode; + if (loading && !refreshing) { + body = ( ); - } - - if (error) { - return ( + } else if (error) { + body = ( {error} - + void loadFiles(current.id)} + > Retry ); - } - - if (visibleFiles.length === 0) { - return ( - - - Files - - - - No files yet - + } else if (visibleFiles.length === 0) { + body = ( + + + + {stack.length > 1 ? 'This folder is empty' : 'No files yet'} + ); + } else if (viewMode === 'grid') { + body = ( + item.id} + columnWrapperStyle={styles.gridRow} + contentContainerStyle={styles.gridContent} + renderItem={({ item }) => renderGridCell(item)} + refreshControl={ + + } + /> + ); + } else { + body = ( + item.id} + renderItem={({ item }) => renderRow(item)} + refreshControl={ + + } + /> + ); } return ( - - Files - - item.id} - renderItem={({ item }) => { - const Icon = item.type === 'directory' ? Folder : pickFileIcon(item.name); - const tint = item.type === 'directory' - ? (coloredIcons ? c.primary : c.textMuted) - : (coloredIcons ? fileIconColor(item.name, c) : c.textMuted); - return ( - - {showIcons && } - - - {item.name} - - {item.size != null && ( - {formatFileSize(item.size)} - )} - - - ); - }} - /> + {renderHeader()} + {body} ); } function makeStyles(c: ThemePalette) { return StyleSheet.create({ - container: { - flex: 1, - backgroundColor: c.background, - }, - header: { - paddingTop: 60, - paddingHorizontal: spacing.md, - paddingBottom: spacing.sm, - borderBottomWidth: 1, - borderBottomColor: c.border, - }, - title: { - fontSize: typography.h2.fontSize, - fontWeight: '700' as const, - color: c.text, - }, - center: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - gap: spacing.sm, - }, - emptyText: { - color: c.textMuted, - fontSize: typography.body.fontSize, - marginTop: spacing.sm, - }, - errorText: { - color: c.error, - fontSize: typography.body.fontSize, - textAlign: 'center', - paddingHorizontal: spacing.lg, - }, - retryButton: { - marginTop: spacing.md, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - backgroundColor: c.primary, - borderRadius: 8, - }, - retryText: { - color: c.primaryForeground, - fontWeight: '600' as const, - }, - fileRow: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: spacing.sm, - paddingHorizontal: spacing.md, - borderBottomWidth: 1, - borderBottomColor: c.border, - gap: spacing.sm, - }, - fileInfo: { - flex: 1, - }, - fileName: { - fontSize: typography.body.fontSize, - color: c.text, - }, - fileSize: { - fontSize: typography.caption.fontSize, - color: c.textMuted, - marginTop: 2, - }, + container: { + flex: 1, + backgroundColor: c.background, + }, + header: { + paddingTop: 60, + paddingHorizontal: spacing.md, + paddingBottom: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm, + }, + headerLeft: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + headerBtn: { + padding: spacing.xs, + borderRadius: radius.sm, + }, + title: { + fontSize: typography.h2.fontSize, + fontWeight: '700' as const, + color: c.text, + flexShrink: 1, + }, + breadcrumb: { + marginTop: 2, + fontSize: typography.caption.fontSize, + color: c.textMuted, + }, + center: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + gap: spacing.sm, + }, + emptyText: { + color: c.textMuted, + fontSize: typography.body.fontSize, + marginTop: spacing.sm, + }, + errorText: { + color: c.error, + fontSize: typography.body.fontSize, + textAlign: 'center', + paddingHorizontal: spacing.lg, + }, + retryButton: { + marginTop: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: c.primary, + borderRadius: 8, + }, + retryText: { + color: c.primaryForeground, + fontWeight: '600' as const, + }, + fileRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderBottomWidth: 1, + borderBottomColor: c.border, + gap: spacing.sm, + }, + fileInfo: { + flex: 1, + }, + fileName: { + fontSize: typography.body.fontSize, + color: c.text, + }, + fileMetaRow: { + flexDirection: 'row', + gap: spacing.sm, + marginTop: 2, + }, + fileMeta: { + fontSize: typography.caption.fontSize, + color: c.textMuted, + }, + gridContent: { + padding: spacing.sm, + }, + gridRow: { + gap: spacing.sm, + }, + gridItem: { + flex: 1, + alignItems: 'center', + gap: spacing.xs, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xs, + borderRadius: radius.md, + }, + gridName: { + fontSize: typography.caption.fontSize, + color: c.text, + textAlign: 'center', + }, + gridMeta: { + fontSize: typography.small.fontSize, + color: c.textMuted, + textAlign: 'center', + }, }); }