clash_tui/src/connections_view.rs
yly 4d79ba5884 Implement Clash TUI with iocraft
Fullscreen terminal UI for Clash proxy manager with proxy selector,
connections viewer, configurable keybindings, and TOML config support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 02:09:51 +08:00

242 lines
9.9 KiB
Rust

use iocraft::prelude::*;
use std::time::Duration;
use crate::clash::{format_bytes, ConnectionItem};
enum DataState {
Init,
Loading,
Loaded(Vec<ConnectionItem>),
Error(String),
}
#[component]
pub fn ConnectionsView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
let ctx = hooks.use_context::<crate::AppContext>();
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 + "~"
}
}