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>
242 lines
9.9 KiB
Rust
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 + "~"
|
|
}
|
|
}
|