use std::collections::HashSet;
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::Duration;
use anyhow::Result;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, BorderType, Borders, Cell, Clear, Padding, Paragraph, Row, Table, TableState,
};
use ratatui::{DefaultTerminal, Frame};
use ratatui_notifications::Notifications;
use super::lsof::{self, PortEntry};
use crate::theme;
use crate::widgets::{
input_paragraph, key_hint_line, list_cursor_style, render_toasts, tick_toasts, toast_error,
toast_ok, toast_warn, wrapping_index,
};
type RowKey = (u16, i32);
const POLL_INTERVAL: Duration = Duration::from_millis(120);
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const LIST_HINTS: &[(&str, &str)] = &[
("↑/↓", "移动"),
("/", "搜索"),
("Space", "选择"),
("A", "全选"),
("Enter", "关闭"),
("R", "刷新"),
("Esc", "退出"),
];
const FILTER_HINTS: &[(&str, &str)] = &[("输入", "搜索"), ("Enter", "回列表"), ("Esc", "清空")];
const CONFIRM_HINTS: &[(&str, &str)] = &[("Enter/Y", "确认"), ("n", "取消"), ("Esc", "返回")];
enum Mode {
List,
Confirm,
Killing,
}
struct Pending {
ports: Vec<u16>,
pids: Vec<i32>,
}
struct KillOutcome {
killed: usize,
errors: Vec<String>,
}
pub struct App {
all: Vec<PortEntry>,
filter: String,
filtering: bool,
selected: HashSet<RowKey>,
state: TableState,
toasts: Notifications,
quit: bool,
mode: Mode,
pending: Option<Pending>,
kill_rx: Option<Receiver<KillOutcome>>,
spinner: usize,
}
impl App {
pub fn new(all: Vec<PortEntry>) -> Self {
let mut state = TableState::default();
if !all.is_empty() {
state.select(Some(0));
}
Self {
all,
filter: String::new(),
filtering: false,
selected: HashSet::new(),
state,
toasts: Notifications::new(),
quit: false,
mode: Mode::List,
pending: None,
kill_rx: None,
spinner: 0,
}
}
fn filtered(&self) -> Vec<PortEntry> {
lsof::filter_ports(&self.all, &self.filter)
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
while !self.quit {
if matches!(self.mode, Mode::Killing) {
if let Some(outcome) = self.kill_rx.as_ref().and_then(|rx| rx.try_recv().ok()) {
self.finish_kill(outcome);
}
}
tick_toasts(&mut self.toasts, POLL_INTERVAL);
let view = self.filtered();
terminal.draw(|frame| render(frame, self, &view))?;
if event::poll(POLL_INTERVAL)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
self.on_key(key, &view);
}
}
}
if matches!(self.mode, Mode::Killing) || self.toasts.has_notification() {
self.spinner = self.spinner.wrapping_add(1);
}
}
Ok(())
}
fn on_key(&mut self, key: KeyEvent, view: &[PortEntry]) {
match self.mode {
Mode::Killing => {}
Mode::Confirm => self.on_confirm_key(key),
Mode::List => self.on_list_key(key, view),
}
}
fn on_list_key(&mut self, key: KeyEvent, view: &[PortEntry]) {
if self.filtering {
self.on_filter_key(key);
return;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('c') if ctrl => self.quit = true,
KeyCode::Char('q') | KeyCode::Esc => self.quit = true,
KeyCode::Char('/') => self.filtering = true,
KeyCode::Char('a' | 'A') => {
let keys: Vec<RowKey> = view.iter().map(row_key).collect();
toggle_all(&mut self.selected, &keys);
}
KeyCode::Char('r' | 'R') => self.rescan(),
KeyCode::Up => self.move_cursor(-1, view.len()),
KeyCode::Down => self.move_cursor(1, view.len()),
KeyCode::Char(' ') => self.toggle_current(view),
KeyCode::Enter => self.begin_confirm(view),
_ => {}
}
}
fn on_filter_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Enter => self.filtering = false,
KeyCode::Esc => {
self.filter.clear();
self.filtering = false;
self.reset_cursor();
}
KeyCode::Backspace => {
self.filter.pop();
self.reset_cursor();
}
KeyCode::Char(c) => {
self.filter.push(c);
self.reset_cursor();
}
_ => {}
}
}
fn on_confirm_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('c') if ctrl => self.quit = true,
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => self.start_kill(),
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.pending = None;
self.mode = Mode::List;
}
_ => {}
}
}
fn reset_cursor(&mut self) {
self.state.select(Some(0));
}
fn move_cursor(&mut self, delta: isize, len: usize) {
match wrapping_index(self.state.selected().unwrap_or(0), delta, len) {
Some(next) => self.state.select(Some(next)),
None => self.state.select(None),
}
}
fn toggle_current(&mut self, view: &[PortEntry]) {
if let Some(entry) = self.state.selected().and_then(|i| view.get(i)) {
toggle(&mut self.selected, row_key(entry));
}
}
fn begin_confirm(&mut self, view: &[PortEntry]) {
let mut keys: Vec<RowKey> = if self.selected.is_empty() {
match self.state.selected().and_then(|i| view.get(i)) {
Some(entry) => vec![row_key(entry)],
None => return,
}
} else {
self.selected.iter().copied().collect()
};
keys.sort_unstable();
let ports: Vec<u16> = keys.iter().map(|(port, _)| *port).collect();
let mut pids: Vec<i32> = keys.iter().map(|(_, pid)| *pid).collect();
pids.sort_unstable();
pids.dedup();
self.pending = Some(Pending { ports, pids });
self.mode = Mode::Confirm;
}
fn start_kill(&mut self) {
let Some(pending) = self.pending.take() else {
self.mode = Mode::List;
return;
};
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut killed = 0;
let mut errors = Vec::new();
for pid in pending.pids {
match lsof::kill_pid(pid) {
Ok(()) => killed += 1,
Err(err) => errors.push(err.to_string()),
}
}
let _ = tx.send(KillOutcome { killed, errors });
});
self.kill_rx = Some(rx);
self.mode = Mode::Killing;
}
fn finish_kill(&mut self, outcome: KillOutcome) {
self.kill_rx = None;
self.selected.clear();
self.reload();
if outcome.errors.is_empty() {
toast_ok(
&mut self.toasts,
format!("已关闭 {} 个进程", outcome.killed),
);
} else {
toast_warn(
&mut self.toasts,
format!(
"已关闭 {} 个;{} 个失败:{}",
outcome.killed,
outcome.errors.len(),
outcome.errors.join(";")
),
);
}
self.mode = Mode::List;
}
fn rescan(&mut self) {
self.selected.clear();
if self.reload() {
toast_ok(&mut self.toasts, "已刷新");
}
}
fn reload(&mut self) -> bool {
match lsof::list_listening_ports() {
Ok(list) => {
self.all = list;
true
}
Err(err) => {
toast_error(&mut self.toasts, format!("刷新失败:{err}"));
false
}
}
}
}
fn row_key(entry: &PortEntry) -> RowKey {
(entry.port, entry.pid)
}
pub fn toggle(set: &mut HashSet<RowKey>, key: RowKey) {
if !set.remove(&key) {
set.insert(key);
}
}
pub fn toggle_all(set: &mut HashSet<RowKey>, keys: &[RowKey]) {
let all_selected = !keys.is_empty() && keys.iter().all(|key| set.contains(key));
if all_selected {
for key in keys {
set.remove(key);
}
} else {
for key in keys {
set.insert(*key);
}
}
}
fn render(frame: &mut Frame, app: &mut App, view: &[PortEntry]) {
if view.is_empty() {
app.state.select(None);
} else {
let selected = app.state.selected().unwrap_or(0).min(view.len() - 1);
app.state.select(Some(selected));
}
let area = frame.area();
let chunks = Layout::vertical([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(5), Constraint::Length(1), ])
.split(area);
render_filter(frame, app, chunks[0]);
render_table(frame, app, view, chunks[1]);
render_card(frame, app, view, chunks[2]);
render_hints(frame, app, chunks[3]);
if let Mode::Confirm = app.mode {
if let Some(pending) = &app.pending {
render_confirm_popup(frame, pending, area);
}
} else if matches!(app.mode, Mode::Killing) {
render_killing_popup(frame, app.spinner, area);
}
render_toasts(&mut app.toasts, frame, area);
}
fn panel(title: impl Into<String>, focused: bool) -> Block<'static> {
let border = if focused {
theme::accent()
} else {
theme::muted()
};
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border))
.title(Span::styled(
title.into(),
Style::default()
.fg(theme::accent())
.add_modifier(Modifier::BOLD),
))
}
fn render_filter(frame: &mut Frame, app: &App, area: Rect) {
let widget = input_paragraph(
&app.filter,
"按 / 搜索:端口 / 进程 / 目录",
app.filtering,
" 搜索 ",
);
frame.render_widget(widget, area);
}
fn render_table(frame: &mut Frame, app: &mut App, view: &[PortEntry], area: Rect) {
let block = panel(format!(" 监听端口 ({}) ", view.len()), false);
if view.is_empty() {
let message = if app.filter.is_empty() {
"当前没有监听中的 node 端口"
} else {
"没有匹配的端口"
};
let widget = Paragraph::new(message)
.style(Style::default().fg(theme::muted()))
.alignment(Alignment::Center)
.block(block);
frame.render_widget(widget, area);
return;
}
let inner_width = area.width.saturating_sub(2);
let flexible = inner_width.saturating_sub(2 + 3 + 7 + 8 + 4) as usize;
let longest_cmd = view
.iter()
.map(|entry| entry.full_command.chars().count())
.max()
.unwrap_or(0);
let command_width = longest_cmd.clamp("COMMAND".len(), (flexible * 3 / 5).max(1));
let dir_width = flexible.saturating_sub(command_width).max(1);
let rows = view.iter().map(|entry| {
let key = row_key(entry);
let checked = app.selected.contains(&key);
let mark = if checked { "[x]" } else { "[ ]" };
let command = lsof::truncate_text(&entry.full_command, command_width);
let dir = if entry.cwd.is_empty() {
"—".to_string()
} else {
lsof::truncate_text(&abbreviate_home(&entry.cwd), dir_width)
};
let row = Row::new(vec![
Cell::from(mark),
Cell::from(entry.port.to_string()),
Cell::from(entry.pid.to_string()),
Cell::from(command),
Cell::from(dir),
]);
if checked {
row.style(Style::default().fg(theme::selected()))
} else {
row
}
});
let table = Table::new(
rows,
[
Constraint::Length(3),
Constraint::Length(7),
Constraint::Length(8),
Constraint::Length(command_width as u16),
Constraint::Min(1),
],
)
.header(
Row::new(vec![" ", "PORT", "PID", "COMMAND", "DIR"]).style(
Style::default()
.fg(theme::muted())
.add_modifier(Modifier::BOLD),
),
)
.block(block)
.highlight_symbol("❯ ")
.row_highlight_style(list_cursor_style());
frame.render_stateful_widget(table, area, &mut app.state);
}
fn render_card(frame: &mut Frame, app: &App, view: &[PortEntry], area: Rect) {
let block = panel(" 详情 ", false).padding(Padding::horizontal(1));
let inner = block.inner(area);
frame.render_widget(block, area);
let label = |text: &str| Span::styled(text.to_string(), Style::default().fg(theme::muted()));
let value = |text: String| Span::styled(text, Style::default().fg(theme::text()));
let selected = app.state.selected().and_then(|i| view.get(i));
let lines = match selected {
Some(entry) => {
let budget = (inner.width as usize).saturating_sub(5);
let dir = if entry.cwd.is_empty() {
"—".to_string()
} else {
abbreviate_home(&entry.cwd)
};
vec![
Line::from(vec![
label("端口 "),
Span::styled(
entry.port.to_string(),
Style::default()
.fg(theme::accent())
.add_modifier(Modifier::BOLD),
),
label(" PID "),
value(entry.pid.to_string()),
]),
Line::from(vec![
label("命令 "),
value(lsof::truncate_text(&entry.full_command, budget)),
]),
Line::from(vec![label("目录 "), value(lsof::truncate_text(&dir, budget))]),
]
}
None => vec![Line::from(Span::styled(
"未选中",
Style::default().fg(theme::muted()),
))],
};
frame.render_widget(Paragraph::new(lines), inner);
}
fn abbreviate_home(path: &str) -> String {
if let Some(home) = dirs::home_dir() {
if let Some(home) = home.to_str() {
if let Some(rest) = path.strip_prefix(home) {
if rest.is_empty() {
return "~".to_string();
}
if let Some(tail) = rest.strip_prefix('/') {
return format!("~/{tail}");
}
}
}
}
path.to_string()
}
fn render_hints(frame: &mut Frame, app: &App, area: Rect) {
let hints = match app.mode {
Mode::Confirm | Mode::Killing => return,
Mode::List if app.filtering => FILTER_HINTS,
Mode::List => LIST_HINTS,
};
frame.render_widget(Paragraph::new(key_hint_line(hints)), area);
}
fn confirm_message(pending: &Pending) -> Line<'static> {
let text = Style::default().fg(theme::text());
let port_style = Style::default()
.fg(theme::highlight())
.add_modifier(Modifier::BOLD);
let mut spans = vec![Span::styled("关闭端口 ", text)];
for (index, port) in pending.ports.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" ", text));
}
spans.push(Span::styled(port.to_string(), port_style));
}
spans.push(Span::styled("?", text));
Line::from(spans)
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let [h] = Layout::horizontal([Constraint::Length(width.min(area.width))])
.flex(Flex::Center)
.areas(area);
let [v] = Layout::vertical([Constraint::Length(height.min(area.height))])
.flex(Flex::Center)
.areas(h);
v
}
fn render_confirm_popup(frame: &mut Frame, pending: &Pending, area: Rect) {
let width = 48.min(area.width.saturating_sub(4));
let popup = centered(area, width, 7);
frame.render_widget(Clear, popup);
let block = panel(" 确认关闭 ", false).padding(Padding::uniform(1));
let inner = block.inner(popup);
frame.render_widget(block, popup);
let [message_area, _gap, hints_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(inner);
frame.render_widget(
Paragraph::new(confirm_message(pending)).alignment(Alignment::Center),
message_area,
);
frame.render_widget(
Paragraph::new(key_hint_line(CONFIRM_HINTS)).alignment(Alignment::Center),
hints_area,
);
}
fn render_killing_popup(frame: &mut Frame, spinner: usize, area: Rect) {
let width = 36.min(area.width.saturating_sub(4));
let popup = centered(area, width, 5);
frame.render_widget(Clear, popup);
let block = panel(" 请稍候 ", false).padding(Padding::uniform(1));
let inner = block.inner(popup);
frame.render_widget(block, popup);
let spin = SPINNER[spinner % SPINNER.len()];
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!("{spin} "), Style::default().fg(theme::warning())),
Span::styled("正在关闭进程…", Style::default().fg(theme::text())),
]))
.alignment(Alignment::Center),
inner,
);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn sample() -> Vec<PortEntry> {
vec![
PortEntry {
port: 3000,
pid: 90344,
command: "node".into(),
full_command: "node /Users/dev/app/server.js".into(),
cwd: "/Users/dev/app".into(),
},
PortEntry {
port: 5173,
pid: 90345,
command: "node".into(),
full_command: "node vite".into(),
cwd: "/Users/dev/web".into(),
},
]
}
#[test]
fn preview_port() {
let mut terminal = Terminal::new(TestBackend::new(72, 20)).unwrap();
let mut app = App::new(sample());
app.selected.insert((5173, 90345));
let view = app.filtered();
terminal
.draw(|frame| render(frame, &mut app, &view))
.unwrap();
let buffer = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
out.push_str(buffer[(x, y)].symbol());
}
out.push('\n');
}
assert!(out.contains("PORT"));
assert!(out.contains("COMMAND"));
assert!(out.contains("DIR"));
assert!(out.contains('❯'));
assert!(out.contains("[x]"));
assert!(out.contains("/Users/dev/app"), "missing cwd column:\n{out}");
}
#[test]
fn preview_confirm_popup() {
let mut terminal = Terminal::new(TestBackend::new(72, 14)).unwrap();
let mut app = App::new(sample());
app.selected.insert((3000, 90344));
app.selected.insert((5173, 90345));
let view = app.filtered();
app.begin_confirm(&view);
terminal
.draw(|frame| render(frame, &mut app, &view))
.unwrap();
let buffer = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
out.push_str(buffer[(x, y)].symbol());
}
out.push('\n');
}
assert!(out.contains("3000"));
assert!(out.contains("5173"));
assert!(out.contains('Y'));
}
#[test]
fn toggle_adds_then_removes() {
let mut set: HashSet<RowKey> = HashSet::new();
toggle(&mut set, (3000, 1));
assert!(set.contains(&(3000, 1)));
toggle(&mut set, (3000, 1));
assert!(!set.contains(&(3000, 1)));
}
#[test]
fn toggle_all_selects_then_clears() {
let mut set: HashSet<RowKey> = HashSet::new();
let keys = vec![(3000, 1), (3001, 2)];
toggle_all(&mut set, &keys);
assert_eq!(set.len(), 2);
toggle_all(&mut set, &keys);
assert!(set.is_empty());
}
#[test]
fn toggle_all_fills_when_partial() {
let mut set: HashSet<RowKey> = HashSet::new();
set.insert((3000, 1));
let keys = vec![(3000, 1), (3001, 2)];
toggle_all(&mut set, &keys);
assert_eq!(set.len(), 2);
}
}