use crate::config::PortForgeConfig;
use crate::error::{PortForgeError, Result};
use crate::models::*;
use crate::process;
use crate::scanner;
use crate::tui::ui;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::io;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq)]
pub enum ViewMode {
Table,
Detail,
ProcessTree,
Search,
Help,
KillConfirm,
}
pub struct App {
pub config: PortForgeConfig,
pub show_all: bool,
pub entries: Vec<PortEntry>,
pub filtered_entries: Vec<usize>,
pub selected: usize,
pub view_mode: ViewMode,
pub sort_field: SortField,
pub sort_direction: SortDirection,
pub search_query: String,
pub refresh_interval: u64,
pub should_quit: bool,
pub process_tree: Vec<process::ProcessTreeEntry>,
pub last_scan: Option<Instant>,
pub status_message: Option<(String, Instant)>,
pub loading: bool,
pub table_scroll_offset: usize,
}
impl App {
pub fn new(config: PortForgeConfig, show_all: bool) -> Self {
let refresh = config.general.refresh_interval;
Self {
config,
show_all,
entries: Vec::new(),
filtered_entries: Vec::new(),
selected: 0,
view_mode: ViewMode::Table,
sort_field: SortField::Port,
sort_direction: SortDirection::Ascending,
search_query: String::new(),
refresh_interval: refresh,
should_quit: false,
process_tree: Vec::new(),
last_scan: None,
status_message: None,
loading: true,
table_scroll_offset: 0,
}
}
pub fn set_refresh_interval(&mut self, interval: u64) {
self.refresh_interval = interval;
}
pub async fn run(&mut self) -> Result<()> {
enable_raw_mode().map_err(|e| PortForgeError::TuiError(e.to_string()))?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
.map_err(|e| PortForgeError::TuiError(e.to_string()))?;
let backend = CrosstermBackend::new(stdout);
let mut terminal =
Terminal::new(backend).map_err(|e| PortForgeError::TuiError(e.to_string()))?;
self.refresh_data().await;
let tick_rate = Duration::from_millis(100);
let refresh_duration = Duration::from_secs(self.refresh_interval);
let mut last_refresh = Instant::now();
loop {
terminal
.draw(|f| ui::render(f, self))
.map_err(|e| PortForgeError::TuiError(e.to_string()))?;
if event::poll(tick_rate).map_err(|e| PortForgeError::TuiError(e.to_string()))? {
if let Event::Key(key) =
event::read().map_err(|e| PortForgeError::TuiError(e.to_string()))?
{
self.handle_key_event(key).await;
}
}
if last_refresh.elapsed() >= refresh_duration {
self.refresh_data().await;
last_refresh = Instant::now();
}
if let Some((_, created)) = &self.status_message {
if created.elapsed() > Duration::from_secs(3) {
self.status_message = None;
}
}
if self.should_quit {
break;
}
}
disable_raw_mode().map_err(|e| PortForgeError::TuiError(e.to_string()))?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)
.map_err(|e| PortForgeError::TuiError(e.to_string()))?;
terminal
.show_cursor()
.map_err(|e| PortForgeError::TuiError(e.to_string()))?;
Ok(())
}
async fn refresh_data(&mut self) {
self.loading = true;
match scanner::scan_ports(&self.config, self.show_all).await {
Ok(mut entries) => {
scanner::sort_entries(&mut entries, self.sort_field, self.sort_direction);
self.entries = entries;
self.apply_filter();
self.loading = false;
self.last_scan = Some(Instant::now());
}
Err(e) => {
self.loading = false;
self.set_status(format!("Scan error: {}", e));
}
}
}
fn apply_filter(&mut self) {
if self.search_query.is_empty() {
self.filtered_entries = (0..self.entries.len()).collect();
} else {
let query = self.search_query.to_lowercase();
self.filtered_entries = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| {
e.port.to_string().contains(&query)
|| e.process_name.to_lowercase().contains(&query)
|| e.project_display().to_lowercase().contains(&query)
|| e.git_display().to_lowercase().contains(&query)
|| e.tunnel_display().to_lowercase().contains(&query)
|| e.docker_display().to_lowercase().contains(&query)
|| e.command.to_lowercase().contains(&query)
})
.map(|(i, _)| i)
.collect();
}
if self.selected >= self.filtered_entries.len() {
self.selected = self.filtered_entries.len().saturating_sub(1);
}
}
pub fn selected_entry(&self) -> Option<&PortEntry> {
self.filtered_entries
.get(self.selected)
.and_then(|&idx| self.entries.get(idx))
}
fn set_status(&mut self, message: String) {
self.status_message = Some((message, Instant::now()));
}
async fn handle_key_event(&mut self, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.should_quit = true;
return;
}
match self.view_mode {
ViewMode::Table => self.handle_table_keys(key).await,
ViewMode::Detail => self.handle_detail_keys(key).await,
ViewMode::ProcessTree => self.handle_tree_keys(key).await,
ViewMode::Search => self.handle_search_keys(key).await,
ViewMode::Help => self.handle_help_keys(key).await,
ViewMode::KillConfirm => self.handle_kill_confirm_keys(key).await,
}
}
async fn handle_table_keys(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
KeyCode::Char('j') | KeyCode::Down => self.move_selection(1),
KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1),
KeyCode::Char('g') => {
self.selected = 0;
self.table_scroll_offset = 0;
}
KeyCode::Char('G') => {
self.selected = self.filtered_entries.len().saturating_sub(1);
self.table_scroll_offset = self.selected.saturating_sub(10);
}
KeyCode::Home => {
self.selected = 0;
self.table_scroll_offset = 0;
}
KeyCode::End => {
self.selected = self.filtered_entries.len().saturating_sub(1);
self.table_scroll_offset = self.selected.saturating_sub(10);
}
KeyCode::PageDown => self.move_selection(20),
KeyCode::PageUp => self.move_selection(-20),
KeyCode::Enter | KeyCode::Char('d') => {
if self.selected_entry().is_some() {
self.view_mode = ViewMode::Detail;
}
}
KeyCode::Char('t') => {
if let Some(entry) = self.selected_entry() {
self.process_tree = process::get_process_tree(entry.pid);
self.view_mode = ViewMode::ProcessTree;
}
}
KeyCode::Char('K') => {
if self.selected_entry().is_some() {
self.view_mode = ViewMode::KillConfirm;
}
}
KeyCode::Char('/') => {
self.view_mode = ViewMode::Search;
}
KeyCode::Char('?') => {
self.view_mode = ViewMode::Help;
}
KeyCode::Char('a') | KeyCode::Char('A') => {
self.show_all = !self.show_all;
self.set_status(format!(
"Showing {}",
if self.show_all {
"all ports"
} else {
"dev ports"
}
));
self.refresh_data().await;
}
KeyCode::Char('1') => self.toggle_sort(SortField::Port),
KeyCode::Char('2') => self.toggle_sort(SortField::Pid),
KeyCode::Char('3') => self.toggle_sort(SortField::Process),
KeyCode::Char('4') => self.toggle_sort(SortField::Project),
KeyCode::Char('5') => self.toggle_sort(SortField::Memory),
KeyCode::Char('6') => self.toggle_sort(SortField::Cpu),
KeyCode::Char('7') => self.toggle_sort(SortField::Uptime),
KeyCode::Char('8') => self.toggle_sort(SortField::Status),
KeyCode::Char('r') => {
self.set_status("Refreshing...".to_string());
self.refresh_data().await;
}
_ => {}
}
}
async fn handle_search_keys(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
self.search_query.clear();
self.apply_filter();
self.view_mode = ViewMode::Table;
}
KeyCode::Enter => {
self.view_mode = ViewMode::Table;
}
KeyCode::Backspace => {
self.search_query.pop();
self.apply_filter();
}
KeyCode::Char(c) => {
self.search_query.push(c);
self.apply_filter();
}
_ => {}
}
}
async fn handle_detail_keys(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Backspace => {
self.view_mode = ViewMode::Table;
}
KeyCode::Char('K') => {
self.view_mode = ViewMode::KillConfirm;
}
KeyCode::Char('t') => {
if let Some(entry) = self.selected_entry() {
self.process_tree = process::get_process_tree(entry.pid);
self.view_mode = ViewMode::ProcessTree;
}
}
_ => {}
}
}
async fn handle_tree_keys(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Backspace => {
self.view_mode = ViewMode::Table;
}
_ => {}
}
}
async fn handle_help_keys(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => {
self.view_mode = ViewMode::Table;
}
_ => {}
}
}
async fn handle_kill_confirm_keys(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let Some(entry) = self.selected_entry().cloned() {
match process::kill_process(&entry, false) {
Ok(()) => {
self.set_status(format!(
"✓ Killed PID {} on port {}",
entry.pid, entry.port
));
self.refresh_data().await;
}
Err(e) => {
self.set_status(format!("✗ Failed to kill: {}", e));
}
}
}
self.view_mode = ViewMode::Table;
}
KeyCode::Char('f') | KeyCode::Char('F') => {
if let Some(entry) = self.selected_entry().cloned() {
match process::kill_process(&entry, true) {
Ok(()) => {
self.set_status(format!(
"✓ Force killed PID {} on port {}",
entry.pid, entry.port
));
self.refresh_data().await;
}
Err(e) => {
self.set_status(format!("✗ Failed to kill: {}", e));
}
}
}
self.view_mode = ViewMode::Table;
}
_ => {
self.view_mode = ViewMode::Table;
}
}
}
fn move_selection(&mut self, delta: i32) {
let len = self.filtered_entries.len();
if len == 0 {
self.selected = 0;
return;
}
if delta > 0 {
self.selected = (self.selected + delta as usize).min(len - 1);
let visible_rows = 10; if self.selected > self.table_scroll_offset + visible_rows {
self.table_scroll_offset = self.selected - visible_rows;
}
} else {
self.selected = self.selected.saturating_sub((-delta) as usize);
if self.selected < self.table_scroll_offset {
self.table_scroll_offset = self.selected;
}
}
}
fn toggle_sort(&mut self, field: SortField) {
if self.sort_field == field {
self.sort_direction = self.sort_direction.toggle();
} else {
self.sort_field = field;
self.sort_direction = SortDirection::Ascending;
}
scanner::sort_entries(&mut self.entries, self.sort_field, self.sort_direction);
self.apply_filter();
}
}