use std::io;
use std::path::Path;
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
prelude::*,
widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph},
};
use crate::convert::{self, ConvertResult};
use crate::doctor::{self, CheckResult, CheckStatus};
use crate::env::{self, EnvReport};
use crate::http::{self, SecurityAudit, CertInfo, RedirectHop};
use crate::ports::{self, PortEntry, QuickScanEntry};
use crate::sweep::{self, ScanResult};
use crate::utils::safe_truncate;
pub struct App {
menu_state: ListState,
screen: Screen,
results: ResultCache,
scroll_offset: u16,
scroll_total: u16,
should_quit: bool,
status_msg: String,
http_url_input: String,
input_mode: bool,
convert_path_input: String,
convert_format_input: String,
convert_field: u8,
}
#[derive(Clone, PartialEq)]
enum Screen {
Menu,
Doctor,
Ports,
Env,
Sweep,
Http,
HttpInput,
Convert,
ConvertInput,
Help,
About,
}
#[derive(Default)]
struct ResultCache {
doctor: Option<Vec<CheckResult>>,
ports: Option<Vec<PortEntry>>,
quick_scan: Option<Vec<QuickScanEntry>>,
env: Option<EnvReport>,
sweep: Option<ScanResult>,
http_output: Option<String>,
http_audit: Option<SecurityAudit>,
http_cert: Option<CertInfo>,
http_redirects: Option<Vec<RedirectHop>>,
convert: Option<ConvertResult>,
error: Option<String>,
}
const MENU_ITEMS: &[(&str, &str, &str)] = &[
(" Doctor ", "Dev environment health checker", "Git, Node, Rust, Python, Docker, Disk, SSH"),
(" Ports ", "Listening port inspector", "TCP ports, PIDs, banners, parallel scan"),
(" Env ", "Environment variable analyzer", "PATH, vars, proxy, CI, git config, SSH keys"),
(" Sweep ", "Build artifact scanner", "node_modules, target, __pycache__, etc."),
(" HTTP ", "HTTP request timing", "Timing, security audit, TLS certs, redirects"),
(" Convert ", "Config format converter", "JSON ↔ YAML ↔ TOML ↔ .env"),
(" About ", "About DevPulse", "Version, credits, and links"),
];
impl App {
pub fn new() -> Self {
let mut menu_state = ListState::default();
menu_state.select(Some(0));
Self {
menu_state,
screen: Screen::Menu,
results: ResultCache::default(),
scroll_offset: 0,
scroll_total: 0,
should_quit: false,
status_msg: String::from("Arrow keys to navigate | Enter to select | q to quit"),
http_url_input: String::from("https://httpbin.org/get"),
input_mode: false,
convert_path_input: String::new(),
convert_format_input: String::from("json"),
convert_field: 0,
}
}
fn run_doctor(&mut self) {
self.status_msg = "Running doctor checks...".into();
match doctor::collect_checks() {
Ok(checks) => {
self.results.doctor = Some(checks);
self.results.error = None;
self.status_msg = "Doctor complete | Esc = back | ↑↓ = scroll".into();
}
Err(e) => {
self.results.error = Some(format!("Doctor error: {e}"));
self.status_msg = "Error running doctor | Esc = back".into();
}
}
}
fn run_ports(&mut self) {
self.status_msg = "Scanning ports...".into();
match ports::collect_ports() {
Ok(entries) => {
self.results.ports = Some(entries);
self.results.error = None;
self.status_msg = "Running network scan...".into();
let scan = ports::collect_quick_scan("127.0.0.1");
self.results.quick_scan = Some(scan);
self.status_msg = "Ports scan complete | Esc = back | ↑↓ = scroll".into();
}
Err(e) => {
self.results.error = Some(format!("Ports error: {e}"));
self.status_msg = "Error scanning ports | Esc = back".into();
}
}
}
fn run_env(&mut self) {
self.status_msg = "Analyzing environment...".into();
let report = env::collect_env();
self.results.env = Some(report);
self.results.error = None;
self.status_msg = "Environment analysis complete | Esc = back | ↑↓ = scroll".into();
}
fn run_sweep(&mut self) {
self.status_msg = "Scanning for build artifacts...".into();
let root = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
match sweep::scan(&root, sweep::parse_min_size("1M")) {
Ok(result) => {
self.results.sweep = Some(result);
self.results.error = None;
self.status_msg = "Sweep scan complete | Esc = back | ↑↓ = scroll".into();
}
Err(e) => {
self.results.error = Some(format!("Sweep error: {e}"));
self.status_msg = "Error scanning | Esc = back".into();
}
}
}
fn run_http(&mut self) {
let url = self.http_url_input.clone();
self.status_msg = format!("Fetching {url}...");
match http::collect_timing_follow_redirects(&url, "GET", &[], &None, 10) {
Ok((redirects, timing, response, remote_addr)) => {
let mut lines = Vec::new();
lines.push(format!("URL: {url}"));
lines.push(format!("Status: {}", response.status_line));
lines.push(format!("Remote: {remote_addr}"));
lines.push(String::new());
if !redirects.is_empty() {
lines.push(format!("─── Redirect Chain ({} hop{}) ───",
redirects.len(),
if redirects.len() == 1 { "" } else { "s" }
));
for (i, hop) in redirects.iter().enumerate() {
let loc = hop.location.as_deref().unwrap_or("—");
lines.push(format!(" {}. {} → {} ({}ms)",
i + 1, hop.status_code, loc, hop.time_ms));
}
lines.push(String::new());
}
lines.push("─── Timing Breakdown ───".to_string());
lines.push(format!(" DNS Lookup: {:>6}ms", timing.dns_ms));
lines.push(format!(" TCP Connect: {:>6}ms", timing.tcp_ms));
if let Some(tls) = timing.tls_ms {
lines.push(format!(" TLS Handshake: {:>6}ms", tls));
}
lines.push(format!(" Server Wait: {:>6}ms", timing.server_ms));
lines.push(format!(" Transfer: {:>6}ms", timing.transfer_ms));
lines.push(" ─────────────────────".to_string());
lines.push(format!(" Total: {:>6}ms", timing.total_ms));
lines.push(String::new());
let connect = timing.dns_ms + timing.tcp_ms;
let pretransfer = connect + timing.tls_ms.unwrap_or(0);
let ttfb = pretransfer + timing.server_ms;
lines.push("─── Cumulative ───".to_string());
lines.push(format!(" namelookup: {:>6}ms", timing.dns_ms));
lines.push(format!(" connect: {:>6}ms", connect));
if timing.tls_ms.is_some() {
lines.push(format!(" pretransfer:{:>6}ms", pretransfer));
}
lines.push(format!(" TTFB: {:>6}ms", ttfb));
lines.push(format!(" total: {:>6}ms", timing.total_ms));
lines.push(String::new());
let audit = http::audit_headers(&response.headers);
lines.push(format!("─── Security Audit (Grade: {}) ───", audit.grade));
lines.push(format!(" {}/{} security headers present", audit.present, audit.present + audit.missing));
for check in &audit.checks {
let symbol = if check.present { "✓" } else { "✗" };
let val = check.value.as_deref().unwrap_or("missing");
let severity_tag = match check.severity.as_str() {
"critical" => "[CRITICAL]",
"important" => "[IMPORTANT]",
_ => "[NICE]",
};
lines.push(format!(" {} {} {} {}",
symbol, check.header, severity_tag, val));
}
lines.push(String::new());
self.results.http_audit = Some(audit);
self.results.http_redirects = Some(redirects);
if url.starts_with("https://") {
self.status_msg = format!("Inspecting TLS certificate for {url}...");
match http::inspect_cert(
url.trim_start_matches("https://").split('/').next().unwrap_or(""),
443,
) {
Ok(cert) => {
lines.push("─── TLS Certificate ───".to_string());
lines.push(format!(" Subject: {}", cert.subject));
lines.push(format!(" Issuer: {}", cert.issuer));
lines.push(format!(" Not Before: {}", cert.not_before));
lines.push(format!(" Not After: {}", cert.not_after));
lines.push(format!(" Expires In: {} days", cert.days_until_expiry));
lines.push(format!(" Algorithm: {}{}", cert.key_algorithm,
cert.key_bits.map(|b| format!(" ({b}-bit)")).unwrap_or_default()));
if !cert.san.is_empty() {
lines.push(format!(" SANs: {}", cert.san.join(", ")));
}
lines.push(String::new());
self.results.http_cert = Some(cert);
}
Err(e) => {
lines.push(format!("─── TLS Certificate (error: {e}) ───"));
lines.push(String::new());
}
}
}
lines.push("─── Response Headers ───".to_string());
for (key, val) in &response.headers {
lines.push(format!(" {key}: {val}"));
}
lines.push(String::new());
lines.push(format!("Body size: {} bytes", response.body_size));
self.results.http_output = Some(lines.join("\n"));
self.results.error = None;
self.status_msg = "HTTP timing complete | Esc = back | ↑↓ = scroll".into();
}
Err(e) => {
self.results.error = Some(format!("HTTP error: {e}"));
self.results.http_output = None;
self.results.http_audit = None;
self.results.http_cert = None;
self.results.http_redirects = None;
self.status_msg = "Error fetching URL | Esc = back".into();
}
}
}
fn run_convert(&mut self) {
let path = self.convert_path_input.clone();
let to = self.convert_format_input.clone();
self.status_msg = format!("Converting {path} → {to}...");
match convert::convert_for_tui(&path, &to, None) {
Ok(result) => {
self.results.convert = Some(result);
self.results.error = None;
self.status_msg = "Conversion complete | Esc = back | ↑↓ = scroll".into();
}
Err(e) => {
self.results.convert = None;
self.results.error = Some(format!("Convert error: {e}"));
self.status_msg = "Conversion error | Esc = back".into();
}
}
}
}
pub fn run() -> io::Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
loop {
terminal.draw(|f| ui(f, &mut app))?;
if event::poll(std::time::Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
app.should_quit = true;
}
if app.input_mode {
handle_input_keys(&mut app, key.code);
} else {
handle_keys(&mut app, key.code);
}
}
}
if app.should_quit {
break;
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn handle_keys(app: &mut App, key: KeyCode) {
match app.screen {
Screen::Menu => match key {
KeyCode::Char('q') | KeyCode::Char('Q') => app.should_quit = true,
KeyCode::Up | KeyCode::Char('k') => {
let i = app.menu_state.selected().unwrap_or(0);
let new = if i == 0 { MENU_ITEMS.len() - 1 } else { i - 1 };
app.menu_state.select(Some(new));
}
KeyCode::Down | KeyCode::Char('j') => {
let i = app.menu_state.selected().unwrap_or(0);
let new = if i >= MENU_ITEMS.len() - 1 { 0 } else { i + 1 };
app.menu_state.select(Some(new));
}
KeyCode::Enter => {
let idx = app.menu_state.selected().unwrap_or(0);
app.scroll_offset = 0;
match idx {
0 => { app.screen = Screen::Doctor; app.run_doctor(); }
1 => { app.screen = Screen::Ports; app.run_ports(); }
2 => { app.screen = Screen::Env; app.run_env(); }
3 => { app.screen = Screen::Sweep; app.run_sweep(); }
4 => {
app.screen = Screen::HttpInput;
app.input_mode = true;
app.status_msg = "Type URL and press Enter | Esc = cancel".into();
}
5 => {
app.screen = Screen::ConvertInput;
app.input_mode = true;
app.convert_field = 0;
app.status_msg = "Enter file path, Tab = switch field, Enter = convert | Esc = cancel".into();
}
6 => {
app.screen = Screen::About;
app.status_msg = "Esc = back to menu".into();
}
_ => {}
}
}
KeyCode::Char('1') => { app.scroll_offset = 0; app.screen = Screen::Doctor; app.run_doctor(); }
KeyCode::Char('2') => { app.scroll_offset = 0; app.screen = Screen::Ports; app.run_ports(); }
KeyCode::Char('3') => { app.scroll_offset = 0; app.screen = Screen::Env; app.run_env(); }
KeyCode::Char('4') => { app.scroll_offset = 0; app.screen = Screen::Sweep; app.run_sweep(); }
KeyCode::Char('5') => {
app.scroll_offset = 0;
app.screen = Screen::HttpInput;
app.input_mode = true;
app.status_msg = "Type URL and press Enter | Esc = cancel".into();
}
KeyCode::Char('6') => {
app.scroll_offset = 0;
app.screen = Screen::ConvertInput;
app.input_mode = true;
app.convert_field = 0;
app.status_msg = "Enter file path, Tab = switch field, Enter = convert | Esc = cancel".into();
}
KeyCode::Char('7') => {
app.scroll_offset = 0;
app.screen = Screen::About;
app.status_msg = "Esc = back to menu".into();
}
KeyCode::F(1) | KeyCode::Char('?') => {
app.scroll_offset = 0;
app.screen = Screen::Help;
app.status_msg = "Esc = back to menu".into();
}
_ => {}
},
_ => match key {
KeyCode::Char('q') | KeyCode::Char('Q') => app.should_quit = true,
KeyCode::F(1) | KeyCode::Char('?') if app.screen != Screen::Help => {
app.scroll_offset = 0;
app.screen = Screen::Help;
app.status_msg = "Esc = back to menu".into();
}
KeyCode::Char('r') | KeyCode::Char('R') => {
app.scroll_offset = 0;
match app.screen {
Screen::Doctor => app.run_doctor(),
Screen::Ports => app.run_ports(),
Screen::Env => app.run_env(),
Screen::Sweep => app.run_sweep(),
Screen::Http => app.run_http(),
Screen::Convert => app.run_convert(),
_ => {}
}
}
KeyCode::Tab | KeyCode::BackTab => {
app.scroll_offset = 0;
let forward = key == KeyCode::Tab;
let next = match app.screen {
Screen::Doctor => if forward { Screen::Ports } else { Screen::Convert },
Screen::Ports => if forward { Screen::Env } else { Screen::Doctor },
Screen::Env => if forward { Screen::Sweep } else { Screen::Ports },
Screen::Sweep => if forward { Screen::Http } else { Screen::Env },
Screen::Http => if forward { Screen::Convert } else { Screen::Sweep },
Screen::Convert => if forward { Screen::Doctor } else { Screen::Http },
_ => Screen::Doctor,
};
app.screen = next.clone();
match next {
Screen::Doctor => app.run_doctor(),
Screen::Ports => app.run_ports(),
Screen::Env => app.run_env(),
Screen::Sweep => app.run_sweep(),
Screen::Http => app.run_http(),
Screen::Convert => app.run_convert(),
_ => {}
}
}
KeyCode::Char('1') => { app.scroll_offset = 0; app.screen = Screen::Doctor; app.run_doctor(); }
KeyCode::Char('2') => { app.scroll_offset = 0; app.screen = Screen::Ports; app.run_ports(); }
KeyCode::Char('3') => { app.scroll_offset = 0; app.screen = Screen::Env; app.run_env(); }
KeyCode::Char('4') => { app.scroll_offset = 0; app.screen = Screen::Sweep; app.run_sweep(); }
KeyCode::Char('5') => {
app.scroll_offset = 0;
app.screen = Screen::HttpInput;
app.input_mode = true;
app.status_msg = "Type URL and press Enter | Esc = cancel".into();
}
KeyCode::Char('6') => {
app.scroll_offset = 0;
app.screen = Screen::ConvertInput;
app.input_mode = true;
app.convert_field = 0;
app.status_msg = "Enter file path, Tab = switch field, Enter = convert | Esc = cancel".into();
}
KeyCode::Char('7') => {
app.scroll_offset = 0;
app.screen = Screen::About;
app.status_msg = "Esc = back to menu".into();
}
KeyCode::Esc | KeyCode::Backspace => {
app.screen = Screen::Menu;
app.scroll_offset = 0;
app.status_msg = "Arrow keys to navigate | Enter to select | q to quit".into();
}
KeyCode::Up | KeyCode::Char('k') => {
app.scroll_offset = app.scroll_offset.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if app.scroll_offset < app.scroll_total.saturating_sub(1) {
app.scroll_offset += 1;
}
}
KeyCode::PageUp => {
app.scroll_offset = app.scroll_offset.saturating_sub(10);
}
KeyCode::PageDown => {
app.scroll_offset = (app.scroll_offset + 10).min(app.scroll_total.saturating_sub(1));
}
KeyCode::Home => { app.scroll_offset = 0; }
KeyCode::End => { app.scroll_offset = app.scroll_total.saturating_sub(1); }
_ => {}
},
}
}
fn handle_input_keys(app: &mut App, key: KeyCode) {
match app.screen {
Screen::ConvertInput => match key {
KeyCode::Esc => {
app.input_mode = false;
app.screen = Screen::Menu;
app.status_msg = "Arrow keys to navigate | Enter to select | q to quit".into();
}
KeyCode::Tab => {
app.convert_field = if app.convert_field == 0 { 1 } else { 0 };
}
KeyCode::Enter => {
if app.convert_field == 0 && !app.convert_path_input.is_empty() {
app.convert_field = 1;
} else if !app.convert_path_input.is_empty() && !app.convert_format_input.is_empty() {
app.input_mode = false;
app.screen = Screen::Convert;
app.run_convert();
}
}
KeyCode::Backspace => {
if app.convert_field == 0 {
app.convert_path_input.pop();
} else {
app.convert_format_input.pop();
}
}
KeyCode::Char(c) => {
if app.convert_field == 0 {
app.convert_path_input.push(c);
} else {
app.convert_format_input.push(c);
}
}
_ => {}
},
_ => match key {
KeyCode::Esc => {
app.input_mode = false;
app.screen = Screen::Menu;
app.status_msg = "Arrow keys to navigate | Enter to select | q to quit".into();
}
KeyCode::Enter => {
app.input_mode = false;
if !app.http_url_input.is_empty() {
app.screen = Screen::Http;
app.run_http();
} else {
app.screen = Screen::Menu;
app.status_msg = "No URL entered | Arrow keys to navigate".into();
}
}
KeyCode::Backspace => {
app.http_url_input.pop();
}
KeyCode::Char(c) => {
app.http_url_input.push(c);
}
_ => {}
},
}
}
fn ui(f: &mut Frame, app: &mut App) {
let area = f.area();
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4), Constraint::Min(10), Constraint::Length(1), ])
.split(area);
render_header(f, layout[0]);
match &app.screen {
Screen::Menu => render_menu(f, layout[1], app),
Screen::Doctor => render_doctor(f, layout[1], app),
Screen::Ports => render_ports(f, layout[1], app),
Screen::Env => render_env(f, layout[1], app),
Screen::Sweep => render_sweep(f, layout[1], app),
Screen::Http => render_http(f, layout[1], app),
Screen::HttpInput => render_http_input(f, layout[1], app),
Screen::Convert => render_convert(f, layout[1], app),
Screen::ConvertInput => render_convert_input(f, layout[1], app),
Screen::Help => render_help(f, layout[1], app),
Screen::About => render_about(f, layout[1], app),
}
render_status_bar(f, layout[2], app);
}
fn render_header(f: &mut Frame, area: Rect) {
let w = area.width as usize;
if w < 20 {
return; }
let inner = w.saturating_sub(4);
let version = env!("CARGO_PKG_VERSION");
let line1_text = format!(" ⚡ DevPulse v{} — Take the pulse of your dev environment ", version);
let line2_text = " 🐸 LazyFrog │ kindware.dev │ github.com/Brutus1066 ";
let pad1 = inner.saturating_sub(line1_text.chars().count());
let pad2 = inner.saturating_sub(line2_text.chars().count());
let top = format!(" ╔{}╗", "═".repeat(inner));
let bot = format!(" ╚{}╝", "═".repeat(inner));
let banner = vec![
Line::from(Span::styled(&top, Style::default().fg(Color::Cyan))),
Line::from(vec![
Span::styled(" ║", Style::default().fg(Color::Cyan)),
Span::styled(" ⚡ ", Style::default().fg(Color::Yellow)),
Span::styled("DevPulse", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
Span::styled(format!(" v{}", env!("CARGO_PKG_VERSION")), Style::default().fg(Color::DarkGray)),
Span::styled(" — ", Style::default().fg(Color::DarkGray)),
Span::styled("Take the pulse of your dev environment", Style::default().fg(Color::Green)),
Span::styled(
" ".repeat(pad1),
Style::default(),
),
Span::styled("║", Style::default().fg(Color::Cyan)),
]),
Line::from(vec![
Span::styled(" ║", Style::default().fg(Color::Cyan)),
Span::styled(" 🐸 ", Style::default().fg(Color::Green)),
Span::styled("LazyFrog", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
Span::styled("kindware.dev", Style::default().fg(Color::Cyan)),
Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
Span::styled("github.com/Brutus1066", Style::default().fg(Color::DarkGray)),
Span::styled(
" ".repeat(pad2),
Style::default(),
),
Span::styled("║", Style::default().fg(Color::Cyan)),
]),
Line::from(Span::styled(&bot, Style::default().fg(Color::Cyan))),
];
let header = Paragraph::new(banner);
f.render_widget(header, area);
}
fn render_menu(f: &mut Frame, area: Rect, app: &mut App) {
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(2), Constraint::Length(30), Constraint::Min(30), Constraint::Length(2), ])
.split(area);
let items: Vec<ListItem> = MENU_ITEMS
.iter()
.enumerate()
.map(|(i, (name, _desc, _))| {
let num = format!(" {} ", i + 1);
let content = Line::from(vec![
Span::styled(num, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled(*name, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
]);
ListItem::new(content)
})
.collect();
let menu = List::new(items)
.block(
Block::default()
.title(Line::from(vec![
Span::styled(" Tools ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.padding(Padding::vertical(1)),
)
.highlight_style(
Style::default()
.bg(Color::Rgb(30, 60, 90))
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
f.render_stateful_widget(menu, layout[1], &mut app.menu_state);
let selected = app.menu_state.selected().unwrap_or(0);
let (_, desc, detail) = MENU_ITEMS[selected];
let desc_lines = vec![
Line::from(""),
Line::from(Span::styled(
desc,
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(detail, Style::default().fg(Color::Green))),
Line::from(""),
Line::from(Span::styled(
"───────────────────────────",
Style::default().fg(Color::Rgb(40, 40, 60)),
)),
Line::from(""),
Line::from(Span::styled(
"Shortcuts:",
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
)),
Line::from(vec![
Span::styled(" 1-6 ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled("Quick select tool", Style::default().fg(Color::DarkGray)),
]),
Line::from(vec![
Span::styled(" Enter ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled("Run selected tool", Style::default().fg(Color::DarkGray)),
]),
Line::from(vec![
Span::styled(" Esc ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled("Back to menu", Style::default().fg(Color::DarkGray)),
]),
Line::from(vec![
Span::styled(" F1 / ? ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled("Help & shortcuts", Style::default().fg(Color::DarkGray)),
]),
Line::from(vec![
Span::styled(" q ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled("Quit DevPulse", Style::default().fg(Color::DarkGray)),
]),
];
let desc_widget = Paragraph::new(desc_lines).block(
Block::default()
.title(Line::from(vec![
Span::styled(" Details ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.padding(Padding::new(2, 2, 1, 1)),
);
f.render_widget(desc_widget, layout[2]);
}
fn render_doctor(f: &mut Frame, area: Rect, app: &mut App) {
if let Some(ref error) = app.results.error {
render_error(f, area, error);
return;
}
let checks = match &app.results.doctor {
Some(c) => c,
None => { render_loading(f, area, "Running doctor checks..."); return; }
};
let mut lines = Vec::new();
let mut pass = 0u32;
let mut warn = 0u32;
let mut fail = 0u32;
for check in checks {
let (icon, style) = match check.status {
CheckStatus::Pass => { pass += 1; (" ✓ PASS", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)) }
CheckStatus::Warn => { warn += 1; (" ⚠ WARN", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) }
CheckStatus::Fail => { fail += 1; (" ✗ FAIL", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) }
};
let version_str = check.version.as_deref().unwrap_or("—");
lines.push(Line::from(vec![
Span::styled(icon, style),
Span::styled(
format!(" {:<12}", check.name),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{:<10}", version_str),
Style::default().fg(Color::Cyan),
),
Span::styled(&check.detail, Style::default().fg(Color::DarkGray)),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Result: ", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
Span::styled(format!("{pass} passed"), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled(", ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{warn} warning(s)"), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled(", ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{fail} failure(s)"), Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
]));
app.scroll_total = lines.len() as u16;
let content = Paragraph::new(lines)
.block(
Block::default()
.title(Line::from(vec![
Span::styled(" 🩺 Doctor ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled("— Environment Health ", Style::default().fg(Color::DarkGray)),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green))
.padding(Padding::new(1, 1, 1, 1)),
)
.scroll((app.scroll_offset, 0));
f.render_widget(content, area);
}
fn render_ports(f: &mut Frame, area: Rect, app: &mut App) {
if let Some(ref error) = app.results.error {
render_error(f, area, error);
return;
}
let entries = match &app.results.ports {
Some(e) => e,
None => { render_loading(f, area, "Scanning ports..."); return; }
};
let mut lines = Vec::new();
lines.push(Line::from(vec![
Span::styled(
format!(" {:<8} {:<8} {:<8} {:<20} {}", "Port", "Proto", "PID", "Process", "Address"),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(Span::styled(
format!(" {}", "─".repeat(65)),
Style::default().fg(Color::DarkGray),
)));
if entries.is_empty() {
lines.push(Line::from(Span::styled(
" No listening ports found.",
Style::default().fg(Color::Yellow),
)));
} else {
for entry in entries {
let hint = ports::service_hint_pub(entry.port)
.map(|h| format!(" ({h})"))
.unwrap_or_default();
lines.push(Line::from(vec![
Span::styled(
format!(" {:<8}", entry.port),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
Span::styled(format!("{:<8}", entry.proto), Style::default().fg(Color::DarkGray)),
Span::styled(format!("{:<8}", entry.pid), Style::default().fg(Color::DarkGray)),
Span::styled(format!("{:<20}", entry.process), Style::default().fg(Color::Green)),
Span::styled(&entry.address, Style::default().fg(Color::Cyan)),
Span::styled(hint, Style::default().fg(Color::Yellow)),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
format!(" {} listening port(s) found", entries.len()),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
]));
}
if let Some(ref scan) = app.results.quick_scan {
let open_count = scan.iter().filter(|e| e.open).count();
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" ── Network Scan (localhost) ── {open_count}/{} open ──", scan.len()),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" {:<8} {:<16} {:<8} {:<8} {}", "Port", "Service", "Status", "Latency", "Banner"),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" {}", "─".repeat(70)),
Style::default().fg(Color::DarkGray),
)));
for entry in scan {
if entry.open {
let latency = entry
.latency_ms
.map(|ms| format!("{ms}ms"))
.unwrap_or_default();
let banner_text = entry
.banner
.as_deref()
.unwrap_or("");
let banner_display = safe_truncate(banner_text, 50);
lines.push(Line::from(vec![
Span::styled(format!(" {:<8}", entry.port), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
Span::styled(format!("{:<16}", entry.service), Style::default().fg(Color::Green)),
Span::styled(format!("{:<8}", "OPEN"), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled(format!("{:<8}", latency), Style::default().fg(Color::DarkGray)),
Span::styled(banner_display, Style::default().fg(Color::Yellow)),
]));
} else {
lines.push(Line::from(vec![
Span::styled(format!(" {:<8}", entry.port), Style::default().fg(Color::DarkGray)),
Span::styled(format!("{:<16}", entry.service), Style::default().fg(Color::DarkGray)),
Span::styled(format!("{:<8}", "closed"), Style::default().fg(Color::DarkGray)),
]));
}
}
}
app.scroll_total = lines.len() as u16;
let content = Paragraph::new(lines)
.block(
Block::default()
.title(Line::from(vec![
Span::styled(" 🔌 Ports ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
Span::styled("— Listening ", Style::default().fg(Color::DarkGray)),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.padding(Padding::new(1, 1, 1, 1)),
)
.scroll((app.scroll_offset, 0));
f.render_widget(content, area);
}
fn render_env(f: &mut Frame, area: Rect, app: &mut App) {
let report = match &app.results.env {
Some(r) => r,
None => { render_loading(f, area, "Analyzing environment..."); return; }
};
let mut lines = Vec::new();
lines.push(Line::from(vec![
Span::styled(
format!(" PATH ({} entries, {} issue(s)):", report.path_entries.len(), report.path_issues),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
]));
for entry in &report.path_entries {
let idx = format!(" {:>3}", entry.index);
if !entry.exists {
lines.push(Line::from(vec![
Span::styled(idx.clone(), Style::default().fg(Color::DarkGray)),
Span::styled(format!(" {} ", entry.path), Style::default().fg(Color::White)),
Span::styled("[NOT FOUND]", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
]));
} else if let Some(dup) = entry.duplicate_of {
lines.push(Line::from(vec![
Span::styled(idx.clone(), Style::default().fg(Color::DarkGray)),
Span::styled(format!(" {} ", entry.path), Style::default().fg(Color::White)),
Span::styled(format!("[DUPLICATE of #{dup}]"), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
]));
} else if !entry.tools.is_empty() {
lines.push(Line::from(vec![
Span::styled(idx.clone(), Style::default().fg(Color::DarkGray)),
Span::styled(format!(" {} ", entry.path), Style::default().fg(Color::White)),
Span::styled(entry.tools.join(", "), Style::default().fg(Color::Green)),
]));
} else {
lines.push(Line::from(vec![
Span::styled(idx, Style::default().fg(Color::DarkGray)),
Span::styled(format!(" {}", entry.path), Style::default().fg(Color::White)),
]));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Dev Tools:",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
for (key, val) in &report.dev_vars {
match val {
Some(v) => {
lines.push(Line::from(vec![
Span::styled(format!(" {:<20}", key), Style::default().fg(Color::White)),
Span::styled(v, Style::default().fg(Color::Green)),
]));
}
None => {
lines.push(Line::from(vec![
Span::styled(format!(" {:<20}", key), Style::default().fg(Color::White)),
Span::styled("— (not set)", Style::default().fg(Color::DarkGray)),
]));
}
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Proxy:",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
if report.proxy_vars.is_empty() {
lines.push(Line::from(Span::styled(
" none configured",
Style::default().fg(Color::DarkGray),
)));
} else {
for (key, val) in &report.proxy_vars {
if let Some(v) = val {
lines.push(Line::from(vec![
Span::styled(format!(" {:<20}", key), Style::default().fg(Color::White)),
Span::styled(v, Style::default().fg(Color::Green)),
]));
}
}
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" CI: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
match &report.ci_detected {
Some(name) => Span::styled(name, Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
None => Span::styled("not detected", Style::default().fg(Color::DarkGray)),
},
]));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" .env Files ({} found):", report.dotenv_files.len()),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
if report.dotenv_files.is_empty() {
lines.push(Line::from(Span::styled(
" none found in project tree",
Style::default().fg(Color::DarkGray),
)));
} else {
for df in &report.dotenv_files {
let git_badge = if df.gitignored {
Span::styled(" [gitignored] ", Style::default().fg(Color::Green))
} else {
Span::styled(" [NOT gitignored] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
};
lines.push(Line::from(vec![
Span::styled(format!(" {}", df.path), Style::default().fg(Color::White)),
Span::styled(format!(" {} keys", df.key_count), Style::default().fg(Color::DarkGray)),
git_badge,
]));
if !df.sensitive_keys.is_empty() {
lines.push(Line::from(vec![
Span::styled(" ⚠ sensitive: ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled(df.sensitive_keys.join(", "), Style::default().fg(Color::Yellow)),
]));
}
}
}
lines.push(Line::from(""));
if !report.git_config.is_empty() {
lines.push(Line::from(Span::styled(
" Git Config:",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
for entry in &report.git_config {
let val_span = match &entry.value {
Some(v) => Span::styled(v, Style::default().fg(Color::Green)),
None => Span::styled("— (not set)", Style::default().fg(Color::DarkGray)),
};
let mut spans = vec![
Span::styled(format!(" {:<22}", entry.key), Style::default().fg(Color::White)),
val_span,
];
if let Some(ref warn) = entry.warning {
spans.push(Span::styled(format!(" ⚠ {warn}"), Style::default().fg(Color::Yellow)));
}
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
}
if !report.ssh_keys.is_empty() {
lines.push(Line::from(Span::styled(
format!(" SSH Keys ({} found):", report.ssh_keys.len()),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
for key in &report.ssh_keys {
let bits_str = key.bits.map(|b| format!("{b}-bit")).unwrap_or_default();
let age_str = key.age_days.map(|d| format!("{d}d old")).unwrap_or_default();
let mut spans = vec![
Span::styled(format!(" {:<24}", key.filename), Style::default().fg(Color::White)),
Span::styled(format!("{:<10}", key.key_type), Style::default().fg(Color::Green)),
Span::styled(format!("{:<10}", bits_str), Style::default().fg(Color::DarkGray)),
Span::styled(age_str, Style::default().fg(Color::DarkGray)),
];
if let Some(ref warn) = key.warning {
spans.push(Span::styled(format!(" ⚠ {warn}"), Style::default().fg(Color::Red)));
}
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
} else {
lines.push(Line::from(Span::styled(
" SSH Keys: none found in ~/.ssh",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
}
app.scroll_total = lines.len() as u16;
let content = Paragraph::new(lines)
.block(
Block::default()
.title(Line::from(vec![
Span::styled(" 🌍 Env ", Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)),
Span::styled("— Developer Environment ", Style::default().fg(Color::DarkGray)),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue))
.padding(Padding::new(1, 1, 1, 1)),
)
.scroll((app.scroll_offset, 0));
f.render_widget(content, area);
}
fn render_sweep(f: &mut Frame, area: Rect, app: &mut App) {
if let Some(ref error) = app.results.error {
render_error(f, area, error);
return;
}
let result = match &app.results.sweep {
Some(r) => r,
None => { render_loading(f, area, "Scanning..."); return; }
};
let mut lines = Vec::new();
if result.entries.is_empty() {
lines.push(Line::from(Span::styled(
" No build artifacts found above 1M threshold.",
Style::default().fg(Color::Yellow),
)));
} else {
lines.push(Line::from(Span::styled(
format!(" {:<4} {:<11} {:<13} {}", "#", "Size", "Type", "Path"),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" {}", "─".repeat(65)),
Style::default().fg(Color::DarkGray),
)));
for (i, entry) in result.entries.iter().enumerate() {
let size_color = if entry.size_bytes >= 1_073_741_824 {
Color::Red
} else if entry.size_bytes >= 104_857_600 {
Color::Yellow
} else if entry.size_bytes >= 10_485_760 {
Color::Green
} else {
Color::White
};
lines.push(Line::from(vec![
Span::styled(format!(" {:<4}", i + 1), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
Span::styled(format!("{:<11}", entry.size_human), Style::default().fg(size_color).add_modifier(Modifier::BOLD)),
Span::styled(format!("{:<13}", entry.artifact_type), Style::default().fg(Color::DarkGray)),
Span::styled(&entry.path, Style::default().fg(Color::White)),
]));
}
lines.push(Line::from(Span::styled(
format!(" {}", "─".repeat(65)),
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(vec![
Span::styled(format!(" {}", result.total_human), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled(
format!(" total reclaimable ({} directories)", result.entries.len()),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
]));
}
app.scroll_total = lines.len() as u16;
let content = Paragraph::new(lines)
.block(
Block::default()
.title(Line::from(vec![
Span::styled(" 🧹 Sweep ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled("— Build Artifacts ", Style::default().fg(Color::DarkGray)),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.padding(Padding::new(1, 1, 1, 1)),
)
.scroll((app.scroll_offset, 0));
f.render_widget(content, area);
}
fn render_http(f: &mut Frame, area: Rect, app: &mut App) {
if let Some(ref error) = app.results.error {
render_error(f, area, error);
return;
}
let output = match &app.results.http_output {
Some(o) => o.clone(),
None => { render_loading(f, area, "Fetching..."); return; }
};
let lines: Vec<Line> = output
.lines()
.map(|l| {
let style = if l.starts_with("───") || l.starts_with(" ─") {
Style::default().fg(Color::DarkGray)
} else if l.starts_with(" DNS") || l.starts_with(" namelookup") {
Style::default().fg(Color::Cyan)
} else if l.starts_with(" TCP") || l.starts_with(" connect") {
Style::default().fg(Color::Green)
} else if l.starts_with(" TLS") || l.starts_with(" pretransfer") {
Style::default().fg(Color::Yellow)
} else if l.starts_with(" Server") || l.starts_with(" TTFB") {
Style::default().fg(Color::Magenta)
} else if l.starts_with(" Transfer") || l.starts_with(" total") || l.starts_with(" Total") {
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)
} else if l.starts_with("Status:") {
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
} else if l.starts_with("URL:") || l.starts_with("Remote:") || l.starts_with("Body") {
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
} else if l.contains("✓") {
Style::default().fg(Color::Green)
} else if l.contains("✗") && l.contains("[CRITICAL]") {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else if l.contains("✗") {
Style::default().fg(Color::Yellow)
} else if l.starts_with(" Subject:") || l.starts_with(" Issuer:") || l.starts_with(" Algorithm:") {
Style::default().fg(Color::Cyan)
} else if l.starts_with(" Expires In:") || l.starts_with(" Not After:") {
if l.contains('-') {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
}
} else if l.starts_with(" SANs:") {
Style::default().fg(Color::DarkGray)
} else if l.contains("→") {
Style::default().fg(Color::Yellow)
} else if l.starts_with("─── Security") {
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
} else if l.starts_with("─── TLS") {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else if l.starts_with("─── Redirect") {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
Line::styled(l, style)
})
.collect();
app.scroll_total = lines.len() as u16;
let content = Paragraph::new(lines)
.block(
Block::default()
.title(Line::from(vec![
Span::styled(" ⚡ HTTP Timing ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.padding(Padding::new(2, 2, 1, 1)),
)
.scroll((app.scroll_offset, 0));
f.render_widget(content, area);
}
fn render_http_input(f: &mut Frame, area: Rect, app: &mut App) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(5),
Constraint::Min(3),
])
.margin(4)
.split(area);
let title = Paragraph::new(Line::from(vec![
Span::styled("Enter URL to test: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
]));
f.render_widget(title, layout[0]);
let input = Paragraph::new(Line::from(vec![
Span::styled(" ▶ ", Style::default().fg(Color::Yellow)),
Span::styled(&app.http_url_input, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
Span::styled("█", Style::default().fg(Color::Cyan)), ]))
.block(
Block::default()
.title(" URL ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.padding(Padding::vertical(1)),
);
f.render_widget(input, layout[1]);
let hints = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(" Enter = fetch | Esc = cancel | Backspace = delete", Style::default().fg(Color::DarkGray))),
Line::from(Span::styled(" Examples: https://httpbin.org/get | http://localhost:3000", Style::default().fg(Color::DarkGray))),
]);
f.render_widget(hints, layout[2]);
}
fn render_convert_input(f: &mut Frame, area: Rect, app: &mut App) {
let path_style = if app.convert_field == 0 {
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let fmt_style = if app.convert_field == 1 {
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let cursor_path = if app.convert_field == 0 { "█" } else { "" };
let cursor_fmt = if app.convert_field == 1 { "█" } else { "" };
let lines = vec![
Line::from(""),
Line::from(Span::styled(" Config Format Converter", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))),
Line::from(Span::styled(" ─────────────────────────────────────────", Style::default().fg(Color::DarkGray))),
Line::from(""),
Line::from(Span::styled(" Supports: JSON, YAML, TOML, .env", Style::default().fg(Color::DarkGray))),
Line::from(Span::styled(" Input format is auto-detected from file extension", Style::default().fg(Color::DarkGray))),
Line::from(Span::styled(" Dot-flattening: database.host=val ↔ nested JSON", Style::default().fg(Color::DarkGray))),
Line::from(""),
Line::from(vec![
Span::styled(" File path: ", Style::default().fg(Color::Cyan)),
Span::styled(&app.convert_path_input, path_style),
Span::styled(cursor_path, Style::default().fg(Color::Yellow)),
]),
Line::from(""),
Line::from(vec![
Span::styled(" Target format (json/yaml/toml/env): ", Style::default().fg(Color::Cyan)),
Span::styled(&app.convert_format_input, fmt_style),
Span::styled(cursor_fmt, Style::default().fg(Color::Yellow)),
]),
Line::from(""),
Line::from(Span::styled(" Tab = switch field | Enter = convert | Esc = cancel", Style::default().fg(Color::DarkGray))),
];
let content = Paragraph::new(lines).block(
Block::default()
.title(Line::from(vec![
Span::styled(" 🔄 Convert ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
Span::styled("— Config Format Converter ", Style::default().fg(Color::DarkGray)),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.padding(Padding::new(1, 1, 0, 0)),
);
f.render_widget(content, area);
}
fn render_convert(f: &mut Frame, area: Rect, app: &mut App) {
if let Some(ref err) = app.results.error {
render_error(f, area, err);
return;
}
let result = match &app.results.convert {
Some(r) => r,
None => {
render_loading(f, area, "No conversion result");
return;
}
};
let mut lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" Format: ", Style::default().fg(Color::DarkGray)),
Span::styled(&result.input_format, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled(" → ", Style::default().fg(Color::DarkGray)),
Span::styled(&result.output_format, Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(" Input: ", Style::default().fg(Color::DarkGray)),
Span::styled(&result.input_path, Style::default().fg(Color::White)),
]),
Line::from(""),
Line::from(Span::styled(" ── Converted Output ──", Style::default().fg(Color::DarkGray))),
Line::from(""),
];
for line in result.output.lines() {
lines.push(Line::from(Span::styled(format!(" {line}"), Style::default().fg(Color::White))));
}
lines.push(Line::from(""));
app.scroll_total = lines.len() as u16;
let content = Paragraph::new(lines)
.block(
Block::default()
.title(Line::from(vec![
Span::styled(" 🔄 Convert ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)),
Span::styled(format!("— {} → {} ", result.input_format, result.output_format), Style::default().fg(Color::DarkGray)),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.padding(Padding::new(1, 1, 0, 0)),
)
.scroll((app.scroll_offset, 0));
f.render_widget(content, area);
}
fn render_help(f: &mut Frame, area: Rect, app: &mut App) {
let cyan_bold = Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD);
let white = Style::default().fg(Color::White);
let gray = Style::default().fg(Color::DarkGray);
let yellow_bold = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let green = Style::default().fg(Color::Green);
let lines = vec![
Line::from(""),
Line::from(Span::styled(" Keyboard Reference", yellow_bold)),
Line::from(Span::styled(" ─────────────────────────────────────────", gray)),
Line::from(""),
Line::from(Span::styled(" Menu Navigation", cyan_bold)),
Line::from(vec![Span::styled(" ↑ / k ", green), Span::styled("Move selection up", white)]),
Line::from(vec![Span::styled(" ↓ / j ", green), Span::styled("Move selection down", white)]),
Line::from(vec![Span::styled(" Enter ", green), Span::styled("Run selected tool", white)]),
Line::from(vec![Span::styled(" 1-7 ", green), Span::styled("Quick select & run tool", white)]),
Line::from(""),
Line::from(Span::styled(" Result Screens", cyan_bold)),
Line::from(vec![Span::styled(" ↑ / k ", green), Span::styled("Scroll up one line", white)]),
Line::from(vec![Span::styled(" ↓ / j ", green), Span::styled("Scroll down one line", white)]),
Line::from(vec![Span::styled(" PgUp ", green), Span::styled("Scroll up 10 lines", white)]),
Line::from(vec![Span::styled(" PgDn ", green), Span::styled("Scroll down 10 lines", white)]),
Line::from(vec![Span::styled(" Home ", green), Span::styled("Jump to top", white)]),
Line::from(vec![Span::styled(" End ", green), Span::styled("Jump to bottom", white)]),
Line::from(vec![Span::styled(" Tab ", green), Span::styled("Cycle to next tool (no menu)", white)]),
Line::from(vec![Span::styled(" Shift+Tab ", green), Span::styled("Cycle to previous tool", white)]),
Line::from(vec![Span::styled(" r ", green), Span::styled("Re-run current tool (refresh)", white)]),
Line::from(vec![Span::styled(" 1-7 ", green), Span::styled("Jump directly to tool", white)]),
Line::from(vec![Span::styled(" Esc ", green), Span::styled("Back to menu", white)]),
Line::from(""),
Line::from(Span::styled(" HTTP Input", cyan_bold)),
Line::from(vec![Span::styled(" Type ", green), Span::styled("Enter URL characters", white)]),
Line::from(vec![Span::styled(" Backspace ", green), Span::styled("Delete last character", white)]),
Line::from(vec![Span::styled(" Enter ", green), Span::styled("Fetch the URL", white)]),
Line::from(vec![Span::styled(" Esc ", green), Span::styled("Cancel and return to menu", white)]),
Line::from(""),
Line::from(Span::styled(" Convert Input", cyan_bold)),
Line::from(vec![Span::styled(" Type ", green), Span::styled("Enter file path / format", white)]),
Line::from(vec![Span::styled(" Tab ", green), Span::styled("Switch between path and format fields", white)]),
Line::from(vec![Span::styled(" Enter ", green), Span::styled("Run conversion", white)]),
Line::from(vec![Span::styled(" Esc ", green), Span::styled("Cancel and return to menu", white)]),
Line::from(""),
Line::from(Span::styled(" Global", cyan_bold)),
Line::from(vec![Span::styled(" F1 / ? ", green), Span::styled("Show this help screen", white)]),
Line::from(vec![Span::styled(" Ctrl+C ", green), Span::styled("Force quit", white)]),
Line::from(vec![Span::styled(" q ", green), Span::styled("Quit DevPulse", white)]),
Line::from(""),
Line::from(Span::styled(" ─────────────────────────────────────────", gray)),
Line::from(""),
Line::from(Span::styled(" Tools Available", yellow_bold)),
Line::from(vec![Span::styled(" 1 Doctor ", cyan_bold), Span::styled("Git, Node, Rust, Python, Docker, Disk, SSH", gray)]),
Line::from(vec![Span::styled(" 2 Ports ", cyan_bold), Span::styled("Listening ports, PIDs, banners, parallel scan", gray)]),
Line::from(vec![Span::styled(" 3 Env ", cyan_bold), Span::styled("PATH, vars, proxy, CI, git config, SSH keys", gray)]),
Line::from(vec![Span::styled(" 4 Sweep ", cyan_bold), Span::styled("node_modules, target, __pycache__ cleanup", gray)]),
Line::from(vec![Span::styled(" 5 HTTP ", cyan_bold), Span::styled("Timing, security audit, TLS certs, redirects", gray)]),
Line::from(vec![Span::styled(" 6 Convert ", cyan_bold), Span::styled("JSON, YAML, TOML, .env format converter", gray)]),
Line::from(vec![Span::styled(" 7 About ", cyan_bold), Span::styled("Version, credits, links", gray)]),
Line::from(""),
Line::from(Span::styled(" CLI Mode", yellow_bold)),
Line::from(Span::styled(" Run with subcommand to skip TUI: devpulse doctor", gray)),
Line::from(Span::styled(" JSON output for scripting: devpulse --json ports", gray)),
Line::from(Span::styled(" Shell completions: devpulse completions bash", gray)),
Line::from(""),
];
app.scroll_total = lines.len() as u16;
let content = Paragraph::new(lines)
.block(
Block::default()
.title(Line::from(vec![
Span::styled(" ❓ Help ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled("— Keyboard Reference ", Style::default().fg(Color::DarkGray)),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.padding(Padding::new(1, 1, 0, 0)),
)
.scroll((app.scroll_offset, 0));
f.render_widget(content, area);
}
fn render_about(f: &mut Frame, area: Rect, _app: &mut App) {
let lines = vec![
Line::from(""),
Line::from(Span::styled(format!(" DevPulse v{}", env!("CARGO_PKG_VERSION")), Style::default().fg(Color::White).add_modifier(Modifier::BOLD))),
Line::from(Span::styled(" Take the pulse of your dev environment", Style::default().fg(Color::Green))),
Line::from(""),
Line::from(Span::styled(" ─────────────────────────────────────────", Style::default().fg(Color::DarkGray))),
Line::from(""),
Line::from(vec![
Span::styled(" Author: ", Style::default().fg(Color::Cyan)),
Span::styled("LazyFrog", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(" Email: ", Style::default().fg(Color::Cyan)),
Span::styled("support@kindware.dev", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" Website: ", Style::default().fg(Color::Cyan)),
Span::styled("kindware.dev", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" GitHub: ", Style::default().fg(Color::Cyan)),
Span::styled("github.com/Brutus1066", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" License: ", Style::default().fg(Color::Cyan)),
Span::styled("MIT", Style::default().fg(Color::White)),
]),
Line::from(""),
Line::from(Span::styled(" ─────────────────────────────────────────", Style::default().fg(Color::DarkGray))),
Line::from(""),
Line::from(Span::styled(" Tools included:", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))),
Line::from(Span::styled(" 1. Doctor — Dev environment health checker", Style::default().fg(Color::White))),
Line::from(Span::styled(" 2. Ports — Listening port inspector", Style::default().fg(Color::White))),
Line::from(Span::styled(" 3. Env — Environment variable analyzer", Style::default().fg(Color::White))),
Line::from(Span::styled(" 4. Sweep — Build artifact scanner/cleaner", Style::default().fg(Color::White))),
Line::from(Span::styled(" 5. HTTP — HTTP request timing visualizer", Style::default().fg(Color::White))),
Line::from(Span::styled(" 6. Convert — Config format converter", Style::default().fg(Color::White))),
Line::from(""),
Line::from(Span::styled(" Built with Rust 🦀 | Zero async | Cross-platform", Style::default().fg(Color::DarkGray))),
Line::from(""),
];
let content = Paragraph::new(lines).block(
Block::default()
.title(Line::from(vec![
Span::styled(" 🐸 About DevPulse ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green))
.padding(Padding::new(1, 1, 0, 0)),
);
f.render_widget(content, area);
}
fn render_loading(f: &mut Frame, area: Rect, msg: &str) {
let content = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
format!(" ⏳ {msg}"),
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.padding(Padding::new(2, 2, 2, 2)),
);
f.render_widget(content, area);
}
fn render_error(f: &mut Frame, area: Rect, msg: &str) {
let content = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
format!(" ✗ {msg}"),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
" Press Esc to go back",
Style::default().fg(Color::DarkGray),
)),
])
.block(
Block::default()
.title(" Error ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red))
.padding(Padding::new(2, 2, 2, 2)),
);
f.render_widget(content, area);
}
fn render_status_bar(f: &mut Frame, area: Rect, app: &App) {
let bg = Color::Rgb(20, 20, 35);
let mut spans = vec![
Span::styled(" ⚡ ", Style::default().fg(Color::Yellow).bg(bg)),
Span::styled(&app.status_msg, Style::default().fg(Color::Cyan).bg(bg)),
];
let is_scrollable = matches!(
app.screen,
Screen::Doctor | Screen::Ports | Screen::Env | Screen::Sweep | Screen::Http | Screen::Convert | Screen::Help
);
let scroll_text = if is_scrollable && app.scroll_total > 0 {
let pct = if app.scroll_total <= 1 {
100
} else {
((app.scroll_offset as u32 * 100) / (app.scroll_total.saturating_sub(1) as u32)).min(100)
};
format!(" │ Line {}/{} {}% │ Tab=next r=rerun ", app.scroll_offset + 1, app.scroll_total, pct)
} else {
String::new()
};
if !scroll_text.is_empty() {
let used = 4 + app.status_msg.len() + scroll_text.len();
let padding = (area.width as usize).saturating_sub(used);
spans.push(Span::styled(" ".repeat(padding), Style::default().bg(bg)));
spans.push(Span::styled(scroll_text, Style::default().fg(Color::DarkGray).bg(bg)));
}
let bar = Paragraph::new(Line::from(spans)).style(Style::default().bg(bg));
f.render_widget(bar, area);
}