diff --git a/android/src/services/brainApi.ts b/android/src/services/brainApi.ts index 2ed5dc5..2bd065d 100644 --- a/android/src/services/brainApi.ts +++ b/android/src/services/brainApi.ts @@ -637,11 +637,16 @@ export const brainApi = { return _send(`/projects/${encodeURIComponent(projectId)}/files`); }, - /** Inhalt einer Projekt-Datei laden. */ + /** Inhalt einer Projekt-Datei laden (Text). */ readProjectFile(projectId: string, path: string): Promise<{ projectId: string; path: string; content: string }> { return _send(`/projects/${encodeURIComponent(projectId)}/file?path=${encodeURIComponent(path)}`); }, + /** Binaere Projekt-Datei (z.B. Bild) als Base64 + MIME laden. */ + readProjectFileBinary(projectId: string, path: string): Promise<{ path: string; mime: string; base64: string }> { + return _send(`/projects/${encodeURIComponent(projectId)}/file?binary=1&path=${encodeURIComponent(path)}`, { timeoutMs: 30000 }); + }, + // ── QEMU-VMs pro Projekt ───────────────────────────────────────── listProjectVms(projectId: string): Promise<{ projectId: string; vms: ProjectVm[] }> { return _send(`/projects/${encodeURIComponent(projectId)}/vms`, { timeoutMs: 20000 }); diff --git a/android/src/workspace/WorkspaceDeck.tsx b/android/src/workspace/WorkspaceDeck.tsx index 6cccbd2..51ec8ae 100644 --- a/android/src/workspace/WorkspaceDeck.tsx +++ b/android/src/workspace/WorkspaceDeck.tsx @@ -15,6 +15,7 @@ import { TileId } from './layout'; import { useWorkspaceLayout } from './useWorkspaceLayout'; import WorkspaceDock from './WorkspaceDock'; import ChatTile from './tiles/ChatTile'; +import FilesTile from './tiles/FilesTile'; import CodeEditorTile from './tiles/CodeEditorTile'; import DesktopTile from './tiles/DesktopTile'; @@ -57,6 +58,7 @@ const WorkspaceDeck: React.FC = ({ projectId, panels, badges }) => { const render = (id: TileId) => { switch (id) { case 'chat': return ; + case 'files': return ; case 'editor': return ; case 'vnc': return ; default: return null; diff --git a/android/src/workspace/WorkspaceScreen.tsx b/android/src/workspace/WorkspaceScreen.tsx index fcee944..40af6f8 100644 --- a/android/src/workspace/WorkspaceScreen.tsx +++ b/android/src/workspace/WorkspaceScreen.tsx @@ -17,7 +17,7 @@ import ChatScreen from '../screens/ChatScreen'; import { TileId } from './layout'; import WorkspaceDeck from './WorkspaceDeck'; -const COCKPIT_PANELS: TileId[] = ['chat', 'editor', 'vnc']; +const COCKPIT_PANELS: TileId[] = ['chat', 'files', 'editor', 'vnc']; const WorkspaceScreen: React.FC = () => { const [mode, setMode] = useState(viewMode.get()); diff --git a/android/src/workspace/layout.ts b/android/src/workspace/layout.ts index 9a9046c..45a093b 100644 --- a/android/src/workspace/layout.ts +++ b/android/src/workspace/layout.ts @@ -2,12 +2,13 @@ * layout — Panel-Definitionen der Workbench (Metadaten fuer das Dock). */ -export type TileId = 'chat' | 'editor' | 'vnc' | 'preview'; +export type TileId = 'chat' | 'files' | 'editor' | 'vnc' | 'preview'; export interface TileDef { id: TileId; title: string; icon: string } export const TILE_META: Record = { chat: { id: 'chat', title: 'Chat', icon: '💬' }, + files: { id: 'files', title: 'Dateien', icon: '📁' }, editor: { id: 'editor', title: 'Code', icon: '📝' }, vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' }, preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' }, diff --git a/android/src/workspace/tiles/FilesTile.tsx b/android/src/workspace/tiles/FilesTile.tsx new file mode 100644 index 0000000..76354e8 --- /dev/null +++ b/android/src/workspace/tiles/FilesTile.tsx @@ -0,0 +1,148 @@ +/** + * FilesTile — Datei-Browser eines Projekts (/shared/projects//). + * + * 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 = ({ projectId, focused }) => { + const [files, setFiles] = useState([]); + 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(() => { + 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 ( + + 📁 + Dateien + + ); + } + + return ( + + + Dateien{files.length ? ` (${files.length})` : ''} + + + + {loading && files.length === 0 ? ( + + ) : err ? ( + {err} + ) : files.length === 0 ? ( + Noch keine Dateien in diesem Projekt. + ) : ( + files.map(f => ( + open(f)} style={styles.row} disabled={previewBusy === f.path}> + {iconFor(f.path)} + {f.path} + {previewBusy === f.path + ? + : {humanSize(f.size)}} + + )) + )} + + + setPreview(null)}> + + + {preview?.path} + setPreview(null)}> + + {preview?.kind === 'image' ? ( + + ) : ( + + {preview?.data} + + )} + + + + ); +}; + +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; diff --git a/aria-brain/main.py b/aria-brain/main.py index 5032005..5690e17 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -912,13 +912,24 @@ def project_files(project_id: str): @app.get("/projects/{project_id}/file") -def project_file(project_id: str, path: str): +def project_file(project_id: str, path: str, binary: bool = False): base = _project_dir(project_id) target = os.path.realpath(os.path.join(base, path)) if target != base and not target.startswith(base + os.sep): raise HTTPException(status_code=400, detail="Pfad ausserhalb des Projekts") if not os.path.isfile(target): raise HTTPException(status_code=404, detail="Datei nicht gefunden") + # Binaer (z.B. Bilder) → Base64. Grosszuegigeres Limit als beim Text-Editor. + if binary: + import base64 + import mimetypes + if os.path.getsize(target) > 8 * 1024 * 1024: + raise HTTPException(status_code=413, detail="Datei zu gross (max 8 MB)") + with open(target, "rb") as f: + data = f.read() + mime, _ = mimetypes.guess_type(target) + return {"projectId": project_id, "path": path, "mime": mime or "application/octet-stream", + "base64": base64.b64encode(data).decode("ascii")} if os.path.getsize(target) > _PROJECT_FILE_MAX: raise HTTPException(status_code=413, detail="Datei zu gross fuer den Editor") try: