feat(app): Workbench-Dock statt Zoom-Landkarte + bedienbare VNC-Konsole (0.2.2.0)
Cockpit neu gedacht — das Pinch-Zoom-Landkarten-Konzept war auf 5 Zoll fummelig: - WorkspaceDeck + WorkspaceDock: Taskleiste unten (Chat · Code · Desktop), Ein-Tap-Panelwechsel im Daumenbereich, animierter Indikator, Aktivitaets- Badges (Code blau / Desktop gruen), Safe-Area, blendet bei offener Tastatur aus. Panels bildschirmfuellend + immer gemountet (kein Remount). - noVNC bedienbar: novncHtml bekommt Tastatur-Bridge (verstecktes Input-Feld → rfb.sendKey inkl. Enter/Backspace/Pfeile), Strg-Alt-Entf, Fit↔1:1. VncTile zeigt eine Steuerungs-Leiste (⌨ / Strg+Alt+Entf / ⤢). - Entfernt: WorkspaceCanvas, Tile, PreviewTile, CockpitOverviewButton, cockpitNav (Pinch-Canvas + Uebersichts-Button obsolet). layout.ts auf Panel-Metadaten geschlankt. Header nur noch mit Kompakt/Cockpit-Umschalter. tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,25 @@ Alle Änderungen am Projekt. Format: [Keep a Changelog](https://keepachangelog.c
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.2.2.0] — 2026-07-17 — Workbench: Taskleisten-Dock statt Zoom-Landkarte + bedienbare VNC-Konsole
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
**Cockpit-Navigation neu gedacht — Dock statt Pinch-Canvas**
|
||||||
|
- Die Zoom-Landkarte (mit 2 Fingern rauszoomen, Kachel finden, reintippen) fühlte sich auf 5 Zoll fummelig an. Ersetzt durch eine **Taskleiste unten im Daumenbereich** (Chat · Code · Desktop): **ein Tap wechselt sofort** das Panel, ein animierter Indikator gleitet unter das aktive Icon. Desktop-*Umfang*, Handy-*Ergonomie*.
|
||||||
|
- Jedes Panel ist **bildschirmfüllend** und Handy-optimiert; alle bleiben gemountet (nur das aktive ist sichtbar) → kein Remount, Chat behält RVS/Audio/Queue, WebViews ihren Zustand.
|
||||||
|
- Das Dock **blendet bei offener Tastatur aus** (mehr Platz zum Tippen) und respektiert die Gesten-Navigationsleiste (Safe-Area).
|
||||||
|
- **Aktivitäts-Punkte** am Dock: Code blau, sobald Dateien da sind; Desktop grün, wenn die VM verbunden ist.
|
||||||
|
- Entfällt: Pinch/Pan-Canvas, Übersichts-Button im Header, die „Landkarten"-Thumbnails.
|
||||||
|
|
||||||
|
**noVNC-Konsole endlich bedienbar**
|
||||||
|
- Steuerungs-Leiste im Desktop-Panel: **⌨ Tastatur** (blendet die Handy-Tastatur ein, tippt direkt in die VM — inkl. Enter/Backspace/Pfeiltasten), **Strg+Alt+Entf**, und **⤢ Fit ↔ 1:1**. Tippen/Ziehen steuert weiterhin die Maus.
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
Nur **App neu bauen** (kein Backend). APK 0.2.2.0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.2.1.9] — 2026-07-17 — Kompakt ↔ Cockpit: Umschalter für den Kachel-Desktop
|
## [0.2.1.9] — 2026-07-17 — Kompakt ↔ Cockpit: Umschalter für den Kachel-Desktop
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -500,7 +500,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **Desktop-Workspace (zoombarer Canvas)**: Die App ist eine desktop-artige Arbeitsfläche — rausgezoomt eine **Landkarte aus Kacheln** (Chat/Editor/Desktop/Vorschau), die man mit **2 Fingern zoomt und verschiebt**; Tippen auf eine Kachel zoomt sie voll auf und macht sie echt bedienbar („Übersicht + Fokus"). Reiner Chat verhält sich exakt wie bisher (eine Kachel, dauerhaft fokussiert). Zurück per „⤢ Übersicht" oder Hardware-Back. Basiert auf gesture-handler + reanimated; interaktive Inhalte werden nie unter Zoom-Transform gerendert (Keyboard/Touch bleiben korrekt)
|
- **Desktop-Workbench (Kompakt ↔ Cockpit)**: Umschalter oben rechts im Header. **Kompakt** = klassischer Vollbild-Chat (Default). **Cockpit** = Workbench mit **Taskleiste unten** (Chat · Code · Desktop) — ein Daumen-Tap wechselt sofort das Panel, animierter Indikator, Aktivitäts-Punkte (Code blau / Desktop grün). Jedes Panel bildschirmfüllend und Handy-optimiert; Dock blendet bei offener Tastatur aus. Desktop-Umfang, aber auf 5 Zoll bedienbar
|
||||||
- **Live-Code-Editor** (Code-Projekte): Wird ein Projekt zum Code-Projekt (`set_project_kind('code')`), zeigt eine Editor-Kachel **live, was ARIA schreibt** (Syntax-Highlighting, offline) und du kannst selbst editieren → zurück an ARIA. ARIAs `Write`/`Edit` unter `/shared/projects/<id>/` werden im Proxy abgefangen und als `code_file` gespiegelt; deine Edits kommen als `code_file_edit` pfad-sicher zurück
|
- **Live-Code-Editor** (Code-Projekte): Wird ein Projekt zum Code-Projekt (`set_project_kind('code')`), zeigt eine Editor-Kachel **live, was ARIA schreibt** (Syntax-Highlighting, offline) und du kannst selbst editieren → zurück an ARIA. ARIAs `Write`/`Edit` unter `/shared/projects/<id>/` werden im Proxy abgefangen und als `code_file` gespiegelt; deine Edits kommen als `code_file_edit` pfad-sicher zurück
|
||||||
- **Live-Desktop per VNC (durch RVS getunnelt)**: ARIA baut/testet VMs mit **QEMU für jede Architektur** (x86/ARM/MIPS/PPC/RISC-V/SPARC, Host-Helper `aria-vm`, KVM für x86). Der QEMU-Desktop erscheint **live in der Desktop-Kachel** — noVNC in der WebView, RFB-Bytes werden als Base64 über RVS gebrückt (Bridge ↔ QEMU `127.0.0.1:5901`). Du bedienst die VM **live mit Maus/Tastatur**, NAT-sicher, kein offener Port am Host
|
- **Live-Desktop per VNC (durch RVS getunnelt)**: ARIA baut/testet VMs mit **QEMU für jede Architektur** (x86/ARM/MIPS/PPC/RISC-V/SPARC, Host-Helper `aria-vm`, KVM für x86). Der QEMU-Desktop erscheint **live in der Desktop-Kachel** — noVNC in der WebView, RFB-Bytes werden als Base64 über RVS gebrückt (Bridge ↔ QEMU `127.0.0.1:5901`). Du bedienst die VM **live mit Maus/Tastatur**, NAT-sicher, kein offener Port am Host
|
||||||
- Text-Chat mit ARIA
|
- Text-Chat mit ARIA
|
||||||
@@ -1044,7 +1044,8 @@ docker exec aria-brain curl localhost:8080/memory/stats
|
|||||||
- [x] Background Audio Service: TTS, Wake-Word-Lauschen + Aufnahme laufen auch bei minimierter App weiter (Foreground-Service mit mediaPlayback|microphone, dynamische Notification)
|
- [x] Background Audio Service: TTS, Wake-Word-Lauschen + Aufnahme laufen auch bei minimierter App weiter (Foreground-Service mit mediaPlayback|microphone, dynamische Notification)
|
||||||
- [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen
|
- [x] Disk-Voll Banner in Diagnostic mit copy-baren Cleanup-Befehlen
|
||||||
- [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon
|
- [x] Wake-Word on-device via openWakeWord (ONNX Runtime, kein API-Key) + State-Icon
|
||||||
- [x] **Desktop-Workspace**: zoom-/verschiebbarer Kachel-Canvas (gesture-handler + reanimated), „Übersicht + Fokus" — Chat/Editor/Desktop/Vorschau als Kacheln; reiner Chat unverändert (eine Kachel)
|
- [x] **Desktop-Workbench**: Taskleisten-Dock (Chat · Code · Desktop), Ein-Tap-Panelwechsel im Daumenbereich, Aktivitäts-Badges, keyboard-aware; Kompakt↔Cockpit-Umschalter. (Ersetzte den anfänglichen Pinch-Zoom-Kachel-Canvas — auf 5 Zoll zu fummelig)
|
||||||
|
- [x] **noVNC-Konsole bedienbar**: Tastatur-Einblendung (tippt in die VM), Strg+Alt+Entf, Fit↔1:1
|
||||||
- [x] **Live-Code-Editor** für Code-Projekte (WebView, offline, bidirektional) — ARIAs Write/Edit unter `/shared/projects/<id>/` live gespiegelt (`code_file`), eigene Edits zurück (`code_file_edit`)
|
- [x] **Live-Code-Editor** für Code-Projekte (WebView, offline, bidirektional) — ARIAs Write/Edit unter `/shared/projects/<id>/` live gespiegelt (`code_file`), eigene Edits zurück (`code_file_edit`)
|
||||||
- [x] **QEMU für alle Architekturen** (Host-Helper `aria-vm`, `qemu-setup.sh`) + `set_project_kind`-Tool + Seed-Regel
|
- [x] **QEMU für alle Architekturen** (Host-Helper `aria-vm`, `qemu-setup.sh`) + `set_project_kind`-Tool + Seed-Regel
|
||||||
- [x] **VNC-Live-Desktop durch RVS getunnelt** (Bridge RFB-TCP ↔ RVS, noVNC-WebView mit WebSocket-Shim) — VM live mit Maus/Tastatur bedienbar
|
- [x] **VNC-Live-Desktop durch RVS getunnelt** (Bridge RFB-TCP ↔ RVS, noVNC-WebView mit WebSocket-Shim) — VM live mit Maus/Tastatur bedienbar
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
|||||||
import WorkspaceScreen from './src/workspace/WorkspaceScreen';
|
import WorkspaceScreen from './src/workspace/WorkspaceScreen';
|
||||||
import SettingsScreen from './src/screens/SettingsScreen';
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
import ViewModeToggle from './src/components/ViewModeToggle';
|
import ViewModeToggle from './src/components/ViewModeToggle';
|
||||||
import CockpitOverviewButton from './src/components/CockpitOverviewButton';
|
|
||||||
import rvs from './src/services/rvs';
|
import rvs from './src/services/rvs';
|
||||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||||
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||||
@@ -172,7 +171,6 @@ const App: React.FC = () => {
|
|||||||
options={{
|
options={{
|
||||||
title: 'ARIA Chat',
|
title: 'ARIA Chat',
|
||||||
headerTitle: 'ARIA Cockpit',
|
headerTitle: 'ARIA Cockpit',
|
||||||
headerLeft: () => <CockpitOverviewButton />,
|
|
||||||
headerRight: () => <ViewModeToggle />,
|
headerRight: () => <ViewModeToggle />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* CockpitOverviewButton — Header-Button (links) für „⤢ Übersicht".
|
|
||||||
*
|
|
||||||
* Nur sichtbar im Cockpit-Modus, wenn gerade eine Kachel fokussiert ist. Sitzt
|
|
||||||
* im Navigations-Header, kollidiert also mit nichts im Chat (früher hing er über
|
|
||||||
* dem Abbrechen-Button der „ARIA denkt"-Leiste).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { StyleSheet, Text, TouchableOpacity } from 'react-native';
|
|
||||||
import cockpitNav from '../services/cockpitNav';
|
|
||||||
import viewMode, { ViewModeValue } from '../services/viewMode';
|
|
||||||
|
|
||||||
const CockpitOverviewButton: React.FC = () => {
|
|
||||||
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
|
|
||||||
const [focused, setFocused] = useState<boolean>(cockpitNav.isFocused());
|
|
||||||
|
|
||||||
useEffect(() => viewMode.subscribe(setMode), []);
|
|
||||||
useEffect(() => cockpitNav.subscribe(setFocused), []);
|
|
||||||
|
|
||||||
if (mode !== 'cockpit' || !focused) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => cockpitNav.requestOverview()}
|
|
||||||
style={styles.pill}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
activeOpacity={0.75}
|
|
||||||
>
|
|
||||||
<Text style={styles.text}>⤢ Übersicht</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
pill: {
|
|
||||||
marginLeft: 12,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 6,
|
|
||||||
borderRadius: 16,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#0096FF',
|
|
||||||
backgroundColor: '#0A1F33',
|
|
||||||
},
|
|
||||||
text: { color: '#0096FF', fontSize: 13, fontWeight: '700' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default CockpitOverviewButton;
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/**
|
|
||||||
* cockpitNav — kleine Brücke zwischen WorkspaceCanvas (Fokus-Zustand) und dem
|
|
||||||
* Navigations-Header, damit der „⤢ Übersicht"-Button im Header sitzen kann
|
|
||||||
* (kollidiert dort mit nichts im Chat) statt im überfüllten Chat-Inhalt.
|
|
||||||
*
|
|
||||||
* Der Canvas meldet, ob gerade eine Kachel fokussiert ist, und registriert eine
|
|
||||||
* Aktion zum Zurück-zur-Übersicht. Der Header abonniert den Zustand und ruft die
|
|
||||||
* Aktion. Muster wie services/rvs.ts (Singleton).
|
|
||||||
*/
|
|
||||||
|
|
||||||
type Sub = (focused: boolean) => void;
|
|
||||||
|
|
||||||
class CockpitNav {
|
|
||||||
private focused = false;
|
|
||||||
private overviewHandler: (() => void) | null = null;
|
|
||||||
private subs: Sub[] = [];
|
|
||||||
|
|
||||||
isFocused(): boolean { return this.focused; }
|
|
||||||
|
|
||||||
setFocused(f: boolean): void {
|
|
||||||
if (this.focused === f) return;
|
|
||||||
this.focused = f;
|
|
||||||
this.subs.forEach((cb) => cb(f));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Canvas registriert seine „zur Übersicht"-Aktion (null beim Unmount). */
|
|
||||||
registerOverview(fn: (() => void) | null): void {
|
|
||||||
this.overviewHandler = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestOverview(): void {
|
|
||||||
this.overviewHandler?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(cb: Sub): () => void {
|
|
||||||
this.subs.push(cb);
|
|
||||||
cb(this.focused);
|
|
||||||
return () => { this.subs = this.subs.filter((s) => s !== cb); };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cockpitNav = new CockpitNav();
|
|
||||||
export default cockpitNav;
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tile — leichte Thumbnail-Darstellung einer Kachel in der gezoomten "Landkarte".
|
|
||||||
*
|
|
||||||
* Zeigt NUR Icon + Titel (+ optionalen Untertitel). Die schweren, interaktiven
|
|
||||||
* Inhalte (ChatScreen, WebViews) liegen NICHT hier, sondern in der separaten
|
|
||||||
* Identity-Content-Ebene des WorkspaceCanvas — Thumbnails werden nie skaliert
|
|
||||||
* interaktiv. Ein Tap fokussiert die Kachel (zoomt sie voll auf).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
|
||||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
||||||
import { runOnJS } from 'react-native-reanimated';
|
|
||||||
import { TileId, TileRect, TILE_META } from './layout';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: TileId;
|
|
||||||
/** Welt-View-lokales Rect (relativ zur Bounds-Ecke). */
|
|
||||||
rect: TileRect;
|
|
||||||
subtitle?: string;
|
|
||||||
onFocus: (id: TileId) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Tile: React.FC<Props> = ({ id, rect, subtitle, onFocus }) => {
|
|
||||||
const meta = TILE_META[id];
|
|
||||||
const tap = Gesture.Tap()
|
|
||||||
.maxDuration(300)
|
|
||||||
.onEnd((_e, success) => {
|
|
||||||
if (success) runOnJS(onFocus)(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GestureDetector gesture={tap}>
|
|
||||||
<View style={[styles.tile, { left: rect.x, top: rect.y, width: rect.w, height: rect.h }]}>
|
|
||||||
<Text style={styles.icon}>{meta.icon}</Text>
|
|
||||||
<Text style={styles.title}>{meta.title}</Text>
|
|
||||||
{!!subtitle && <Text style={styles.subtitle} numberOfLines={2}>{subtitle}</Text>}
|
|
||||||
<Text style={styles.hint}>Tippen zum Öffnen</Text>
|
|
||||||
</View>
|
|
||||||
</GestureDetector>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
tile: {
|
|
||||||
position: 'absolute',
|
|
||||||
backgroundColor: '#12122A',
|
|
||||||
borderRadius: 32,
|
|
||||||
borderWidth: 3,
|
|
||||||
borderColor: '#1E1E2E',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 40,
|
|
||||||
},
|
|
||||||
icon: { fontSize: 220, marginBottom: 24 },
|
|
||||||
title: { color: '#FFFFFF', fontSize: 96, fontWeight: '800' },
|
|
||||||
subtitle: { color: '#9090B0', fontSize: 52, marginTop: 20, textAlign: 'center' },
|
|
||||||
hint: { color: '#555570', fontSize: 46, marginTop: 40 },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default Tile;
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
/**
|
|
||||||
* WorkspaceCanvas — die zoom-/verschiebbare "Landkarte" plus Fokus-Modus.
|
|
||||||
*
|
|
||||||
* Zwei Ebenen uebereinander:
|
|
||||||
* 1. Welt-Ebene (skaliert/verschoben): nur leichte Thumbnail-Kacheln. Hier
|
|
||||||
* wirken 2-Finger-Pinch-Zoom + 2-Finger-Pan (nur in der Uebersicht).
|
|
||||||
* 2. Identity-Content-Ebene (Scale 1, nie transformiert): die schweren,
|
|
||||||
* interaktiven Inhalte (ChatScreen + WebViews). Alle sichtbaren Kacheln
|
|
||||||
* sind hier IMMER gemountet; nur die fokussierte ist per display sichtbar.
|
|
||||||
* Dadurch bleiben Touch-Koordinaten/Keyboard korrekt und nichts remountet
|
|
||||||
* beim Fokuswechsel.
|
|
||||||
*
|
|
||||||
* Tap auf eine Thumbnail-Kachel → Fokus (voll aufgezoomt + interaktiv). Der
|
|
||||||
* "⤢ Übersicht"-Button bzw. der Hardware-Back fuehren zurueck zur Landkarte.
|
|
||||||
* Bei nur einer Kachel (reiner Chat) ist diese dauerhaft fokussiert und der
|
|
||||||
* Canvas verhaelt sich exakt wie der bisherige Vollbild-Chat.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { BackHandler, StyleSheet, Text, useWindowDimensions, View } from 'react-native';
|
|
||||||
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
|
||||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
||||||
|
|
||||||
import {
|
|
||||||
boundsOf, Camera, focusCamera, overviewCamera, TileId, TILE_RECTS, toLocal,
|
|
||||||
} from './layout';
|
|
||||||
import cockpitNav from '../services/cockpitNav';
|
|
||||||
import Tile from './Tile';
|
|
||||||
import { useWorkspaceLayout } from './useWorkspaceLayout';
|
|
||||||
import ChatTile from './tiles/ChatTile';
|
|
||||||
import CodeEditorTile from './tiles/CodeEditorTile';
|
|
||||||
import VncTile from './tiles/VncTile';
|
|
||||||
import PreviewTile from './tiles/PreviewTile';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
projectId: string;
|
|
||||||
visibleTiles: TileId[];
|
|
||||||
subtitles?: Partial<Record<TileId, string>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ANIM = { duration: 260 };
|
|
||||||
const MIN_SCALE = 0.15;
|
|
||||||
const MAX_SCALE = 4;
|
|
||||||
|
|
||||||
const WorkspaceCanvas: React.FC<Props> = ({ projectId, visibleTiles, subtitles }) => {
|
|
||||||
const { width: vw, height: vh } = useWindowDimensions();
|
|
||||||
const [focusedTileId, setFocusedTileId] = useState<TileId | null>('chat');
|
|
||||||
|
|
||||||
const visibleKey = visibleTiles.join(',');
|
|
||||||
const bounds = useMemo(() => boundsOf(visibleTiles), [visibleKey]);
|
|
||||||
const worldW = bounds.w;
|
|
||||||
const worldH = bounds.h;
|
|
||||||
const { loaded: layoutLoaded, getFocus, saveFocus } = useWorkspaceLayout(projectId);
|
|
||||||
|
|
||||||
// Kamera (shared values fuer 60fps auf dem UI-Thread).
|
|
||||||
const scale = useSharedValue(1);
|
|
||||||
const tx = useSharedValue(0);
|
|
||||||
const ty = useSharedValue(0);
|
|
||||||
const savedScale = useSharedValue(1);
|
|
||||||
const savedTx = useSharedValue(0);
|
|
||||||
const savedTy = useSharedValue(0);
|
|
||||||
|
|
||||||
// Verschwindet die fokussierte Kachel aus der Sichtbarkeit → auf Chat zurueck.
|
|
||||||
useEffect(() => {
|
|
||||||
if (focusedTileId && !visibleTiles.includes(focusedTileId)) {
|
|
||||||
setFocusedTileId('chat');
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [visibleKey]);
|
|
||||||
|
|
||||||
// Zuletzt fokussierte Kachel pro Projekt wiederherstellen.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!layoutLoaded) return;
|
|
||||||
const stored = getFocus();
|
|
||||||
if (stored === undefined) return;
|
|
||||||
if (stored === null || visibleTiles.includes(stored)) setFocusedTileId(stored);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [projectId, layoutLoaded, visibleKey]);
|
|
||||||
|
|
||||||
// Fokus-Wahl persistieren.
|
|
||||||
useEffect(() => {
|
|
||||||
if (layoutLoaded) saveFocus(focusedTileId);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [focusedTileId, layoutLoaded]);
|
|
||||||
|
|
||||||
// Kamera auf das Ziel fahren (Fokus-Rect oder Uebersicht).
|
|
||||||
useEffect(() => {
|
|
||||||
const cam: Camera = focusedTileId
|
|
||||||
? focusCamera(toLocal(TILE_RECTS[focusedTileId], bounds), worldW, worldH, vw, vh)
|
|
||||||
: overviewCamera(worldW, worldH, vw, vh);
|
|
||||||
scale.value = withTiming(cam.scale, ANIM);
|
|
||||||
tx.value = withTiming(cam.tx, ANIM);
|
|
||||||
ty.value = withTiming(cam.ty, ANIM);
|
|
||||||
savedScale.value = cam.scale;
|
|
||||||
savedTx.value = cam.tx;
|
|
||||||
savedTy.value = cam.ty;
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [focusedTileId, visibleKey, vw, vh]);
|
|
||||||
|
|
||||||
// Fokus-Zustand + „zur Uebersicht"-Aktion an den Header (cockpitNav) melden,
|
|
||||||
// damit der Uebersicht-Button dort sitzt (kollidiert nicht mit dem Chat).
|
|
||||||
useEffect(() => {
|
|
||||||
cockpitNav.setFocused(focusedTileId != null);
|
|
||||||
cockpitNav.registerOverview(() => setFocusedTileId(null));
|
|
||||||
return () => { cockpitNav.registerOverview(null); cockpitNav.setFocused(false); };
|
|
||||||
}, [focusedTileId]);
|
|
||||||
|
|
||||||
// Hardware-Back: im Fokus → zurueck zur Uebersicht.
|
|
||||||
useEffect(() => {
|
|
||||||
const onBack = () => {
|
|
||||||
if (focusedTileId) {
|
|
||||||
setFocusedTileId(null);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
const sub = BackHandler.addEventListener('hardwareBackPress', onBack);
|
|
||||||
return () => sub.remove();
|
|
||||||
}, [focusedTileId]);
|
|
||||||
|
|
||||||
// Gesten nur in der Uebersicht (Fokus-Modus: Touches fallen an die Kachel).
|
|
||||||
const gesturesEnabled = !focusedTileId;
|
|
||||||
const canvasGesture = useMemo(() => {
|
|
||||||
const pinch = Gesture.Pinch()
|
|
||||||
.enabled(gesturesEnabled)
|
|
||||||
.onUpdate((e) => {
|
|
||||||
'worklet';
|
|
||||||
scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, savedScale.value * e.scale));
|
|
||||||
})
|
|
||||||
.onEnd(() => {
|
|
||||||
'worklet';
|
|
||||||
savedScale.value = scale.value;
|
|
||||||
});
|
|
||||||
const pan = Gesture.Pan()
|
|
||||||
.enabled(gesturesEnabled)
|
|
||||||
.minPointers(2)
|
|
||||||
.onUpdate((e) => {
|
|
||||||
'worklet';
|
|
||||||
tx.value = savedTx.value + e.translationX;
|
|
||||||
ty.value = savedTy.value + e.translationY;
|
|
||||||
})
|
|
||||||
.onEnd(() => {
|
|
||||||
'worklet';
|
|
||||||
savedTx.value = tx.value;
|
|
||||||
savedTy.value = ty.value;
|
|
||||||
});
|
|
||||||
return Gesture.Simultaneous(pinch, pan);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [gesturesEnabled]);
|
|
||||||
|
|
||||||
const worldStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [
|
|
||||||
{ translateX: tx.value },
|
|
||||||
{ translateY: ty.value },
|
|
||||||
{ scale: scale.value },
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const renderContent = (id: TileId) => {
|
|
||||||
switch (id) {
|
|
||||||
case 'chat': return <ChatTile />;
|
|
||||||
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
|
||||||
case 'vnc': return <VncTile projectId={projectId} focused={focusedTileId === 'vnc'} />;
|
|
||||||
case 'preview': return <PreviewTile />;
|
|
||||||
default: return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.root}>
|
|
||||||
{/* Ebene 1 — skalierte Landkarte mit Thumbnails */}
|
|
||||||
<View style={StyleSheet.absoluteFill} pointerEvents={focusedTileId ? 'none' : 'auto'}>
|
|
||||||
<GestureDetector gesture={canvasGesture}>
|
|
||||||
<Animated.View style={[styles.world, { width: worldW, height: worldH }, worldStyle]}>
|
|
||||||
{visibleTiles.map((id) => (
|
|
||||||
<Tile
|
|
||||||
key={id}
|
|
||||||
id={id}
|
|
||||||
rect={toLocal(TILE_RECTS[id], bounds)}
|
|
||||||
subtitle={subtitles?.[id]}
|
|
||||||
onFocus={setFocusedTileId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Animated.View>
|
|
||||||
</GestureDetector>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Ebene 2 — Identity-Content, immer gemountet, nur fokussierte sichtbar */}
|
|
||||||
<View style={StyleSheet.absoluteFill} pointerEvents={focusedTileId ? 'box-none' : 'none'}>
|
|
||||||
{visibleTiles.map((id) => (
|
|
||||||
<View
|
|
||||||
key={id}
|
|
||||||
style={[StyleSheet.absoluteFill, { display: focusedTileId === id ? 'flex' : 'none' }]}
|
|
||||||
>
|
|
||||||
{renderContent(id)}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Steuerung — der „⤢ Übersicht"-Button sitzt im Header (cockpitNav),
|
|
||||||
nicht hier im Chat-Inhalt. */}
|
|
||||||
{!focusedTileId && (
|
|
||||||
<View style={styles.hintBar} pointerEvents="none">
|
|
||||||
<Text style={styles.hintText}>Kachel antippen zum Öffnen · 2 Finger: zoomen & schieben</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
root: { flex: 1, backgroundColor: '#0D0D1A' },
|
|
||||||
world: { position: 'absolute', left: 0, top: 0 },
|
|
||||||
hintBar: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 16,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
hintText: {
|
|
||||||
color: '#9090B0',
|
|
||||||
fontSize: 12,
|
|
||||||
backgroundColor: 'rgba(18,18,42,0.85)',
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 6,
|
|
||||||
borderRadius: 14,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default WorkspaceCanvas;
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* WorkspaceDeck — die Workbench: Vollbild-Panels + Taskleisten-Dock unten.
|
||||||
|
*
|
||||||
|
* Statt einer Zoom-Landkarte: jedes Panel ist bildschirmfuellend und Handy-
|
||||||
|
* optimiert, das Dock wechselt per Daumen-Tap sofort. Alle Panels sind IMMER
|
||||||
|
* gemountet (nur das aktive ist via display sichtbar) → kein Remount, Chat
|
||||||
|
* behaelt RVS/Audio/Queue, WebViews behalten ihren Zustand.
|
||||||
|
*
|
||||||
|
* Bei offener Tastatur blendet das Dock aus (mehr Platz zum Tippen).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Keyboard, StyleSheet, View } from 'react-native';
|
||||||
|
import { TileId } from './layout';
|
||||||
|
import { useWorkspaceLayout } from './useWorkspaceLayout';
|
||||||
|
import WorkspaceDock from './WorkspaceDock';
|
||||||
|
import ChatTile from './tiles/ChatTile';
|
||||||
|
import CodeEditorTile from './tiles/CodeEditorTile';
|
||||||
|
import VncTile from './tiles/VncTile';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
panels: TileId[];
|
||||||
|
badges?: Partial<Record<TileId, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceDeck: React.FC<Props> = ({ projectId, panels, badges }) => {
|
||||||
|
const [active, setActive] = useState<TileId>('chat');
|
||||||
|
const [kbVisible, setKbVisible] = useState(false);
|
||||||
|
const { loaded, getFocus, saveFocus } = useWorkspaceLayout(projectId);
|
||||||
|
|
||||||
|
// Aktives Panel pro Projekt wiederherstellen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded) return;
|
||||||
|
const stored = getFocus();
|
||||||
|
if (stored && panels.includes(stored)) setActive(stored);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [projectId, loaded]);
|
||||||
|
|
||||||
|
// Falls das aktive Panel wegfaellt → erstes nehmen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!panels.includes(active)) setActive(panels[0] || 'chat');
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [panels.join(',')]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loaded) saveFocus(active);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [active, loaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const s1 = Keyboard.addListener('keyboardDidShow', () => setKbVisible(true));
|
||||||
|
const s2 = Keyboard.addListener('keyboardDidHide', () => setKbVisible(false));
|
||||||
|
return () => { s1.remove(); s2.remove(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const render = (id: TileId) => {
|
||||||
|
switch (id) {
|
||||||
|
case 'chat': return <ChatTile />;
|
||||||
|
case 'editor': return <CodeEditorTile projectId={projectId} />;
|
||||||
|
case 'vnc': return <VncTile projectId={projectId} focused={active === 'vnc'} />;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<View style={styles.stack}>
|
||||||
|
{panels.map((id) => (
|
||||||
|
<View
|
||||||
|
key={id}
|
||||||
|
style={[StyleSheet.absoluteFill, { display: active === id ? 'flex' : 'none' }]}
|
||||||
|
>
|
||||||
|
{render(id)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{!kbVisible && (
|
||||||
|
<WorkspaceDock panels={panels} active={active} badges={badges} onSelect={setActive} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#0D0D1A' },
|
||||||
|
stack: { flex: 1, position: 'relative' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default WorkspaceDeck;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* WorkspaceDock — die Taskleiste unten (Daumenzone). Ein Tap wechselt sofort
|
||||||
|
* das Panel; ein animierter Indikator gleitet unter das aktive Icon. Kleine
|
||||||
|
* Aktivitaets-Punkte (Badges) zeigen z.B. „Desktop verbunden" / „Code da".
|
||||||
|
*
|
||||||
|
* Das ist der Kern des Workbench-Gefuehls: Desktop-Umfang, aber Handy-schnell
|
||||||
|
* per Daumen erreichbar — statt einer Zoom-Landkarte.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { LayoutChangeEvent, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { TileId, TILE_META } from './layout';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
panels: TileId[];
|
||||||
|
active: TileId;
|
||||||
|
badges?: Partial<Record<TileId, string>>; // TileId → Punkt-Farbe (undefined = kein Punkt)
|
||||||
|
onSelect: (id: TileId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WorkspaceDock: React.FC<Props> = ({ panels, active, badges, onSelect }) => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [rowW, setRowW] = useState(0);
|
||||||
|
const n = Math.max(1, panels.length);
|
||||||
|
const idx = Math.max(0, panels.indexOf(active));
|
||||||
|
const slot = rowW / n;
|
||||||
|
|
||||||
|
const onLayout = (e: LayoutChangeEvent) => setRowW(e.nativeEvent.layout.width);
|
||||||
|
|
||||||
|
const indicatorStyle = useAnimatedStyle(() => ({
|
||||||
|
width: slot,
|
||||||
|
transform: [{ translateX: withTiming(slot * idx, { duration: 200 }) }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.dock, { paddingBottom: Math.max(insets.bottom, 6) }]}>
|
||||||
|
<View style={styles.row} onLayout={onLayout}>
|
||||||
|
{rowW > 0 && <Animated.View style={[styles.indicator, indicatorStyle]} pointerEvents="none" />}
|
||||||
|
{panels.map((id) => {
|
||||||
|
const meta = TILE_META[id];
|
||||||
|
const isActive = id === active;
|
||||||
|
const badge = badges?.[id];
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={id}
|
||||||
|
style={styles.item}
|
||||||
|
onPress={() => onSelect(id)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Text style={[styles.icon, isActive && styles.iconActive]}>{meta.icon}</Text>
|
||||||
|
{!!badge && <View style={[styles.badge, { backgroundColor: badge }]} />}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.label, isActive && styles.labelActive]} numberOfLines={1}>
|
||||||
|
{meta.title}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
dock: {
|
||||||
|
backgroundColor: '#0B0B18',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#1E1E2E',
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
height: 58,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
indicator: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: '#0096FF',
|
||||||
|
borderBottomLeftRadius: 3,
|
||||||
|
borderBottomRightRadius: 3,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
icon: { fontSize: 22, opacity: 0.55 },
|
||||||
|
iconActive: { opacity: 1 },
|
||||||
|
label: { color: '#6A6A85', fontSize: 10, fontWeight: '600' },
|
||||||
|
labelActive: { color: '#0096FF' },
|
||||||
|
badge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -2,
|
||||||
|
right: -6,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#0B0B18',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default WorkspaceDock;
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* WorkspaceScreen — Screen-Wrapper fuer den Workspace-Canvas.
|
* WorkspaceScreen — Screen-Wrapper fuer die Workbench.
|
||||||
*
|
*
|
||||||
* Buendelt die Signale, die entscheiden welche Kacheln sichtbar sind:
|
* Kompakt-Modus → klassischer Vollbild-Chat (wie vor dem Umbau).
|
||||||
* - projectFocus: aktives Projekt + dessen kind ('code'|'chat')
|
* Cockpit-Modus → Workbench mit Taskleisten-Dock: Chat · Code · Desktop.
|
||||||
* - codeFile: kam schon eine Code-Datei rein? (Live-Reveal des Editors)
|
|
||||||
* - desktop: ist ein QEMU-Desktop verfuegbar? (Live-Reveal der VNC-Kachel)
|
|
||||||
*
|
*
|
||||||
* Ersetzt den bisherigen Chat-Tab: die ChatScreen lebt als Kachel im Canvas,
|
* Aktivitaets-Badges am Dock: Editor blau, wenn schon Code-Dateien da sind;
|
||||||
* bleibt aber genau eine Instanz.
|
* Desktop gruen, wenn eine VM verbunden ist.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
@@ -16,8 +14,10 @@ import codeFile from '../services/codeFile';
|
|||||||
import desktop from '../services/desktop';
|
import desktop from '../services/desktop';
|
||||||
import viewMode, { ViewModeValue } from '../services/viewMode';
|
import viewMode, { ViewModeValue } from '../services/viewMode';
|
||||||
import ChatScreen from '../screens/ChatScreen';
|
import ChatScreen from '../screens/ChatScreen';
|
||||||
import { TileId, TILE_ORDER } from './layout';
|
import { TileId } from './layout';
|
||||||
import WorkspaceCanvas from './WorkspaceCanvas';
|
import WorkspaceDeck from './WorkspaceDeck';
|
||||||
|
|
||||||
|
const COCKPIT_PANELS: TileId[] = ['chat', 'editor', 'vnc'];
|
||||||
|
|
||||||
const WorkspaceScreen: React.FC = () => {
|
const WorkspaceScreen: React.FC = () => {
|
||||||
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
|
const [mode, setMode] = useState<ViewModeValue>(viewMode.get());
|
||||||
@@ -47,29 +47,18 @@ const WorkspaceScreen: React.FC = () => {
|
|||||||
return unsub;
|
return unsub;
|
||||||
}, [pid, kind]);
|
}, [pid, kind]);
|
||||||
|
|
||||||
// Im Cockpit immer alle Kacheln zeigen (Editor/Desktop/Vorschau als
|
const badges = useMemo(() => ({
|
||||||
// Platzhalter, bis ein Code-Projekt/VM aktiv ist) — sonst „fehlen" sie
|
editor: hasCode ? '#0096FF' : undefined,
|
||||||
// gefuehlt. isCode blendet nur die Untertitel-Info um.
|
vnc: hasDesktop ? '#34C759' : undefined,
|
||||||
const visibleTiles: TileId[] = TILE_ORDER;
|
} as Partial<Record<TileId, string>>), [hasCode, hasDesktop]);
|
||||||
const isCode = kind === 'code' || hasCode || hasDesktop;
|
|
||||||
|
|
||||||
const subtitles = useMemo(() => {
|
|
||||||
const fileCount = codeFile.getFiles(pid).length;
|
|
||||||
return {
|
|
||||||
chat: pid ? projectFocus.getProjectName(pid) : 'Hauptchat',
|
|
||||||
editor: isCode ? `${fileCount} Datei${fileCount === 1 ? '' : 'en'}` : 'kein Code-Projekt',
|
|
||||||
vnc: hasDesktop ? 'verbunden' : 'kein Desktop',
|
|
||||||
preview: undefined,
|
|
||||||
} as Partial<Record<TileId, string>>;
|
|
||||||
}, [pid, focus.projectNameById, isCode, hasCode, hasDesktop]);
|
|
||||||
|
|
||||||
// Kompakt-Ansicht: klassischer Vollbild-Chat, exakt wie vor dem Umbau.
|
// Kompakt-Ansicht: klassischer Vollbild-Chat, exakt wie vor dem Umbau.
|
||||||
if (mode === 'compact') {
|
if (mode === 'compact') {
|
||||||
return <ChatScreen />;
|
return <ChatScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cockpit: zoombarer Kachel-Desktop.
|
// Cockpit: Workbench mit Dock.
|
||||||
return <WorkspaceCanvas projectId={pid} visibleTiles={visibleTiles} subtitles={subtitles} />;
|
return <WorkspaceDeck projectId={pid} panels={COCKPIT_PANELS} badges={badges} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WorkspaceScreen;
|
export default WorkspaceScreen;
|
||||||
|
|||||||
@@ -76,10 +76,20 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
|||||||
};
|
};
|
||||||
|
|
||||||
var msg=document.getElementById('msg');
|
var msg=document.getElementById('msg');
|
||||||
// noVNC per ESM vom CDN laden.
|
|
||||||
|
// Verstecktes Eingabefeld → Software-Tastatur des Handys tippt in die VM.
|
||||||
|
var kbd=document.createElement('input');
|
||||||
|
kbd.setAttribute('autocomplete','off'); kbd.setAttribute('autocorrect','off');
|
||||||
|
kbd.setAttribute('autocapitalize','off'); kbd.spellcheck=false;
|
||||||
|
kbd.style.cssText='position:absolute;left:-1000px;top:0;width:1px;height:1px;opacity:0;';
|
||||||
|
document.body.appendChild(kbd);
|
||||||
|
var SPECIAL={Enter:0xff0d,Backspace:0xff08,Tab:0xff09,Escape:0xff1b,Delete:0xffff,
|
||||||
|
ArrowLeft:0xff51,ArrowUp:0xff52,ArrowRight:0xff53,ArrowDown:0xff54,Home:0xff50,End:0xff57};
|
||||||
|
|
||||||
import('https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/core/rfb.js').then(function(mod){
|
import('https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/core/rfb.js').then(function(mod){
|
||||||
var RFB = mod.default;
|
var RFB = mod.default;
|
||||||
var rfb = new RFB(document.getElementById('screen'), 'ws://aria-vnc/', {});
|
var rfb = new RFB(document.getElementById('screen'), 'ws://aria-vnc/', {});
|
||||||
|
var fit=true;
|
||||||
rfb.scaleViewport = true;
|
rfb.scaleViewport = true;
|
||||||
rfb.clipViewport = false;
|
rfb.clipViewport = false;
|
||||||
rfb.addEventListener('connect', function(){ msg.style.display='none'; post({event:'vnc_state', state:'connected'}); });
|
rfb.addEventListener('connect', function(){ msg.style.display='none'; post({event:'vnc_state', state:'connected'}); });
|
||||||
@@ -88,6 +98,24 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
|||||||
post({event:'vnc_state', state:'disconnected'});
|
post({event:'vnc_state', state:'disconnected'});
|
||||||
});
|
});
|
||||||
window.__rfb = rfb;
|
window.__rfb = rfb;
|
||||||
|
|
||||||
|
// Tasten aus dem versteckten Feld an die VM schicken.
|
||||||
|
function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} }
|
||||||
|
kbd.addEventListener('keydown', function(e){
|
||||||
|
if(SPECIAL[e.key]!==undefined){ tap(SPECIAL[e.key], e.code); e.preventDefault(); }
|
||||||
|
});
|
||||||
|
kbd.addEventListener('input', function(){
|
||||||
|
var v=kbd.value; for(var i=0;i<v.length;i++){ tap(v.charCodeAt(i)); } kbd.value='';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Steuerungs-API fuer die App (per injectJavaScript).
|
||||||
|
window.ariaVncCtl = {
|
||||||
|
focusKeyboard: function(){ try{ kbd.focus(); }catch(_){} },
|
||||||
|
blurKeyboard: function(){ try{ kbd.blur(); }catch(_){} },
|
||||||
|
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
|
||||||
|
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
|
||||||
|
};
|
||||||
|
|
||||||
post({event:'ready'});
|
post({event:'ready'});
|
||||||
}).catch(function(err){
|
}).catch(function(err){
|
||||||
msg.textContent='noVNC konnte nicht geladen werden (Internet?)';
|
msg.textContent='noVNC konnte nicht geladen werden (Internet?)';
|
||||||
|
|||||||
@@ -1,95 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* layout — Kachel-Geometrie + Kamera-Mathematik fuer den Workspace-Canvas.
|
* layout — Panel-Definitionen der Workbench (Metadaten fuer das Dock).
|
||||||
*
|
|
||||||
* Welt-Koordinaten = Pixel bei Scale 1. Kacheln liegen auf einem festen 2x2-
|
|
||||||
* Raster. Die "Welt-View" (Animated.View) ist exakt die Bounding-Box der gerade
|
|
||||||
* sichtbaren Kacheln; Kinder werden relativ zu deren Ursprung positioniert.
|
|
||||||
*
|
|
||||||
* RN 0.73 kennt noch kein transformOrigin — Scale dreht um die View-MITTE.
|
|
||||||
* Alle Kamera-Formeln rechnen deshalb mit Center-Origin:
|
|
||||||
* screen = center + scale*(p - center) + translate
|
|
||||||
* wobei center = (worldW/2, worldH/2) (die Welt-View sitzt bei screen 0,0).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type TileId = 'chat' | 'editor' | 'vnc' | 'preview';
|
export type TileId = 'chat' | 'editor' | 'vnc' | 'preview';
|
||||||
|
|
||||||
export interface TileRect { x: number; y: number; w: number; h: number; }
|
export interface TileDef { id: TileId; title: string; icon: string }
|
||||||
|
|
||||||
export interface TileDef { id: TileId; title: string; icon: string; }
|
|
||||||
|
|
||||||
// Feste Kachelgroesse im Welt-Raster (Pixel bei Scale 1).
|
|
||||||
const TILE_W = 1100;
|
|
||||||
const TILE_H = 1500;
|
|
||||||
const GAP = 140;
|
|
||||||
|
|
||||||
/** Absolute Welt-Rects pro Kachel (2x2-Raster). */
|
|
||||||
export const TILE_RECTS: Record<TileId, TileRect> = {
|
|
||||||
chat: { x: 0, y: 0, w: TILE_W, h: TILE_H },
|
|
||||||
editor: { x: TILE_W + GAP, y: 0, w: TILE_W, h: TILE_H },
|
|
||||||
vnc: { x: 0, y: TILE_H + GAP, w: TILE_W, h: TILE_H },
|
|
||||||
preview: { x: TILE_W + GAP, y: TILE_H + GAP, w: TILE_W, h: TILE_H },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TILE_META: Record<TileId, TileDef> = {
|
export const TILE_META: Record<TileId, TileDef> = {
|
||||||
chat: { id: 'chat', title: 'Chat', icon: '💬' },
|
chat: { id: 'chat', title: 'Chat', icon: '💬' },
|
||||||
editor: { id: 'editor', title: 'Editor', icon: '📝' },
|
editor: { id: 'editor', title: 'Code', icon: '📝' },
|
||||||
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
|
vnc: { id: 'vnc', title: 'Desktop', icon: '🖥️' },
|
||||||
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
|
preview: { id: 'preview', title: 'Vorschau', icon: '🖼️' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reihenfolge fuer stabiles Rendering.
|
|
||||||
export const TILE_ORDER: TileId[] = ['chat', 'editor', 'vnc', 'preview'];
|
|
||||||
|
|
||||||
export interface VisibilitySignals {
|
|
||||||
kind: 'code' | 'chat';
|
|
||||||
hasCode: boolean; // schon ein code_file empfangen
|
|
||||||
hasDesktop: boolean; // Desktop verfuegbar gemeldet
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Welche Kacheln sind fuer den aktuellen Kontext sichtbar?
|
|
||||||
* Chat ist immer da; Code-Projekt (explizit ODER durch ein Live-Signal)
|
|
||||||
* blendet Editor/Desktop/Vorschau ein. */
|
|
||||||
export function visibleTilesFor(sig: VisibilitySignals): TileId[] {
|
|
||||||
const isCode = sig.kind === 'code' || sig.hasCode || sig.hasDesktop;
|
|
||||||
if (!isCode) return ['chat'];
|
|
||||||
return ['chat', 'editor', 'vnc', 'preview'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bounding-Box mehrerer Kacheln. */
|
|
||||||
export function boundsOf(ids: TileId[]): TileRect {
|
|
||||||
if (ids.length === 0) return { x: 0, y: 0, w: TILE_W, h: TILE_H };
|
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
||||||
for (const id of ids) {
|
|
||||||
const r = TILE_RECTS[id];
|
|
||||||
minX = Math.min(minX, r.x);
|
|
||||||
minY = Math.min(minY, r.y);
|
|
||||||
maxX = Math.max(maxX, r.x + r.w);
|
|
||||||
maxY = Math.max(maxY, r.y + r.h);
|
|
||||||
}
|
|
||||||
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rect in Welt-View-lokale Koordinaten (relativ zur Bounds-Ecke) umrechnen. */
|
|
||||||
export function toLocal(rect: TileRect, bounds: TileRect): TileRect {
|
|
||||||
return { x: rect.x - bounds.x, y: rect.y - bounds.y, w: rect.w, h: rect.h };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Camera { scale: number; tx: number; ty: number; }
|
|
||||||
|
|
||||||
/** Kamera, die ein (lokales) Rect bildschirmfuellend zeigt (Fokus-Modus). */
|
|
||||||
export function focusCamera(localRect: TileRect, worldW: number, worldH: number, vw: number, vh: number): Camera {
|
|
||||||
const s = Math.min(vw / localRect.w, vh / localRect.h);
|
|
||||||
const pcx = localRect.x + localRect.w / 2;
|
|
||||||
const pcy = localRect.y + localRect.h / 2;
|
|
||||||
const tx = vw / 2 - worldW / 2 - s * (pcx - worldW / 2);
|
|
||||||
const ty = vh / 2 - worldH / 2 - s * (pcy - worldH / 2);
|
|
||||||
return { scale: s, tx, ty };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Kamera, die die gesamte Welt zentriert einpasst (Uebersicht). */
|
|
||||||
export function overviewCamera(worldW: number, worldH: number, vw: number, vh: number, pad = 0.86): Camera {
|
|
||||||
const s = Math.min(vw / worldW, vh / worldH) * pad;
|
|
||||||
const tx = vw / 2 - worldW / 2;
|
|
||||||
const ty = vh / 2 - worldH / 2;
|
|
||||||
return { scale: s, tx, ty };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* PreviewTile — zeigt den letzten Screenshot/Vorschau-Frame eines Code-Projekts
|
|
||||||
* (z. B. aria-vm screenshot). Fuellt sich, sobald ARIA ein Bild in den
|
|
||||||
* Vorschau-Kanal legt; bis dahin ein ruhiger Platzhalter.
|
|
||||||
*
|
|
||||||
* (Screenshot-Anbindung folgt mit dem QEMU-Track; hier zunaechst die Kachel.)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Image, StyleSheet, Text, View } from 'react-native';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
imageUri?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PreviewTile: React.FC<Props> = ({ imageUri }) => {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
{imageUri ? (
|
|
||||||
<Image source={{ uri: imageUri }} style={styles.image} resizeMode="contain" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Text style={styles.icon}>🖼️</Text>
|
|
||||||
<Text style={styles.text}>Noch keine Vorschau</Text>
|
|
||||||
<Text style={styles.sub}>Screenshots der VM erscheinen hier.</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1, backgroundColor: '#0D0D1A', alignItems: 'center', justifyContent: 'center' },
|
|
||||||
image: { width: '100%', height: '100%' },
|
|
||||||
icon: { fontSize: 64, marginBottom: 16 },
|
|
||||||
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
|
||||||
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default PreviewTile;
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
|
* VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS).
|
||||||
*
|
*
|
||||||
* Nur im Fokus aktiv: dann wird die WebView gemountet, bei 'ready' der
|
* Nur aktiv, wenn das Desktop-Panel offen ist (focused): dann WebView mounten,
|
||||||
* RVS-VNC-Tunnel geoeffnet (desktop.openVnc). Server-Bytes (vnc_data) werden in
|
* bei 'ready' den RVS-VNC-Tunnel oeffnen. Eine kleine Steuerungs-Leiste macht
|
||||||
* die WebView injiziert, RFB-Bytes der WebView (vnc_send) gehen als vnc_input
|
* die VM auf dem Handy bedienbar: Tastatur einblenden (tippt in die VM),
|
||||||
* zurueck. Verlaesst man die Kachel, wird der Tunnel geschlossen (die VM laeuft
|
* Strg-Alt-Entf, und Fit ↔ 1:1 umschalten.
|
||||||
* auf dem Host weiter).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
||||||
import desktop from '../../services/desktop';
|
import desktop from '../../services/desktop';
|
||||||
import { NOVNC_HTML } from '../assets/novncHtml';
|
import { NOVNC_HTML } from '../assets/novncHtml';
|
||||||
@@ -24,23 +23,24 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
|
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
|
||||||
const unsubDataRef = useRef<null | (() => void)>(null);
|
const unsubDataRef = useRef<null | (() => void)>(null);
|
||||||
|
|
||||||
// Aufraeumen: Tunnel zu + Daten-Abo weg.
|
|
||||||
const teardown = useCallback(() => {
|
const teardown = useCallback(() => {
|
||||||
if (unsubDataRef.current) { unsubDataRef.current(); unsubDataRef.current = null; }
|
if (unsubDataRef.current) { unsubDataRef.current(); unsubDataRef.current = null; }
|
||||||
desktop.closeVnc();
|
desktop.closeVnc();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fokus verloren / Unmount → Tunnel schliessen.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focused) { teardown(); setStatus('idle'); }
|
if (!focused) { teardown(); setStatus('idle'); }
|
||||||
return () => teardown();
|
return () => teardown();
|
||||||
}, [focused, teardown]);
|
}, [focused, teardown]);
|
||||||
|
|
||||||
|
const ctl = useCallback((fn: string) => {
|
||||||
|
webRef.current?.injectJavaScript(`window.ariaVncCtl && window.ariaVncCtl.${fn}(); true;`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onMessage = useCallback((e: WebViewMessageEvent) => {
|
const onMessage = useCallback((e: WebViewMessageEvent) => {
|
||||||
let m: any;
|
let m: any;
|
||||||
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
|
try { m = JSON.parse(e.nativeEvent.data); } catch { return; }
|
||||||
if (m.event === 'ready') {
|
if (m.event === 'ready') {
|
||||||
// WebView + RFB bereit → Tunnel oeffnen und Server-Bytes einspeisen.
|
|
||||||
setStatus('connecting');
|
setStatus('connecting');
|
||||||
unsubDataRef.current = desktop.onVncData((b64) => {
|
unsubDataRef.current = desktop.onVncData((b64) => {
|
||||||
const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`;
|
const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`;
|
||||||
@@ -62,11 +62,12 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
<View style={styles.placeholder}>
|
<View style={styles.placeholder}>
|
||||||
<Text style={styles.icon}>🖥️</Text>
|
<Text style={styles.icon}>🖥️</Text>
|
||||||
<Text style={styles.text}>Desktop</Text>
|
<Text style={styles.text}>Desktop</Text>
|
||||||
<Text style={styles.sub}>Antippen zum Verbinden</Text>
|
<Text style={styles.sub}>Panel öffnen zum Verbinden</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const connected = status === 'connected';
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<WebView
|
<WebView
|
||||||
@@ -79,8 +80,25 @@ const VncTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
domStorageEnabled
|
domStorageEnabled
|
||||||
mixedContentMode="always"
|
mixedContentMode="always"
|
||||||
androidLayerType="hardware"
|
androidLayerType="hardware"
|
||||||
|
keyboardDisplayRequiresUserAction={false}
|
||||||
/>
|
/>
|
||||||
{status !== 'connected' && (
|
|
||||||
|
{/* Steuerungs-Leiste — nur wenn verbunden */}
|
||||||
|
{connected && (
|
||||||
|
<View style={styles.ctlBar}>
|
||||||
|
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('focusKeyboard')} activeOpacity={0.7}>
|
||||||
|
<Text style={styles.ctlText}>⌨</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('cad')} activeOpacity={0.7}>
|
||||||
|
<Text style={styles.ctlTextSmall}>Strg+Alt+Entf</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('toggleFit')} activeOpacity={0.7}>
|
||||||
|
<Text style={styles.ctlText}>⤢</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!connected && (
|
||||||
<View style={styles.overlay} pointerEvents="none">
|
<View style={styles.overlay} pointerEvents="none">
|
||||||
<Text style={styles.overlayText}>
|
<Text style={styles.overlayText}>
|
||||||
{status === 'connecting' ? 'Verbinde …' : status === 'disconnected' ? 'Getrennt' : ''}
|
{status === 'connecting' ? 'Verbinde …' : status === 'disconnected' ? 'Getrennt' : ''}
|
||||||
@@ -98,7 +116,27 @@ const styles = StyleSheet.create({
|
|||||||
icon: { fontSize: 64, marginBottom: 16 },
|
icon: { fontSize: 64, marginBottom: 16 },
|
||||||
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' },
|
||||||
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||||
overlay: { position: 'absolute', top: 10, left: 0, right: 0, alignItems: 'center' },
|
ctlBar: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
ctlBtn: {
|
||||||
|
backgroundColor: 'rgba(18,18,42,0.9)',
|
||||||
|
borderColor: '#2A2A3E',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 7,
|
||||||
|
minWidth: 38,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
ctlText: { color: '#E0E0F0', fontSize: 16, fontWeight: '700' },
|
||||||
|
ctlTextSmall: { color: '#E0E0F0', fontSize: 11, fontWeight: '700' },
|
||||||
|
overlay: { position: 'absolute', top: 12, left: 0, right: 0, alignItems: 'center' },
|
||||||
overlayText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, overflow: 'hidden' },
|
overlayText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, overflow: 'hidden' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user