Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame231 lines · 1 contributor
c53cf00Claude1import React, { useMemo } from 'react';
2import {
3 View,
4 Text,
5 ScrollView,
6 StyleSheet,
7} from 'react-native';
8import { SafeAreaView } from 'react-native-safe-area-context';
9import { type RouteProp } from '@react-navigation/native';
10import { colors } from '../theme/colors';
11import { fontSizes, fonts } from '../theme/typography';
12import { useFileContent } from '../hooks/useRepo';
13import { LoadingSpinner } from '../components/LoadingSpinner';
14import { ErrorState } from '../components/ErrorState';
9d7a803Claude15import { type MainStackParamList } from '../navigation/types';
c53cf00Claude16
17type Props = {
18 route: RouteProp<MainStackParamList, 'FileViewer'>;
19};
20
21// Minimal keyword syntax highlighting via regex replacement.
22// Returns an array of {text, color} segments for a single line.
23type Segment = { text: string; color: string };
24
25const KEYWORDS: Record<string, RegExp> = {
26 keyword: /\b(const|let|var|function|class|if|else|for|while|return|import|export|default|from|async|await|try|catch|throw|new|typeof|instanceof|void|null|undefined|true|false|extends|implements|interface|type|enum|namespace|module|declare|abstract|public|private|protected|static|readonly|override|def|fn|pub|use|mod|struct|impl|match|where|self|super|trait|yield|in|of|do|switch|case|break|continue|pass|and|or|not|is|as|with|lambda|del|global|nonlocal|assert|finally|raise|except|elif)\b/g,
27 string: /(["'`])(?:\\.|(?!\1)[^\\])*\1/g,
28 comment: /(\/\/[^\n]*|#[^\n]*|\/\*[\s\S]*?\*\/)/g,
29 number: /\b(\d+\.?\d*)\b/g,
30};
31
32function tokenizeLine(line: string, lang: string): Segment[] {
33 // Skip highlighting for binary or very long lines
34 if (line.length > 500) return [{ text: line, color: colors.text }];
35
36 // Collect all token ranges
37 const ranges: Array<{ start: number; end: number; color: string }> = [];
38
39 function addRanges(regex: RegExp, color: string) {
40 regex.lastIndex = 0;
41 let m: RegExpExecArray | null;
42 while ((m = regex.exec(line)) !== null) {
43 ranges.push({ start: m.index, end: m.index + m[0].length, color });
44 }
45 }
46
47 addRanges(new RegExp(KEYWORDS.comment.source, 'g'), colors.textMuted);
48 addRanges(new RegExp(KEYWORDS.string.source, 'g'), colors.green);
49 addRanges(new RegExp(KEYWORDS.keyword.source, 'g'), colors.accent);
50 addRanges(new RegExp(KEYWORDS.number.source, 'g'), colors.yellow);
51
52 if (ranges.length === 0) return [{ text: line, color: colors.text }];
53
54 // Sort by start, resolve overlaps
55 ranges.sort((a, b) => a.start - b.start);
56
57 const segments: Segment[] = [];
58 let cursor = 0;
59 for (const r of ranges) {
60 if (r.start < cursor) continue; // overlapping — skip
61 if (r.start > cursor) {
62 segments.push({ text: line.slice(cursor, r.start), color: colors.text });
63 }
64 segments.push({ text: line.slice(r.start, r.end), color: r.color });
65 cursor = r.end;
66 }
67 if (cursor < line.length) {
68 segments.push({ text: line.slice(cursor), color: colors.text });
69 }
70
71 return segments;
72}
73
74function getExtension(path: string): string {
75 const parts = path.split('.');
76 return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
77}
78
79const CODE_EXTS = new Set([
80 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
81 'py', 'rb', 'go', 'rs', 'c', 'cpp', 'cc', 'h', 'hpp',
82 'java', 'kt', 'swift', 'cs', 'php', 'sh', 'bash', 'zsh',
83 'toml', 'yaml', 'yml', 'json', 'md', 'sql', 'graphql',
84 'css', 'scss', 'sass', 'html', 'xml', 'svelte', 'vue',
85]);
86
87export function FileViewerScreen({ route }: Props) {
88 const { owner, repo, path, ref } = route.params;
89 const { file, loading, error } = useFileContent(owner, repo, path, ref);
90
91 const ext = getExtension(path);
92 const isCode = CODE_EXTS.has(ext);
93
94 const content = useMemo(() => {
95 if (!file) return '';
96 if (file.encoding === 'base64') {
97 try {
98 return atob(file.content.replace(/\n/g, ''));
99 } catch {
100 return file.content;
101 }
102 }
103 return file.content;
104 }, [file]);
105
106 const lines = useMemo(() => content.split('\n'), [content]);
107
108 if (loading) return <LoadingSpinner fullScreen />;
109 if (error || !file) return <ErrorState message={error || 'File not found'} />;
110
111 const isBinary = !isCode && file.size > 0;
112
113 return (
114 <SafeAreaView style={styles.safe} edges={['top', 'bottom']}>
115 {/* File info bar */}
116 <View style={styles.infoBar}>
117 <Text style={styles.fileName} numberOfLines={1}>{path.split('/').pop()}</Text>
118 <Text style={styles.fileMeta}>{formatSize(file.size)} · {lines.length} lines</Text>
119 </View>
120
121 {isBinary ? (
122 <View style={styles.binaryWrap}>
123 <Text style={styles.binaryText}>Binary file — preview not available</Text>
124 <Text style={styles.binarySize}>{formatSize(file.size)}</Text>
125 </View>
126 ) : (
127 <ScrollView horizontal showsHorizontalScrollIndicator style={styles.hScroll}>
128 <ScrollView style={styles.vScroll}>
129 <View style={styles.codeWrap}>
130 {lines.map((line, idx) => {
131 const segments = isCode ? tokenizeLine(line, ext) : [{ text: line, color: colors.text }];
132 return (
133 <View key={idx} style={styles.codeLine}>
134 <Text style={styles.lineNo}>{idx + 1}</Text>
135 <Text style={styles.lineContent}>
136 {segments.map((seg, si) => (
137 <Text key={si} style={{ color: seg.color }}>
138 {seg.text}
139 </Text>
140 ))}
141 </Text>
142 </View>
143 );
144 })}
145 </View>
146 </ScrollView>
147 </ScrollView>
148 )}
149 </SafeAreaView>
150 );
151}
152
153function formatSize(bytes: number): string {
154 if (bytes < 1024) return `${bytes} B`;
155 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
156 return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
157}
158
159const styles = StyleSheet.create({
160 safe: {
161 flex: 1,
162 backgroundColor: colors.bg,
163 },
164 infoBar: {
165 flexDirection: 'row',
166 justifyContent: 'space-between',
167 alignItems: 'center',
168 paddingHorizontal: 16,
169 paddingVertical: 10,
170 borderBottomWidth: 1,
171 borderBottomColor: colors.border,
172 backgroundColor: colors.bgSecondary,
173 },
174 fileName: {
175 color: colors.text,
176 fontSize: fontSizes.sm,
177 fontFamily: fonts.mono,
178 fontWeight: '600',
179 flex: 1,
180 marginRight: 12,
181 },
182 fileMeta: {
183 color: colors.textMuted,
184 fontSize: fontSizes.xs,
185 },
186 hScroll: {
187 flex: 1,
188 },
189 vScroll: {
190 flex: 1,
191 },
192 codeWrap: {
193 padding: 8,
194 paddingBottom: 40,
195 },
196 codeLine: {
197 flexDirection: 'row',
198 minHeight: 20,
199 },
200 lineNo: {
201 width: 36,
202 color: colors.textMuted,
203 fontSize: fontSizes.xs,
204 fontFamily: fonts.mono,
205 textAlign: 'right',
206 paddingRight: 12,
207 lineHeight: 20,
208 userSelect: 'none',
209 },
210 lineContent: {
211 fontSize: fontSizes.xs,
212 fontFamily: fonts.mono,
213 lineHeight: 20,
214 color: colors.text,
215 },
216 binaryWrap: {
217 flex: 1,
218 alignItems: 'center',
219 justifyContent: 'center',
220 padding: 32,
221 },
222 binaryText: {
223 color: colors.textMuted,
224 fontSize: fontSizes.base,
225 marginBottom: 8,
226 },
227 binarySize: {
228 color: colors.textMuted,
229 fontSize: fontSizes.sm,
230 },
231});