diff --git a/README.md b/README.md index 372ffaf..6165d16 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ and find the binary in `target/release`. ## Further Reading -If you want to intergrate `wsrx` in your own server project, you can read the [crate docs](https://docs.rs/crate/wsrx/latest). +If you want to integrate `wsrx` in your own server project, you can read the [crate docs](https://docs.rs/crate/wsrx/latest). Also, `wsrx` is a simple tool that using plain WebSocket protocol to tunnel TCP connections, so you can implement your own server / client in other languages you like. diff --git a/crates/wsrx-desktop-gpui/ACTUAL_ARCHITECTURE.md b/crates/wsrx-desktop-gpui/ACTUAL_ARCHITECTURE.md new file mode 100644 index 0000000..ac6eafc --- /dev/null +++ b/crates/wsrx-desktop-gpui/ACTUAL_ARCHITECTURE.md @@ -0,0 +1,195 @@ +# Actual Architecture Analysis from Slint Project + +## Core Concept: Scope-Based Architecture + +The application is **scope-centric**, not page-centric. Each "scope" represents a domain/site that can control tunnels. + +### Scope Types +1. **Default Scope** (`default-scope`): User-controlled, manual tunnel creation +2. **External Scopes**: Remote domains that request control (e.g., `gzctf.example.com`) + +### Sidebar Navigation +The sidebar does NOT show pages. It shows: +1. "Get Started" - Main page with tunnel creation form +2. "Network Logs" - Tracing logs page +3. **Separator** +4. "Default Scope" - User's manual tunnels +5. **Dynamic Scope List** - External domains (with status icons) +6. **Separator** +7. "Settings" - Configuration +8. "Controller Port" - API status button + +### Page/View Structure + +#### 1. Get Started Page (`home`) +**Purpose**: Primary tunnel creation interface + +**Components**: +- Application branding with animated cursor +- Update notification button (if available) +- **Network interface selector dropdown** (127.0.0.1, 0.0.0.0, LAN IPs) +- **Port input field** +- **Remote WebSocket address input** (ws:// or wss://) +- **Send button** - Creates tunnel and navigates to default-scope + +**NOT a modal - this IS the main page** + +#### 2. Connections Page (Dynamic, scope-specific) +**Purpose**: Display tunnels for current scope + +**Displayed for**: Any scope page (default-scope or external domain) + +**Header Section**: +- Scope icon (globe-star for default, globe-warning if pending, lock-closed if allowed) +- Scope name and host +- Control type badge (Manually Controlled vs External Controlled) +- Features list (e.g., "basic", "basic,pingfall") +- **Accept/Decline buttons** (for pending external scopes) +- **Remove button** (for allowed external scopes) + +**Instance List** (scrollable): +- Each tunnel shows: + - Label (custom name) + - Local address (clickable to copy) + - Remote address + - Latency in ms (or "--" if connecting) + - Click anywhere to copy local address + - Click latency area to close tunnel + +**Behavior**: +- Default scope: User creates tunnels from Get Started page +- External scopes: Remote API creates tunnels, user accepts/declines scope access + +#### 3. Network Logs Page (`logs`) +**Purpose**: Real-time tracing log display + +**Features**: +- Streams from `wsrx.log` file (JSON format) +- Displays logs with: + - Level badge (DEBUG/INFO/WARN/ERROR) with color + - Target module name + - Timestamp + - Message (word-wrapped) +- Opacity varies by level (DEBUG=0.5, INFO=0.8, WARN/ERROR=1.0) +- Separator lines between entries + +**NOT sample data - reads actual tracing logs** + +#### 4. Settings Page (`settings`) +**Purpose**: Configuration and about info + +**Sections**: +- Theme selector +- Language selector +- Running in tray toggle +- System information display +- Version info + +### Data Models + +#### Instance (Tunnel) +```rust +pub struct Instance { + label: String, // Display name + remote: String, // ws:// or wss:// address + local: String, // IP:port + latency: i32, // -1 if not connected + scope_host: String, // Which scope owns this tunnel +} +``` + +#### Scope +```rust +pub struct Scope { + host: String, // Domain name (unique ID) + name: String, // Display name + state: String, // "pending", "allowed", "syncing" + features: String, // Comma-separated: "basic", "pingfall" + settings: HashMap, // Feature-specific config +} +``` + +#### Log Entry +```rust +pub struct Log { + timestamp: String, // "2025-11-10 15:30:45" + level: String, // "DEBUG", "INFO", "WARN", "ERROR" + target: String, // Module path (e.g., "wsrx::tunnel") + message: String, // Log message +} +``` + +### Bridges (State Management) + +#### WindowControlBridge +- Window control actions (drag, minimize, maximize, close) + +#### SystemInfoBridge +- OS type, version, has_updates +- Network interfaces list +- **Logs array** (for Network Logs page) +- Callbacks: refresh_interfaces, open_link, open_logs + +#### InstanceBridge +- **instances**: All tunnels across all scopes +- **scoped_instances**: Filtered tunnels for current scope +- Callbacks: + - `add(remote, local)`: Create tunnel (default-scope only) + - `del(local)`: Delete tunnel + +#### ScopeBridge +- **scopes**: Array of all external scopes +- Callbacks: + - `allow(host)`: Accept external scope + - `del(host)`: Remove/decline scope + +#### SettingsBridge +- theme, language, running_in_tray +- api_port, online (daemon status) + +#### UiState (Global State) +- **page**: Current page ID (string) + - "home" - Get Started + - "logs" - Network Logs + - "settings" - Settings + - "default-scope" - User's tunnels + - `` - External scope (e.g., "gzctf.example.com") +- **scope**: Current scope object (for connections page) +- **show_sidebar**: Boolean +- Callbacks: + - `change_scope(host)`: Updates scope and filters scoped_instances + +### Navigation Flow + +1. **App starts** → "home" page (Get Started) +2. **User creates tunnel** → Navigates to "default-scope" page +3. **External domain requests** → New scope appears in sidebar with "pending" status +4. **User clicks pending scope** → Shows connections page with Accept/Decline buttons +5. **User accepts** → Scope state becomes "allowed", shows tunnels +6. **Click "Network Logs"** → Shows logs page +7. **Click "Settings"** → Shows settings page + +### Key Differences from Initial Implementation + +#### ❌ What I Got Wrong: +1. Tunnel creation via modal dialog - **WRONG**, it's the main Get Started page +2. Connections page as standalone - **WRONG**, it's scope-specific +3. Sample log data - **WRONG**, must stream from tracing log file +4. Static page navigation - **WRONG**, sidebar shows scopes not pages +5. Separate pages for each view - **PARTIALLY WRONG**, connections page is dynamic + +#### ✅ What to Keep: +- GPUI entity-based state management +- Component library (buttons, inputs, etc.) +- Vertical scrolling implementation +- Window controls and title bar + +### Implementation Priority + +1. **Phase 1**: Correct data models (Scope, Instance, Log with proper fields) +2. **Phase 2**: Implement UiState global state with scope management +3. **Phase 3**: Rewrite Get Started page as tunnel creation form +4. **Phase 4**: Make Connections page scope-aware with accept/decline +5. **Phase 5**: Implement tracing log subscriber and file streaming +6. **Phase 6**: Update sidebar to show scopes dynamically +7. **Phase 7**: Wire up bridges for actual daemon communication diff --git a/crates/wsrx-desktop-gpui/Cargo.toml b/crates/wsrx-desktop-gpui/Cargo.toml index b000485..6bdbf11 100644 --- a/crates/wsrx-desktop-gpui/Cargo.toml +++ b/crates/wsrx-desktop-gpui/Cargo.toml @@ -47,6 +47,7 @@ toml = { workspace = true } # Error handling and utilities anyhow = "1.0" chrono = { workspace = true } +phf = { version = "0.13", features = ["macros"] } thiserror = { workspace = true } url = { workspace = true } @@ -66,9 +67,9 @@ tower-http = { workspace = true } [build-dependencies] build-target = { workspace = true } git-version = { workspace = true } +rust-i18n = "3" rustc_version = { workspace = true } winres = { workspace = true } -rust-i18n = "3" [[bin]] name = "wsrx-desktop-gpui" @@ -85,5 +86,5 @@ short_description = "Controlled TCP-over-WebSocket forwarding tunnel (GPUI Editi icon = [ "../../arts/logo.png", "../../macos/WebSocketReflectorX.icns", - "../../windows/WebSocketReflectorX.ico", + "../../windows/WebSocketReflectorX.ico" ] diff --git a/crates/wsrx-desktop-gpui/MIGRATION_PLAN.md b/crates/wsrx-desktop-gpui/MIGRATION_PLAN.md index cc3cab6..df868ce 100644 --- a/crates/wsrx-desktop-gpui/MIGRATION_PLAN.md +++ b/crates/wsrx-desktop-gpui/MIGRATION_PLAN.md @@ -1,5 +1,73 @@ # WebSocketReflectorX GPUI Migration Plan +## ⚠️ CORRECTED ARCHITECTURE (2025-11-10) + +**See `ACTUAL_ARCHITECTURE.md` for detailed analysis.** + +### Key Corrections from Original Slint Analysis: + +1. **Application is Scope-Centric, NOT Page-Centric** + - Sidebar shows **scopes** (domains), not pages + - Each scope has its own connections view + - "Default Scope" for user-created tunnels + - External scopes for domain-controlled tunnels + +2. **Get Started Page IS the Tunnel Creation Form** + - NOT a modal dialog + - Main page with network interface selector, port input, remote address input + - Creating a tunnel navigates to "default-scope" + +3. **Connections Page is Dynamic per Scope** + - Shows tunnels filtered by current scope + - Header shows scope status (pending/allowed) + - Accept/Decline buttons for pending scopes + - Each tunnel displays: label, local (copyable), remote, latency + +4. **Network Logs Must Stream from Tracing** + - Reads `wsrx.log` JSON file + - NOT sample data + - Displays real-time tracing output + +5. **Sidebar Structure**: + ``` + - Get Started (home) + - Network Logs (logs) + --- + - Default Scope (default-scope) + - [External Scopes...] (dynamic) + --- + - Settings (settings) + - Controller Port (API status) + ``` + +### Data Models (Corrected): + +```rust +pub struct Instance { // NOT "Tunnel" + label: String, + remote: String, + local: String, + latency: i32, + scope_host: String, +} + +pub struct Scope { + host: String, // Unique ID + name: String, // Display name + state: String, // "pending" | "allowed" | "syncing" + features: String, // "basic,pingfall" + settings: HashMap, +} + +pub struct UiState { + page: String, // "home", "logs", "settings", "default-scope", or scope.host + scope: Scope, // Current scope for connections page + show_sidebar: bool, +} +``` + +--- + ## Overview This document provides a comprehensive migration plan for transitioning the WebSocketReflectorX desktop application from the Slint-based `crates/desktop` to the GPUI-based `crates/wsrx-desktop-gpui`. diff --git a/crates/wsrx-desktop-gpui/src/bridges/mod.rs b/crates/wsrx-desktop-gpui/src/bridges/mod.rs index 73b9dbb..f4157e8 100644 --- a/crates/wsrx-desktop-gpui/src/bridges/mod.rs +++ b/crates/wsrx-desktop-gpui/src/bridges/mod.rs @@ -2,6 +2,8 @@ // This module contains the bridges that connect the UI to the wsrx daemon and // other services +#![allow(dead_code)] // Bridges defined for future implementation + pub mod daemon; pub mod settings; pub mod system_info; diff --git a/crates/wsrx-desktop-gpui/src/components/checkbox.rs b/crates/wsrx-desktop-gpui/src/components/checkbox.rs index 2b1d6be..629b5c4 100644 --- a/crates/wsrx-desktop-gpui/src/components/checkbox.rs +++ b/crates/wsrx-desktop-gpui/src/components/checkbox.rs @@ -96,7 +96,7 @@ impl IntoElement for Checkbox { box_div = box_div.child( // Checkmark icon svg() - .path("icons/checkmark.svg") + .path("checkmark") .size(sizes::icon_xs()) .text_color(colors::window_bg()), ); diff --git a/crates/wsrx-desktop-gpui/src/components/mod.rs b/crates/wsrx-desktop-gpui/src/components/mod.rs index a1cbc37..e06f8fc 100644 --- a/crates/wsrx-desktop-gpui/src/components/mod.rs +++ b/crates/wsrx-desktop-gpui/src/components/mod.rs @@ -1,6 +1,8 @@ // Components - Reusable UI elements built with GPUI // These are lower-level components used across different views +#![allow(dead_code)] // Components defined for future use + pub mod button; pub mod checkbox; pub mod icon_button; diff --git a/crates/wsrx-desktop-gpui/src/components/prelude.rs b/crates/wsrx-desktop-gpui/src/components/prelude.rs index 3b57440..e3381a7 100644 --- a/crates/wsrx-desktop-gpui/src/components/prelude.rs +++ b/crates/wsrx-desktop-gpui/src/components/prelude.rs @@ -1,6 +1,8 @@ // Component prelude - Common imports for all components // Following Zed's pattern from crates/ui/src/component_prelude.rs +#![allow(unused_imports)] // Prelude exports for convenience + pub use gpui::{ App, AppContext, InteractiveElement, IntoElement, ParentElement, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, svg, diff --git a/crates/wsrx-desktop-gpui/src/components/select.rs b/crates/wsrx-desktop-gpui/src/components/select.rs index 4ddf51a..337bc9e 100644 --- a/crates/wsrx-desktop-gpui/src/components/select.rs +++ b/crates/wsrx-desktop-gpui/src/components/select.rs @@ -99,7 +99,6 @@ where .unwrap_or_else(|| self.placeholder.clone()); let disabled = self.disabled; - let options = self.options.clone(); div() .id(self.id.clone()) @@ -124,10 +123,9 @@ where if let (Some(index), Some(callback)) = (this.selected_index, &this.on_select) + && let Some(item) = this.options.get(index).cloned() { - if let Some(item) = this.options.get(index).cloned() { - callback(window, cx, item); - } + callback(window, cx, item); } cx.notify(); diff --git a/crates/wsrx-desktop-gpui/src/components/title_bar.rs b/crates/wsrx-desktop-gpui/src/components/title_bar.rs index 31e67b3..bec8978 100644 --- a/crates/wsrx-desktop-gpui/src/components/title_bar.rs +++ b/crates/wsrx-desktop-gpui/src/components/title_bar.rs @@ -23,7 +23,7 @@ impl TitleBar { impl Render for TitleBar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let window = self.window.clone(); + let window = self.window; let is_macos = cfg!(target_os = "macos"); div() @@ -39,7 +39,6 @@ impl Render for TitleBar { .bg(gpui::transparent_black()) // Drag area .on_mouse_down(MouseButton::Left, { - let window = window.clone(); cx.listener(move |_this, _event: &MouseDownEvent, _window, cx| { window .update(cx, |_view, window, _cx| { @@ -72,7 +71,7 @@ impl Render for TitleBar { })) .child( svg() - .path("icons/navigation.svg") + .path("navigation") .size(styles::sizes::icon_sm()) .text_color(styles::colors::window_fg()), ), diff --git a/crates/wsrx-desktop-gpui/src/components/traits.rs b/crates/wsrx-desktop-gpui/src/components/traits.rs index 999a54c..e8ff731 100644 --- a/crates/wsrx-desktop-gpui/src/components/traits.rs +++ b/crates/wsrx-desktop-gpui/src/components/traits.rs @@ -1,6 +1,8 @@ // Component traits - Common interfaces for UI components // Following Zed's pattern for reusable component behavior +#![allow(dead_code)] // Traits defined for future use + use gpui::{Context, Window}; /// Trait for components that can be clicked diff --git a/crates/wsrx-desktop-gpui/src/components/window_controls.rs b/crates/wsrx-desktop-gpui/src/components/window_controls.rs index b33b586..0d22b13 100644 --- a/crates/wsrx-desktop-gpui/src/components/window_controls.rs +++ b/crates/wsrx-desktop-gpui/src/components/window_controls.rs @@ -15,7 +15,7 @@ impl WindowControls { impl Render for WindowControls { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let window = self.window.clone(); + let window = self.window; let is_macos = cfg!(target_os = "macos"); div() @@ -36,7 +36,6 @@ impl Render for WindowControls { .hover(|this| this.bg(styles::colors::layer_2())) .cursor_pointer() .on_click({ - let window = window.clone(); cx.listener(move |_this, _event, _window, cx| { window .update(cx, |_view, window, _cx| { @@ -47,7 +46,7 @@ impl Render for WindowControls { }) .child( svg() - .path("icons/subtract.svg") + .path("subtract") .size(styles::sizes::icon_sm()) .text_color(styles::colors::window_fg()), ), @@ -64,7 +63,6 @@ impl Render for WindowControls { .hover(|this| this.bg(styles::colors::layer_2())) .cursor_pointer() .on_click({ - let window = window.clone(); cx.listener(move |_this, _event, _window, cx| { window .update(cx, |_view, window, _cx| { @@ -75,7 +73,7 @@ impl Render for WindowControls { }) .child( svg() - .path("icons/maximize.svg") + .path("maximize") .size(styles::sizes::icon_sm()) .text_color(styles::colors::window_fg()), ), @@ -96,7 +94,7 @@ impl Render for WindowControls { })) .child( svg() - .path("icons/dismiss.svg") + .path("dismiss") .size(styles::sizes::icon_sm()) .text_color(styles::colors::window_fg()), ), diff --git a/crates/wsrx-desktop-gpui/src/i18n.rs b/crates/wsrx-desktop-gpui/src/i18n.rs index faf5ff2..0292608 100644 --- a/crates/wsrx-desktop-gpui/src/i18n.rs +++ b/crates/wsrx-desktop-gpui/src/i18n.rs @@ -1,5 +1,7 @@ // i18n - Internationalization support using rust-i18n +#![allow(dead_code)] // Functions defined for future use + // Provides multi-language support with YAML locale files // NOTE: The i18n! macro is initialized in lib.rs at crate root diff --git a/crates/wsrx-desktop-gpui/src/icons.rs b/crates/wsrx-desktop-gpui/src/icons.rs index ae740c7..8c8c1ae 100644 --- a/crates/wsrx-desktop-gpui/src/icons.rs +++ b/crates/wsrx-desktop-gpui/src/icons.rs @@ -1,6 +1,8 @@ // Embedded SVG icons // All SVG files are embedded directly into the binary at compile time +use gpui::SharedString; + pub const HOME: &str = include_str!("../icons/home.svg"); pub const CODE: &str = include_str!("../icons/code.svg"); pub const SETTINGS: &str = include_str!("../icons/settings.svg"); @@ -18,25 +20,33 @@ pub const CHECKBOX_UNCHECKED: &str = include_str!("../icons/checkbox-unchecked.s pub const ARROW_UP_RIGHT: &str = include_str!("../icons/arrow-up-right.svg"); pub const ARROW_SYNC_OFF: &str = include_str!("../icons/arrow-sync-off.svg"); +static ICON_MAP: phf::Map<&'static str, &'static str> = phf::phf_map! { + "home" => HOME, + "code" => CODE, + "settings" => SETTINGS, + "globe-star" => GLOBE_STAR, + "navigation" => NAVIGATION, + "logo" => LOGO, + "logo-stroked" => LOGO_STROKED, + "warning" => WARNING, + "dismiss" => DISMISS, + "maximize" => MAXIMIZE, + "subtract" => SUBTRACT, + "lock-closed" => LOCK_CLOSED, + "checkmark" => CHECKMARK, + "checkbox-unchecked" => CHECKBOX_UNCHECKED, + "arrow-up-right" => ARROW_UP_RIGHT, + "arrow-sync-off" => ARROW_SYNC_OFF, +}; + /// Get icon SVG content by name pub fn get_icon(name: &str) -> Option<&'static str> { - match name { - "home" => Some(HOME), - "code" => Some(CODE), - "settings" => Some(SETTINGS), - "globe-star" => Some(GLOBE_STAR), - "navigation" => Some(NAVIGATION), - "logo" => Some(LOGO), - "logo-stroked" => Some(LOGO_STROKED), - "warning" => Some(WARNING), - "dismiss" => Some(DISMISS), - "maximize" => Some(MAXIMIZE), - "subtract" => Some(SUBTRACT), - "lock-closed" => Some(LOCK_CLOSED), - "checkmark" => Some(CHECKMARK), - "checkbox-unchecked" => Some(CHECKBOX_UNCHECKED), - "arrow-up-right" => Some(ARROW_UP_RIGHT), - "arrow-sync-off" => Some(ARROW_SYNC_OFF), - _ => None, - } + ICON_MAP.get(name).copied() +} + +pub fn list_icons() -> Vec { + ICON_MAP + .keys() + .map(|k| SharedString::new_static(k)) + .collect() } diff --git a/crates/wsrx-desktop-gpui/src/logging.rs b/crates/wsrx-desktop-gpui/src/logging.rs index bfd2bdc..776cbb9 100644 --- a/crates/wsrx-desktop-gpui/src/logging.rs +++ b/crates/wsrx-desktop-gpui/src/logging.rs @@ -1,15 +1,112 @@ -// Logging setup for wsrx-desktop-gpui -use std::fs; +// UI Logger - Custom tracing subscriber that sends logs to the UI +// +// This layer captures tracing events and forwards them to a channel +// that the UI can consume to display logs in real-time. -use anyhow::Result; -use directories::ProjectDirs; -use tracing_appender::non_blocking; -use tracing_subscriber::{EnvFilter, fmt, prelude::*}; +use std::sync::Arc; -pub fn setup() -> Result<( +use chrono::Local; +use tokio::sync::mpsc; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::{Layer, layer::Context}; + +use crate::models::LogEntry; + +/// A tracing layer that sends log entries to the UI via a channel +pub struct UILogLayer { + sender: Arc>, +} + +impl UILogLayer { + /// Create a new UI log layer + /// Returns the layer and a receiver for consuming log entries + pub fn new() -> (Self, mpsc::UnboundedReceiver) { + let (sender, receiver) = mpsc::unbounded_channel(); + let layer = Self { + sender: Arc::new(sender), + }; + (layer, receiver) + } +} + +impl Layer for UILogLayer +where + S: Subscriber, +{ + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + // Extract log level + let level = match *event.metadata().level() { + Level::TRACE => "TRACE", + Level::DEBUG => "DEBUG", + Level::INFO => "INFO", + Level::WARN => "WARN", + Level::ERROR => "ERROR", + }; + + // Extract target (module path) + let target = event.metadata().target(); + + // Format timestamp + let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + // Extract message from event + // Note: This is a simplified approach. For production, you'd want to + // use a visitor pattern to properly extract all fields. + let mut message = String::new(); + event.record(&mut MessageVisitor { + message: &mut message, + }); + + // Create log entry + let log_entry = LogEntry { + timestamp, + level: level.to_string(), + target: target.to_string(), + message, + }; + + // Send to UI (ignore errors if receiver is dropped) + let _ = self.sender.send(log_entry); + } +} + +/// Visitor for extracting the message from a tracing event +struct MessageVisitor<'a> { + message: &'a mut String, +} + +impl<'a> tracing::field::Visit for MessageVisitor<'a> { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + *self.message = format!("{:?}", value); + // Remove quotes added by Debug formatting + if self.message.starts_with('"') && self.message.ends_with('"') { + *self.message = self.message[1..self.message.len() - 1].to_string(); + } + } else { + // Append other fields to the message + if !self.message.is_empty() { + self.message.push_str(", "); + } + self.message + .push_str(&format!("{}={:?}", field.name(), value)); + } + } +} + +/// Set up logging with UI layer +/// Returns guards for file and console loggers, plus a receiver for UI logs +pub fn setup_with_ui() -> anyhow::Result<( tracing_appender::non_blocking::WorkerGuard, tracing_appender::non_blocking::WorkerGuard, + mpsc::UnboundedReceiver, )> { + use std::fs; + + use directories::ProjectDirs; + use tracing_appender::non_blocking; + use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + // Get platform-specific directories let proj_dirs = ProjectDirs::from("org", "xdsec", "wsrx-desktop-gpui") .ok_or_else(|| anyhow::anyhow!("Failed to get project directories"))?; @@ -24,7 +121,10 @@ pub fn setup() -> Result<( let file_appender = tracing_appender::rolling::daily(log_dir, "wsrx-desktop-gpui.log"); let (file_non_blocking, file_guard) = non_blocking(file_appender); - // Set up the subscriber with both console and file output + // UI logger + let (ui_layer, ui_receiver) = UILogLayer::new(); + + // Set up the subscriber with console, file, and UI output tracing_subscriber::registry() .with( fmt::layer() @@ -37,9 +137,10 @@ pub fn setup() -> Result<( .with_writer(file_non_blocking) .with_filter(EnvFilter::from_default_env()), ) + .with(ui_layer) .init(); - tracing::info!("Logging initialized for wsrx-desktop-gpui"); + tracing::info!("Logger initialized."); - Ok((console_guard, file_guard)) + Ok((console_guard, file_guard, ui_receiver)) } diff --git a/crates/wsrx-desktop-gpui/src/main.rs b/crates/wsrx-desktop-gpui/src/main.rs index d1f583f..d3e9bb7 100644 --- a/crates/wsrx-desktop-gpui/src/main.rs +++ b/crates/wsrx-desktop-gpui/src/main.rs @@ -2,7 +2,7 @@ use anyhow::Result; use gpui::{ - App, AppContext, Application, AssetSource, Bounds, SharedString, TitlebarOptions, + App, AppContext, Application, AssetSource, AsyncApp, Bounds, SharedString, TitlebarOptions, WindowBounds, WindowDecorations, WindowKind, WindowOptions, point, px, size, }; @@ -29,68 +29,81 @@ include!(concat!(env!("OUT_DIR"), "/constants.rs")); use views::RootView; /// Asset source that loads embedded SVG icons from binary -struct EmbeddedAssets; +struct IconAssets; -impl AssetSource for EmbeddedAssets { +impl AssetSource for IconAssets { fn load(&self, path: &str) -> Result>> { - // Handle icon paths like "icons/home.svg" - if let Some(icon_name) = path.strip_prefix("icons/").and_then(|p| p.strip_suffix(".svg")) { - if let Some(svg_content) = icons::get_icon(icon_name) { - return Ok(Some(std::borrow::Cow::Borrowed(svg_content.as_bytes()))); - } - } - Ok(None) + Ok(icons::get_icon(path).map(|s| Cow::Borrowed(s.as_bytes()))) } fn list(&self, _path: &str) -> Result> { // Return empty list - we don't need directory listing for embedded assets - Ok(Vec::new()) + Ok(icons::list_icons()) } } fn main() -> Result<()> { - // Initialize logging - let (_console_guard, _file_guard) = logging::setup()?; + // Initialize logging with UI logger + let (_console_guard, _file_guard, mut log_receiver) = logging::setup_with_ui()?; + + tracing::info!("Starting wsrx-desktop-gpui"); // Initialize i18n with system locale i18n::init_locale(); // Create and run the GPUI application with embedded assets Application::new() - .with_assets(EmbeddedAssets) + .with_assets(IconAssets) .run(|cx: &mut App| { - // Create main window with centered bounds - let bounds = Bounds::centered(None, size(px(1200.0), px(800.0)), cx); - - // Platform-specific window configuration (following Zed's pattern) - let titlebar_config = Some(TitlebarOptions { - title: None, // Custom titlebar will show title - appears_transparent: true, - traffic_light_position: Some(point(px(9.0), px(9.0))), - ..Default::default() + // Create main window with centered bounds + let bounds = Bounds::centered(None, size(px(1200.0), px(800.0)), cx); + + // Platform-specific window configuration (following Zed's pattern) + let titlebar_config = Some(TitlebarOptions { + title: None, // Custom titlebar will show title + appears_transparent: true, + traffic_light_position: Some(point(px(9.0), px(9.0))), + }); + + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: titlebar_config, + window_decorations: Some(WindowDecorations::Client), // Client-side decorations + kind: WindowKind::Normal, + is_movable: true, + focus: true, + show: true, + window_min_size: Some(gpui::Size { + width: px(800.0), + height: px(600.0), + }), + ..Default::default() + }, + |window, cx| { + let root_view = cx.new(|cx| RootView::new(window, cx)); + let root_view_ref = root_view.downgrade(); + cx.spawn(move |async_app: &mut AsyncApp| { + let mut async_app = async_app.clone(); + async move { + while let Some(log_entry) = log_receiver.recv().await { + let _ = + root_view_ref.update(&mut async_app, |root, root_view_cx| { + root.add_log(log_entry, root_view_cx); + }); + } + } + }) + .detach(); + root_view + }, + ) + .expect("Failed to open window"); + + tracing::info!("Application window created"); + + cx.activate(true); }); - cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - titlebar: titlebar_config, - window_decorations: Some(WindowDecorations::Client), // Client-side decorations - kind: WindowKind::Normal, - is_movable: true, - focus: true, - show: true, - window_min_size: Some(gpui::Size { - width: px(800.0), - height: px(600.0), - }), - ..Default::default() - }, - |window, cx| cx.new(|cx| RootView::new(window, cx)), - ) - .expect("Failed to open window"); - - cx.activate(true); - }); - Ok(()) } diff --git a/crates/wsrx-desktop-gpui/src/models/app_state.rs b/crates/wsrx-desktop-gpui/src/models/app_state.rs index 6155e09..4c3d23a 100644 --- a/crates/wsrx-desktop-gpui/src/models/app_state.rs +++ b/crates/wsrx-desktop-gpui/src/models/app_state.rs @@ -1,96 +1,179 @@ // Application State - Global application state management -use std::collections::VecDeque; +// Architecture: Scope-centric with dynamic page navigation -use super::{Connection, LogEntry, Settings, Tunnel}; +use gpui::SharedString; -/// Current active page in the application -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +use super::{Instance, LogEntry, Scope, Settings}; + +/// Current page identifier +/// - "home": Get Started page (tunnel creation) +/// - "logs": Network Logs page +/// - "settings": Settings page +/// - "default-scope": User's manual tunnels +/// - : External scope (e.g., "gzctf.example.com") +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum Page { - GetStarted, - Connections, - NetworkLogs, + #[default] + Home, + Logs, Settings, + DefaultScope, + Scope(SharedString), +} + +impl Page { + pub fn as_page_id(&self) -> &str { + match self { + Page::Home => "home", + Page::Logs => "logs", + Page::Settings => "settings", + Page::DefaultScope => "default", + Page::Scope(scope) => scope, + } + } +} + +/// Global UI state +pub struct UiState { + /// Current active page/scope + pub page: Page, + + /// Current scope for connections page + pub current_scope: Option, + + /// Whether sidebar is visible + pub show_sidebar: bool, +} + +impl UiState { + pub fn new() -> Self { + Self { + page: Page::Home, + current_scope: None, + show_sidebar: true, + } + } + + /// Navigate to a page + pub fn navigate_to(&mut self, page: Page) { + self.page = page; + } + + /// Change to a specific scope (for connections page) + pub fn change_scope(&mut self, scope: Scope) { + self.page = Page::Scope(scope.host.clone()); + self.current_scope = Some(scope.clone()); + } + + /// Check if current page is a scope (connections page) + pub fn is_scope_page(&self) -> bool { + matches!(self.page, Page::Scope(_) | Page::DefaultScope) + } } -/// Daemon connection status -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DaemonStatus { - Stopped, - Starting, - Running, - Stopping, - Error, +impl Default for UiState { + fn default() -> Self { + Self::new() + } } /// Global application state -/// This struct holds the main application data that is shared across views +/// Holds the main application data shared across views pub struct AppState { - /// Currently active page - pub current_page: Page, + /// UI state (page navigation, current scope) + pub ui_state: UiState, - /// List of configured tunnels - pub tunnels: Vec, + /// All tunnel instances across all scopes + pub instances: Vec, - /// Active connections - pub connections: Vec, + /// All scopes (external domains) + pub scopes: Vec, /// Application settings pub settings: Settings, - /// Recent log entries (circular buffer) - pub recent_logs: VecDeque, + /// Recent log entries from tracing + pub logs: Vec, /// Maximum number of logs to keep in memory pub max_logs: usize, - - /// Current daemon status - pub daemon_status: DaemonStatus, } impl AppState { /// Create a new AppState with default values pub fn new() -> Self { Self { - current_page: Page::GetStarted, - tunnels: Vec::new(), - connections: Vec::new(), + ui_state: UiState::new(), + instances: Vec::new(), + scopes: Vec::new(), settings: Settings::default(), - recent_logs: VecDeque::new(), - max_logs: 10000, - daemon_status: DaemonStatus::Stopped, + logs: Vec::new(), + max_logs: 1000, } } /// Add a log entry, removing oldest if over capacity pub fn add_log(&mut self, entry: LogEntry) { - if self.recent_logs.len() >= self.max_logs { - self.recent_logs.pop_front(); + if self.logs.len() >= self.max_logs { + self.logs.remove(0); } - self.recent_logs.push_back(entry); + self.logs.push(entry); } /// Clear all logs pub fn clear_logs(&mut self) { - self.recent_logs.clear(); + self.logs.clear(); + } + + /// Get instances for current scope + pub fn scoped_instances(&self) -> Vec { + if let Some(ref scope) = self.ui_state.current_scope { + self.instances + .iter() + .filter(|i| i.scope_host == scope.host) + .cloned() + .collect() + } else { + Vec::new() + } } - /// Add or update a tunnel - pub fn upsert_tunnel(&mut self, tunnel: Tunnel) { - if let Some(pos) = self.tunnels.iter().position(|t| t.id == tunnel.id) { - self.tunnels[pos] = tunnel; + /// Add an instance to a scope + pub fn add_instance(&mut self, instance: Instance) { + self.instances.push(instance); + } + + /// Remove an instance by local address + pub fn remove_instance(&mut self, local: &str) { + self.instances.retain(|i| i.local != local); + } + + /// Add or update a scope + pub fn upsert_scope(&mut self, scope: Scope) { + if let Some(pos) = self.scopes.iter().position(|s| s.host == scope.host) { + self.scopes[pos] = scope; } else { - self.tunnels.push(tunnel); + self.scopes.push(scope); } } - /// Remove a tunnel by ID - pub fn remove_tunnel(&mut self, tunnel_id: &str) { - self.tunnels.retain(|t| t.id != tunnel_id); + /// Remove a scope by host + pub fn remove_scope(&mut self, host: &str) { + self.scopes.retain(|s| s.host != host); + // Also remove all instances for this scope + self.instances.retain(|i| i.scope_host != host); + } + + /// Get a scope by host + pub fn get_scope(&self, host: &str) -> Option<&Scope> { + self.scopes.iter().find(|s| s.host == host) } - /// Get a tunnel by ID - pub fn get_tunnel(&self, tunnel_id: &str) -> Option<&Tunnel> { - self.tunnels.iter().find(|t| t.id == tunnel_id) + /// Allow (accept) a pending scope + pub fn allow_scope(&mut self, host: &str) { + if let Some(scope) = self.scopes.iter_mut().find(|s| s.host == host) { + scope.state = "allowed".to_string(); + } } } diff --git a/crates/wsrx-desktop-gpui/src/models/events.rs b/crates/wsrx-desktop-gpui/src/models/events.rs index eaad67b..21dca66 100644 --- a/crates/wsrx-desktop-gpui/src/models/events.rs +++ b/crates/wsrx-desktop-gpui/src/models/events.rs @@ -1,6 +1,6 @@ // Events - Application event definitions for inter-component communication -use super::{Connection, LogEntry, Tunnel}; +use super::{Instance, LogEntry, Scope}; /// Events that can occur in the application #[derive(Clone, Debug)] @@ -8,20 +8,17 @@ pub enum AppEvent { /// Page navigation event NavigateToPage(super::app_state::Page), - /// Tunnel-related events - TunnelCreated(Tunnel), - TunnelUpdated(Tunnel), - TunnelDeleted(String), // tunnel_id - TunnelEnabled(String), - TunnelDisabled(String), - - /// Connection-related events - ConnectionEstablished(Connection), - ConnectionClosed(String), // connection_id - ConnectionError { - connection_id: String, - error: String, - }, + /// Instance (tunnel) related events + InstanceCreated(Instance), + InstanceUpdated(Instance), + InstanceDeleted(String), // local address + + /// Scope-related events + ScopeAdded(Scope), + ScopeUpdated(Scope), + ScopeRemoved(String), // host + ScopeAllowed(String), // host + ScopeDeclined(String), // host /// Daemon-related events DaemonStarted, diff --git a/crates/wsrx-desktop-gpui/src/models/mod.rs b/crates/wsrx-desktop-gpui/src/models/mod.rs index efc6d7a..ee4a3ea 100644 --- a/crates/wsrx-desktop-gpui/src/models/mod.rs +++ b/crates/wsrx-desktop-gpui/src/models/mod.rs @@ -1,65 +1,92 @@ // Models - Data structures for the application // This module contains all the data models used throughout the application +// Architecture: Scope-centric (not page-centric) -use std::net::SocketAddr; +#![allow(dead_code)] // Models defined for future use +use std::collections::HashMap; + +use gpui::SharedString; use serde::{Deserialize, Serialize}; +use serde_json::Value; pub mod app_state; pub mod events; -/// Represents a WebSocket tunnel configuration +/// Represents a tunnel instance (connection between local and remote) +/// Called "Instance" in original Slint code #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Tunnel { - pub id: String, - pub name: String, - pub local_addr: SocketAddr, - pub remote_addr: SocketAddr, - pub enabled: bool, -} - -/// Represents an active connection -#[derive(Clone, Debug)] -pub struct Connection { - pub id: String, - pub tunnel_id: String, - pub status: ConnectionStatus, - pub bytes_sent: u64, - pub bytes_received: u64, +pub struct Instance { + /// Display label for the tunnel + pub label: String, + /// Remote WebSocket address (ws:// or wss://) + pub remote: String, + /// Local address (IP:port) + pub local: String, + /// Latency in milliseconds (-1 if not connected) + pub latency: i32, + /// Which scope owns this tunnel + pub scope_host: String, } -#[derive(Clone, Debug, Copy, PartialEq, Eq)] -pub enum ConnectionStatus { - Pending, - Connected, - Disconnected, - Error, +/// Represents a scope (domain that can control tunnels) +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Scope { + /// Unique identifier (domain name) + pub host: SharedString, + /// Display name for the scope + pub name: String, + /// Current state: "pending", "allowed", "syncing" + pub state: String, + /// Comma-separated feature flags: "basic", "pingfall" + pub features: String, + /// Feature-specific settings (JSON) + #[serde(default)] + pub settings: HashMap, } -/// Represents a log entry -#[derive(Clone, Debug)] +/// Represents a log entry from tracing +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct LogEntry { + /// Timestamp string (e.g., "2025-11-10 15:30:45") pub timestamp: String, - pub level: LogLevel, + /// Log level: "DEBUG", "INFO", "WARN", "ERROR" + pub level: String, + /// Module/target name (e.g., "wsrx::tunnel") pub target: String, + /// Log message pub message: String, } -#[derive(Clone, Debug, Copy, PartialEq, Eq)] -pub enum LogLevel { - Debug, - Info, - Warn, - Error, +impl LogEntry { + pub fn level_color(&self) -> gpui::Rgba { + match self.level.as_str() { + "DEBUG" => gpui::rgba(0x888888FF), + "INFO" => gpui::rgba(0x5DADE2FF), + "WARN" => gpui::rgba(0xF39C12FF), + "ERROR" => gpui::rgba(0xE74C3CFF), + _ => gpui::rgba(0xAAAAAAFF), + } + } + + pub fn opacity(&self) -> f32 { + match self.level.as_str() { + "DEBUG" => 0.5, + "INFO" => 0.8, + _ => 1.0, + } + } } /// Application settings #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Settings { - pub daemon_auto_start: bool, - pub logging_level: String, - pub show_network_logs: bool, pub theme: Theme, + pub language: String, + pub running_in_tray: bool, + pub api_port: u16, + #[serde(default)] + pub online: bool, } #[derive(Clone, Debug, Serialize, Deserialize, Copy, PartialEq, Eq)] @@ -72,10 +99,11 @@ pub enum Theme { impl Default for Settings { fn default() -> Self { Self { - daemon_auto_start: true, - logging_level: "info".to_string(), - show_network_logs: true, theme: Theme::Auto, + language: "en".to_string(), + running_in_tray: false, + api_port: 7609, + online: false, } } } diff --git a/crates/wsrx-desktop-gpui/src/styles/mod.rs b/crates/wsrx-desktop-gpui/src/styles/mod.rs index b7fb707..05af36e 100644 --- a/crates/wsrx-desktop-gpui/src/styles/mod.rs +++ b/crates/wsrx-desktop-gpui/src/styles/mod.rs @@ -1,6 +1,8 @@ // Styles - Theme and styling definitions for the application // This module contains all the styling, colors, and theming configuration +#![allow(dead_code)] // Utility functions defined for future use + /// Color palette for the application /// Aligned with Slint design system for consistency pub mod colors { diff --git a/crates/wsrx-desktop-gpui/src/views/connections.rs b/crates/wsrx-desktop-gpui/src/views/connections.rs index c3a3c44..24a452e 100644 --- a/crates/wsrx-desktop-gpui/src/views/connections.rs +++ b/crates/wsrx-desktop-gpui/src/views/connections.rs @@ -1,75 +1,53 @@ -// Connections view - Manage tunnels and connections -use gpui::{Context, Render, SharedString, Window, div, prelude::*}; +// Connections view - Manage instances (tunnels) and connections +use gpui::{Context, Render, SharedString, Window, div, prelude::*, px}; -use crate::{models::Tunnel, styles::colors}; +use crate::{models::Instance, styles::colors}; pub struct ConnectionsView { - tunnels: Vec, + instances: Vec, + show_add_modal: bool, + new_instance_label: String, + new_instance_local: String, + new_instance_remote: String, } impl ConnectionsView { pub fn new(_window: &mut Window, _cx: &mut Context) -> Self { Self { - tunnels: Vec::new(), + instances: Vec::new(), + show_add_modal: false, + new_instance_label: String::new(), + new_instance_local: String::from("127.0.0.1:8080"), + new_instance_remote: String::from("ws://example.com"), } } - fn render_tunnel_item(&self, tunnel: &Tunnel, index: usize) -> impl IntoElement { - let id = SharedString::from(format!("tunnel-{}", index)); - let status_color = if tunnel.enabled { - colors::success() - } else { - gpui::rgba(0x888888FF) + fn add_instance(&mut self, cx: &mut Context) { + let instance = Instance { + label: if self.new_instance_label.is_empty() { + format!("Instance {}", self.instances.len() + 1) + } else { + self.new_instance_label.clone() + }, + local: self.new_instance_local.clone(), + remote: self.new_instance_remote.clone(), + latency: -1, // Not connected yet + scope_host: "default-scope".to_string(), }; - div() - .id(id) - .flex() - .items_center() - .justify_between() - .px_4() - .py_3() - .mb_2() - .bg(gpui::rgba(0x2A2A2AFF)) - .rounded_md() - .hover(|div| div.bg(gpui::rgba(0x333333FF))) - .child( - div() - .flex() - .items_center() - .gap_3() - .child(div().w_3().h_3().rounded_full().bg(status_color)) - .child( - div() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_color(colors::foreground()) - .child(tunnel.name.clone()), - ) - .child( - div() - .text_sm() - .text_color(gpui::rgba(0xAAAAAAFF)) - .child(format!( - "{} → {}", - tunnel.local_addr, tunnel.remote_addr - )), - ), - ), - ) - .child( - div() - .text_sm() - .text_color(gpui::rgba(0xAAAAAAFF)) - .child(if tunnel.enabled { - "Enabled" - } else { - "Disabled" - }), - ) + self.instances.push(instance); + self.show_add_modal = false; + self.new_instance_label.clear(); + self.new_instance_local = String::from("127.0.0.1:8080"); + self.new_instance_remote = String::from("ws://example.com"); + cx.notify(); + } + + fn remove_instance(&mut self, index: usize, cx: &mut Context) { + if index < self.instances.len() { + self.instances.remove(index); + cx.notify(); + } } fn render_empty_state(&self) -> impl IntoElement { @@ -84,18 +62,18 @@ impl ConnectionsView { div() .text_xl() .text_color(gpui::rgba(0xAAAAAAFF)) - .child("No tunnels configured"), + .child("No instances configured"), ) .child( div() .text_color(gpui::rgba(0x888888FF)) - .child("Click the + button to create your first tunnel"), + .child("Click the + button to create your first instance"), ) } } impl Render for ConnectionsView { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .flex() .flex_col() @@ -123,24 +101,247 @@ impl Render for ConnectionsView { .rounded_md() .cursor_pointer() .hover(|div| div.bg(gpui::rgba(0x0088DDFF))) - .child("+ Add Tunnel"), + .on_click(cx.listener(|this, _event, _window, cx| { + this.show_add_modal = true; + cx.notify(); + })) + .child("+ Add Instance"), ), ) - .child(if self.tunnels.is_empty() { + .child(if self.instances.is_empty() { self.render_empty_state().into_any_element() } else { - let elements: Vec<_> = self - .tunnels - .iter() - .enumerate() - .map(|(i, tunnel)| self.render_tunnel_item(tunnel, i)) - .collect(); div() .flex() .flex_col() .gap_2() - .children(elements) + .children( + self.instances + .iter() + .enumerate() + .map(|(index, instance)| { + let id = SharedString::from(format!("instance-{}", index)); + let latency_text = if instance.latency >= 0 { + format!("{} ms", instance.latency) + } else { + "--".to_string() + }; + let status_color = if instance.latency >= 0 { + colors::success() + } else { + gpui::rgba(0x888888FF) + }; + + div() + .id(id) + .flex() + .items_center() + .justify_between() + .px_4() + .py_3() + .mb_2() + .bg(gpui::rgba(0x2A2A2AFF)) + .rounded_md() + .hover(|div| div.bg(gpui::rgba(0x333333FF))) + .child( + div() + .flex() + .items_center() + .gap_3() + .child( + div().w_3().h_3().rounded_full().bg(status_color), + ) + .child( + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_color(colors::foreground()) + .child(instance.label.clone()), + ) + .child( + div() + .text_sm() + .text_color(gpui::rgba(0xAAAAAAFF)) + .child(format!( + "{} → {}", + instance.local, instance.remote + )), + ), + ), + ) + .child( + div() + .flex() + .gap_2() + .child( + div() + .px_3() + .py_1() + .rounded_md() + .text_sm() + .text_color(status_color) + .child(latency_text), + ) + .child( + div() + .id(SharedString::from(format!( + "delete-{}", + index + ))) + .px_3() + .py_1() + .rounded_md() + .text_sm() + .cursor_pointer() + .bg(colors::error()) + .hover(|div| div.bg(gpui::rgba(0xFF6655FF))) + .on_click(cx.listener( + move |this, _event, _window, cx| { + this.remove_instance(index, cx); + }, + )) + .child("Delete"), + ), + ) + }) + .collect::>(), + ) .into_any_element() }) + .when(self.show_add_modal, |div| { + div.child(self.render_add_modal(cx)) + }) + } +} + +impl ConnectionsView { + fn render_add_modal(&self, cx: &mut Context) -> impl IntoElement { + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .flex() + .items_center() + .justify_center() + .bg(gpui::rgba(0x00000080)) + .child( + div() + .bg(colors::background()) + .rounded_lg() + .p_6() + .w(px(400.0)) + .flex() + .flex_col() + .gap_4() + .child( + div() + .text_xl() + .text_color(colors::foreground()) + .child("Add New Instance"), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .text_color(colors::foreground()) + .child("Tunnel Name"), + ) + .child( + div() + .px_3() + .py_2() + .rounded_md() + .bg(gpui::rgba(0x2A2A2AFF)) + .text_color(colors::foreground()) + .child(self.new_instance_label.clone()), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .text_color(colors::foreground()) + .child("Local Address"), + ) + .child( + div() + .px_3() + .py_2() + .rounded_md() + .bg(gpui::rgba(0x2A2A2AFF)) + .text_color(colors::foreground()) + .child(self.new_instance_local.clone()), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .text_color(colors::foreground()) + .child("Remote Address"), + ) + .child( + div() + .px_3() + .py_2() + .rounded_md() + .bg(gpui::rgba(0x2A2A2AFF)) + .text_color(colors::foreground()) + .child(self.new_instance_remote.clone()), + ), + ) + .child( + div() + .flex() + .gap_2() + .justify_end() + .child( + div() + .id("cancel-button") + .px_4() + .py_2() + .rounded_md() + .bg(gpui::rgba(0x444444FF)) + .cursor_pointer() + .hover(|div| div.bg(gpui::rgba(0x555555FF))) + .on_click(cx.listener(|this, _event, _window, cx| { + this.show_add_modal = false; + cx.notify(); + })) + .child("Cancel"), + ) + .child( + div() + .id("add-button") + .px_4() + .py_2() + .rounded_md() + .bg(colors::accent()) + .cursor_pointer() + .hover(|div| div.bg(gpui::rgba(0x0088DDFF))) + .on_click(cx.listener(|this, _event, _window, cx| { + this.add_instance(cx); + })) + .child("Add"), + ), + ), + ) } } diff --git a/crates/wsrx-desktop-gpui/src/views/network_logs.rs b/crates/wsrx-desktop-gpui/src/views/network_logs.rs index f2a7ceb..0eb4cd9 100644 --- a/crates/wsrx-desktop-gpui/src/views/network_logs.rs +++ b/crates/wsrx-desktop-gpui/src/views/network_logs.rs @@ -3,10 +3,7 @@ use std::collections::VecDeque; use gpui::{Context, Render, SharedString, Window, div, prelude::*}; -use crate::{ - models::{LogEntry, LogLevel}, - styles::colors, -}; +use crate::{models::LogEntry, styles::colors}; pub struct NetworkLogsView { logs: VecDeque, @@ -14,33 +11,26 @@ pub struct NetworkLogsView { impl NetworkLogsView { pub fn new(_window: &mut Window, _cx: &mut Context) -> Self { + // Start with empty logs - will be populated from tracing Self { logs: VecDeque::new(), } } - fn log_level_color(&self, level: LogLevel) -> gpui::Rgba { - match level { - LogLevel::Debug => gpui::rgba(0x888888FF), - LogLevel::Info => colors::foreground(), - LogLevel::Warn => colors::warning(), - LogLevel::Error => colors::error(), - } - } - - fn log_level_text(&self, level: LogLevel) -> &'static str { - match level { - LogLevel::Debug => "DEBUG", - LogLevel::Info => "INFO", - LogLevel::Warn => "WARN", - LogLevel::Error => "ERROR", + /// Add a log entry (called from RootView when logs are received) + pub fn add_log(&mut self, entry: LogEntry) { + // Keep max 1000 logs to prevent memory issues + if self.logs.len() >= 1000 { + self.logs.pop_front(); } + self.logs.push_back(entry); } fn render_log_entry(&self, entry: &LogEntry, index: usize) -> impl IntoElement { let id = SharedString::from(format!("log-entry-{}", index)); - let level_color = self.log_level_color(entry.level); - let level_text = self.log_level_text(entry.level); + let level_color = entry.level_color(); + let level_text = &entry.level; + let opacity = entry.opacity(); div() .id(id) @@ -65,7 +55,7 @@ impl NetworkLogsView { .text_sm() .text_color(level_color) .min_w_16() - .child(level_text), + .child(level_text.clone()), ) .child( div() @@ -79,6 +69,7 @@ impl NetworkLogsView { .flex_1() .text_sm() .text_color(colors::foreground()) + .opacity(opacity) .child(entry.message.clone()), ) } @@ -142,7 +133,7 @@ impl Render for NetworkLogsView { this.logs.clear(); cx.notify(); })) - .child("Clear"), + .child("Clear Logs"), ), ), ) diff --git a/crates/wsrx-desktop-gpui/src/views/root.rs b/crates/wsrx-desktop-gpui/src/views/root.rs index 166a0e4..b2679da 100644 --- a/crates/wsrx-desktop-gpui/src/views/root.rs +++ b/crates/wsrx-desktop-gpui/src/views/root.rs @@ -1,13 +1,14 @@ // Root view - Main application window -use gpui::{AnyWindowHandle, Context, Entity, Render, Window, div, prelude::*}; +use gpui::{Context, Entity, Render, Window, div, prelude::*}; use super::{ConnectionsView, GetStartedView, NetworkLogsView, SettingsView, SidebarView}; -use crate::{components::title_bar::TitleBar, models::app_state::Page, styles::colors}; +use crate::{ + components::title_bar::TitleBar, + models::{LogEntry, app_state::Page}, + styles::colors, +}; pub struct RootView { - /// Window handle - window: AnyWindowHandle, - /// Current active page current_page: Page, @@ -29,15 +30,13 @@ pub struct RootView { impl RootView { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let current_page = Page::GetStarted; let window_handle = window.window_handle(); let root = Self { - window: window_handle.clone(), - current_page, + current_page: Default::default(), show_sidebar: true, - title_bar: cx.new(|_cx| TitleBar::new(window_handle.clone())), - sidebar: cx.new(|cx| SidebarView::new(window, cx, current_page)), + title_bar: cx.new(|_cx| TitleBar::new(window_handle)), + sidebar: cx.new(|cx| SidebarView::new(window, cx, Default::default())), get_started: cx.new(|cx| GetStartedView::new(window, cx)), connections: cx.new(|cx| ConnectionsView::new(window, cx)), network_logs: cx.new(|cx| NetworkLogsView::new(window, cx)), @@ -81,6 +80,14 @@ impl RootView { cx.notify(); } + /// Add a log entry to the network logs view + pub fn add_log(&mut self, log_entry: LogEntry, cx: &mut Context) { + self.network_logs.update(cx, |logs_view, cx| { + logs_view.add_log(log_entry); + cx.notify(); + }); + } + fn render_sidebar(&self) -> impl IntoElement { div() .flex() @@ -105,13 +112,14 @@ impl RootView { fn render_page_content(&self) -> impl IntoElement { div() + .id("page-content") .flex_1() - .overflow_hidden() // Use basic overflow hidden + .overflow_y_scroll() // Allow vertical scrolling when content overflows .child(match self.current_page { - Page::GetStarted => div().h_full().child(self.get_started.clone()), - Page::Connections => div().h_full().child(self.connections.clone()), - Page::NetworkLogs => div().h_full().child(self.network_logs.clone()), + Page::Home => div().h_full().child(self.get_started.clone()), + Page::Logs => div().h_full().child(self.network_logs.clone()), Page::Settings => div().h_full().child(self.settings.clone()), + _ => div().h_full().child(self.connections.clone()), // Scope pages show connections }) } } diff --git a/crates/wsrx-desktop-gpui/src/views/settings.rs b/crates/wsrx-desktop-gpui/src/views/settings.rs index 9efeb43..6abf25f 100644 --- a/crates/wsrx-desktop-gpui/src/views/settings.rs +++ b/crates/wsrx-desktop-gpui/src/views/settings.rs @@ -69,16 +69,8 @@ impl Render for SettingsView { .flex_col() .child(self.render_section_title("Application")) .child(self.render_setting_row( - "Auto-start Daemon", - if self.settings.daemon_auto_start { - "Enabled" - } else { - "Disabled" - }, - )) - .child(self.render_setting_row( - "Show Network Logs", - if self.settings.show_network_logs { + "Running in Tray", + if self.settings.running_in_tray { "Enabled" } else { "Disabled" @@ -97,16 +89,26 @@ impl Render for SettingsView { Theme::Dark => "Dark", Theme::Auto => "Auto", }, - )), + )) + .child(self.render_setting_row("Language", &self.settings.language)), ) .child( div() .flex() .flex_col() - .child(self.render_section_title("Logging")) - .child( - self.render_setting_row("Log Level", &self.settings.logging_level), - ), + .child(self.render_section_title("Daemon")) + .child(self.render_setting_row( + "API Port", + &self.settings.api_port.to_string(), + )) + .child(self.render_setting_row( + "Status", + if self.settings.online { + "Online" + } else { + "Offline" + }, + )), ) .child( div() diff --git a/crates/wsrx-desktop-gpui/src/views/sidebar.rs b/crates/wsrx-desktop-gpui/src/views/sidebar.rs index ce8ff35..625e039 100644 --- a/crates/wsrx-desktop-gpui/src/views/sidebar.rs +++ b/crates/wsrx-desktop-gpui/src/views/sidebar.rs @@ -25,22 +25,16 @@ impl SidebarView { self.on_page_change = Some(callback); } + #[allow(dead_code)] // Intended for future use pub fn set_active_page(&mut self, page: Page) { self.active_page = page; } fn render_tab( - &self, page: Page, icon_path: &'static str, cx: &mut Context, + &self, page: Page, label: &str, icon_path: &'static str, cx: &mut Context, ) -> impl IntoElement { let is_active = self.active_page == page; - let label_text = match page { - Page::GetStarted => t!("get_started"), - Page::Connections => t!("connections"), - Page::NetworkLogs => t!("network_logs"), - Page::Settings => t!("settings"), - }; - - let id = SharedString::from(format!("sidebar-tab-{:?}", page)); + let id = SharedString::from(format!("sidebar-tab-{}", page.as_page_id())); div() .id(id) @@ -64,10 +58,10 @@ impl SidebarView { }) .on_click(cx.listener(move |this, _event, _window, cx| { // Update our own state first - this.active_page = page; + this.active_page = page.clone(); // Then notify parent if let Some(ref callback) = this.on_page_change { - callback(page, cx); + callback(page.clone(), cx); } })) .child( @@ -89,7 +83,7 @@ impl SidebarView { } else { gpui::FontWeight::NORMAL }) - .child(label_text.to_string()), + .child(label.to_string()), ) } } @@ -113,13 +107,12 @@ impl Render for SidebarView { .bg(colors::layer_1()) .border_r_1() .border_color(colors::element_border()) - .child(self.render_tab(Page::GetStarted, "icons/home.svg", cx)) - .child(self.render_tab(Page::Connections, "icons/globe-star.svg", cx)) - .child(self.render_tab(Page::NetworkLogs, "icons/code.svg", cx)) + .child(self.render_tab(Page::Home, &t!("get_started"), "home", cx)) + .child(self.render_tab(Page::Logs, &t!("network_logs"), "code", cx)) .child( // Spacer div().flex_1(), ) - .child(self.render_tab(Page::Settings, "icons/settings.svg", cx)) + .child(self.render_tab(Page::Settings, &t!("settings"), "settings", cx)) } }