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>
This commit is contained in:
yly 2026-04-18 02:09:51 +08:00
commit 4d79ba5884
12 changed files with 4207 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

2975
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "clash_tui"
version = "0.1.0"
edition = "2024"
[dependencies]
iocraft = "0.8"
smol = "2"
surf = "2"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
anyhow = "1"
dirs = "6"
chrono = { version = "0.4", features = ["serde"] }
crossterm = "0.29"

78
SUMMARY.md Normal file
View File

@ -0,0 +1,78 @@
# Clash TUI
A fullscreen terminal UI for controlling the [Clash](https://github.com/Dreamacro/clash) proxy manager API, built with Rust and [iocraft](https://github.com/ccbrown/iocraft).
## Features
- **Proxy selector** -- browse proxy groups in a two-pane layout, switch active proxies
- **Connections viewer** -- view active connections in a sortable table, close individual or all connections
- **Configurable keybindings** -- customize all shortcuts via TOML config
- **Periodic auto-refresh** -- configurable refresh interval (default 2s)
- **Mouse support** -- click tabs to switch views
## Configuration
Config file: `~/.config/clash_tui/config.toml`
```toml
clash_api_url = "http://127.0.0.1:9090"
clash_api_secret = "your-secret" # optional
refresh_interval = 2 # seconds
[keybindings]
quit = "q"
tab_next = "Tab"
tab_prev = "BackTab"
up = "Up"
down = "Down"
left = "Left"
right = "Right"
select = "Enter"
close_connection = "d"
close_all_connections = "shift+d"
refresh = "r"
```
All keybindings and settings are optional and use the defaults shown above.
## CLI Usage
```
cargo run -- [OPTIONS]
Options:
--api-address <URL> Override Clash API address
--api-secret <KEY> Override Clash API secret
--refresh-interval <SECS> Override refresh interval
```
CLI arguments override config file values.
## Default Keybindings
| Key | Action |
|-----------|-----------------------|
| `q` | Quit |
| `Tab` | Next tab |
| `BackTab` | Previous tab |
| `Up/Down` | Navigate list |
| `Left` | Switch to groups pane |
| `Right` | Switch to members pane|
| `Enter` | Select proxy |
| `d` | Close connection |
| `D` | Close all connections |
| `r` | Force refresh |
## File Structure
```
src/
main.rs -- Entry point, CLI parsing, config loading
config.rs -- Config structs, TOML loading, CLI merging
keybindings.rs -- KeyBinding parsing and matching
clash.rs -- Clash API client + serde types
app.rs -- Root App component (fullscreen, tabs)
tab_bar.rs -- Clickable tab bar component
proxy_view.rs -- Proxy selector (two-pane layout)
connections_view.rs -- Connections table view
```

63
src/app.rs Normal file
View File

@ -0,0 +1,63 @@
use iocraft::prelude::*;
use crate::tab_bar;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum Tab {
#[default]
Proxies,
Connections,
}
#[component]
pub fn App(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
let (width, height) = hooks.use_terminal_size();
let mut system = hooks.use_context_mut::<SystemContext>();
let ctx = hooks.use_context::<crate::AppContext>();
let mut active_tab = hooks.use_state(|| Tab::Proxies);
let mut should_exit = hooks.use_state(|| false);
let kb = ctx.keybindings.clone();
hooks.use_terminal_events(move |event| {
if let TerminalEvent::Key(key_event) = event {
if kb.quit.matches(&key_event) {
should_exit.set(true);
} else if kb.tab_next.matches(&key_event) || kb.tab_prev.matches(&key_event) {
active_tab.set(match active_tab.get() {
Tab::Proxies => Tab::Connections,
Tab::Connections => Tab::Proxies,
});
}
}
});
if should_exit.get() {
system.exit();
}
system.set_mouse_capture(true);
let tab = active_tab.get();
element! {
View(
width,
height,
flex_direction: FlexDirection::Column,
background_color: Color::Black,
) {
tab_bar::TabBar(
active_tab: tab,
on_proxies_click: move |_| active_tab.set(Tab::Proxies),
on_connections_click: move |_| active_tab.set(Tab::Connections),
)
#(match tab {
Tab::Proxies => element! {
crate::proxy_view::ProxyView
}.into_any(),
Tab::Connections => element! {
crate::connections_view::ConnectionsView
}.into_any(),
})
}
}
}

214
src/clash.rs Normal file
View File

@ -0,0 +1,214 @@
use anyhow::{anyhow, Result};
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct ProxyHistory {
pub time: String,
pub delay: u64,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct ProxyItem {
pub name: String,
#[serde(rename = "type")]
pub proxy_type: String,
pub all: Option<Vec<String>>,
pub now: Option<String>,
pub history: Vec<ProxyHistory>,
}
impl ProxyItem {
pub fn latest_delay(&self) -> Option<u64> {
self.history.last().map(|h| h.delay)
}
pub fn is_selector(&self) -> bool {
self.proxy_type == "Selector"
}
pub fn is_url_test(&self) -> bool {
self.proxy_type == "URLTest"
}
pub fn is_group(&self) -> bool {
self.is_selector() || self.is_url_test()
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct ProxiesResponse {
pub proxies: HashMap<String, ProxyItem>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct ConnectionMetadata {
pub host: String,
pub network: String,
#[serde(rename = "type")]
pub conn_type: String,
pub source_ip: String,
pub destination_ip: String,
pub source_port: u16,
pub destination_port: u16,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct ConnectionItem {
pub id: String,
pub metadata: ConnectionMetadata,
pub chains: Vec<String>,
pub rule: String,
pub start: String,
pub download: u64,
pub upload: u64,
}
impl ConnectionItem {
pub fn display_host(&self) -> String {
if self.metadata.host.is_empty() {
format!(
"{}:{}",
self.metadata.destination_ip, self.metadata.destination_port
)
} else {
format!(
"{}:{}",
self.metadata.host, self.metadata.destination_port
)
}
}
pub fn display_chains(&self) -> String {
self.chains.join("->")
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct ConnectionsResponse {
#[serde(default)]
pub connections: Vec<ConnectionItem>,
}
#[derive(Clone, Debug)]
pub struct ClashClient {
base_url: String,
secret: Option<String>,
}
impl ClashClient {
pub fn new(base_url: String, secret: Option<String>) -> Self {
Self { base_url, secret }
}
fn auth_header(&self) -> Option<String> {
self.secret
.as_ref()
.map(|s| format!("Bearer {}", s))
}
pub async fn get_proxies(&self) -> Result<ProxiesResponse> {
let mut req = surf::get(format!("{}/proxies", self.base_url));
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth.as_str());
}
let data: ProxiesResponse = req
.recv_json()
.await
.map_err(|e| anyhow!("failed to get proxies: {}", e))?;
Ok(data)
}
pub async fn select_proxy(&self, group: &str, proxy: &str) -> Result<()> {
let mut req = surf::put(format!("{}/proxies/{}", self.base_url, group));
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth.as_str());
}
let body = serde_json::json!({ "name": proxy });
req = req
.body_json(&body)
.map_err(|e| anyhow!("failed to serialize: {}", e))?;
req.await
.map_err(|e| anyhow!("failed to select proxy: {}", e))?;
Ok(())
}
pub async fn get_connections(&self) -> Result<ConnectionsResponse> {
let mut req = surf::get(format!("{}/connections", self.base_url));
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth.as_str());
}
let data: ConnectionsResponse = req
.recv_json()
.await
.map_err(|e| anyhow!("failed to get connections: {}", e))?;
Ok(data)
}
pub async fn close_connection(&self, id: &str) -> Result<()> {
let mut req = surf::delete(format!("{}/connections/{}", self.base_url, id));
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth.as_str());
}
req.await
.map_err(|e| anyhow!("failed to close connection: {}", e))?;
Ok(())
}
pub async fn close_all_connections(&self) -> Result<()> {
let mut req = surf::delete(format!("{}/connections", self.base_url));
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth.as_str());
}
req.await
.map_err(|e| anyhow!("failed to close all connections: {}", e))?;
Ok(())
}
}
pub fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1}GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1}MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1}KB", bytes as f64 / KB as f64)
} else {
format!("{}B", bytes)
}
}
#[allow(dead_code)]
pub fn format_duration(start_str: &str) -> String {
let start = match chrono::DateTime::parse_from_rfc3339(start_str) {
Ok(t) => t.with_timezone(&chrono::Local),
Err(_) => return "?".to_string(),
};
let now = chrono::Local::now();
let diff = now.signed_duration_since(start);
if diff.num_seconds() < 0 {
return "0s".to_string();
}
let total_secs = diff.num_seconds();
let hours = total_secs / 3600;
let mins = (total_secs % 3600) / 60;
let secs = total_secs % 60;
if hours > 0 {
format!("{}h{}m", hours, mins)
} else if mins > 0 {
format!("{}m{}s", mins, secs)
} else {
format!("{}s", secs)
}
}

85
src/config.rs Normal file
View File

@ -0,0 +1,85 @@
use anyhow::Result;
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
pub struct AppConfig {
pub clash_api_url: String,
pub clash_api_secret: Option<String>,
pub refresh_interval: u64,
pub keybindings: KeybindingsConfig,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
clash_api_url: "http://127.0.0.1:9090".to_string(),
clash_api_secret: None,
refresh_interval: 2,
keybindings: KeybindingsConfig::default(),
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
pub struct KeybindingsConfig {
pub quit: String,
pub tab_next: String,
pub tab_prev: String,
pub up: String,
pub down: String,
pub left: String,
pub right: String,
pub select: String,
pub close_connection: String,
pub close_all_connections: String,
pub refresh: String,
}
impl Default for KeybindingsConfig {
fn default() -> Self {
Self {
quit: "q".to_string(),
tab_next: "Tab".to_string(),
tab_prev: "BackTab".to_string(),
up: "Up".to_string(),
down: "Down".to_string(),
left: "Left".to_string(),
right: "Right".to_string(),
select: "Enter".to_string(),
close_connection: "d".to_string(),
close_all_connections: "shift+d".to_string(),
refresh: "r".to_string(),
}
}
}
fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("clash_tui").join("config.toml"))
}
pub fn load_config() -> Result<AppConfig> {
let path = match config_path() {
Some(p) => p,
None => return Ok(AppConfig::default()),
};
if !path.exists() {
return Ok(AppConfig::default());
}
let content = std::fs::read_to_string(&path)?;
let config: AppConfig = toml::from_str(&content)?;
Ok(config)
}
pub fn merge_cli(config: AppConfig, api_address: Option<&str>, api_secret: Option<&str>, refresh_interval: Option<u64>) -> AppConfig {
AppConfig {
clash_api_url: api_address.map(String::from).unwrap_or(config.clash_api_url),
clash_api_secret: api_secret.map(String::from).or(config.clash_api_secret),
refresh_interval: refresh_interval.unwrap_or(config.refresh_interval),
..config
}
}

241
src/connections_view.rs Normal file
View File

@ -0,0 +1,241 @@
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 + "~"
}
}

108
src/keybindings.rs Normal file
View File

@ -0,0 +1,108 @@
use anyhow::{anyhow, Result};
use iocraft::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
#[derive(Clone, Copy, Debug)]
pub struct KeyBinding {
pub code: KeyCode,
pub modifiers: KeyModifiers,
}
impl KeyBinding {
pub fn parse(s: &str) -> Result<Self> {
let s = s.trim();
let (modifiers, key_str) = if s.contains('+') {
let parts: Vec<&str> = s.splitn(2, '+').collect();
let mut mods = KeyModifiers::NONE;
for m in parts[0].to_lowercase().split('+') {
match m.trim() {
"ctrl" | "control" => mods.insert(KeyModifiers::CONTROL),
"shift" => mods.insert(KeyModifiers::SHIFT),
"alt" => mods.insert(KeyModifiers::ALT),
other => return Err(anyhow!("unknown modifier: {}", other)),
}
}
(mods, parts[1].trim())
} else {
(KeyModifiers::NONE, s)
};
let code = parse_key_code(key_str)?;
Ok(Self { code, modifiers })
}
pub fn matches(&self, event: &KeyEvent) -> bool {
event.kind != KeyEventKind::Release
&& event.code == self.code
&& event.modifiers == self.modifiers
}
}
fn parse_key_code(s: &str) -> Result<KeyCode> {
let lower = s.to_lowercase();
match lower.as_str() {
"enter" | "return" => Ok(KeyCode::Enter),
"tab" => Ok(KeyCode::Tab),
"backtab" => Ok(KeyCode::BackTab),
"esc" | "escape" => Ok(KeyCode::Esc),
"up" => Ok(KeyCode::Up),
"down" => Ok(KeyCode::Down),
"left" => Ok(KeyCode::Left),
"right" => Ok(KeyCode::Right),
"home" => Ok(KeyCode::Home),
"end" => Ok(KeyCode::End),
"pageup" | "page_up" => Ok(KeyCode::PageUp),
"pagedown" | "page_down" => Ok(KeyCode::PageDown),
"delete" | "del" => Ok(KeyCode::Delete),
"space" => Ok(KeyCode::Char(' ')),
"backspace" => Ok(KeyCode::Backspace),
"insert" => Ok(KeyCode::Insert),
"f1" => Ok(KeyCode::F(1)),
"f2" => Ok(KeyCode::F(2)),
"f3" => Ok(KeyCode::F(3)),
"f4" => Ok(KeyCode::F(4)),
"f5" => Ok(KeyCode::F(5)),
"f6" => Ok(KeyCode::F(6)),
"f7" => Ok(KeyCode::F(7)),
"f8" => Ok(KeyCode::F(8)),
"f9" => Ok(KeyCode::F(9)),
"f10" => Ok(KeyCode::F(10)),
"f11" => Ok(KeyCode::F(11)),
"f12" => Ok(KeyCode::F(12)),
"null" => Ok(KeyCode::Null),
c if c.len() == 1 => Ok(KeyCode::Char(c.chars().next().unwrap())),
_ => Err(anyhow!("unknown key: {}", s)),
}
}
#[derive(Clone, Debug)]
pub struct Keybindings {
pub quit: KeyBinding,
pub tab_next: KeyBinding,
pub tab_prev: KeyBinding,
pub up: KeyBinding,
pub down: KeyBinding,
pub left: KeyBinding,
pub right: KeyBinding,
pub select: KeyBinding,
pub close_connection: KeyBinding,
pub close_all_connections: KeyBinding,
pub refresh: KeyBinding,
}
impl Keybindings {
pub fn from_config(cfg: &crate::config::KeybindingsConfig) -> Result<Self> {
Ok(Self {
quit: KeyBinding::parse(&cfg.quit)?,
tab_next: KeyBinding::parse(&cfg.tab_next)?,
tab_prev: KeyBinding::parse(&cfg.tab_prev)?,
up: KeyBinding::parse(&cfg.up)?,
down: KeyBinding::parse(&cfg.down)?,
left: KeyBinding::parse(&cfg.left)?,
right: KeyBinding::parse(&cfg.right)?,
select: KeyBinding::parse(&cfg.select)?,
close_connection: KeyBinding::parse(&cfg.close_connection)?,
close_all_connections: KeyBinding::parse(&cfg.close_all_connections)?,
refresh: KeyBinding::parse(&cfg.refresh)?,
})
}
}

58
src/main.rs Normal file
View File

@ -0,0 +1,58 @@
mod app;
mod clash;
mod config;
mod connections_view;
mod keybindings;
mod proxy_view;
mod tab_bar;
use clap::Parser;
use iocraft::prelude::*;
#[derive(Parser)]
#[command(name = "clash_tui", about = "A TUI for Clash proxy manager")]
struct CliArgs {
#[arg(long = "api-address")]
api_address: Option<String>,
#[arg(long = "api-secret")]
api_secret: Option<String>,
#[arg(long = "refresh-interval")]
refresh_interval: Option<u64>,
}
#[derive(Clone)]
pub struct AppContext {
pub client: clash::ClashClient,
pub keybindings: keybindings::Keybindings,
pub refresh_interval: u64,
}
fn main() -> anyhow::Result<()> {
let cli = CliArgs::parse();
let config = config::load_config()?;
let config = config::merge_cli(
config,
cli.api_address.as_deref(),
cli.api_secret.as_deref(),
cli.refresh_interval,
);
let keybindings = keybindings::Keybindings::from_config(&config.keybindings)?;
let client = clash::ClashClient::new(config.clash_api_url, config.clash_api_secret);
let ctx = AppContext {
client,
keybindings,
refresh_interval: config.refresh_interval,
};
smol::block_on(
element! {
ContextProvider(value: Context::owned(ctx)) {
app::App
}
}
.fullscreen(),
)?;
Ok(())
}

306
src/proxy_view.rs Normal file
View File

@ -0,0 +1,306 @@
use iocraft::prelude::*;
use std::time::Duration;
use crate::clash::ProxyItem;
enum DataState {
Init,
Loading,
Loaded(Vec<ProxyItem>),
Error(String),
}
#[derive(Clone, Copy, PartialEq)]
enum FocusPane {
Groups,
Members,
}
#[component]
pub fn ProxyView(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_group = hooks.use_state(|| 0usize);
let mut selected_member = hooks.use_state(|| 0usize);
let mut focus_pane = hooks.use_state(|| FocusPane::Groups);
// Clone everything from context before any closures that need Send
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_proxies().await {
Ok(resp) => {
let mut groups: Vec<ProxyItem> = resp
.proxies
.into_values()
.filter(|p| p.is_group())
.collect();
groups.sort_by(|a, b| a.name.cmp(&b.name));
state.set(DataState::Loaded(groups));
}
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_for_select = ctx.client.clone();
let load_select = load.clone();
hooks.use_terminal_events(move |event| {
if let TerminalEvent::Key(key_event) = event {
let state_read = state.read();
if let DataState::Loaded(groups) = &*state_read {
let group_count = groups.len();
if group_count == 0 {
return;
}
let current_group = selected_group.get().min(group_count - 1);
let current_members = groups[current_group]
.all
.as_ref()
.map(|m| m.len())
.unwrap_or(0);
match focus_pane.get() {
FocusPane::Groups => {
if kb.up.matches(&key_event) {
if current_group > 0 {
selected_group.set(current_group - 1);
selected_member.set(0);
}
} else if kb.down.matches(&key_event) {
if current_group + 1 < group_count {
selected_group.set(current_group + 1);
selected_member.set(0);
}
} else if (kb.right.matches(&key_event) || kb.select.matches(&key_event))
&& current_members > 0
{
focus_pane.set(FocusPane::Members);
}
}
FocusPane::Members => {
if kb.up.matches(&key_event) {
if selected_member.get() > 0 {
selected_member.set(selected_member.get() - 1);
}
} else if kb.down.matches(&key_event) {
if selected_member.get() + 1 < current_members {
selected_member.set(selected_member.get() + 1);
}
} else if kb.left.matches(&key_event) {
focus_pane.set(FocusPane::Groups);
} else if kb.select.matches(&key_event) {
let group = &groups[current_group];
if let Some(ref members) = group.all
&& selected_member.get() < members.len()
{
let group_name = group.name.clone();
let member_name = members[selected_member.get()].clone();
let client = client_for_select.clone();
let load_after = load_select.clone();
smol::spawn(async move {
let _ = client.select_proxy(&group_name, &member_name).await;
smol::Timer::after(Duration::from_millis(200)).await;
load_after(());
})
.detach();
}
}
}
}
}
}
});
let body_height = if height > 4 { height - 3 } else { 1 };
let left_width = if width > 20 { width * 3 / 10 } else { 10 };
let right_width = width.saturating_sub(left_width);
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 proxies...", 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(groups) => {
let current_group_idx = selected_group.get().min(groups.len().saturating_sub(1));
let current_group = &groups[current_group_idx];
let members = current_group.all.as_deref().unwrap_or(&[]);
let current_now = current_group.now.as_deref().unwrap_or("");
element! {
View(width: 100pct, height: body_height, flex_direction: FlexDirection::Row) {
// Left pane: group list
View(
width: left_width,
height: body_height,
flex_direction: FlexDirection::Column,
border_style: BorderStyle::Single,
border_color: Color::DarkGrey,
border_edges: Edges::Right,
) {
View(
width: 100pct,
border_style: BorderStyle::Single,
border_edges: Edges::Bottom,
border_color: Color::DarkGrey,
padding_left: 1,
) {
Text(content: "Proxy Groups", weight: Weight::Bold, color: Color::Cyan)
}
ScrollView {
View(width: 100pct, flex_direction: FlexDirection::Column) {
#(groups.iter().enumerate().map(|(i, group)| {
let is_selected = i == current_group_idx;
let is_focused = focus_pane.get() == FocusPane::Groups && is_selected;
let bg = if is_focused {
Some(Color::DarkCyan)
} else if is_selected {
Some(Color::DarkGrey)
} else {
None
};
let marker = if is_focused { ">" } else { " " };
let now_display = group.now.as_deref().unwrap_or("");
element! {
View(
width: 100pct,
background_color: bg,
padding_left: 1,
) {
Text(content: format!("{}{} ", marker, group.name), color: if is_selected { Color::White } else { Color::Grey })
Text(content: now_display.to_string(), color: Color::DarkGrey)
}
}
}))
}
}
}
// Right pane: member list
View(
width: right_width,
height: body_height,
flex_direction: FlexDirection::Column,
) {
// Header
View(
width: 100pct,
border_style: BorderStyle::Single,
border_edges: Edges::Bottom,
border_color: Color::DarkGrey,
padding_left: 1,
flex_direction: FlexDirection::Column,
) {
View {
Text(content: format!("Group: {}", current_group.name), weight: Weight::Bold, color: Color::Cyan)
}
View {
Text(content: "Current: ", color: Color::Grey)
Text(content: current_now.to_string(), color: Color::Green, weight: Weight::Bold)
}
}
// Members
ScrollView {
View(width: 100pct, flex_direction: FlexDirection::Column) {
#(members.iter().enumerate().map(|(i, member)| {
let is_selected = i == selected_member.get();
let is_focused = focus_pane.get() == FocusPane::Members && is_selected;
let is_current = member == current_now;
let bg = if is_focused {
Some(Color::DarkCyan)
} else if is_selected {
Some(Color::DarkGrey)
} else {
None
};
let marker = if is_focused { ">" } else { " " };
let delay_str = groups
.iter()
.find(|g| g.name == *member)
.and_then(|g| g.latest_delay())
.map(|d| {
if d == 0 {
"timeout".to_string()
} else {
format!("{}ms", d)
}
})
.unwrap_or_default();
element! {
View(
width: 100pct,
background_color: bg,
padding_left: 1,
) {
Text(content: format!("{}{} ", marker, member), color: if is_current { Color::Green } else if is_selected { Color::White } else { Color::Grey })
Text(content: format!("({})", delay_str), color: Color::DarkGrey)
#(if is_current {
element! { Text(content: " <--", color: Color::Green) }.into_any()
} else {
element! { Fragment }.into_any()
})
}
}
}))
}
}
// Footer
View(
width: 100pct,
border_style: BorderStyle::Single,
border_edges: Edges::Top,
border_color: Color::DarkGrey,
padding_left: 1,
) {
Text(content: "[Enter] Select [Up/Down] Navigate [Left/Right] Switch Pane", color: Color::DarkGrey)
}
}
}
}
}
}
}

61
src/tab_bar.rs Normal file
View File

@ -0,0 +1,61 @@
use iocraft::prelude::*;
use crate::app::Tab;
#[derive(Default, Props)]
pub struct TabBarProps {
pub active_tab: Tab,
pub on_proxies_click: HandlerMut<'static, ()>,
pub on_connections_click: HandlerMut<'static, ()>,
}
#[component]
pub fn TabBar(props: &mut TabBarProps) -> impl Into<AnyElement<'static>> {
let proxies_active = props.active_tab == Tab::Proxies;
let connections_active = props.active_tab == Tab::Connections;
element! {
View(
width: 100pct,
flex_direction: FlexDirection::Row,
) {
Button(handler: props.on_proxies_click.take()) {
View(
padding_left: 2,
padding_right: 2,
padding_top: 1,
padding_bottom: 1,
background_color: if proxies_active { Some(Color::Cyan) } else { Some(Color::DarkGrey) },
border_style: BorderStyle::Round,
border_edges: Edges::Bottom,
border_color: if proxies_active { Color::Cyan } else { Color::DarkGrey },
) {
Text(
content: " Proxies ",
color: if proxies_active { Color::Black } else { Color::Grey },
weight: if proxies_active { Weight::Bold } else { Weight::Normal },
)
}
}
Button(handler: props.on_connections_click.take()) {
View(
padding_left: 2,
padding_right: 2,
padding_top: 1,
padding_bottom: 1,
background_color: if connections_active { Some(Color::Cyan) } else { Some(Color::DarkGrey) },
border_style: BorderStyle::Round,
border_edges: Edges::Bottom,
border_color: if connections_active { Color::Cyan } else { Color::DarkGrey },
) {
Text(
content: " Connections ",
color: if connections_active { Color::Black } else { Color::Grey },
weight: if connections_active { Weight::Bold } else { Weight::Normal },
)
}
}
View(flex_grow: 1.0, border_style: BorderStyle::Single, border_edges: Edges::Bottom, border_color: Color::DarkGrey) {}
}
}
}