prt 0.2.0

Real-time terminal UI for monitoring network ports — interactive alternative to lsof/ss with colors, filtering and process trees
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crossterm::terminal::{
    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::ExecutableCommand;
use prt_core::core::{killer, session::Session};
use prt_core::i18n;
use prt_core::model::{DetailTab, TrackedEntry, TICK_RATE};
use ratatui::prelude::*;
use std::io::stdout;
use std::time::Instant;

use crate::input::handle_key;
use crate::ui::draw;

pub struct App {
    pub session: Session,
    pub filtered_indices: Vec<usize>,
    pub selected: usize,
    pub filter: String,
    pub filter_mode: bool,
    pub show_help: bool,
    pub show_details: bool,
    pub detail_tab: DetailTab,
    pub confirm_kill: Option<(u32, String)>,
    pub sudo_prompt: bool,
    pub sudo_password: String,
    pub status_msg: Option<(String, Instant)>,
    pub should_quit: bool,
}

impl App {
    pub fn new() -> Self {
        Self {
            session: Session::new(),
            filtered_indices: Vec::new(),
            selected: 0,
            filter: String::new(),
            filter_mode: false,
            show_help: false,
            show_details: true,
            detail_tab: DetailTab::Tree,
            confirm_kill: None,
            sudo_prompt: false,
            sudo_password: String::new(),
            status_msg: None,
            should_quit: false,
        }
    }

    pub fn refresh(&mut self) {
        if let Err(msg) = self.session.refresh() {
            self.set_status(msg);
        }
        self.update_filtered();
    }

    pub fn update_filtered(&mut self) {
        self.filtered_indices = self.session.filtered_indices(&self.filter);
        if self.selected >= self.filtered_indices.len() {
            self.selected = self.filtered_indices.len().saturating_sub(1);
        }
    }

    pub fn selected_entry(&self) -> Option<&TrackedEntry> {
        self.filtered_indices
            .get(self.selected)
            .and_then(|&i| self.session.entries.get(i))
    }

    pub fn kill_selected(&mut self, force: bool) {
        if let Some(entry) = self.selected_entry() {
            let pid = entry.entry.process.pid;
            let name = entry.entry.process.name.clone();
            let s = i18n::strings();
            match killer::kill_process(pid, force) {
                Ok(()) => {
                    let sig = if force { "SIGKILL" } else { "SIGTERM" };
                    self.set_status(s.fmt_kill_sent(sig, &name, pid));
                    self.confirm_kill = None;
                }
                Err(e) => {
                    self.set_status(s.fmt_kill_failed(&e.to_string()));
                    self.confirm_kill = None;
                }
            }
        }
    }

    pub fn copy_to_clipboard(&mut self, text: &str) {
        let s = i18n::strings();
        match arboard::Clipboard::new().and_then(|mut clip| clip.set_text(text)) {
            Ok(()) => self.set_status(s.copied.into()),
            Err(_) => self.set_status(s.clipboard_unavailable.into()),
        }
    }

    pub fn set_status(&mut self, msg: String) {
        self.status_msg = Some((msg, Instant::now()));
    }

    pub fn try_sudo(&mut self) {
        let password = std::mem::take(&mut self.sudo_password);
        self.sudo_prompt = false;
        let msg = self.session.try_sudo(&password);
        self.set_status(msg);
        self.update_filtered();
    }
}

pub fn run() -> Result<()> {
    let original_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        let _ = disable_raw_mode();
        let _ = stdout().execute(LeaveAlternateScreen);
        original_hook(info);
    }));

    enable_raw_mode()?;
    stdout().execute(EnterAlternateScreen)?;

    let backend = CrosstermBackend::new(stdout());
    let mut terminal = Terminal::new(backend)?;
    let mut app = App::new();
    let mut last_tick = Instant::now();

    app.refresh();

    loop {
        terminal.draw(|f| draw(f, &app))?;

        let timeout = TICK_RATE.saturating_sub(last_tick.elapsed());
        if event::poll(timeout)? {
            if let Event::Key(key) = event::read()? {
                if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
                    app.should_quit = true;
                }
                handle_key(&mut app, key);
            }
        }

        if last_tick.elapsed() >= TICK_RATE {
            app.refresh();
            last_tick = Instant::now();
        }

        if app.should_quit {
            break;
        }
    }

    disable_raw_mode()?;
    stdout().execute(LeaveAlternateScreen)?;
    Ok(())
}