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
934 lines
30 KiB
Rust
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],
|
|
);
|
|
}
|