feat: finish Files tab with folder nav, grid view, and refresh

This commit is contained in:
Linus Rath
2026-05-08 00:57:51 +02:00
parent 00be851611
commit c21661ac7a
+352 -129
View File
@@ -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<FolderFrame[]>([ROOT_FRAME]);
const [files, setFiles] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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<FilesViewMode | null>(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 (
<View style={styles.header}>
<View style={styles.headerRow}>
<View style={styles.headerLeft}>
{canBack ? (
<Pressable onPress={goBack} hitSlop={8} style={styles.headerBtn}>
<ChevronLeft size={22} color={c.text} />
</Pressable>
) : null}
<Text style={styles.title} numberOfLines={1}>
{current.name}
</Text>
</View>
<Pressable
onPress={toggleView}
hitSlop={8}
style={styles.headerBtn}
accessibilityLabel={viewMode === 'list' ? 'Switch to grid view' : 'Switch to list view'}
>
<ToggleIcon size={20} color={c.text} />
</Pressable>
</View>
{canBack ? (
<Text style={styles.breadcrumb} numberOfLines={1}>
{stack.map((f) => f.name).join(' / ')}
</Text>
) : null}
</View>
);
};
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 (
<Pressable
onPress={() => openNode(item)}
disabled={!tappable}
style={({ pressed }) => [
styles.fileRow,
pressed && tappable && { backgroundColor: c.surfaceHover },
]}
>
{showIcons && <Icon size={20} color={tint} />}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>
{item.name}
</Text>
<View style={styles.fileMetaRow}>
{item.size != null && item.type !== 'directory' ? (
<Text style={styles.fileMeta}>{formatFileSize(item.size)}</Text>
) : null}
{item.updated ? (
<Text style={styles.fileMeta}>{formatModified(item.updated)}</Text>
) : null}
</View>
</View>
</Pressable>
);
};
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 (
<Pressable
onPress={() => openNode(item)}
disabled={!tappable}
style={({ pressed }) => [
styles.gridItem,
pressed && tappable && { backgroundColor: c.surfaceHover },
]}
>
{showIcons ? (
<Icon size={36} color={tint} />
) : (
<View style={{ width: 36, height: 36 }} />
)}
<Text style={styles.gridName} numberOfLines={2}>
{item.name}
</Text>
{item.size != null && item.type !== 'directory' ? (
<Text style={styles.gridMeta} numberOfLines={1}>
{formatFileSize(item.size)}
</Text>
) : null}
</Pressable>
);
};
let body: React.ReactNode;
if (loading && !refreshing) {
body = (
<View style={styles.center}>
<ActivityIndicator color={c.primary} />
</View>
);
}
if (error) {
return (
} else if (error) {
body = (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={loadFiles}>
<TouchableOpacity
style={styles.retryButton}
onPress={() => void loadFiles(current.id)}
>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
</View>
);
}
if (visibleFiles.length === 0) {
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Files</Text>
</View>
<View style={styles.center}>
<HardDrive size={48} color={c.textMuted} />
<Text style={styles.emptyText}>No files yet</Text>
</View>
} else if (visibleFiles.length === 0) {
body = (
<View style={styles.center}>
<HardDrive size={48} color={c.textMuted} />
<Text style={styles.emptyText}>
{stack.length > 1 ? 'This folder is empty' : 'No files yet'}
</Text>
</View>
);
} else if (viewMode === 'grid') {
body = (
<FlatList
data={visibleFiles}
key="grid"
numColumns={3}
keyExtractor={(item) => item.id}
columnWrapperStyle={styles.gridRow}
contentContainerStyle={styles.gridContent}
renderItem={({ item }) => renderGridCell(item)}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={c.primary}
/>
}
/>
);
} else {
body = (
<FlatList
data={visibleFiles}
key="list"
keyExtractor={(item) => item.id}
renderItem={({ item }) => renderRow(item)}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={c.primary}
/>
}
/>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Files</Text>
</View>
<FlatList
data={visibleFiles}
keyExtractor={(item) => 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 (
<View style={styles.fileRow}>
{showIcons && <Icon size={20} color={tint} />}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>
{item.name}
</Text>
{item.size != null && (
<Text style={styles.fileSize}>{formatFileSize(item.size)}</Text>
)}
</View>
</View>
);
}}
/>
{renderHeader()}
{body}
</View>
);
}
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',
},
});
}