feat(app): Dateien-Panel im Dock (zwischen Chat und Code)

Neues 📁-Panel listet ALLE Projektdateien (/shared/projects/<id>/) — auch
erzeugte Bilder, nicht nur Code; dieselben, die in der Projektliste als 📄
gezaehlt werden. Bild antippen → Vollbild-Vorschau; Textdatei → Text-Vorschau.
- Brain: /projects/<id>/file?binary=1 → Base64 + MIME (fuer Bilder, max 8 MB).
- App: brainApi.readProjectFileBinary; layout 'files'-Tile; Dock-Reihenfolge
  Chat · Dateien · Code · Desktop; FilesTile mit Bild-/Text-Modal.

py/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 00:09:34 +02:00
co-authored by Claude Opus 4.8
parent 239f1094f9
commit 5890c17ec0
6 changed files with 171 additions and 4 deletions
+6 -1
View File
@@ -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 });
+2
View File
@@ -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<Props> = ({ projectId, panels, badges }) => {
const render = (id: TileId) => {
switch (id) {
case 'chat': return <ChatTile />;
case 'files': return <FilesTile projectId={projectId} focused={active === 'files'} />;
case 'editor': return <CodeEditorTile projectId={projectId} />;
case 'vnc': return <DesktopTile projectId={projectId} focused={active === 'vnc'} />;
default: return null;
+1 -1
View File
@@ -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<ViewModeValue>(viewMode.get());
+2 -1
View File
@@ -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<TileId, TileDef> = {
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: '🖼️' },
+148
View File
@@ -0,0 +1,148 @@
/**
* 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(() => {
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}>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;
+12 -1
View File
@@ -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: