use iocraft::prelude::*; use std::time::Duration; use crate::clash::{format_bytes, ConnectionItem}; enum DataState { Init, Loading, Loaded(Vec), Error(String), } #[component] pub fn ConnectionsView(mut hooks: Hooks) -> impl Into> { let ctx = hooks.use_context::(); let (_width, height) = hooks.use_terminal_size(); let mut state = hooks.use_state(|| DataState::Init); let mut selected = hooks.use_state(|| 0usize); // Clone everything from context before closures let client = ctx.client.clone(); let kb = ctx.keybindings.clone(); let refresh_interval = ctx.refresh_interval; let load = hooks.use_async_handler(move |_: ()| { let client = client.clone(); async move { state.set(DataState::Loading); match client.get_connections().await { Ok(resp) => { let mut conns = resp.connections; conns.sort_by(|a, b| b.download.cmp(&a.download)); state.set(DataState::Loaded(conns)); } Err(e) => { state.set(DataState::Error(e.to_string())); } } } }); if matches!(*state.read(), DataState::Init) { load(()); } let load_refresh = load.clone(); hooks.use_future(async move { loop { smol::Timer::after(Duration::from_secs(refresh_interval)).await; load_refresh(()); } }); let client_close = ctx.client.clone(); let client_close_all = ctx.client.clone(); let load_after_close = load.clone(); let load_after_close_all = load.clone(); hooks.use_terminal_events(move |event| { if let TerminalEvent::Key(key_event) = event { let state_read = state.read(); if let DataState::Loaded(conns) = &*state_read { let count = conns.len(); if count == 0 { return; } let current = selected.get().min(count - 1); if kb.up.matches(&key_event) { if current > 0 { selected.set(current - 1); } } else if kb.down.matches(&key_event) { if current + 1 < count { selected.set(current + 1); } } else if kb.close_connection.matches(&key_event) { let id = conns[current].id.clone(); let client = client_close.clone(); let load = load_after_close.clone(); smol::spawn(async move { let _ = client.close_connection(&id).await; smol::Timer::after(Duration::from_millis(200)).await; load(()); }) .detach(); } else if kb.close_all_connections.matches(&key_event) { let client = client_close_all.clone(); let load = load_after_close_all.clone(); smol::spawn(async move { let _ = client.close_all_connections().await; smol::Timer::after(Duration::from_millis(200)).await; load(()); }) .detach(); } else if kb.refresh.matches(&key_event) { load(()); } } } }); let body_height = if height > 4 { height - 3 } else { 1 }; let state_read = state.read(); match &*state_read { DataState::Init | DataState::Loading => { element! { View( width: 100pct, height: body_height, justify_content: JustifyContent::Center, align_items: AlignItems::Center, ) { Text(content: "Loading connections...", color: Color::Grey) } } } DataState::Error(e) => { element! { View( width: 100pct, height: body_height, justify_content: JustifyContent::Center, align_items: AlignItems::Center, ) { Text(content: format!("Error: {}", e), color: Color::Red) } } } DataState::Loaded(conns) => { let current_selected = selected.get().min(conns.len().saturating_sub(1)); element! { View(width: 100pct, height: body_height, flex_direction: FlexDirection::Column) { // Header row View( width: 100pct, border_style: BorderStyle::Single, border_edges: Edges::Bottom, border_color: Color::DarkGrey, background_color: Some(Color::DarkGrey), ) { View(width: 28pct, padding_left: 1) { Text(content: "Host", weight: Weight::Bold, color: Color::Cyan) } View(width: 6pct) { Text(content: "Net", weight: Weight::Bold, color: Color::Cyan) } View(width: 7pct) { Text(content: "Type", weight: Weight::Bold, color: Color::Cyan) } View(width: 24pct) { Text(content: "Chains", weight: Weight::Bold, color: Color::Cyan) } View(width: 14pct) { Text(content: "Rule", weight: Weight::Bold, color: Color::Cyan) } View(width: 10pct) { Text(content: "DL", weight: Weight::Bold, color: Color::Cyan) } View(width: 10pct) { Text(content: "UL", weight: Weight::Bold, color: Color::Cyan) } } // Connections ScrollView { View(width: 100pct, flex_direction: FlexDirection::Column) { #(conns.iter().enumerate().map(|(i, conn)| { let is_selected = i == current_selected; let bg = if is_selected { Some(Color::DarkCyan) } else if i % 2 == 0 { None } else { Some(Color::DarkGrey) }; let marker = if is_selected { ">" } else { " " }; let host = conn.display_host(); let chains = conn.display_chains(); let dl = format_bytes(conn.download); let ul = format_bytes(conn.upload); element! { View( width: 100pct, background_color: bg, ) { View(width: 28pct, padding_left: 1) { Text(content: format!("{}{}", marker, truncate_str(&host, 25)), color: if is_selected { Color::White } else { Color::Grey }) } View(width: 6pct) { Text(content: truncate_str(&conn.metadata.network, 5), color: if is_selected { Color::White } else { Color::Grey }) } View(width: 7pct) { Text(content: truncate_str(&conn.metadata.conn_type, 6), color: if is_selected { Color::White } else { Color::Grey }) } View(width: 24pct) { Text(content: truncate_str(&chains, 22), color: if is_selected { Color::White } else { Color::Grey }) } View(width: 14pct) { Text(content: truncate_str(&conn.rule, 12), color: if is_selected { Color::White } else { Color::Grey }) } View(width: 10pct) { Text(content: dl, color: Color::Green) } View(width: 10pct) { Text(content: ul, color: Color::Yellow) } } } })) } } // Footer View( width: 100pct, border_style: BorderStyle::Single, border_edges: Edges::Top, border_color: Color::DarkGrey, padding_left: 1, ) { Text(content: format!("[d] Close [D] Close All [r] Refresh [q] Quit {} connections", conns.len()), color: Color::DarkGrey) } } } } } } fn truncate_str(s: &str, max_len: usize) -> String { if s.len() <= max_len { s.to_string() } else { let truncated: String = s.chars().take(max_len - 1).collect(); truncated + "~" } }