Blame · Line-by-line history
FileViewerScreen.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 9a8fbed | 1 | import React, { useEffect, useState, useCallback } from 'react'; |
| 2 | import { | |
| 3 | FlatList, | |
| 4 | ScrollView, | |
| 5 | StyleSheet, | |
| 6 | Text, | |
| 7 | TouchableOpacity, | |
| 8 | View, | |
| 9 | } from 'react-native'; | |
| 10 | import { SafeAreaView } from 'react-native-safe-area-context'; | |
| 11 | import type { NativeStackScreenProps } from '@react-navigation/native-stack'; | |
| 12 | import { getFileContent, getTree } from '../api/repos'; | |
| 13 | import type { TreeEntry } from '../api/repos'; | |
| 14 | import { LoadingSpinner } from '../components/LoadingSpinner'; | |
| 15 | import { ErrorBanner } from '../components/ErrorBanner'; | |
| 16 | import { colors } from '../theme/colors'; | |
| 17 | import type { RepoStackParamList } from '../navigation/AppNavigator'; | |
| 18 | ||
| 19 | type Props = NativeStackScreenProps<RepoStackParamList, 'FileViewer'>; | |
| 20 | ||
| 21 | function isTextFile(path: string): boolean { | |
| 22 | const ext = path.split('.').pop()?.toLowerCase() ?? ''; | |
| 23 | const textExts = new Set([ | |
| 24 | 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', | |
| 25 | 'json', 'yaml', 'yml', 'toml', 'env', | |
| 26 | 'md', 'mdx', 'txt', 'rst', | |
| 27 | 'py', 'rb', 'go', 'rs', 'java', 'kt', 'swift', | |
| 28 | 'c', 'cpp', 'h', 'hpp', 'cs', | |
| 29 | 'html', 'htm', 'css', 'scss', 'sass', 'less', | |
| 30 | 'sh', 'bash', 'zsh', 'fish', | |
| 31 | 'sql', 'graphql', 'proto', | |
| 32 | 'xml', 'svg', 'gitignore', 'dockerignore', | |
| 33 | ]); | |
| 34 | return textExts.has(ext); | |
| 35 | } | |
| 36 | ||
| 37 | function isImageFile(path: string): boolean { | |
| 38 | const ext = path.split('.').pop()?.toLowerCase() ?? ''; | |
| 39 | return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico'].includes(ext); | |
| 40 | } | |
| 41 | ||
| 42 | export function FileViewerScreen({ route, navigation }: Props): React.ReactElement { | |
| 43 | const { owner, repo, ref, path } = route.params; | |
| 44 | ||
| 45 | const [content, setContent] = useState<string | null>(null); | |
| 46 | const [dirEntries, setDirEntries] = useState<TreeEntry[] | null>(null); | |
| 47 | const [isLoading, setIsLoading] = useState(true); | |
| 48 | const [error, setError] = useState<string | null>(null); | |
| 49 | const [mode, setMode] = useState<'file' | 'dir'>('file'); | |
| 50 | ||
| 51 | useEffect(() => { | |
| 52 | let cancelled = false; | |
| 53 | setIsLoading(true); | |
| 54 | setError(null); | |
| 55 | setContent(null); | |
| 56 | setDirEntries(null); | |
| 57 | ||
| 58 | // Try directory first, then file | |
| 59 | getTree(owner, repo, ref, path) | |
| 60 | .then((entries) => { | |
| 61 | if (!cancelled) { | |
| 62 | if (entries.length > 0 && entries[0].path !== path) { | |
| 63 | // It's a directory listing | |
| 64 | setMode('dir'); | |
| 65 | setDirEntries(entries); | |
| 66 | } else { | |
| 67 | // It's a file — fetch content | |
| 68 | setMode('file'); | |
| 69 | return getFileContent(owner, repo, ref, path).then((c) => { | |
| 70 | if (!cancelled) setContent(c); | |
| 71 | }); | |
| 72 | } | |
| 73 | } | |
| 74 | }) | |
| 75 | .catch(() => { | |
| 76 | // Fallback: treat as file | |
| 77 | getFileContent(owner, repo, ref, path) | |
| 78 | .then((c) => { | |
| 79 | if (!cancelled) { | |
| 80 | setMode('file'); | |
| 81 | setContent(c); | |
| 82 | } | |
| 83 | }) | |
| 84 | .catch((err) => { | |
| 85 | if (!cancelled) { | |
| 86 | setError(err instanceof Error ? err.message : 'Failed to load file'); | |
| 87 | } | |
| 88 | }); | |
| 89 | }) | |
| 90 | .finally(() => { | |
| 91 | if (!cancelled) setIsLoading(false); | |
| 92 | }); | |
| 93 | ||
| 94 | return () => { | |
| 95 | cancelled = true; | |
| 96 | }; | |
| 97 | }, [owner, repo, ref, path]); | |
| 98 | ||
| 99 | const retry = useCallback(() => { | |
| 100 | setIsLoading(true); | |
| 101 | setError(null); | |
| 102 | }, []); | |
| 103 | ||
| 104 | const handleEntryPress = useCallback( | |
| 105 | (entry: TreeEntry) => { | |
| 106 | navigation.push('FileViewer', { | |
| 107 | owner, | |
| 108 | repo, | |
| 109 | ref, | |
| 110 | path: entry.path, | |
| 111 | }); | |
| 112 | }, | |
| 113 | [navigation, owner, repo, ref], | |
| 114 | ); | |
| 115 | ||
| 116 | const renderEntry = useCallback( | |
| 117 | ({ item }: { item: TreeEntry }) => ( | |
| 118 | <TouchableOpacity | |
| 119 | style={styles.fileRow} | |
| 120 | onPress={() => handleEntryPress(item)} | |
| 121 | activeOpacity={0.75} | |
| 122 | > | |
| 123 | <Text style={styles.fileIcon}>{item.type === 'tree' ? '📁' : '📄'}</Text> | |
| 124 | <Text style={styles.fileName} numberOfLines={1}> | |
| 125 | {item.name} | |
| 126 | </Text> | |
| 127 | {item.type === 'tree' && <Text style={styles.chevron}>›</Text>} | |
| 128 | </TouchableOpacity> | |
| 129 | ), | |
| 130 | [handleEntryPress], | |
| 131 | ); | |
| 132 | ||
| 133 | const keyExtractor = useCallback((item: TreeEntry) => item.path, []); | |
| 134 | ||
| 135 | if (isLoading) return <LoadingSpinner fullScreen />; | |
| 136 | ||
| 137 | return ( | |
| 138 | <SafeAreaView style={styles.safe} edges={['bottom']}> | |
| 139 | {/* Breadcrumb */} | |
| 140 | <View style={styles.breadcrumb}> | |
| 141 | <Text style={styles.breadcrumbText} numberOfLines={1}> | |
| 142 | {owner}/{repo}/{path} | |
| 143 | </Text> | |
| 144 | </View> | |
| 145 | ||
| 146 | {error !== null && <ErrorBanner message={error} onRetry={retry} />} | |
| 147 | ||
| 148 | {mode === 'dir' && dirEntries !== null && ( | |
| 149 | <FlatList | |
| 150 | data={dirEntries} | |
| 151 | keyExtractor={keyExtractor} | |
| 152 | renderItem={renderEntry} | |
| 153 | style={styles.dirList} | |
| 154 | /> | |
| 155 | )} | |
| 156 | ||
| 157 | {mode === 'file' && content !== null && ( | |
| 158 | <ScrollView style={styles.fileScroll} horizontal={false}> | |
| 159 | <ScrollView horizontal showsHorizontalScrollIndicator> | |
| 160 | {isImageFile(path) ? ( | |
| 161 | <View style={styles.unsupported}> | |
| 162 | <Text style={styles.unsupportedText}>Image preview not available</Text> | |
| 163 | </View> | |
| 164 | ) : isTextFile(path) ? ( | |
| 165 | <View style={styles.codeContainer}> | |
| 166 | <Text style={styles.codeText} selectable> | |
| 167 | {content} | |
| 168 | </Text> | |
| 169 | </View> | |
| 170 | ) : ( | |
| 171 | <View style={styles.unsupported}> | |
| 172 | <Text style={styles.unsupportedText}> | |
| 173 | Binary file — {path.split('.').pop()?.toUpperCase()} cannot be previewed | |
| 174 | </Text> | |
| 175 | </View> | |
| 176 | )} | |
| 177 | </ScrollView> | |
| 178 | </ScrollView> | |
| 179 | )} | |
| 180 | </SafeAreaView> | |
| 181 | ); | |
| 182 | } | |
| 183 | ||
| 184 | const styles = StyleSheet.create({ | |
| 185 | safe: { | |
| 186 | flex: 1, | |
| 187 | backgroundColor: colors.bg, | |
| 188 | }, | |
| 189 | breadcrumb: { | |
| 190 | paddingHorizontal: 14, | |
| 191 | paddingVertical: 10, | |
| 192 | backgroundColor: colors.bgSecondary, | |
| 193 | borderBottomWidth: 1, | |
| 194 | borderBottomColor: colors.border, | |
| 195 | }, | |
| 196 | breadcrumbText: { | |
| 197 | fontSize: 12, | |
| 198 | fontFamily: 'monospace', | |
| 199 | color: colors.textMuted, | |
| 200 | }, | |
| 201 | dirList: { | |
| 202 | flex: 1, | |
| 203 | backgroundColor: colors.bgSecondary, | |
| 204 | }, | |
| 205 | fileRow: { | |
| 206 | flexDirection: 'row', | |
| 207 | alignItems: 'center', | |
| 208 | paddingVertical: 10, | |
| 209 | paddingHorizontal: 16, | |
| 210 | borderBottomWidth: 1, | |
| 211 | borderBottomColor: colors.border, | |
| 212 | gap: 10, | |
| 213 | }, | |
| 214 | fileIcon: { | |
| 215 | fontSize: 15, | |
| 216 | }, | |
| 217 | fileName: { | |
| 218 | flex: 1, | |
| 219 | fontSize: 14, | |
| 220 | color: colors.textLink, | |
| 221 | }, | |
| 222 | chevron: { | |
| 223 | fontSize: 18, | |
| 224 | color: colors.textMuted, | |
| 225 | }, | |
| 226 | fileScroll: { | |
| 227 | flex: 1, | |
| 228 | backgroundColor: colors.bg, | |
| 229 | }, | |
| 230 | codeContainer: { | |
| 231 | padding: 16, | |
| 232 | minWidth: '100%', | |
| 233 | }, | |
| 234 | codeText: { | |
| 235 | fontSize: 12, | |
| 236 | fontFamily: 'monospace', | |
| 237 | color: colors.text, | |
| 238 | lineHeight: 18, | |
| 239 | }, | |
| 240 | unsupported: { | |
| 241 | flex: 1, | |
| 242 | padding: 32, | |
| 243 | alignItems: 'center', | |
| 244 | justifyContent: 'center', | |
| 245 | }, | |
| 246 | unsupportedText: { | |
| 247 | fontSize: 14, | |
| 248 | color: colors.textMuted, | |
| 249 | textAlign: 'center', | |
| 250 | }, | |
| 251 | }); |