Files
openfang/crates/openfang-cli/src/tui/screens/channels.rs
iven 92e5def702
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
初始化提交
2026-03-01 16:24:24 +08:00

934 lines
30 KiB
Rust

//! Channels screen: list all 40 adapters, setup wizards, test & toggle.
use crate::tui::theme;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph};
use ratatui::Frame;
// ── Data types ──────────────────────────────────────────────────────────────
#[derive(Clone)]
pub struct ChannelInfo {
pub name: String,
pub display_name: String,
pub category: String,
pub status: ChannelStatus,
pub env_vars: Vec<(String, bool)>, // (var_name, is_set)
pub enabled: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum ChannelStatus {
Ready,
MissingEnv,
NotConfigured,
}
// ── Channel definitions — all 40 adapters ───────────────────────────────────
struct ChannelDef {
name: &'static str,
display_name: &'static str,
category: &'static str,
env_vars: &'static [&'static str],
description: &'static str,
}
const CHANNEL_DEFS: &[ChannelDef] = &[
// ── Messaging (12)
ChannelDef {
name: "telegram",
display_name: "Telegram",
category: "Messaging",
env_vars: &["TELEGRAM_BOT_TOKEN"],
description: "Telegram Bot API adapter",
},
ChannelDef {
name: "discord",
display_name: "Discord",
category: "Messaging",
env_vars: &["DISCORD_BOT_TOKEN"],
description: "Discord bot adapter",
},
ChannelDef {
name: "slack",
display_name: "Slack",
category: "Messaging",
env_vars: &["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN"],
description: "Slack Socket Mode adapter",
},
ChannelDef {
name: "whatsapp",
display_name: "WhatsApp",
category: "Messaging",
env_vars: &["WHATSAPP_ACCESS_TOKEN", "WHATSAPP_VERIFY_TOKEN"],
description: "WhatsApp Cloud API adapter",
},
ChannelDef {
name: "signal",
display_name: "Signal",
category: "Messaging",
env_vars: &[],
description: "Signal via signal-cli REST API",
},
ChannelDef {
name: "matrix",
display_name: "Matrix",
category: "Messaging",
env_vars: &["MATRIX_ACCESS_TOKEN"],
description: "Matrix/Element adapter",
},
ChannelDef {
name: "email",
display_name: "Email",
category: "Messaging",
env_vars: &["EMAIL_PASSWORD"],
description: "IMAP/SMTP email adapter",
},
ChannelDef {
name: "line",
display_name: "LINE",
category: "Messaging",
env_vars: &["LINE_CHANNEL_SECRET", "LINE_CHANNEL_ACCESS_TOKEN"],
description: "LINE Messaging API adapter",
},
ChannelDef {
name: "viber",
display_name: "Viber",
category: "Messaging",
env_vars: &["VIBER_AUTH_TOKEN"],
description: "Viber Bot API adapter",
},
ChannelDef {
name: "messenger",
display_name: "Messenger",
category: "Messaging",
env_vars: &["MESSENGER_PAGE_TOKEN", "MESSENGER_VERIFY_TOKEN"],
description: "Facebook Messenger adapter",
},
ChannelDef {
name: "threema",
display_name: "Threema",
category: "Messaging",
env_vars: &["THREEMA_SECRET"],
description: "Threema Gateway adapter",
},
ChannelDef {
name: "keybase",
display_name: "Keybase",
category: "Messaging",
env_vars: &["KEYBASE_PAPERKEY"],
description: "Keybase chat adapter",
},
// ── Social (5)
ChannelDef {
name: "reddit",
display_name: "Reddit",
category: "Social",
env_vars: &["REDDIT_CLIENT_SECRET", "REDDIT_PASSWORD"],
description: "Reddit API bot adapter",
},
ChannelDef {
name: "mastodon",
display_name: "Mastodon",
category: "Social",
env_vars: &["MASTODON_ACCESS_TOKEN"],
description: "Mastodon Streaming API adapter",
},
ChannelDef {
name: "bluesky",
display_name: "Bluesky",
category: "Social",
env_vars: &["BLUESKY_APP_PASSWORD"],
description: "Bluesky/AT Protocol adapter",
},
ChannelDef {
name: "linkedin",
display_name: "LinkedIn",
category: "Social",
env_vars: &["LINKEDIN_ACCESS_TOKEN"],
description: "LinkedIn Messaging API adapter",
},
ChannelDef {
name: "nostr",
display_name: "Nostr",
category: "Social",
env_vars: &["NOSTR_PRIVATE_KEY"],
description: "Nostr relay protocol adapter",
},
// ── Enterprise (10)
ChannelDef {
name: "teams",
display_name: "Teams",
category: "Enterprise",
env_vars: &["TEAMS_APP_PASSWORD"],
description: "Microsoft Teams Bot Framework adapter",
},
ChannelDef {
name: "mattermost",
display_name: "Mattermost",
category: "Enterprise",
env_vars: &["MATTERMOST_TOKEN"],
description: "Mattermost WebSocket adapter",
},
ChannelDef {
name: "google_chat",
display_name: "Google Chat",
category: "Enterprise",
env_vars: &["GOOGLE_CHAT_SERVICE_ACCOUNT"],
description: "Google Chat service account adapter",
},
ChannelDef {
name: "webex",
display_name: "Webex",
category: "Enterprise",
env_vars: &["WEBEX_BOT_TOKEN"],
description: "Cisco Webex bot adapter",
},
ChannelDef {
name: "feishu",
display_name: "Feishu/Lark",
category: "Enterprise",
env_vars: &["FEISHU_APP_SECRET"],
description: "Feishu/Lark Open Platform adapter",
},
ChannelDef {
name: "dingtalk",
display_name: "DingTalk",
category: "Enterprise",
env_vars: &["DINGTALK_ACCESS_TOKEN", "DINGTALK_SECRET"],
description: "DingTalk Robot API adapter",
},
ChannelDef {
name: "pumble",
display_name: "Pumble",
category: "Enterprise",
env_vars: &["PUMBLE_BOT_TOKEN"],
description: "Pumble bot adapter",
},
ChannelDef {
name: "flock",
display_name: "Flock",
category: "Enterprise",
env_vars: &["FLOCK_BOT_TOKEN"],
description: "Flock bot adapter",
},
ChannelDef {
name: "twist",
display_name: "Twist",
category: "Enterprise",
env_vars: &["TWIST_TOKEN"],
description: "Twist API v3 adapter",
},
ChannelDef {
name: "zulip",
display_name: "Zulip",
category: "Enterprise",
env_vars: &["ZULIP_API_KEY"],
description: "Zulip event queue adapter",
},
// ── Developer (9)
ChannelDef {
name: "irc",
display_name: "IRC",
category: "Developer",
env_vars: &[],
description: "IRC raw TCP adapter",
},
ChannelDef {
name: "xmpp",
display_name: "XMPP",
category: "Developer",
env_vars: &["XMPP_PASSWORD"],
description: "XMPP/Jabber adapter",
},
ChannelDef {
name: "gitter",
display_name: "Gitter",
category: "Developer",
env_vars: &["GITTER_TOKEN"],
description: "Gitter Streaming API adapter",
},
ChannelDef {
name: "discourse",
display_name: "Discourse",
category: "Developer",
env_vars: &["DISCOURSE_API_KEY"],
description: "Discourse forum API adapter",
},
ChannelDef {
name: "revolt",
display_name: "Revolt",
category: "Developer",
env_vars: &["REVOLT_BOT_TOKEN"],
description: "Revolt bot adapter",
},
ChannelDef {
name: "guilded",
display_name: "Guilded",
category: "Developer",
env_vars: &["GUILDED_BOT_TOKEN"],
description: "Guilded bot adapter",
},
ChannelDef {
name: "nextcloud",
display_name: "Nextcloud",
category: "Developer",
env_vars: &["NEXTCLOUD_TOKEN"],
description: "Nextcloud Talk adapter",
},
ChannelDef {
name: "rocketchat",
display_name: "Rocket.Chat",
category: "Developer",
env_vars: &["ROCKETCHAT_TOKEN"],
description: "Rocket.Chat REST adapter",
},
ChannelDef {
name: "twitch",
display_name: "Twitch",
category: "Developer",
env_vars: &["TWITCH_OAUTH_TOKEN"],
description: "Twitch IRC gateway adapter",
},
// ── Notifications (4)
ChannelDef {
name: "ntfy",
display_name: "ntfy",
category: "Notifications",
env_vars: &["NTFY_TOKEN"],
description: "ntfy.sh pub/sub adapter",
},
ChannelDef {
name: "gotify",
display_name: "Gotify",
category: "Notifications",
env_vars: &["GOTIFY_APP_TOKEN", "GOTIFY_CLIENT_TOKEN"],
description: "Gotify WebSocket adapter",
},
ChannelDef {
name: "webhook",
display_name: "Webhook",
category: "Notifications",
env_vars: &["WEBHOOK_SECRET"],
description: "Generic webhook adapter",
},
ChannelDef {
name: "mumble",
display_name: "Mumble",
category: "Notifications",
env_vars: &["MUMBLE_PASSWORD"],
description: "Mumble text chat adapter",
},
];
const CATEGORIES: &[&str] = &[
"All",
"Messaging",
"Social",
"Enterprise",
"Developer",
"Notifications",
];
// ── State ───────────────────────────────────────────────────────────────────
#[derive(Clone, PartialEq, Eq)]
pub enum ChannelSubScreen {
List,
Setup,
Testing,
}
pub struct ChannelState {
pub sub: ChannelSubScreen,
pub channels: Vec<ChannelInfo>,
pub list_state: ListState,
pub loading: bool,
pub tick: usize,
// Category filter
pub category_idx: usize,
// Setup wizard
pub setup_channel_idx: Option<usize>,
pub setup_field_idx: usize,
pub setup_input: String,
pub setup_values: Vec<(String, String)>, // collected (env_var, value) pairs
// Test
pub test_result: Option<(bool, String)>,
pub status_msg: String,
}
pub enum ChannelAction {
Continue,
Refresh,
TestChannel(String),
ToggleChannel(String, bool),
SaveChannel(String, Vec<(String, String)>),
}
impl ChannelState {
pub fn new() -> Self {
Self {
sub: ChannelSubScreen::List,
channels: Vec::new(),
list_state: ListState::default(),
loading: false,
tick: 0,
category_idx: 0,
setup_channel_idx: None,
setup_field_idx: 0,
setup_input: String::new(),
setup_values: Vec::new(),
test_result: None,
status_msg: String::new(),
}
}
pub fn tick(&mut self) {
self.tick = self.tick.wrapping_add(1);
}
fn current_category(&self) -> &str {
CATEGORIES[self.category_idx]
}
fn filtered_channels(&self) -> Vec<&ChannelInfo> {
let cat = self.current_category();
self.channels
.iter()
.filter(|ch| cat == "All" || ch.category == cat)
.collect()
}
fn ready_count(&self) -> usize {
self.channels
.iter()
.filter(|ch| ch.status == ChannelStatus::Ready)
.count()
}
/// Build the default channel list from env var detection.
pub fn build_default_channels(&mut self) {
self.channels.clear();
for def in CHANNEL_DEFS {
let env_vars: Vec<(String, bool)> = def
.env_vars
.iter()
.map(|v| (v.to_string(), std::env::var(v).is_ok()))
.collect();
let all_set = env_vars.is_empty() || env_vars.iter().all(|(_, set)| *set);
let any_set = env_vars.iter().any(|(_, set)| *set);
let status = if all_set && !env_vars.is_empty() {
ChannelStatus::Ready
} else if any_set {
ChannelStatus::MissingEnv
} else {
ChannelStatus::NotConfigured
};
self.channels.push(ChannelInfo {
name: def.name.to_string(),
display_name: def.display_name.to_string(),
category: def.category.to_string(),
status,
env_vars,
enabled: false,
});
}
self.list_state.select(Some(0));
}
pub fn handle_key(&mut self, key: KeyEvent) -> ChannelAction {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return ChannelAction::Continue;
}
match self.sub {
ChannelSubScreen::List => self.handle_list(key),
ChannelSubScreen::Setup => self.handle_setup(key),
ChannelSubScreen::Testing => self.handle_testing(key),
}
}
fn handle_list(&mut self, key: KeyEvent) -> ChannelAction {
let filtered = self.filtered_channels();
let total = filtered.len();
if total == 0 {
match key.code {
KeyCode::Char('r') => return ChannelAction::Refresh,
KeyCode::Tab => {
self.category_idx = (self.category_idx + 1) % CATEGORIES.len();
self.list_state.select(Some(0));
}
KeyCode::BackTab => {
self.category_idx = if self.category_idx == 0 {
CATEGORIES.len() - 1
} else {
self.category_idx - 1
};
self.list_state.select(Some(0));
}
_ => {}
}
return ChannelAction::Continue;
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
let i = self.list_state.selected().unwrap_or(0);
let next = if i == 0 { total - 1 } else { i - 1 };
self.list_state.select(Some(next));
}
KeyCode::Down | KeyCode::Char('j') => {
let i = self.list_state.selected().unwrap_or(0);
let next = (i + 1) % total;
self.list_state.select(Some(next));
}
KeyCode::Tab => {
self.category_idx = (self.category_idx + 1) % CATEGORIES.len();
self.list_state.select(Some(0));
}
KeyCode::BackTab => {
self.category_idx = if self.category_idx == 0 {
CATEGORIES.len() - 1
} else {
self.category_idx - 1
};
self.list_state.select(Some(0));
}
KeyCode::Enter => {
if let Some(sel) = self.list_state.selected() {
let filtered = self.filtered_channels();
if let Some(ch) = filtered.get(sel) {
// Find the global index for this channel
let ch_name = ch.name.clone();
if let Some(idx) = self.channels.iter().position(|c| c.name == ch_name) {
self.setup_channel_idx = Some(idx);
self.setup_field_idx = 0;
self.setup_input.clear();
self.setup_values.clear();
self.sub = ChannelSubScreen::Setup;
}
}
}
}
KeyCode::Char('t') => {
if let Some(sel) = self.list_state.selected() {
let filtered = self.filtered_channels();
if let Some(ch) = filtered.get(sel) {
let name = ch.name.clone();
self.test_result = None;
self.sub = ChannelSubScreen::Testing;
return ChannelAction::TestChannel(name);
}
}
}
KeyCode::Char('e') => {
if let Some(sel) = self.list_state.selected() {
let filtered = self.filtered_channels();
if let Some(ch) = filtered.get(sel) {
let name = ch.name.clone();
if let Some(c) = self.channels.iter_mut().find(|c| c.name == name) {
c.enabled = true;
}
return ChannelAction::ToggleChannel(name, true);
}
}
}
KeyCode::Char('d') => {
if let Some(sel) = self.list_state.selected() {
let filtered = self.filtered_channels();
if let Some(ch) = filtered.get(sel) {
let name = ch.name.clone();
if let Some(c) = self.channels.iter_mut().find(|c| c.name == name) {
c.enabled = false;
}
return ChannelAction::ToggleChannel(name, false);
}
}
}
KeyCode::Char('r') => return ChannelAction::Refresh,
_ => {}
}
ChannelAction::Continue
}
fn handle_setup(&mut self, key: KeyEvent) -> ChannelAction {
match key.code {
KeyCode::Esc => {
self.sub = ChannelSubScreen::List;
}
KeyCode::Char(c) => {
self.setup_input.push(c);
}
KeyCode::Backspace => {
self.setup_input.pop();
}
KeyCode::Enter => {
if let Some(idx) = self.setup_channel_idx {
if idx < self.channels.len() {
let env_vars = &CHANNEL_DEFS
.iter()
.find(|d| d.name == self.channels[idx].name)
.map(|d| d.env_vars)
.unwrap_or(&[]);
// Save current field value
if self.setup_field_idx < env_vars.len() && !self.setup_input.is_empty() {
self.setup_values.push((
env_vars[self.setup_field_idx].to_string(),
self.setup_input.clone(),
));
}
if self.setup_field_idx + 1 < env_vars.len() {
self.setup_field_idx += 1;
self.setup_input.clear();
} else {
// All fields collected — emit save action
let name = self.channels[idx].name.clone();
let values = self.setup_values.clone();
self.sub = ChannelSubScreen::List;
if !values.is_empty() {
return ChannelAction::SaveChannel(name, values);
}
}
}
}
}
_ => {}
}
ChannelAction::Continue
}
fn handle_testing(&mut self, key: KeyEvent) -> ChannelAction {
match key.code {
KeyCode::Esc | KeyCode::Enter => {
self.sub = ChannelSubScreen::List;
}
_ => {}
}
ChannelAction::Continue
}
}
// ── Drawing ─────────────────────────────────────────────────────────────────
pub fn draw(f: &mut Frame, area: Rect, state: &mut ChannelState) {
let ready = state.ready_count();
let total = state.channels.len();
let title = format!(" Channels ({ready}/{total} ready) ");
let block = Block::default()
.title(Line::from(vec![Span::styled(title, theme::title_style())]))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::ACCENT))
.padding(Padding::horizontal(1));
let inner = block.inner(area);
f.render_widget(block, area);
match state.sub {
ChannelSubScreen::List => draw_list(f, inner, state),
ChannelSubScreen::Setup => draw_setup(f, inner, state),
ChannelSubScreen::Testing => draw_testing(f, inner, state),
}
}
fn draw_list(f: &mut Frame, area: Rect, state: &mut ChannelState) {
let chunks = Layout::vertical([
Constraint::Length(1), // category tabs
Constraint::Length(2), // header
Constraint::Min(3), // list
Constraint::Length(1), // hints
])
.split(area);
// Category tabs
let cat_spans: Vec<Span> = CATEGORIES
.iter()
.enumerate()
.map(|(i, cat)| {
if i == state.category_idx {
Span::styled(
format!(" [{cat}] "),
Style::default()
.fg(theme::CYAN)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled(format!(" {cat} "), theme::dim_style())
}
})
.collect();
f.render_widget(Paragraph::new(Line::from(cat_spans)), chunks[0]);
// Header
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
format!(
" {:<18} {:<14} {:<16} {}",
"Channel", "Category", "Status", "Env Vars"
),
theme::table_header(),
)])),
chunks[1],
);
if state.loading {
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
Span::styled("Loading channels\u{2026}", theme::dim_style()),
])),
chunks[2],
);
} else {
let filtered = state.filtered_channels();
let items: Vec<ListItem> = filtered
.iter()
.map(|ch| {
let (badge, badge_style) = match ch.status {
ChannelStatus::Ready => ("[Ready]", theme::channel_ready()),
ChannelStatus::MissingEnv => ("[Missing env]", theme::channel_missing()),
ChannelStatus::NotConfigured => ("[Not configured]", theme::channel_off()),
};
let env_summary: String = ch
.env_vars
.iter()
.map(|(v, set)| {
if *set {
format!("\u{2714}{v}")
} else {
format!("\u{2718}{v}")
}
})
.collect::<Vec<_>>()
.join(" ");
let cat_display = format!("{:<14}", ch.category);
ListItem::new(Line::from(vec![
Span::styled(
format!(" {:<18}", ch.display_name),
Style::default().fg(theme::CYAN),
),
Span::styled(cat_display, theme::dim_style()),
Span::styled(format!(" {:<16}", badge), badge_style),
Span::styled(format!(" {env_summary}"), theme::dim_style()),
]))
})
.collect();
let list = List::new(items)
.highlight_style(theme::selected_style())
.highlight_symbol("> ");
f.render_stateful_widget(list, chunks[2], &mut state.list_state);
}
let hints = Paragraph::new(Line::from(vec![Span::styled(
" [\u{2191}\u{2193}] Navigate [Tab] Category [Enter] Setup [t] Test [e/d] Enable/Disable [r] Refresh",
theme::hint_style(),
)]));
f.render_widget(hints, chunks[3]);
}
fn draw_setup(f: &mut Frame, area: Rect, state: &ChannelState) {
let chunks = Layout::vertical([
Constraint::Length(3), // title + description
Constraint::Length(1), // separator
Constraint::Length(2), // current field
Constraint::Length(1), // input
Constraint::Min(2), // TOML preview
Constraint::Length(1), // hints
])
.split(area);
let (ch_name, ch_display, ch_desc, env_vars) = if let Some(idx) = state.setup_channel_idx {
if let Some(def) = CHANNEL_DEFS
.iter()
.find(|d| idx < state.channels.len() && d.name == state.channels[idx].name)
{
(def.name, def.display_name, def.description, def.env_vars)
} else {
("?", "?", "", &[] as &[&str])
}
} else {
("?", "?", "", &[] as &[&str])
};
// Title
f.render_widget(
Paragraph::new(vec![
Line::from(vec![Span::styled(
format!(" Setup: {ch_display}"),
Style::default()
.fg(theme::CYAN)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![Span::styled(
format!(" {ch_desc}"),
theme::dim_style(),
)]),
]),
chunks[0],
);
// Separator
let sep = "\u{2500}".repeat(chunks[1].width as usize);
f.render_widget(
Paragraph::new(Span::styled(sep, theme::dim_style())),
chunks[1],
);
// Current field
if env_vars.is_empty() {
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
" This channel has no secret env vars — configure via config.toml",
theme::dim_style(),
)])),
chunks[2],
);
} else if state.setup_field_idx < env_vars.len() {
let var = env_vars[state.setup_field_idx];
let field_num = state.setup_field_idx + 1;
let total = env_vars.len();
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(format!(" [{field_num}/{total}] Set ")),
Span::styled(var, Style::default().fg(theme::YELLOW)),
Span::raw(":"),
])),
chunks[2],
);
}
// Input
let display = if state.setup_input.is_empty() {
"paste value here..."
} else {
&state.setup_input
};
let style = if state.setup_input.is_empty() {
theme::dim_style()
} else {
theme::input_style()
};
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" > "),
Span::styled(display, style),
Span::styled(
"\u{2588}",
Style::default()
.fg(theme::GREEN)
.add_modifier(Modifier::SLOW_BLINK),
),
])),
chunks[3],
);
// TOML preview
let mut toml_lines = vec![Line::from(Span::styled(
" Add to config.toml:",
theme::dim_style(),
))];
toml_lines.push(Line::from(Span::styled(
format!(" [channels.{ch_name}]"),
Style::default().fg(theme::YELLOW),
)));
for var in env_vars {
toml_lines.push(Line::from(Span::styled(
format!(" # {var} = \"...\""),
Style::default().fg(theme::YELLOW),
)));
}
f.render_widget(Paragraph::new(toml_lines), chunks[4]);
// Hints
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
" [Enter] Next field / Save [Esc] Back",
theme::hint_style(),
)])),
chunks[5],
);
}
fn draw_testing(f: &mut Frame, area: Rect, state: &ChannelState) {
let chunks = Layout::vertical([
Constraint::Length(2),
Constraint::Min(2),
Constraint::Length(1),
])
.split(area);
let ch_name = state
.setup_channel_idx
.and_then(|i| state.channels.get(i))
.map(|c| c.display_name.as_str())
.or_else(|| {
state.list_state.selected().and_then(|i| {
let filtered = state.filtered_channels();
filtered.get(i).map(|c| c.display_name.as_str())
})
})
.unwrap_or("?");
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
format!(" Testing {ch_name}\u{2026}"),
Style::default().fg(theme::CYAN),
)])),
chunks[0],
);
match &state.test_result {
None => {
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
Span::styled("Checking credentials\u{2026}", theme::dim_style()),
])),
chunks[1],
);
}
Some((true, msg)) => {
f.render_widget(
Paragraph::new(vec![
Line::from(vec![
Span::styled(" \u{2714} ", Style::default().fg(theme::GREEN)),
Span::raw("Test passed"),
]),
Line::from(vec![Span::styled(format!(" {msg}"), theme::dim_style())]),
]),
chunks[1],
);
}
Some((false, msg)) => {
f.render_widget(
Paragraph::new(vec![
Line::from(vec![
Span::styled(" \u{2718} ", Style::default().fg(theme::RED)),
Span::raw("Test failed"),
]),
Line::from(vec![Span::styled(
format!(" {msg}"),
Style::default().fg(theme::RED),
)]),
]),
chunks[1],
);
}
}
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
" [Enter/Esc] Back",
theme::hint_style(),
)])),
chunks[2],
);
}