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.
| c53cf00 | 1 | import React, { useMemo } from 'react'; |
| 2 | import { | |
| 3 | View, | |
| 4 | Text, | |
| 5 | ScrollView, | |
| 6 | StyleSheet, | |
| 7 | } from 'react-native'; | |
| 8 | import { SafeAreaView } from 'react-native-safe-area-context'; | |
| 9 | import { type RouteProp } from '@react-navigation/native'; | |
| 10 | import { colors } from '../theme/colors'; | |
| 11 | import { fontSizes, fonts } from '../theme/typography'; | |
| 12 | import { useFileContent } from '../hooks/useRepo'; | |
| 13 | import { LoadingSpinner } from '../components/LoadingSpinner'; | |
| 14 | import { ErrorState } from '../components/ErrorState'; | |
| 9d7a803 | 15 | import { type MainStackParamList } from '../navigation/types'; |
| c53cf00 | 16 | |
| 17 | type 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. | |
| 23 | type Segment = { text: string; color: string }; | |
| 24 | ||
| 25 | const 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 | ||
| 32 | function 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 | ||
| 74 | function getExtension(path: string): string { | |
| 75 | const parts = path.split('.'); | |
| 76 | return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''; | |
| 77 | } | |
| 78 | ||
| 79 | const 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 | ||
| 87 | export 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 | ||
| 153 | function 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 | ||
| 159 | const 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 | }); |