Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

FileViewerScreen.tsxBlame251 lines · 1 contributor
9a8fbedClaude1import React, { useEffect, useState, useCallback } from 'react';
2import {
3 FlatList,
4 ScrollView,
5 StyleSheet,
6 Text,
7 TouchableOpacity,
8 View,
9} from 'react-native';
10import { SafeAreaView } from 'react-native-safe-area-context';
11import type { NativeStackScreenProps } from '@react-navigation/native-stack';
12import { getFileContent, getTree } from '../api/repos';
13import type { TreeEntry } from '../api/repos';
14import { LoadingSpinner } from '../components/LoadingSpinner';
15import { ErrorBanner } from '../components/ErrorBanner';
16import { colors } from '../theme/colors';
17import type { RepoStackParamList } from '../navigation/AppNavigator';
18
19type Props = NativeStackScreenProps<RepoStackParamList, 'FileViewer'>;
20
21function 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
37function isImageFile(path: string): boolean {
38 const ext = path.split('.').pop()?.toLowerCase() ?? '';
39 return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico'].includes(ext);
40}
41
42export 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
184const 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});