Im Hauptchat (projectId='') riefen FilesTile/DesktopTile /projects//files bzw. /vms auf → Brain 404. Beide guarden jetzt leere projectId: kein Fetch, klare Meldung "Kein aktives Projekt — wechsle in ein Projekt". tsc clean. App-only, Deploy: APK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
150 lines
6.7 KiB
TypeScript
150 lines
6.7 KiB
TypeScript
/**
|
|
* FilesTile — Datei-Browser eines Projekts (/shared/projects/<id>/).
|
|
*
|
|
* Listet ALLE Dateien (nicht nur Code): erzeugte Bilder, Logs, Assets … — die
|
|
* gleichen, die in der Projektliste als 📄 gezaehlt werden. Tippen auf ein Bild
|
|
* zeigt es; tippen auf eine Textdatei zeigt eine Vorschau.
|
|
*/
|
|
|
|
import React, { useCallback, useEffect, useState } from 'react';
|
|
import { ActivityIndicator, Image, Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
import brainApi from '../../services/brainApi';
|
|
|
|
interface Props {
|
|
projectId: string;
|
|
focused: boolean;
|
|
}
|
|
|
|
interface FileEntry { path: string; size: number }
|
|
|
|
const IMG_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'];
|
|
|
|
function ext(path: string): string { return (path.split('.').pop() || '').toLowerCase(); }
|
|
function isImage(path: string): boolean { return IMG_EXT.includes(ext(path)); }
|
|
function iconFor(path: string): string {
|
|
const e = ext(path);
|
|
if (isImage(path)) return '🖼️';
|
|
if (['md', 'txt', 'readme'].includes(e)) return '📄';
|
|
if (['asm', 's', 'c', 'h', 'cpp', 'py', 'js', 'ts', 'sh', 'go', 'rs'].includes(e)) return '📝';
|
|
if (['zip', 'tar', 'gz', 'img', 'iso', 'qcow2'].includes(e)) return '📦';
|
|
return '📄';
|
|
}
|
|
function humanSize(n: number): string {
|
|
if (n < 1024) return `${n} B`;
|
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|
|
|
|
const FilesTile: React.FC<Props> = ({ projectId, focused }) => {
|
|
const [files, setFiles] = useState<FileEntry[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [err, setErr] = useState('');
|
|
const [preview, setPreview] = useState<{ path: string; kind: 'image' | 'text'; data: string } | null>(null);
|
|
const [previewBusy, setPreviewBusy] = useState('');
|
|
|
|
const load = useCallback(() => {
|
|
if (!projectId) { setFiles([]); setErr(''); setLoading(false); return; }
|
|
setLoading(true); setErr('');
|
|
brainApi.listProjectFiles(projectId)
|
|
.then(r => setFiles((r.files || []).slice().sort((a, b) => a.path.localeCompare(b.path))))
|
|
.catch(e => setErr(String(e?.message || e)))
|
|
.finally(() => setLoading(false));
|
|
}, [projectId]);
|
|
|
|
useEffect(() => { if (focused) load(); }, [focused, projectId, load]);
|
|
|
|
const open = useCallback((f: FileEntry) => {
|
|
setPreviewBusy(f.path); setErr('');
|
|
if (isImage(f.path)) {
|
|
brainApi.readProjectFileBinary(projectId, f.path)
|
|
.then(r => setPreview({ path: f.path, kind: 'image', data: `data:${r.mime};base64,${r.base64}` }))
|
|
.catch(e => setErr(String(e?.message || e)))
|
|
.finally(() => setPreviewBusy(''));
|
|
} else {
|
|
brainApi.readProjectFile(projectId, f.path)
|
|
.then(r => setPreview({ path: f.path, kind: 'text', data: r.content ?? '' }))
|
|
.catch(e => setErr(String(e?.message || e)))
|
|
.finally(() => setPreviewBusy(''));
|
|
}
|
|
}, [projectId]);
|
|
|
|
if (!focused) {
|
|
return (
|
|
<View style={styles.placeholder}>
|
|
<Text style={styles.icon}>📁</Text>
|
|
<Text style={styles.text}>Dateien</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.bar}>
|
|
<Text style={styles.barTitle}>Dateien{files.length ? ` (${files.length})` : ''}</Text>
|
|
<TouchableOpacity onPress={load} style={styles.barBtn}><Text style={styles.barBtnText}>↻</Text></TouchableOpacity>
|
|
</View>
|
|
<ScrollView contentContainerStyle={{ padding: 8 }}>
|
|
{loading && files.length === 0 ? (
|
|
<ActivityIndicator color="#0096FF" style={{ marginTop: 20 }} />
|
|
) : err ? (
|
|
<Text style={styles.err}>{err}</Text>
|
|
) : files.length === 0 ? (
|
|
<Text style={styles.empty}>{!projectId ? 'Kein aktives Projekt — wechsle in ein Projekt für dessen Dateien.' : 'Noch keine Dateien in diesem Projekt.'}</Text>
|
|
) : (
|
|
files.map(f => (
|
|
<TouchableOpacity key={f.path} onPress={() => open(f)} style={styles.row} disabled={previewBusy === f.path}>
|
|
<Text style={styles.rowIcon}>{iconFor(f.path)}</Text>
|
|
<Text style={styles.rowName} numberOfLines={1}>{f.path}</Text>
|
|
{previewBusy === f.path
|
|
? <ActivityIndicator color="#8888AA" size="small" />
|
|
: <Text style={styles.rowSize}>{humanSize(f.size)}</Text>}
|
|
</TouchableOpacity>
|
|
))
|
|
)}
|
|
</ScrollView>
|
|
|
|
<Modal visible={!!preview} transparent animationType="fade" onRequestClose={() => setPreview(null)}>
|
|
<View style={styles.pvOverlay}>
|
|
<View style={styles.pvBar}>
|
|
<Text style={styles.pvTitle} numberOfLines={1}>{preview?.path}</Text>
|
|
<TouchableOpacity onPress={() => setPreview(null)}><Text style={styles.pvClose}>✕</Text></TouchableOpacity>
|
|
</View>
|
|
{preview?.kind === 'image' ? (
|
|
<Image source={{ uri: preview.data }} style={styles.pvImg} resizeMode="contain" />
|
|
) : (
|
|
<ScrollView style={styles.pvTextWrap} horizontal>
|
|
<ScrollView><Text style={styles.pvText}>{preview?.data}</Text></ScrollView>
|
|
</ScrollView>
|
|
)}
|
|
</View>
|
|
</Modal>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: '#0D0D1A' },
|
|
placeholder: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' },
|
|
icon: { fontSize: 56, marginBottom: 10 },
|
|
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
|
bar: { height: 40, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1 },
|
|
barTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', flex: 1 },
|
|
barBtn: { paddingHorizontal: 10, paddingVertical: 4 },
|
|
barBtnText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
|
|
empty: { color: '#8888AA', fontSize: 13, textAlign: 'center', marginTop: 24 },
|
|
err: { color: '#FF6E6E', fontSize: 13, marginTop: 16, paddingHorizontal: 8 },
|
|
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 8, borderBottomColor: '#161628', borderBottomWidth: 1, gap: 10 },
|
|
rowIcon: { fontSize: 18 },
|
|
rowName: { color: '#E0E0F0', fontSize: 13, flex: 1 },
|
|
rowSize: { color: '#555570', fontSize: 11 },
|
|
pvOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.94)' },
|
|
pvBar: { flexDirection: 'row', alignItems: 'center', padding: 12, gap: 10 },
|
|
pvTitle: { color: '#E0E0F0', fontSize: 13, fontWeight: '700', flex: 1 },
|
|
pvClose: { color: '#E0E0F0', fontSize: 20, paddingHorizontal: 6 },
|
|
pvImg: { flex: 1, width: '100%' },
|
|
pvTextWrap: { flex: 1, padding: 12 },
|
|
pvText: { color: '#C8C8E0', fontSize: 12, fontFamily: 'monospace' },
|
|
});
|
|
|
|
export default FilesTile;
|