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>
169 lines
7.1 KiB
TypeScript
169 lines
7.1 KiB
TypeScript
/**
|
|
* CodeEditorTile — Live-Code-Editor (WebView, editorHtml.ts).
|
|
*
|
|
* 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, useMemo, useRef, useState } from 'react';
|
|
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
|
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);
|
|
// 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(async (path: string | null) => {
|
|
if (!path) { sendToWeb({ cmd: 'setContent', content: '', language: 'text', version: 0 }); return; }
|
|
// 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: vorhandene Dateien vom Brain laden.
|
|
useEffect(() => {
|
|
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]);
|
|
|
|
// Live-Updates aus dem Spiegel.
|
|
useEffect(() => {
|
|
return codeFile.subscribe((u) => {
|
|
if ((u.projectId || '') !== (projectId || '')) return;
|
|
setServerPaths(prev => prev.includes(u.path) ? prev : [...prev, u.path]);
|
|
if (!currentPathRef.current) { setCurrentPath(u.path); 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 {
|
|
sendToWeb({ cmd: 'setContent', content: u.content ?? '', language: u.language, version: u.version });
|
|
}
|
|
});
|
|
}, [projectId, sendToWeb]);
|
|
|
|
// Datei-Auswahl gewechselt → laden (falls WebView bereit).
|
|
useEffect(() => {
|
|
if (readyRef.current) loadFileIntoEditor(currentPath);
|
|
}, [currentPath, loadFileIntoEditor]);
|
|
|
|
const onMessage = useCallback((e: WebViewMessageEvent) => {
|
|
let m: any;
|
|
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
|
|
if (m.event === 'ready') {
|
|
readyRef.current = true;
|
|
loadFileIntoEditor(currentPathRef.current);
|
|
} else if (m.event === 'onEditFromUser') {
|
|
const path = currentPathRef.current;
|
|
if (!path) return;
|
|
codeFile.sendEdit(projectId, path, { from: m.from, to: m.to, insert: m.insert }, m.fullText, m.version);
|
|
}
|
|
}, [projectId, loadFileIntoEditor]);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.tabsRow}>
|
|
{files.length === 0 ? (
|
|
<Text style={styles.noFiles}>{loadErr ? `Fehler: ${loadErr}` : 'Noch keine Datei in diesem Projekt'}</Text>
|
|
) : (
|
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabs}>
|
|
{files.map((path) => {
|
|
const active = path === currentPath;
|
|
const name = path.split('/').pop() || path;
|
|
return (
|
|
<TouchableOpacity key={path} onPress={() => setCurrentPath(path)} style={[styles.tab, active && styles.tabActive]}>
|
|
<Text style={[styles.tabText, active && styles.tabTextActive]} numberOfLines={1}>{name}</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
)}
|
|
</View>
|
|
<WebView
|
|
ref={webRef}
|
|
style={styles.web}
|
|
originWhitelist={['*']}
|
|
source={{ html: EDITOR_HTML, baseUrl: '' }}
|
|
onMessage={onMessage}
|
|
javaScriptEnabled
|
|
domStorageEnabled
|
|
keyboardDisplayRequiresUserAction={false}
|
|
androidLayerType="hardware"
|
|
setBuiltInZoomControls={false}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: '#0D0D1A' },
|
|
tabsRow: { height: 40, backgroundColor: '#12122A', borderBottomColor: '#1E1E2E', borderBottomWidth: 1, justifyContent: 'center' },
|
|
tabs: { alignItems: 'center', paddingHorizontal: 6 },
|
|
noFiles: { color: '#9090B0', fontSize: 13, paddingHorizontal: 12 },
|
|
tab: { paddingHorizontal: 12, paddingVertical: 6, marginHorizontal: 3, borderRadius: 12, backgroundColor: '#0D0D1A', maxWidth: 180 },
|
|
tabActive: { backgroundColor: '#0096FF' },
|
|
tabText: { color: '#9090B0', fontSize: 12, fontWeight: '600' },
|
|
tabTextActive: { color: '#FFFFFF' },
|
|
web: { flex: 1, backgroundColor: '#0D0D1A' },
|
|
});
|
|
|
|
export default CodeEditorTile;
|