feat: Editor laedt vorhandene Dateien + manueller Code-Toggle (App + Diagnostic)

Editor zeigte "keine Datei", obwohl ARIA schon Dateien geschrieben hatte — er las
NUR den Live-code_file-Stream, nie den Bestand. Jetzt:
- Brain: GET /projects/<id>/files + /file (liest /shared/projects/<id>/, pfad-sicher,
  512KB-Cap). kind in ProjectUpdateBody (PATCH akzeptiert 'code'|'chat').
- App: brainApi.listProjectFiles/readProjectFile/setProjectKind. CodeEditorTile
  holt beim Oeffnen die vorhandene Dateiliste + laedt Inhalt (Live-Version hat
  Vorrang). ProjectsBrowser-Edit: Code-Projekt-Toggle (spiegelt sofort in
  projectFocus → Cockpit-Panels).
- Diagnostic: </> Code-Toggle je Projektzeile + Code-Badge.

py/node/tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 23:20:39 +02:00
co-authored by Claude Opus 4.8
parent 9dd7a2cb14
commit 5fa5d79ad8
5 changed files with 182 additions and 30 deletions
+27 -3
View File
@@ -28,6 +28,7 @@ import {
import brainApi, { Project } from '../services/brainApi';
import rvs from '../services/rvs';
import projectFocus from '../services/projectFocus';
interface Props {
/** Optional — wenn als Modal genutzt, sonst inline */
@@ -75,6 +76,7 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
const [editing, setEditing] = useState<Project | null>(null);
const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState('');
const [editKind, setEditKind] = useState<'code' | 'chat'>('chat');
// Versteckte Projekte standardmaessig ausblenden; Toggle blendet sie
// temporaer (gedimmt) ein — zum Ansehen/Auswaehlen oder Wieder-Sichtbarmachen.
const [showHidden, setShowHidden] = useState(false);
@@ -148,18 +150,25 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
setEditing(p);
setEditName(p.name);
setEditDesc(p.description || '');
setEditKind(p.kind === 'code' ? 'code' : 'chat');
}, []);
const saveEdit = useCallback(() => {
if (!editing) return;
const patch: Partial<Pick<Project, 'name' | 'description'>> = {};
const patch: Partial<Pick<Project, 'name' | 'description' | 'kind'>> = {};
if (editName.trim() && editName.trim() !== editing.name) patch.name = editName.trim();
if (editDesc.trim() !== (editing.description || '')) patch.description = editDesc.trim();
const curKind = editing.kind === 'code' ? 'code' : 'chat';
if (editKind !== curKind) patch.kind = editKind;
if (Object.keys(patch).length === 0) { setEditing(null); return; }
brainApi.updateProject(editing.id, patch)
.then(() => { setEditing(null); load(); })
.then(() => {
// Kind sofort in den Workspace spiegeln (Editor/Desktop-Panels).
if (patch.kind) projectFocus.setKind(editing.id, patch.kind);
setEditing(null); load();
})
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
}, [editing, editName, editDesc, load]);
}, [editing, editName, editDesc, editKind, load]);
const endProject = useCallback((p: Project) => {
Alert.alert(`"${p.name}" beenden?`,
@@ -375,6 +384,21 @@ export const ProjectsBrowser: React.FC<Props> = ({ visible = true, onClose, onAc
style={[s.input, { height: 70 }]}
multiline
/>
<TouchableOpacity
onPress={() => setEditKind(k => (k === 'code' ? 'chat' : 'code'))}
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 }}
>
<Text style={{ color: '#E0E0F0', fontSize: 14 }}>💻 Code-Projekt{'\n'}
<Text style={{ color: '#8888AA', fontSize: 11 }}>zeigt Editor + Desktop im Cockpit</Text>
</Text>
<View style={{
width: 46, height: 26, borderRadius: 13, padding: 3,
backgroundColor: editKind === 'code' ? '#0096FF' : '#2A2A3E',
alignItems: editKind === 'code' ? 'flex-end' : 'flex-start',
}}>
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: '#FFFFFF' }} />
</View>
</TouchableOpacity>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
<TouchableOpacity onPress={() => setEditing(null)} style={[s.modalBtn, { backgroundColor: '#2A2A3E' }]}>
<Text style={s.modalBtnText}>Abbrechen</Text>
+20 -2
View File
@@ -600,14 +600,32 @@ export const brainApi = {
});
},
/** Projekt-Metadaten patchen (name / description / hidden). */
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden'>>): Promise<Project> {
/** Projekt-Metadaten patchen (name / description / hidden / kind). */
updateProject(projectId: string, patch: Partial<Pick<Project, 'name' | 'description' | 'hidden' | 'kind'>>): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH',
body: patch,
});
},
/** Projekt manuell als Code-Projekt / normalen Chat markieren. */
setProjectKind(projectId: string, kind: 'code' | 'chat'): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
method: 'PATCH',
body: { kind },
});
},
/** Vorhandene Code-Dateien eines Projekts auflisten (/shared/projects/<id>/). */
listProjectFiles(projectId: string): Promise<{ projectId: string; files: { path: string; size: number }[] }> {
return _send(`/projects/${encodeURIComponent(projectId)}/files`);
},
/** Inhalt einer Projekt-Datei laden. */
readProjectFile(projectId: string, path: string): Promise<{ projectId: string; path: string; content: string }> {
return _send(`/projects/${encodeURIComponent(projectId)}/file?path=${encodeURIComponent(path)}`);
},
/** Projekt verstecken / wieder sichtbar machen (bleibt voll nutzbar). */
setProjectHidden(projectId: string, hidden: boolean): Promise<Project> {
return _send(`/projects/${encodeURIComponent(projectId)}`, {
+65 -25
View File
@@ -1,57 +1,97 @@
/**
* CodeEditorTile — Live-Code-Editor (WebView, editorHtml.ts).
*
* Zeigt live, was ARIA in diesem Projekt schreibt (aus dem codeFile-Spiegel)
* und laesst Stefan selbst editieren — Aenderungen gehen als code_file_edit
* zurueck an die Bridge. Datei-Tabs oben zum Umschalten.
* Zeigt die Dateien eines Code-Projekts aus /shared/projects/<id>/:
* - beim Oeffnen werden die BEREITS vorhandenen Dateien vom Brain geladen
* (listProjectFiles/readProjectFile) — sonst waere der Editor leer, obwohl
* ARIA schon Dateien geschrieben hat.
* - live schreibt ARIA weiter → code_file-Stream aktualisiert die offene Datei.
* Stefan kann selbst editieren → code_file_edit zurueck an die Bridge.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import codeFile, { CodeFileState } from '../../services/codeFile';
import codeFile from '../../services/codeFile';
import brainApi from '../../services/brainApi';
import { EDITOR_HTML } from '../assets/editorHtml';
interface Props {
projectId: string;
}
function guessLang(path: string): string {
const ext = (path.split('.').pop() || '').toLowerCase();
const map: Record<string, string> = {
js: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python',
c: 'c', h: 'c', cpp: 'cpp', asm: 'asm', s: 'asm', sh: 'shell', bash: 'shell',
html: 'html', css: 'css', json: 'json', yaml: 'yaml', yml: 'yaml', md: 'markdown',
go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', txt: 'text',
};
return map[ext] || 'text';
}
const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
const webRef = useRef<WebView>(null);
const [files, setFiles] = useState<CodeFileState[]>(() => codeFile.getFiles(projectId));
const [currentPath, setCurrentPath] = useState<string | null>(files[0]?.path ?? null);
// Pfade aus dem Brain (vorhandene Dateien) — mit Live-Dateien gemergt.
const [serverPaths, setServerPaths] = useState<string[]>([]);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [loadErr, setLoadErr] = useState<string>('');
const readyRef = useRef(false);
const currentPathRef = useRef<string | null>(currentPath);
currentPathRef.current = currentPath;
// Vereinigte, sortierte Dateiliste (Live-Spiegel + Server-Dateien).
const files = useMemo(() => {
const set = new Set<string>(serverPaths);
for (const f of codeFile.getFiles(projectId)) set.add(f.path);
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [serverPaths, projectId]);
const sendToWeb = useCallback((payload: Record<string, unknown>) => {
const js = `window.ariaBridge && window.ariaBridge.onMessage(${JSON.stringify(JSON.stringify(payload))}); true;`;
webRef.current?.injectJavaScript(js);
}, []);
const loadFileIntoEditor = useCallback((path: string | null) => {
const loadFileIntoEditor = useCallback(async (path: string | null) => {
if (!path) { sendToWeb({ cmd: 'setContent', content: '', language: 'text', version: 0 }); return; }
const f = codeFile.getFile(projectId, path);
sendToWeb({ cmd: 'setContent', content: f?.content ?? '', language: f?.language ?? 'text', version: f?.version ?? 0 });
// Live-Version bevorzugen (falls ARIA gerade schreibt), sonst vom Brain holen.
const live = codeFile.getFile(projectId, path);
if (live) {
sendToWeb({ cmd: 'setContent', content: live.content, language: live.language, version: live.version });
return;
}
try {
const res = await brainApi.readProjectFile(projectId, path);
sendToWeb({ cmd: 'setContent', content: res.content ?? '', language: guessLang(path), version: 0 });
} catch (e: any) {
sendToWeb({ cmd: 'setContent', content: `// Konnte ${path} nicht laden: ${e?.message || e}`, language: 'text', version: 0 });
}
}, [projectId, sendToWeb]);
// Projektwechsel: Dateiliste + Auswahl neu.
// Projektwechsel: vorhandene Dateien vom Brain laden.
useEffect(() => {
const list = codeFile.getFiles(projectId);
setFiles(list);
setCurrentPath((prev) => (prev && list.some((f) => f.path === prev) ? prev : list[0]?.path ?? null));
let cancelled = false;
setLoadErr('');
brainApi.listProjectFiles(projectId)
.then(res => {
if (cancelled) return;
const paths = (res.files || []).map(f => f.path);
setServerPaths(paths);
setCurrentPath(prev => (prev && paths.includes(prev)) ? prev : (paths[0] ?? codeFile.getFiles(projectId)[0]?.path ?? null));
})
.catch(e => { if (!cancelled) setLoadErr(String(e?.message || e)); });
return () => { cancelled = true; };
}, [projectId]);
// Eingehende Updates aus dem Spiegel.
// Live-Updates aus dem Spiegel.
useEffect(() => {
return codeFile.subscribe((u) => {
if ((u.projectId || '') !== (projectId || '')) return;
setFiles(codeFile.getFiles(projectId));
// Noch keine Datei gewaehlt → diese oeffnen.
setServerPaths(prev => prev.includes(u.path) ? prev : [...prev, u.path]);
if (!currentPathRef.current) { setCurrentPath(u.path); return; }
if (u.path !== currentPathRef.current) return;
if (!readyRef.current) return;
if (u.path !== currentPathRef.current || !readyRef.current) return;
if (u.patch) {
sendToWeb({ cmd: 'applyPatch', from: u.patch.from, to: u.patch.to, insert: u.patch.insert, version: u.version });
} else {
@@ -60,7 +100,7 @@ const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
});
}, [projectId, sendToWeb]);
// Datei-Auswahl gewechselt → in den Editor laden (falls WebView bereit).
// Datei-Auswahl gewechselt → laden (falls WebView bereit).
useEffect(() => {
if (readyRef.current) loadFileIntoEditor(currentPath);
}, [currentPath, loadFileIntoEditor]);
@@ -82,14 +122,14 @@ const CodeEditorTile: React.FC<Props> = ({ projectId }) => {
<View style={styles.container}>
<View style={styles.tabsRow}>
{files.length === 0 ? (
<Text style={styles.noFiles}>Noch keine Datei</Text>
<Text style={styles.noFiles}>{loadErr ? `Fehler: ${loadErr}` : 'Noch keine Datei in diesem Projekt'}</Text>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabs}>
{files.map((f) => {
const active = f.path === currentPath;
const name = f.path.split('/').pop() || f.path;
{files.map((path) => {
const active = path === currentPath;
const name = path.split('/').pop() || path;
return (
<TouchableOpacity key={f.path} onPress={() => setCurrentPath(f.path)} style={[styles.tab, active && styles.tabActive]}>
<TouchableOpacity key={path} onPress={() => setCurrentPath(path)} style={[styles.tab, active && styles.tabActive]}>
<Text style={[styles.tabText, active && styles.tabTextActive]} numberOfLines={1}>{name}</Text>
</TouchableOpacity>
);
+56
View File
@@ -827,17 +827,73 @@ class ProjectUpdateBody(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
hidden: Optional[bool] = None
kind: Optional[str] = None # 'code' | 'chat' — manuell setzbar (App/Diagnostic)
@app.patch("/projects/{project_id}")
def projects_update(project_id: str, body: ProjectUpdateBody):
patch = body.dict(exclude_unset=True)
if "kind" in patch and patch["kind"] not in ("code", "chat", None):
raise HTTPException(status_code=400, detail="kind muss 'code' oder 'chat' sein")
p = projects_mod.update_project(project_id, patch)
if p is None:
raise HTTPException(status_code=404, detail=f"Projekt {project_id} nicht gefunden")
return p
# ── Code-Dateien eines Projekts (/shared/projects/<pid>/) ───────────
# Der Live-Editor streamt ARIAs Writes; diese Endpoints liefern zusaetzlich die
# BEREITS vorhandenen Dateien, damit der Editor beim Oeffnen nicht leer ist.
_PROJECT_FILES_ROOT = "/shared/projects"
_PROJECT_FILE_MAX = 512 * 1024
def _project_dir(project_id: str) -> str:
base = os.path.realpath(os.path.join(_PROJECT_FILES_ROOT, project_id or ""))
root = os.path.realpath(_PROJECT_FILES_ROOT)
if base != root and not base.startswith(root + os.sep):
raise HTTPException(status_code=400, detail="ungueltige project_id")
return base
@app.get("/projects/{project_id}/files")
def project_files(project_id: str):
base = _project_dir(project_id)
out = []
if os.path.isdir(base):
for dirpath, dirs, files in os.walk(base):
dirs[:] = [d for d in dirs if d not in
(".git", "node_modules", "__pycache__", ".venv", "venv")]
for f in files:
full = os.path.join(dirpath, f)
rel = os.path.relpath(full, base).replace("\\", "/")
try:
sz = os.path.getsize(full)
except OSError:
sz = 0
out.append({"path": rel, "size": sz})
out.sort(key=lambda x: x["path"])
return {"projectId": project_id, "files": out}
@app.get("/projects/{project_id}/file")
def project_file(project_id: str, path: str):
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")
if os.path.getsize(target) > _PROJECT_FILE_MAX:
raise HTTPException(status_code=413, detail="Datei zu gross fuer den Editor")
try:
with open(target, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
return {"projectId": project_id, "path": path, "content": content}
@app.get("/conversation/stats")
def conversation_stats():
return conversation().stats()
+14
View File
@@ -3194,12 +3194,14 @@
${hidden ? '🙈' : '📁'} ${escapeHtml(p.name)}
${hidden ? '<span style="color:#B392F0;font-size:10px;font-weight:700;margin-left:6px;background:rgba(179,146,240,0.15);padding:2px 6px;border-radius:3px;">versteckt</span>' : ''}
${ended ? '<span style="color:#FFD60A;font-size:10px;font-weight:700;margin-left:6px;background:rgba(255,214,10,0.15);padding:2px 6px;border-radius:3px;">beendet</span>' : ''}
${p.kind === 'code' ? '<span style="color:#0096FF;font-size:10px;font-weight:700;margin-left:6px;background:rgba(0,150,255,0.15);padding:2px 6px;border-radius:3px;">&lt;/&gt; Code</span>' : ''}
${isActive ? '<span style="color:#34C759;font-size:10px;font-weight:800;margin-left:6px;">✓ AKTIV</span>' : ''}
</div>
${p.description ? `<div style="color:#8888AA;font-size:12px;margin-top:2px;">${escapeHtml(p.description)}</div>` : ''}
<div style="color:#555570;font-size:11px;margin-top:4px;">${p.turn_count} Turns · zuletzt ${since}</div>
</div>
<div style="display:flex;gap:4px;">
<button class="btn secondary" onclick="setProjectKind('${p.id}', '${p.kind === 'code' ? 'chat' : 'code'}')" style="padding:3px 8px;font-size:10px;color:${p.kind === 'code' ? '#0096FF' : '#8888AA'};" title="${p.kind === 'code' ? 'Ist Code-Projekt — klick fuer normalen Chat' : 'Als Code-Projekt markieren (Editor + Desktop im Cockpit)'}">&lt;/&gt;</button>
<button class="btn secondary" onclick="setProjectHidden('${p.id}', ${!hidden})" style="padding:3px 8px;font-size:10px;color:${eyeColor};" title="${eyeTitle}">${eyeIcon}</button>
${!ended ? `<button class="btn secondary" onclick="endProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;" title="Projekt beenden"></button>` : ''}
<button class="btn secondary" onclick="archiveProject('${p.id}', '${escapeHtmlAttr(p.name)}')" style="padding:3px 8px;font-size:10px;color:#E55C5C;" title="Archivieren">🗑</button>
@@ -3246,6 +3248,18 @@
} catch (e) { alert('Verstecken/Anzeigen fehlgeschlagen: ' + e.message); }
}
async function setProjectKind(id, kind) {
try {
const r = await fetch(`/api/brain/projects/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind }),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
loadProjects();
} catch (e) { alert('Code-Markierung fehlgeschlagen: ' + e.message); }
}
async function switchProject(projectId) {
try {
await fetch('/api/brain/projects/switch', {