use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph};
use ratatui::{DefaultTerminal, Frame};
use ratatui_notifications::Notifications;
use super::discover::{self, PackageGroup, PackageManager, ScriptEntry};
use super::exec::{self, RunOutcome};
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,
};
const PACKAGE_PANE_W: u16 = 34;
const POLL_INTERVAL: Duration = Duration::from_millis(120);
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Focus {
Package,
Script,
}
const PKG_HINTS: &[(&str, &str)] = &[
("↑/↓", "选包"),
("→", "scripts"),
("1-9", "跳包"),
("/", "搜索"),
("R", "刷新"),
("Enter", "运行"),
("Esc", "退出"),
];
const SCRIPT_HINTS: &[(&str, &str)] = &[
("↑/↓", "移动"),
("←", "回包"),
("Space", "选择"),
("A", "全选"),
("/", "搜索"),
("R", "刷新"),
("Enter", "运行"),
("Esc", "退出"),
];
const FILTER_HINTS: &[(&str, &str)] = &[("输入", "搜索"), ("Enter", "回列表"), ("Esc", "清空")];
pub struct App {
all: Vec<ScriptEntry>,
pm: PackageManager,
cwd: PathBuf,
filter: String,
filtering: bool,
selected: HashSet<String>,
focus: Focus,
pkg_state: ListState,
script_state: ListState,
toasts: Notifications,
quit: bool,
pending_run: Option<Vec<ScriptEntry>>,
}
impl App {
pub fn new(discovery: discover::Discovery) -> Self {
let mut pkg_state = ListState::default();
pkg_state.select(Some(0));
let mut script_state = ListState::default();
script_state.select(Some(0));
Self {
all: discovery.entries,
pm: discovery.package_manager,
cwd: discovery.cwd,
filter: String::new(),
filtering: false,
selected: HashSet::new(),
focus: Focus::Package,
pkg_state,
script_state,
toasts: Notifications::new(),
quit: false,
pending_run: None,
}
}
fn groups(&self) -> Vec<PackageGroup> {
let filtered = discover::filter_scripts(&self.all, &self.filter);
discover::group_by_package(&filtered)
}
fn active_scripts<'a>(&self, groups: &'a [PackageGroup]) -> &'a [ScriptEntry] {
groups
.get(self.pkg_state.selected().unwrap_or(0))
.map(|group| group.scripts.as_slice())
.unwrap_or(&[])
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
while !self.quit {
tick_toasts(&mut self.toasts, POLL_INTERVAL);
let groups = self.groups();
terminal.draw(|frame| render(frame, self, &groups))?;
if event::poll(POLL_INTERVAL)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
self.on_key(key, &groups);
}
}
}
if let Some(entries) = self.pending_run.take() {
let outcomes = exec::run_scripts(terminal, &entries, self.pm)?;
self.finish_run(outcomes);
}
}
Ok(())
}
fn on_key(&mut self, key: KeyEvent, groups: &[PackageGroup]) {
if self.filtering {
self.on_filter_key(key);
return;
}
self.on_nav_key(key, groups);
}
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.clamp_cursors(&self.groups());
}
KeyCode::Backspace => {
self.filter.pop();
self.reset_after_filter();
}
KeyCode::Char(c) => {
self.filter.push(c);
self.reset_after_filter();
}
_ => {}
}
}
fn on_nav_key(&mut self, key: KeyEvent, groups: &[PackageGroup]) {
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') => self.toggle_active_package(groups),
KeyCode::Char('r' | 'R') => self.rescan(),
KeyCode::Char(c @ '1'..='9') => self.jump_package(c as usize - '1' as usize, groups),
KeyCode::Enter => self.begin_run(groups),
KeyCode::Up => self.move_cursor(-1, groups),
KeyCode::Down => self.move_cursor(1, groups),
KeyCode::Left | KeyCode::Char('h') => self.focus = Focus::Package,
KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => self.enter_scripts(groups),
KeyCode::Char(' ') => self.toggle_current(groups),
_ => {}
}
}
fn reset_after_filter(&mut self) {
self.pkg_state.select(Some(0));
self.script_state.select(Some(0));
self.focus = Focus::Package;
}
fn clamp_cursors(&mut self, groups: &[PackageGroup]) {
if groups.is_empty() {
self.pkg_state.select(None);
self.script_state.select(None);
return;
}
let pkg = self.pkg_state.selected().unwrap_or(0).min(groups.len() - 1);
self.pkg_state.select(Some(pkg));
let scripts = groups[pkg].scripts.len();
if scripts == 0 {
self.script_state.select(None);
} else {
let cursor = self.script_state.selected().unwrap_or(0).min(scripts - 1);
self.script_state.select(Some(cursor));
}
}
fn move_cursor(&mut self, delta: isize, groups: &[PackageGroup]) {
match self.focus {
Focus::Package => {
move_state(&mut self.pkg_state, delta, groups.len());
self.script_state.select(Some(0));
}
Focus::Script => {
let len = self.active_scripts(groups).len();
move_state(&mut self.script_state, delta, len);
}
}
}
fn enter_scripts(&mut self, groups: &[PackageGroup]) {
if !self.active_scripts(groups).is_empty() {
self.focus = Focus::Script;
if self.script_state.selected().is_none() {
self.script_state.select(Some(0));
}
}
}
fn jump_package(&mut self, index: usize, groups: &[PackageGroup]) {
if index < groups.len() {
self.pkg_state.select(Some(index));
self.script_state.select(Some(0));
self.focus = Focus::Script;
}
}
fn toggle_current(&mut self, groups: &[PackageGroup]) {
if self.focus != Focus::Script {
return;
}
if let Some(entry) = self
.script_state
.selected()
.and_then(|i| self.active_scripts(groups).get(i))
{
toggle(&mut self.selected, entry.key());
}
}
fn toggle_active_package(&mut self, groups: &[PackageGroup]) {
let keys: Vec<String> = self
.active_scripts(groups)
.iter()
.map(ScriptEntry::key)
.collect();
toggle_all(&mut self.selected, &keys);
}
fn begin_run(&mut self, groups: &[PackageGroup]) {
let all_in_view: Vec<&ScriptEntry> = groups
.iter()
.flat_map(|group| group.scripts.iter())
.collect();
let entries: Vec<ScriptEntry> = if self.selected.is_empty() {
match self
.script_state
.selected()
.and_then(|i| self.active_scripts(groups).get(i))
{
Some(entry) => vec![entry.clone()],
None => return,
}
} else {
all_in_view
.into_iter()
.filter(|entry| self.selected.contains(&entry.key()))
.cloned()
.collect()
};
if entries.is_empty() {
return;
}
self.pending_run = Some(entries);
}
fn finish_run(&mut self, outcomes: Vec<RunOutcome>) {
self.selected.clear();
let total = outcomes.len();
let ok = outcomes.iter().filter(|o| o.exit_code == 0).count();
let message = if total == 1 {
let outcome = &outcomes[0];
format!("{} · 退出码 {}", outcome.label, outcome.exit_code)
} else if ok == total {
format!("全部 {total} 个任务完成")
} else {
format!("{ok}/{total} 个成功")
};
if ok == total {
toast_ok(&mut self.toasts, message);
} else {
toast_warn(&mut self.toasts, message);
}
}
fn rescan(&mut self) {
match discover::discover_scripts(&self.cwd) {
Ok(discovery) => {
self.all = discovery.entries;
self.pm = discovery.package_manager;
self.selected.clear();
self.reset_after_filter();
toast_ok(&mut self.toasts, "已刷新");
}
Err(err) => {
self.all.clear();
toast_error(&mut self.toasts, err.to_string());
}
}
}
}
fn move_state(state: &mut ListState, delta: isize, len: usize) {
match wrapping_index(state.selected().unwrap_or(0), delta, len) {
Some(next) => state.select(Some(next)),
None => state.select(None),
}
}
pub fn toggle(set: &mut HashSet<String>, key: String) {
if !set.remove(&key) {
set.insert(key);
}
}
pub fn toggle_all(set: &mut HashSet<String>, keys: &[String]) {
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.clone());
}
}
}
fn render(frame: &mut Frame, app: &mut App, groups: &[PackageGroup]) {
app.clamp_cursors(groups);
let chunks = Layout::vertical([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
.split(frame.area());
render_filter(frame, app, chunks[0]);
render_main(frame, app, groups, chunks[1]);
render_hints(frame, app, chunks[2]);
render_toasts(&mut app.toasts, frame, 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,
"按 / 搜索:包名 / script / 命令",
app.filtering,
" 搜索 ",
);
frame.render_widget(widget, area);
}
fn render_main(frame: &mut Frame, app: &mut App, groups: &[PackageGroup], area: Rect) {
if groups.is_empty() {
let message = if app.filter.is_empty() {
"当前目录下未找到 scripts"
} else {
"没有匹配的 script"
};
let widget = Paragraph::new(message)
.style(Style::default().fg(theme::muted()))
.alignment(Alignment::Center)
.block(panel(" Scripts ", false));
frame.render_widget(widget, area);
return;
}
let [pkg_area, script_area] =
Layout::horizontal([Constraint::Length(PACKAGE_PANE_W), Constraint::Min(1)]).areas(area);
render_packages(frame, app, groups, pkg_area);
render_scripts(frame, app, groups, script_area);
}
fn render_packages(frame: &mut Frame, app: &mut App, groups: &[PackageGroup], area: Rect) {
let focused = app.focus == Focus::Package;
let inner_w = area.width.saturating_sub(4) as usize;
let items: Vec<ListItem> = groups
.iter()
.enumerate()
.map(|(index, group)| {
let badge = discover::count_selected(group, &app.selected);
let mut name = group.package_name.clone();
if group.is_current {
name.push_str(" *");
}
let prefix = format!("{} ", index + 1);
let suffix = if badge > 0 {
format!(" ({badge})")
} else {
String::new()
};
let budget = inner_w.saturating_sub(prefix.chars().count() + suffix.chars().count());
let name = truncate(&name, budget.max(1));
let mut spans = vec![Span::styled(prefix, Style::default().fg(theme::muted()))];
spans.push(Span::raw(name));
if !suffix.is_empty() {
spans.push(Span::styled(suffix, Style::default().fg(theme::selected())));
}
ListItem::new(Line::from(spans))
})
.collect();
let pkg_highlight = if focused {
list_cursor_style()
} else {
Style::default()
.fg(theme::accent())
.add_modifier(Modifier::BOLD)
};
let title = format!(" Packages ({}) ", groups.len());
let list = List::new(items)
.block(panel(title, focused))
.highlight_symbol("❯ ")
.highlight_style(pkg_highlight);
frame.render_stateful_widget(list, area, &mut app.pkg_state);
}
fn render_scripts(frame: &mut Frame, app: &mut App, groups: &[PackageGroup], area: Rect) {
let focused = app.focus == Focus::Script;
let scripts = app.active_scripts(groups);
let title = format!(" Scripts · {} ({}) ", app.pm.label(), scripts.len());
let block = panel(title, focused);
if scripts.is_empty() {
let widget = Paragraph::new("该包无 scripts")
.style(Style::default().fg(theme::muted()))
.alignment(Alignment::Center)
.block(block);
frame.render_widget(widget, area);
return;
}
let inner_w = area.width.saturating_sub(4) as usize; let cursor = if focused {
app.script_state.selected()
} else {
None
};
let items: Vec<ListItem> = scripts
.iter()
.enumerate()
.map(|(index, entry)| {
let checked = app.selected.contains(&entry.key());
let mark = if checked { "[x] " } else { "[ ] " };
let show_cmd = cursor == Some(index);
let name_budget = if show_cmd {
inner_w.saturating_sub(mark.len() + 3)
} else {
inner_w.saturating_sub(mark.len())
};
let name = truncate(&entry.script, name_budget.max(1));
let mark_style = if checked {
Style::default().fg(theme::selected())
} else {
Style::default().fg(theme::muted())
};
let mut spans = vec![Span::styled(mark, mark_style)];
spans.push(Span::styled(name, name_style(checked)));
if show_cmd {
let used = mark.len() + entry.script.chars().count().min(name_budget);
let cmd_budget = inner_w.saturating_sub(used + 3);
if cmd_budget > 1 {
let cmd = truncate(&entry.command, cmd_budget);
spans.push(Span::styled(
format!(" → {cmd}"),
Style::default()
.fg(theme::muted())
.add_modifier(Modifier::DIM),
));
}
}
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(items)
.block(block)
.highlight_symbol("❯ ")
.highlight_style(highlight_style(focused));
frame.render_stateful_widget(list, area, &mut app.script_state);
}
fn highlight_style(focused: bool) -> Style {
if focused {
list_cursor_style()
} else {
Style::default().fg(theme::muted())
}
}
fn name_style(checked: bool) -> Style {
if checked {
Style::default().fg(theme::selected())
} else {
Style::default().fg(theme::muted())
}
}
fn truncate(text: &str, max: usize) -> String {
crate::ports::lsof::truncate_text(text, max)
}
fn render_hints(frame: &mut Frame, app: &App, area: Rect) {
let hints = if app.filtering {
FILTER_HINTS
} else {
match app.focus {
Focus::Package => PKG_HINTS,
Focus::Script => SCRIPT_HINTS,
}
};
frame.render_widget(Paragraph::new(key_hint_line(hints)), area);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn sample() -> discover::Discovery {
discover::Discovery {
cwd: PathBuf::from("/repo/app"),
package_manager: PackageManager::Pnpm,
entries: vec![
ScriptEntry {
package_name: "app".into(),
package_path: PathBuf::from("/repo/app"),
script: "build".into(),
command: "tsc && vite build".into(),
is_current: true,
},
ScriptEntry {
package_name: "app".into(),
package_path: PathBuf::from("/repo/app"),
script: "dev".into(),
command: "vite".into(),
is_current: true,
},
ScriptEntry {
package_name: "lib".into(),
package_path: PathBuf::from("/repo/lib"),
script: "test".into(),
command: "vitest".into(),
is_current: false,
},
],
}
}
fn dump(app: &mut App) -> String {
let mut terminal = Terminal::new(TestBackend::new(90, 16)).unwrap();
let groups = app.groups();
terminal.draw(|frame| render(frame, app, &groups)).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');
}
out
}
#[test]
fn preview_two_pane() {
let mut app = App::new(sample());
app.focus = Focus::Script;
app.selected.insert("/repo/app::build".to_string());
let out = dump(&mut app);
assert!(out.contains("Packages"));
assert!(out.contains("Scripts"));
assert!(out.contains("pnpm"));
assert!(out.contains("app"));
assert!(out.contains("[x]"));
assert!(out.contains('❯'));
}
#[test]
fn filter_narrows_groups() {
let mut app = App::new(sample());
app.filter = "vitest".to_string();
let groups = app.groups();
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].package_name, "lib");
}
#[test]
fn enter_scripts_switches_focus() {
let mut app = App::new(sample());
let groups = app.groups();
assert_eq!(app.focus, Focus::Package);
app.enter_scripts(&groups);
assert_eq!(app.focus, Focus::Script);
}
#[test]
fn jump_package_selects_and_focuses_scripts() {
let mut app = App::new(sample());
let groups = app.groups();
app.jump_package(1, &groups);
assert_eq!(app.pkg_state.selected(), Some(1));
assert_eq!(app.focus, Focus::Script);
}
#[test]
fn run_uses_cursor_script_when_no_selection() {
let mut app = App::new(sample());
app.focus = Focus::Script;
let groups = app.groups();
app.script_state.select(Some(0));
app.begin_run(&groups);
let pending = app.pending_run.expect("queued");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].script, "build");
}
#[test]
fn run_collects_selection_across_packages() {
let mut app = App::new(sample());
app.selected.insert("/repo/app::dev".to_string());
app.selected.insert("/repo/lib::test".to_string());
let groups = app.groups();
app.begin_run(&groups);
assert_eq!(app.pending_run.expect("queued").len(), 2);
}
#[test]
fn toggle_active_package_selects_all_its_scripts() {
let mut app = App::new(sample());
let groups = app.groups();
app.toggle_active_package(&groups); assert_eq!(app.selected.len(), 2);
}
}