From 43473b4ae1a43d196c1995cd69d030bb1aa00e7e Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Wed, 3 Jul 2024 16:08:37 -0600 Subject: [PATCH] (refactor)ui: Refactoring/simplifying TUI state (#8650) ### Description Previously, we held the state for the TUI in the sub-components of `app.rs`. In this PR, we're hoisting those state variables up so we can have better access to that shared state and create more robust UI. ### Testing Instructions There two behavioral changes in this PR: - Reordering of the task list so that running tasks are first in the list, then planned tasks, then done tasks. - We are removing the spinner in favor of the "two arrow" indicator. Users mentioned that the visual noise was more annoying than helpful, so let's quiet it down. Beyond that, you should see no behavioral changes. --------- Co-authored-by: Chris Olszewski --- crates/turborepo-ui/examples/pane.rs | 124 ----- crates/turborepo-ui/examples/table.rs | 131 ----- crates/turborepo-ui/src/tui/app.rs | 610 ++++++++++++++++++--- crates/turborepo-ui/src/tui/input.rs | 18 +- crates/turborepo-ui/src/tui/mod.rs | 2 + crates/turborepo-ui/src/tui/pane.rs | 246 +-------- crates/turborepo-ui/src/tui/table.rs | 324 +---------- crates/turborepo-ui/src/tui/task.rs | 57 ++ crates/turborepo-ui/src/tui/term_output.rs | 68 +++ 9 files changed, 700 insertions(+), 880 deletions(-) delete mode 100644 crates/turborepo-ui/examples/pane.rs delete mode 100644 crates/turborepo-ui/examples/table.rs create mode 100644 crates/turborepo-ui/src/tui/term_output.rs diff --git a/crates/turborepo-ui/examples/pane.rs b/crates/turborepo-ui/examples/pane.rs deleted file mode 100644 index 289d0fd85d4e9..0000000000000 --- a/crates/turborepo-ui/examples/pane.rs +++ /dev/null @@ -1,124 +0,0 @@ -use std::{error::Error, io, sync::mpsc, time::Duration}; - -use crossterm::{ - event::KeyCode, - terminal::{disable_raw_mode, enable_raw_mode}, -}; -use ratatui::{ - backend::{Backend, CrosstermBackend}, - text::Text, - widgets::Widget, - Terminal, TerminalOptions, Viewport, -}; -use turborepo_ui::TerminalPane; - -fn main() -> Result<(), Box> { - enable_raw_mode()?; - let stdout = io::stdout(); - let backend = CrosstermBackend::new(stdout); - - let mut terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Inline(24), - }, - )?; - - terminal.insert_before(1, |buf| { - Text::raw("Press q to exit, use arrow keys to switch panes").render(buf.area, buf) - })?; - - let (tx, rx) = mpsc::sync_channel(1); - - std::thread::spawn(move || handle_input(tx)); - - let size = terminal.get_frame().size(); - - let pane = TerminalPane::new( - size.height, - size.width, - vec!["foo".into(), "bar".into(), "baz".into()], - ); - - run_app(&mut terminal, pane, rx)?; - - terminal.clear()?; - - // restore terminal - disable_raw_mode()?; - terminal.show_cursor()?; - println!(); - - Ok(()) -} - -fn run_app( - terminal: &mut Terminal, - mut pane: TerminalPane<()>, - rx: mpsc::Receiver, -) -> io::Result<()> { - let tasks = ["foo", "bar", "baz"]; - let mut idx: usize = 0; - pane.select("foo").unwrap(); - let mut tick = 0; - while let Ok(event) = rx.recv() { - match event { - Event::Up => { - idx = idx.saturating_sub(1); - let task = tasks[idx]; - pane.select(task).unwrap(); - } - Event::Down => { - idx = (idx + 1).clamp(0, 2); - let task = tasks[idx]; - pane.select(task).unwrap(); - } - Event::Stop => break, - Event::Tick => { - if tick % 3 == 0 { - let color = format!("\x1b[{}m", 30 + (tick % 10)); - for task in tasks { - pane.process_output( - task, - format!("{task}: {color}tick {tick}\x1b[0m\r\n").as_bytes(), - ) - .unwrap(); - } - } - } - } - terminal.draw(|f| f.render_widget(&pane, f.size()))?; - tick += 1; - } - - Ok(()) -} -enum Event { - Up, - Down, - Stop, - Tick, -} - -fn handle_input(tx: mpsc::SyncSender) -> std::io::Result<()> { - loop { - if crossterm::event::poll(Duration::from_millis(20))? { - let event = crossterm::event::read()?; - if let crossterm::event::Event::Key(key_event) = event { - if let Some(event) = match key_event.code { - KeyCode::Up => Some(Event::Up), - KeyCode::Down => Some(Event::Down), - KeyCode::Char('q') => Some(Event::Stop), - _ => None, - } { - if tx.send(event).is_err() { - break; - } - } - } - } else if tx.send(Event::Tick).is_err() { - break; - } - } - Ok(()) -} diff --git a/crates/turborepo-ui/examples/table.rs b/crates/turborepo-ui/examples/table.rs deleted file mode 100644 index e39c5272bf8dd..0000000000000 --- a/crates/turborepo-ui/examples/table.rs +++ /dev/null @@ -1,131 +0,0 @@ -use std::{error::Error, io, sync::mpsc, time::Duration}; - -use crossterm::{ - event::{KeyCode, KeyModifiers}, - terminal::{disable_raw_mode, enable_raw_mode}, -}; -use ratatui::prelude::*; -use turborepo_ui::{tui::event::TaskResult, TaskTable}; - -enum Event { - Tick(u64), - Start(&'static str), - Finish(&'static str), - Up, - Down, - Stop, -} - -fn main() -> Result<(), Box> { - enable_raw_mode()?; - let stdout = io::stdout(); - let backend = CrosstermBackend::new(stdout); - - let mut terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Inline(8), - }, - )?; - - let (tx, rx) = mpsc::sync_channel(1); - let input_tx = tx.clone(); - // Thread forwards user input - let input = std::thread::spawn(move || handle_input(input_tx)); - // Thread simulates starting/finishing of tasks - let events = std::thread::spawn(move || send_events(tx)); - - let table = TaskTable::new((0..6).map(|i| format!("task_{i}"))); - - run_app(&mut terminal, table, rx)?; - - events.join().expect("event thread panicked"); - input.join().expect("input thread panicked")?; - - // restore terminal - disable_raw_mode()?; - terminal.show_cursor()?; - println!(); - - Ok(()) -} - -fn run_app( - terminal: &mut Terminal, - mut table: TaskTable, - rx: mpsc::Receiver, -) -> io::Result<()> { - while let Ok(event) = rx.recv() { - match event { - Event::Tick(_) => { - table.tick(); - } - Event::Start(task) => table.start_task(task).unwrap(), - Event::Finish(task) => table.finish_task(task, TaskResult::Success).unwrap(), - Event::Up => table.previous(), - Event::Down => table.next(), - Event::Stop => break, - } - terminal.draw(|f| table.stateful_render(f, f.size()))?; - } - - Ok(()) -} - -fn send_events(tx: mpsc::SyncSender) { - let mut events = vec![ - Event::Start("task_0"), - Event::Start("task_1"), - Event::Tick(10), - Event::Start("task_2"), - Event::Tick(30), - Event::Start("task_3"), - Event::Finish("task_2"), - Event::Tick(30), - Event::Start("task_4"), - Event::Finish("task_0"), - Event::Tick(10), - Event::Finish("task_1"), - Event::Start("task_5"), - Event::Tick(30), - Event::Finish("task_3"), - Event::Finish("task_4"), - Event::Tick(50), - Event::Finish("task_5"), - Event::Stop, - ]; - events.reverse(); - while let Some(event) = events.pop() { - if let Event::Tick(ticks) = event { - std::thread::sleep(Duration::from_millis(50 * ticks)); - } - if tx.send(event).is_err() { - break; - } - } -} - -fn handle_input(tx: mpsc::SyncSender) -> std::io::Result<()> { - loop { - if crossterm::event::poll(Duration::from_millis(10))? { - let event = crossterm::event::read()?; - if let crossterm::event::Event::Key(key_event) = event { - if let Some(event) = match key_event.code { - KeyCode::Up => Some(Event::Up), - KeyCode::Down => Some(Event::Down), - KeyCode::Char('c') if key_event.modifiers == KeyModifiers::CONTROL => { - Some(Event::Stop) - } - _ => None, - } { - if tx.send(event).is_err() { - break; - } - } - } - } else if tx.send(Event::Tick(0)).is_err() { - break; - } - } - Ok(()) -} diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index e5a05f51f7fa4..cd7a61cf7978f 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -1,4 +1,5 @@ use std::{ + collections::BTreeMap, io::{self, Stdout, Write}, sync::mpsc, time::{Duration, Instant}, @@ -7,6 +8,7 @@ use std::{ use ratatui::{ backend::{Backend, CrosstermBackend}, layout::{Constraint, Layout}, + widgets::TableState, Frame, Terminal, }; use tracing::debug; @@ -14,14 +16,30 @@ use tracing::debug; const PANE_SIZE_RATIO: f32 = 3.0 / 4.0; const FRAMERATE: Duration = Duration::from_millis(3); -use super::{input, AppReceiver, Error, Event, InputOptions, TaskTable, TerminalPane}; +use super::{ + event::TaskResult, input, AppReceiver, Error, Event, InputOptions, TaskTable, TerminalPane, +}; +use crate::tui::{ + task::{Task, TasksByStatus}, + term_output::TerminalOutput, +}; -pub struct App { - table: TaskTable, - pane: TerminalPane, - done: bool, +#[derive(Debug, Clone, Copy)] +pub enum LayoutSections { + Pane, + TaskList, +} + +pub struct App { + rows: u16, + cols: u16, + tasks: BTreeMap>, + tasks_by_status: TasksByStatus, input_options: InputOptions, - started_tasks: Vec, + scroll: TableState, + selected_task_index: usize, + has_user_scrolled: bool, + done: bool, } pub enum Direction { @@ -29,80 +47,280 @@ pub enum Direction { Down, } -impl App { +impl App { pub fn new(rows: u16, cols: u16, tasks: Vec) -> Self { debug!("tasks: {tasks:?}"); - let num_of_tasks = tasks.len(); - let mut this = Self { - table: TaskTable::new(tasks.clone()), - pane: TerminalPane::new(rows, cols, tasks), + + // Initializes with the planned tasks + // and will mutate as tasks change + // to running, finished, etc. + let mut task_list = tasks.clone().into_iter().map(Task::new).collect::>(); + task_list.sort_unstable(); + task_list.dedup(); + + let tasks_by_status = TasksByStatus { + planned: task_list, + finished: Vec::new(), + running: Vec::new(), + }; + + let has_user_interacted = false; + let selected_task_index: usize = 0; + + Self { + rows, + cols, done: false, input_options: InputOptions { - interact: false, + focus: LayoutSections::TaskList, // Check if stdin is a tty that we should read input from tty_stdin: atty::is(atty::Stream::Stdin), }, - started_tasks: Vec::with_capacity(num_of_tasks), - }; - // Start with first task selected - this.next(); - this + tasks: tasks_by_status + .task_names_in_displayed_order() + .map(|task_name| (task_name.to_owned(), TerminalOutput::new(rows, cols, None))) + .collect(), + tasks_by_status, + scroll: TableState::default().with_selected(selected_task_index), + selected_task_index, + has_user_scrolled: has_user_interacted, + } } - pub fn next(&mut self) { - self.table.next(); - if let Some(task) = self.table.selected() { - self.pane.select(task).unwrap(); + pub fn is_focusing_pane(&self) -> bool { + match self.input_options.focus { + LayoutSections::Pane => true, + LayoutSections::TaskList => false, } } + pub fn active_task(&self) -> String { + self.tasks_by_status + .task_name(self.selected_task_index) + .to_string() + } + + pub fn get_full_task_mut(&mut self) -> &mut TerminalOutput { + self.tasks.get_mut(&self.active_task()).unwrap() + } + + pub fn next(&mut self) { + let num_rows = self.tasks_by_status.count_all(); + let next_index = (self.selected_task_index + 1).clamp(0, num_rows - 1); + self.selected_task_index = next_index; + self.scroll.select(Some(next_index)); + self.has_user_scrolled = true; + } + pub fn previous(&mut self) { - self.table.previous(); - if let Some(task) = self.table.selected() { - self.pane.select(task).unwrap(); + let i = match self.selected_task_index { + 0 => 0, + i => i - 1, + }; + self.selected_task_index = i; + self.scroll.select(Some(i)); + self.has_user_scrolled = true; + } + + pub fn scroll_terminal_output(&mut self, direction: Direction) { + self.tasks + .get_mut(&self.active_task()) + .unwrap() + .scroll(direction) + .unwrap_or_default(); + } + + /// Mark the given task as started. + /// If planned, pulls it from planned tasks and starts it. + /// If finished, removes from finished and starts again as new task. + pub fn start_task(&mut self, task: &str) -> Result<(), Error> { + // Name of currently highlighted task. + // We will use this after the order switches. + let highlighted_task = self + .tasks_by_status + .task_name(self.selected_task_index) + .to_string(); + + let mut found_task = false; + + if let Some(planned_idx) = self + .tasks_by_status + .planned + .iter() + .position(|planned| planned.name() == task) + { + let planned = self.tasks_by_status.planned.remove(planned_idx); + let running = planned.start(); + self.tasks_by_status.running.push(running); + + found_task = true; + } else if let Some(finished_idx) = self + .tasks_by_status + .finished + .iter() + .position(|finished| finished.name() == task) + { + let _finished = self.tasks_by_status.finished.remove(finished_idx); + self.tasks_by_status + .running + .push(Task::new(task.to_owned()).start()); + + found_task = true; + } + + if !found_task { + return Err(Error::TaskNotFound { name: task.into() }); + } + + // If user hasn't interacted, keep highlighting top-most task in list. + if !self.has_user_scrolled { + return Ok(()); + } + + if let Some(new_index_to_highlight) = self + .tasks_by_status + .task_names_in_displayed_order() + .position(|running| running == highlighted_task) + { + self.selected_task_index = new_index_to_highlight; + self.scroll.select(Some(new_index_to_highlight)); } + + Ok(()) } - pub fn interact(&mut self, interact: bool) { - let Some(selected_task) = self.table.selected() else { - return; - }; - if self.pane.has_stdin(selected_task) { - self.input_options.interact = interact; - self.pane.highlight(interact); + /// Mark the given running task as finished + /// Errors if given task wasn't a running task + pub fn finish_task(&mut self, task: &str, result: TaskResult) -> Result<(), Error> { + // Name of currently highlighted task. + // We will use this after the order switches. + let highlighted_task = self + .tasks_by_status + .task_name(self.selected_task_index) + .to_string(); + + let running_idx = self + .tasks_by_status + .running + .iter() + .position(|running| running.name() == task) + .ok_or_else(|| { + debug!("could not find '{task}' to finish"); + println!("{:#?}", highlighted_task); + Error::TaskNotFound { name: task.into() } + })?; + + let running = self.tasks_by_status.running.remove(running_idx); + self.tasks_by_status.finished.push(running.finish(result)); + + // If user hasn't interacted, keep highlighting top-most task in list. + if !self.has_user_scrolled { + return Ok(()); + } + + // Find the highlighted task from before the list movement in the new list. + if let Some(new_index_to_highlight) = self + .tasks_by_status + .task_names_in_displayed_order() + .position(|running| running == highlighted_task.as_str()) + { + self.selected_task_index = new_index_to_highlight; + self.scroll.select(Some(new_index_to_highlight)); + } + + Ok(()) + } + + pub fn has_stdin(&self) -> bool { + let active_task = self.active_task(); + if let Some(term) = self.tasks.get(&active_task) { + term.stdin.is_some() + } else { + false + } + } + + pub fn interact(&mut self) { + if matches!(self.input_options.focus, LayoutSections::Pane) { + self.input_options.focus = LayoutSections::TaskList + } else if self.has_stdin() { + self.input_options.focus = LayoutSections::Pane; } } - pub fn scroll(&mut self, direction: Direction) { - let Some(selected_task) = self.table.selected() else { - return; + pub fn update_tasks(&mut self, tasks: Vec) { + // Make sure all tasks have a terminal output + for task in &tasks { + self.tasks + .entry(task.clone()) + .or_insert_with(|| TerminalOutput::new(self.rows, self.cols, None)); + } + // Trim the terminal output to only tasks that exist in new list + self.tasks.retain(|name, _| tasks.contains(name)); + // Update task list + let mut task_list = tasks.into_iter().map(Task::new).collect::>(); + task_list.sort_unstable(); + task_list.dedup(); + self.tasks_by_status = TasksByStatus { + planned: task_list, + running: Default::default(), + finished: Default::default(), }; - self.pane - .scroll(selected_task, direction) - .expect("selected task should be in pane"); } - pub fn term_size(&self) -> (u16, u16) { - self.pane.term_size() + /// Persist all task output to the after closing the TUI + pub fn persist_tasks(&mut self, started_tasks: Vec) -> std::io::Result<()> { + for (task_name, task) in started_tasks.into_iter().filter_map(|started_task| { + (Some(started_task.clone())).zip(self.tasks.get(&started_task)) + }) { + task.persist_screen(&task_name)?; + } + Ok(()) } - pub fn update_tasks(&mut self, tasks: Vec) { - self.table = TaskTable::new(tasks.clone()); - self.next(); + pub fn set_status(&mut self, task: String, status: String) -> Result<(), Error> { + let task = self + .tasks + .get_mut(&task) + .ok_or_else(|| Error::TaskNotFound { + name: task.to_owned(), + })?; + task.status = Some(status); + Ok(()) } } -impl App { +impl App { + /// Insert a stdin to be associated with a task + pub fn insert_stdin(&mut self, task: &str, stdin: Option) -> Result<(), Error> { + let task = self + .tasks + .get_mut(task) + .ok_or_else(|| Error::TaskNotFound { + name: task.to_owned(), + })?; + task.stdin = stdin; + Ok(()) + } + pub fn forward_input(&mut self, bytes: &[u8]) -> Result<(), Error> { - // If we aren't in interactive mode, ignore input - if !self.input_options.interact { - return Ok(()); + if matches!(self.input_options.focus, LayoutSections::Pane) { + let task_output = self.get_full_task_mut(); + if let Some(stdin) = &mut task_output.stdin { + stdin.write_all(bytes).map_err(|e| Error::Stdin { + name: self.active_task(), + e, + })?; + } + Ok(()) + } else { + Ok(()) } - let selected_task = self - .table - .selected() - .expect("table should always have task selected"); - self.pane.process_input(selected_task, bytes)?; + } + + pub fn process_output(&mut self, task: &str, output: &[u8]) -> Result<(), Error> { + let task_output = self.tasks.get_mut(task).unwrap(); + task_output.parser.process(output); Ok(()) } } @@ -121,7 +339,12 @@ pub fn run_app(tasks: Vec, receiver: AppReceiver) -> Result<(), Error> { let mut app: App> = App::new(size.height, full_task_width.max(ratio_pane_width), tasks); - let (result, callback) = match run_app_inner(&mut terminal, &mut app, receiver) { + let (result, callback) = match run_app_inner( + &mut terminal, + &mut app, + receiver, + full_task_width.max(ratio_pane_width), + ) { Ok(callback) => (Ok(()), callback), Err(err) => (Err(err), None), }; @@ -137,9 +360,10 @@ fn run_app_inner( terminal: &mut Terminal, app: &mut App>, receiver: AppReceiver, + cols: u16, ) -> Result>, Error> { // Render initial state to paint the screen - terminal.draw(|f| view(app, f))?; + terminal.draw(|f| view(app, f, cols))?; let mut last_render = Instant::now(); let mut callback = None; while let Some(event) = poll(app.input_options, &receiver, last_render + FRAMERATE) { @@ -148,7 +372,7 @@ fn run_app_inner( break; } if FRAMERATE <= last_render.elapsed() { - terminal.draw(|f| view(app, f))?; + terminal.draw(|f| view(app, f, cols))?; last_render = Instant::now(); } } @@ -200,9 +424,9 @@ fn startup() -> io::Result>> { } /// Restores terminal to expected state -fn cleanup( +fn cleanup( mut terminal: Terminal, - mut app: App, + mut app: App>, callback: Option>, ) -> io::Result<()> { terminal.clear()?; @@ -211,8 +435,8 @@ fn cleanup( crossterm::event::DisableMouseCapture, crossterm::terminal::LeaveAlternateScreen, )?; - let started_tasks = app.table.tasks_started(); - app.pane.persist_tasks(&started_tasks)?; + let tasks_started = app.tasks_by_status.tasks_started(); + app.persist_tasks(tasks_started)?; crossterm::terminal::disable_raw_mode()?; terminal.show_cursor()?; // We can close the channel now that terminal is back restored to a normal state @@ -226,14 +450,13 @@ fn update( ) -> Result>, Error> { match event { Event::StartTask { task } => { - app.table.start_task(&task)?; - app.started_tasks.push(task); + app.start_task(&task)?; } Event::TaskOutput { task, output } => { - app.pane.process_output(&task, &output)?; + app.process_output(&task, &output)?; } Event::Status { task, status } => { - app.pane.set_status(&task, status)?; + app.set_status(task, status)?; } Event::InternalStop => { app.done = true; @@ -243,10 +466,10 @@ fn update( return Ok(Some(callback)); } Event::Tick => { - app.table.tick(); + // app.table.tick(); } Event::EndTask { task, result } => { - app.table.finish_task(&task, result)?; + app.finish_task(&task, result)?; } Event::Up => { app.previous(); @@ -255,35 +478,270 @@ fn update( app.next(); } Event::ScrollUp => { - app.scroll(Direction::Up); + app.has_user_scrolled = true; + app.scroll_terminal_output(Direction::Up) } Event::ScrollDown => { - app.scroll(Direction::Down); + app.has_user_scrolled = true; + app.scroll_terminal_output(Direction::Down) } Event::EnterInteractive => { - app.interact(true); + app.has_user_scrolled = true; + app.interact(); } Event::ExitInteractive => { - app.interact(false); + app.has_user_scrolled = true; + app.interact(); } Event::Input { bytes } => { app.forward_input(&bytes)?; } Event::SetStdin { task, stdin } => { - app.pane.insert_stdin(&task, Some(stdin))?; + app.insert_stdin(&task, Some(stdin))?; } Event::UpdateTasks { tasks } => { app.update_tasks(tasks); - app.table.tick(); + // app.table.tick(); } } Ok(None) } -fn view(app: &mut App, f: &mut Frame) { - let (_, width) = app.term_size(); - let vertical = Layout::horizontal([Constraint::Fill(1), Constraint::Length(width)]); - let [table, pane] = vertical.areas(f.size()); - app.table.stateful_render(f, table); - f.render_widget(&app.pane, pane); +fn view(app: &mut App, f: &mut Frame, cols: u16) { + let horizontal = Layout::horizontal([Constraint::Fill(1), Constraint::Length(cols)]); + let [table, pane] = horizontal.areas(f.size()); + + let active_task = app.active_task(); + + let output_logs = app.tasks.get(&active_task).unwrap(); + let pane_to_render: TerminalPane = + TerminalPane::new(output_logs, &active_task, app.is_focusing_pane()); + + let table_to_render = TaskTable::new(&app.tasks_by_status); + + f.render_stateful_widget(&table_to_render, table, &mut app.scroll); + f.render_widget(&pane_to_render, pane); +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_scroll() { + let mut app: App = App::new( + 100, + 100, + vec!["foo".to_string(), "bar".to_string(), "baz".to_string()], + ); + assert_eq!( + app.scroll.selected(), + Some(0), + "starts with first selection" + ); + app.next(); + assert_eq!( + app.scroll.selected(), + Some(1), + "scroll starts from 0 and goes to 1" + ); + app.previous(); + assert_eq!(app.scroll.selected(), Some(0), "scroll stays in bounds"); + app.next(); + app.next(); + assert_eq!(app.scroll.selected(), Some(2), "scroll moves forwards"); + app.next(); + assert_eq!(app.scroll.selected(), Some(2), "scroll stays in bounds"); + } + + #[test] + fn test_selection_follows() { + let mut app: App = App::new( + 100, + 100, + vec!["a".to_string(), "b".to_string(), "c".to_string()], + ); + app.next(); + assert_eq!(app.scroll.selected(), Some(1), "selected b"); + assert_eq!(app.active_task(), "b", "selected b"); + app.start_task("b").unwrap(); + assert_eq!(app.scroll.selected(), Some(0), "b stays selected"); + assert_eq!(app.active_task(), "b", "selected b"); + app.start_task("a").unwrap(); + assert_eq!(app.scroll.selected(), Some(0), "b stays selected"); + assert_eq!(app.active_task(), "b", "selected b"); + app.finish_task("a", TaskResult::Success).unwrap(); + assert_eq!(app.scroll.selected(), Some(0), "b stays selected"); + assert_eq!(app.active_task(), "b", "selected b"); + } + + #[test] + fn test_restart_task() { + let mut app: App<()> = App::new( + 100, + 100, + vec!["a".to_string(), "b".to_string(), "c".to_string()], + ); + app.next(); + app.next(); + // Start all tasks + app.start_task("b").unwrap(); + app.start_task("a").unwrap(); + app.start_task("c").unwrap(); + assert_eq!( + app.tasks_by_status.task_name(0), + "b", + "b is on top (running)" + ); + app.finish_task("a", TaskResult::Success).unwrap(); + assert_eq!( + ( + app.tasks_by_status.task_name(2), + app.tasks_by_status.task_name(0) + ), + ("a", "b"), + "a is on bottom (done), b is second (running)" + ); + + app.finish_task("b", TaskResult::Success).unwrap(); + assert_eq!( + ( + app.tasks_by_status.task_name(1), + app.tasks_by_status.task_name(2) + ), + ("a", "b"), + "a is second (done), b is last (done)" + ); + + // Restart b + app.start_task("b").unwrap(); + assert_eq!( + ( + app.tasks_by_status.task_name(1), + app.tasks_by_status.task_name(0) + ), + ("b", "c"), + "b is second (running), c is first (running)" + ); + + // Restart a + app.start_task("a").unwrap(); + assert_eq!( + ( + app.tasks_by_status.task_name(0), + app.tasks_by_status.task_name(1), + app.tasks_by_status.task_name(2) + ), + ("c", "b", "a"), + "c is on top (running), b is second (running), a is third + (running)" + ); + } + + #[test] + fn test_selection_stable() { + let mut app: App = App::new( + 100, + 100, + vec!["a".to_string(), "b".to_string(), "c".to_string()], + ); + app.next(); + app.next(); + assert_eq!(app.scroll.selected(), Some(2), "selected c"); + assert_eq!(app.tasks_by_status.task_name(2), "c", "selected c"); + // start c which moves it to "running" which is before "planned" + app.start_task("c").unwrap(); + assert_eq!(app.scroll.selected(), Some(0), "selection stays on c"); + assert_eq!(app.tasks_by_status.task_name(0), "c", "selected c"); + app.start_task("a").unwrap(); + assert_eq!(app.scroll.selected(), Some(0), "selection stays on c"); + assert_eq!(app.tasks_by_status.task_name(0), "c", "selected c"); + // c + // a + // b <- + app.next(); + app.next(); + assert_eq!(app.scroll.selected(), Some(2), "selected b"); + assert_eq!(app.tasks_by_status.task_name(2), "b", "selected b"); + app.finish_task("a", TaskResult::Success).unwrap(); + assert_eq!(app.scroll.selected(), Some(1), "b stays selected"); + assert_eq!(app.tasks_by_status.task_name(1), "b", "selected b"); + // c <- + // b + // a + app.previous(); + app.finish_task("c", TaskResult::Success).unwrap(); + assert_eq!(app.scroll.selected(), Some(2), "c stays selected"); + assert_eq!(app.tasks_by_status.task_name(2), "c", "selected c"); + } + + #[test] + fn test_forward_stdin() { + let mut app: App> = App::new(100, 100, vec!["a".to_string(), "b".to_string()]); + app.next(); + assert_eq!(app.scroll.selected(), Some(1), "selected b"); + assert_eq!(app.tasks_by_status.task_name(1), "b", "selected b"); + // start c which moves it to "running" which is before "planned" + app.start_task("a").unwrap(); + app.start_task("b").unwrap(); + app.insert_stdin("a", Some(Vec::new())).unwrap(); + app.insert_stdin("b", Some(Vec::new())).unwrap(); + + // Interact and type "hello" + app.interact(); + app.forward_input(b"hello!").unwrap(); + + // Exit interaction and move up + app.interact(); + app.previous(); + app.interact(); + app.forward_input(b"world").unwrap(); + + assert_eq!( + app.tasks.get("b").unwrap().stdin.as_deref().unwrap(), + b"hello!" + ); + assert_eq!( + app.tasks.get("a").unwrap().stdin.as_deref().unwrap(), + b"world" + ); + } + + #[test] + fn test_interact() { + let mut app: App> = App::new(100, 100, vec!["a".to_string(), "b".to_string()]); + assert!(!app.is_focusing_pane(), "app starts focused on table"); + app.insert_stdin("a", Some(Vec::new())).unwrap(); + + app.interact(); + assert!(app.is_focusing_pane(), "can focus pane when task has stdin"); + + app.interact(); + assert!( + !app.is_focusing_pane(), + "interact changes focus to table if focused on pane" + ); + + app.next(); + assert!(!app.is_focusing_pane(), "pane isn't focused after move"); + app.interact(); + assert!(!app.is_focusing_pane(), "cannot focus task without stdin"); + } + + #[test] + fn test_task_status() { + let mut app: App> = App::new(100, 100, vec!["a".to_string(), "b".to_string()]); + app.next(); + assert_eq!(app.scroll.selected(), Some(1), "selected b"); + assert_eq!(app.tasks_by_status.task_name(1), "b", "selected b"); + // set status for a + app.set_status("a".to_string(), "building".to_string()) + .unwrap(); + + assert_eq!( + app.tasks.get("a").unwrap().status.as_deref(), + Some("building") + ); + assert!(app.tasks.get("b").unwrap().status.is_none()); + } } diff --git a/crates/turborepo-ui/src/tui/input.rs b/crates/turborepo-ui/src/tui/input.rs index 5b8987f599da9..be8747672ec62 100644 --- a/crates/turborepo-ui/src/tui/input.rs +++ b/crates/turborepo-ui/src/tui/input.rs @@ -2,19 +2,16 @@ use std::time::Duration; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use super::{event::Event, Error}; +use super::{app::LayoutSections, event::Event, Error}; #[derive(Debug, Clone, Copy)] pub struct InputOptions { - pub interact: bool, + pub focus: LayoutSections, pub tty_stdin: bool, } /// Return any immediately available event pub fn input(options: InputOptions) -> Result, Error> { - let InputOptions { - interact, - tty_stdin, - } = options; + let InputOptions { focus, tty_stdin } = options; // If stdin is not a tty, then we do not attempt to read from it if !tty_stdin { return Ok(None); @@ -23,7 +20,7 @@ pub fn input(options: InputOptions) -> Result, Error> { // for input if crossterm::event::poll(Duration::from_millis(0))? { match crossterm::event::read()? { - crossterm::event::Event::Key(k) => Ok(translate_key_event(interact, k)), + crossterm::event::Event::Key(k) => Ok(translate_key_event(&focus, k)), crossterm::event::Event::Mouse(m) => match m.kind { crossterm::event::MouseEventKind::ScrollDown => Ok(Some(Event::ScrollDown)), crossterm::event::MouseEventKind::ScrollUp => Ok(Some(Event::ScrollUp)), @@ -37,7 +34,7 @@ pub fn input(options: InputOptions) -> Result, Error> { } /// Converts a crossterm key event into a TUI interaction event -fn translate_key_event(interact: bool, key_event: KeyEvent) -> Option { +fn translate_key_event(interact: &LayoutSections, key_event: KeyEvent) -> Option { // On Windows events for releasing a key are produced // We skip these to avoid emitting 2 events per key press. // There is still a `Repeat` event for when a key is held that will pass through @@ -51,12 +48,13 @@ fn translate_key_event(interact: bool, key_event: KeyEvent) -> Option { } // Interactive branches KeyCode::Char('z') - if interact && key_event.modifiers == crossterm::event::KeyModifiers::CONTROL => + if matches!(interact, LayoutSections::Pane) + && key_event.modifiers == crossterm::event::KeyModifiers::CONTROL => { Some(Event::ExitInteractive) } // If we're in interactive mode, convert the key event to bytes to send to stdin - _ if interact => Some(Event::Input { + _ if matches!(interact, LayoutSections::Pane) => Some(Event::Input { bytes: encode_key(key_event), }), // Fall through if we aren't in interactive mode diff --git a/crates/turborepo-ui/src/tui/mod.rs b/crates/turborepo-ui/src/tui/mod.rs index 35b0deea7ba06..5bcb487873a69 100644 --- a/crates/turborepo-ui/src/tui/mod.rs +++ b/crates/turborepo-ui/src/tui/mod.rs @@ -6,6 +6,7 @@ mod pane; mod spinner; mod table; mod task; +mod term_output; pub use app::{run_app, terminal_big_enough}; use event::{Event, TaskResult}; @@ -13,6 +14,7 @@ pub use handle::{AppReceiver, AppSender, TuiTask}; use input::{input, InputOptions}; pub use pane::TerminalPane; pub use table::TaskTable; +pub use term_output::TerminalOutput; #[derive(Debug, thiserror::Error)] pub enum Error { diff --git a/crates/turborepo-ui/src/tui/pane.rs b/crates/turborepo-ui/src/tui/pane.rs index 2593253198ba8..ff34d52142476 100644 --- a/crates/turborepo-ui/src/tui/pane.rs +++ b/crates/turborepo-ui/src/tui/pane.rs @@ -1,223 +1,44 @@ -use std::{collections::BTreeMap, io::Write}; - use ratatui::{ style::Style, text::Line, widgets::{Block, Borders, Widget}, }; -use tracing::debug; use tui_term::widget::PseudoTerminal; -use turborepo_vt100 as vt100; -use super::{app::Direction, Error}; +use super::TerminalOutput; const FOOTER_TEXT_ACTIVE: &str = "Press`Ctrl-Z` to stop interacting."; const FOOTER_TEXT_INACTIVE: &str = "Press `Enter` to interact."; -pub struct TerminalPane { - tasks: BTreeMap>, - displayed: Option, - rows: u16, - cols: u16, +pub struct TerminalPane<'a, W> { + terminal_output: &'a TerminalOutput, + task_name: &'a str, highlight: bool, } -struct TerminalOutput { - rows: u16, - cols: u16, - parser: vt100::Parser, - stdin: Option, - status: Option, -} - -impl TerminalPane { - pub fn new(rows: u16, cols: u16, tasks: impl IntoIterator) -> Self { - // We trim 2 from rows and cols as we use them for borders - let rows = rows.saturating_sub(2); - let cols = cols.saturating_sub(2); +impl<'a, W> TerminalPane<'a, W> { + pub fn new( + terminal_output: &'a TerminalOutput, + task_name: &'a str, + highlight: bool, + ) -> Self { Self { - tasks: tasks - .into_iter() - .map(|name| (name, TerminalOutput::new(rows, cols, None))) - .collect(), - displayed: None, - rows, - cols, - highlight: false, + terminal_output, + highlight, + task_name, } } - - pub fn highlight(&mut self, highlight: bool) { - self.highlight = highlight; - } - - pub fn process_output(&mut self, task: &str, output: &[u8]) -> Result<(), Error> { - let task = self - .task_mut(task) - .inspect_err(|_| debug!("cannot find task on process output"))?; - task.parser.process(output); - Ok(()) - } - - pub fn has_stdin(&self, task: &str) -> bool { - self.tasks - .get(task) - .map(|task| task.stdin.is_some()) - .unwrap_or_default() - } - - pub fn resize(&mut self, rows: u16, cols: u16) -> Result<(), Error> { - let changed = self.rows != rows || self.cols != cols; - self.rows = rows; - self.cols = cols; - if changed { - // Eagerly resize currently displayed terminal - if let Some(task_name) = self.displayed.as_deref() { - let task = self - .tasks - .get_mut(task_name) - .expect("displayed should always point to valid task"); - task.resize(rows, cols); - } - } - - Ok(()) - } - - pub fn select(&mut self, task: &str) -> Result<(), Error> { - let rows = self.rows; - let cols = self.cols; - { - let terminal = self.task_mut(task)?; - terminal.resize(rows, cols); - } - self.displayed = Some(task.into()); - - Ok(()) - } - - pub fn set_status(&mut self, task: &str, status: String) -> Result<(), Error> { - let task = self.task_mut(task)?; - task.status = Some(status); - Ok(()) - } - - pub fn scroll(&mut self, task: &str, direction: Direction) -> Result<(), Error> { - let task = self.task_mut(task)?; - let scrollback = task.parser.screen().scrollback(); - let new_scrollback = match direction { - Direction::Up => scrollback + 1, - Direction::Down => scrollback.saturating_sub(1), - }; - task.parser.screen_mut().set_scrollback(new_scrollback); - Ok(()) - } - - /// Persist all task output to the terminal - pub fn persist_tasks(&mut self, started_tasks: &[&str]) -> std::io::Result<()> { - for (task_name, task) in started_tasks - .iter() - .copied() - .filter_map(|started_task| (Some(started_task)).zip(self.tasks.get(started_task))) - { - task.persist_screen(task_name)?; - } - Ok(()) - } - - pub fn term_size(&self) -> (u16, u16) { - (self.rows, self.cols) - } - - fn selected(&self) -> Option<(&String, &TerminalOutput)> { - let task_name = self.displayed.as_deref()?; - self.tasks.get_key_value(task_name) - } - - fn task_mut(&mut self, task: &str) -> Result<&mut TerminalOutput, Error> { - self.tasks.get_mut(task).ok_or_else(|| Error::TaskNotFound { - name: task.to_string(), - }) - } -} - -impl TerminalPane { - /// Insert a stdin to be associated with a task - pub fn insert_stdin(&mut self, task_name: &str, stdin: Option) -> Result<(), Error> { - let task = self.task_mut(task_name)?; - task.stdin = stdin; - Ok(()) - } - - pub fn process_input(&mut self, task: &str, input: &[u8]) -> Result<(), Error> { - let task_output = self.task_mut(task)?; - if let Some(stdin) = &mut task_output.stdin { - stdin.write_all(input).map_err(|e| Error::Stdin { - name: task.into(), - e, - })?; - } - Ok(()) - } -} - -impl TerminalOutput { - fn new(rows: u16, cols: u16, stdin: Option) -> Self { - Self { - parser: vt100::Parser::new(rows, cols, 1024), - stdin, - rows, - cols, - status: None, - } - } - - fn title(&self, task_name: &str) -> String { - match self.status.as_deref() { - Some(status) => format!(" {task_name} > {status} "), - None => format!(" {task_name} > "), - } - } - - fn resize(&mut self, rows: u16, cols: u16) { - if self.rows != rows || self.cols != cols { - self.parser.screen_mut().set_size(rows, cols); - } - self.rows = rows; - self.cols = cols; - } - - #[tracing::instrument(skip(self))] - fn persist_screen(&self, task_name: &str) -> std::io::Result<()> { - let screen = self.parser.entire_screen(); - let title = self.title(task_name); - let mut stdout = std::io::stdout().lock(); - stdout.write_all("┌".as_bytes())?; - stdout.write_all(title.as_bytes())?; - stdout.write_all(b"\r\n")?; - for row in screen.rows_formatted(0, self.cols) { - stdout.write_all("│ ".as_bytes())?; - stdout.write_all(&row)?; - stdout.write_all(b"\r\n")?; - } - stdout.write_all("└────>\r\n".as_bytes())?; - - Ok(()) - } } -impl Widget for &TerminalPane { +impl<'a, W> Widget for &TerminalPane<'a, W> { fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) where Self: Sized, { - let Some((task_name, task)) = self.selected() else { - return; - }; - let screen = task.parser.screen(); + let screen = self.terminal_output.parser.screen(); let mut block = Block::default() .borders(Borders::LEFT) - .title(task.title(task_name)); + .title(self.terminal_output.title(self.task_name)); if self.highlight { block = block.title_bottom(Line::from(FOOTER_TEXT_ACTIVE).centered()); block = block.border_style(Style::new().fg(ratatui::style::Color::Yellow)); @@ -228,38 +49,3 @@ impl Widget for &TerminalPane { term.render(area, buf) } } - -#[cfg(test)] -mod test { - // Used by assert_buffer_eq - #[allow(unused_imports)] - use indoc::indoc; - use ratatui::{assert_buffer_eq, buffer::Buffer, layout::Rect}; - - use super::*; - - #[test] - fn test_basic() { - let mut pane: TerminalPane<()> = TerminalPane::new(6, 8, vec!["foo".into()]); - pane.select("foo").unwrap(); - pane.process_output("foo", b"1\r\n2\r\n3\r\n4\r\n5\r\n") - .unwrap(); - - let area = Rect::new(0, 0, 8, 6); - let mut buffer = Buffer::empty(area); - pane.render(area, &mut buffer); - // Reset style change of the cursor - buffer.set_style(Rect::new(1, 4, 1, 1), Style::reset()); - assert_buffer_eq!( - buffer, - Buffer::with_lines(vec![ - "│ foo > ", - "│3 ", - "│4 ", - "│5 ", - "│█ ", - "│Press `", - ]) - ); - } -} diff --git a/crates/turborepo-ui/src/tui/table.rs b/crates/turborepo-ui/src/tui/table.rs index 50611991e2dae..7d832e776ef20 100644 --- a/crates/turborepo-ui/src/tui/table.rs +++ b/crates/turborepo-ui/src/tui/table.rs @@ -4,43 +4,23 @@ use ratatui::{ text::Text, widgets::{Cell, Row, StatefulWidget, Table, TableState}, }; -use tracing::debug; -use super::{ - event::TaskResult, - spinner::SpinnerState, - task::{Finished, Planned, Running, Task}, - Error, -}; +use super::{event::TaskResult, spinner::SpinnerState, task::TasksByStatus}; /// A widget that renders a table of their tasks and their current status /// /// The table contains finished tasks, running tasks, and planned tasks rendered /// in that order. -pub struct TaskTable { - // Tasks to be displayed - // Ordered by when they finished - finished: Vec>, - // Ordered by when they started - running: Vec>, - // Ordered by task name - planned: Vec>, - // State used for showing things - scroll: TableState, +pub struct TaskTable<'b> { + tasks_by_type: &'b TasksByStatus, spinner: SpinnerState, } -impl TaskTable { +impl<'b> TaskTable<'b> { /// Construct a new table with all of the planned tasks - pub fn new(tasks: impl IntoIterator) -> Self { - let mut planned = tasks.into_iter().map(Task::new).collect::>(); - planned.sort_unstable(); - planned.dedup(); + pub fn new(tasks_by_type: &'b TasksByStatus) -> Self { Self { - planned, - running: Vec::new(), - finished: Vec::new(), - scroll: TableState::default(), + tasks_by_type, spinner: SpinnerState::default(), } } @@ -58,168 +38,13 @@ impl TaskTable { task_name_width + 1 } - /// Number of rows in the table - pub fn len(&self) -> usize { - self.finished.len() + self.running.len() + self.planned.len() - } - - /// If there are no tasks in the table - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - /// Mark the given task as started. - /// If planned, pulls it from planned tasks and starts it. - /// If finished, removes from finished and starts again as new task. - pub fn start_task(&mut self, task: &str) -> Result<(), Error> { - if let Ok(planned_idx) = self - .planned - .binary_search_by(|planned_task| planned_task.name().cmp(task)) - { - let planned = self.planned.remove(planned_idx); - let old_row_idx = self.finished.len() + self.running.len() + planned_idx; - let new_row_idx = self.finished.len() + self.running.len(); - let running = planned.start(); - self.running.push(running); - - if let Some(selected_idx) = self.scroll.selected() { - // If task that was just started is selected, then update selection to follow - // task - if selected_idx == old_row_idx { - self.scroll.select(Some(new_row_idx)); - } else if new_row_idx <= selected_idx && selected_idx < old_row_idx { - // If the selected task is between the old and new row positions - // then increment the selection index to keep selection the same. - self.scroll.select(Some(selected_idx + 1)); - } - } - } else if let Some(finished_idx) = self - .finished - .iter() - .position(|finished_task| finished_task.name() == task) - { - let finished = self.finished.remove(finished_idx); - let old_row_idx = finished_idx; - let new_row_idx = self.finished.len() + self.running.len(); - let running = Task::new(finished.name().to_string()).start(); - self.running.push(running); - - if let Some(selected_idx) = self.scroll.selected() { - // If task that was just started is selected, then update selection to follow - // task - if selected_idx == old_row_idx { - self.scroll.select(Some(new_row_idx)); - } else if new_row_idx <= selected_idx && selected_idx < old_row_idx { - // If the selected task is between the old and new row positions - // then increment the selection index to keep selection the same. - self.scroll.select(Some(selected_idx + 1)); - } - } - } else { - debug!("could not find '{task}' to start"); - return Err(Error::TaskNotFound { name: task.into() }); - } - - self.tick(); - Ok(()) - } - - /// Mark the given running task as finished - /// Errors if given task wasn't a running task - pub fn finish_task(&mut self, task: &str, result: TaskResult) -> Result<(), Error> { - let running_idx = self - .running - .iter() - .position(|running| running.name() == task) - .ok_or_else(|| { - debug!("could not find '{task}' to finish"); - Error::TaskNotFound { name: task.into() } - })?; - let old_row_idx = self.finished.len() + running_idx; - let new_row_idx = self.finished.len(); - let running = self.running.remove(running_idx); - self.finished.push(running.finish(result)); - - if let Some(selected_row) = self.scroll.selected() { - // If task that was just started is selected, then update selection to follow - // task - if selected_row == old_row_idx { - self.scroll.select(Some(new_row_idx)); - } else if new_row_idx <= selected_row && selected_row < old_row_idx { - // If the selected task is between the old and new row positions then increment - // the selection index to keep selection the same. - self.scroll.select(Some(selected_row + 1)); - } - } - - self.tick(); - Ok(()) - } - /// Update the current time of the table pub fn tick(&mut self) { self.spinner.update(); } - /// Select the next row - pub fn next(&mut self) { - let num_rows = self.len(); - let i = match self.scroll.selected() { - Some(i) => (i + 1).clamp(0, num_rows - 1), - None => 0, - }; - self.scroll.select(Some(i)); - } - - /// Select the previous row - pub fn previous(&mut self) { - let i = match self.scroll.selected() { - Some(0) => 0, - Some(i) => i - 1, - None => 0, - }; - self.scroll.select(Some(i)); - } - - pub fn get(&self, i: usize) -> Option<&str> { - if i < self.finished.len() { - let task = self.finished.get(i)?; - Some(task.name()) - } else if i < self.finished.len() + self.running.len() { - let task = self.running.get(i - self.finished.len())?; - Some(task.name()) - } else if i < self.finished.len() + self.running.len() + self.planned.len() { - let task = self - .planned - .get(i - (self.finished.len() + self.running.len()))?; - Some(task.name()) - } else { - None - } - } - - pub fn selected(&self) -> Option<&str> { - let i = self.scroll.selected()?; - self.get(i) - } - - pub fn tasks_started(&self) -> Vec<&str> { - let (errors, success): (Vec<_>, Vec<_>) = self - .finished - .iter() - .partition(|task| matches!(task.result(), TaskResult::Failure)); - - // We return errors last as they most likely have information users want to see - success - .into_iter() - .map(|task| task.name()) - .chain(self.running.iter().map(|task| task.name())) - .chain(errors.into_iter().map(|task| task.name())) - .collect() - } - fn finished_rows(&self) -> impl Iterator + '_ { - self.finished.iter().map(move |task| { + self.tasks_by_type.finished.iter().map(move |task| { Row::new(vec![ Cell::new(task.name()), Cell::new(match task.result() { @@ -232,36 +57,30 @@ impl TaskTable { fn running_rows(&self) -> impl Iterator + '_ { let spinner = self.spinner.current(); - self.running + self.tasks_by_type + .running .iter() .map(move |task| Row::new(vec![Cell::new(task.name()), Cell::new(Text::raw(spinner))])) } fn planned_rows(&self) -> impl Iterator + '_ { - self.planned + self.tasks_by_type + .planned .iter() .map(move |task| Row::new(vec![Cell::new(task.name()), Cell::new(" ")])) } - - /// Convenience method which renders and updates scroll state - pub fn stateful_render(&mut self, frame: &mut ratatui::Frame, area: Rect) { - let mut scroll = self.scroll.clone(); - self.spinner.update(); - frame.render_stateful_widget(&*self, area, &mut scroll); - self.scroll = scroll; - } } -impl<'a> StatefulWidget for &'a TaskTable { +impl<'a> StatefulWidget for &'a TaskTable<'a> { type State = TableState; fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer, state: &mut Self::State) { let width = area.width; let bar = "─".repeat(usize::from(width)); let table = Table::new( - self.finished_rows() - .chain(self.running_rows()) - .chain(self.planned_rows()), + self.running_rows() + .chain(self.planned_rows()) + .chain(self.finished_rows()), [ Constraint::Min(14), // Status takes one cell to render @@ -287,116 +106,3 @@ impl<'a> StatefulWidget for &'a TaskTable { StatefulWidget::render(table, area, buf, state); } } - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_scroll() { - let mut table = TaskTable::new(vec![ - "foo".to_string(), - "bar".to_string(), - "baz".to_string(), - ]); - assert_eq!(table.scroll.selected(), None, "starts with no selection"); - table.next(); - assert_eq!(table.scroll.selected(), Some(0), "scroll starts from 0"); - table.previous(); - assert_eq!(table.scroll.selected(), Some(0), "scroll stays in bounds"); - table.next(); - table.next(); - assert_eq!(table.scroll.selected(), Some(2), "scroll moves forwards"); - table.next(); - assert_eq!(table.scroll.selected(), Some(2), "scroll stays in bounds"); - } - - #[test] - fn test_selection_follows() { - let mut table = TaskTable::new(vec!["a".to_string(), "b".to_string(), "c".to_string()]); - table.next(); - table.next(); - assert_eq!(table.scroll.selected(), Some(1), "selected b"); - assert_eq!(table.selected(), Some("b"), "selected b"); - table.start_task("b").unwrap(); - assert_eq!(table.scroll.selected(), Some(0), "b stays selected"); - assert_eq!(table.selected(), Some("b"), "selected b"); - table.start_task("a").unwrap(); - assert_eq!(table.scroll.selected(), Some(0), "b stays selected"); - assert_eq!(table.selected(), Some("b"), "selected b"); - table.finish_task("a", TaskResult::Success).unwrap(); - assert_eq!(table.scroll.selected(), Some(1), "b stays selected"); - assert_eq!(table.selected(), Some("b"), "selected b"); - } - - #[test] - fn test_restart_task() { - let mut table = TaskTable::new(vec!["a".to_string(), "b".to_string(), "c".to_string()]); - table.next(); - table.next(); - // Start all tasks - table.start_task("b").unwrap(); - table.start_task("a").unwrap(); - table.start_task("c").unwrap(); - assert_eq!(table.get(0), Some("b"), "b is on top (running)"); - table.finish_task("a", TaskResult::Success).unwrap(); - assert_eq!( - (table.get(0), table.get(1)), - (Some("a"), Some("b")), - "a is on top (done), b is second (running)" - ); - - table.finish_task("b", TaskResult::Success).unwrap(); - assert_eq!( - (table.get(0), table.get(1)), - (Some("a"), Some("b")), - "a is on top (done), b is second (done)" - ); - - // Restart b - table.start_task("b").unwrap(); - assert_eq!( - (table.get(1), table.get(2)), - (Some("c"), Some("b")), - "b is third (running)" - ); - - // Restart a - table.start_task("a").unwrap(); - assert_eq!( - (table.get(0), table.get(1), table.get(2)), - (Some("c"), Some("b"), Some("a")), - "c is on top (running), b is second (running), a is third (running)" - ); - } - - #[test] - fn test_selection_stable() { - let mut table = TaskTable::new(vec!["a".to_string(), "b".to_string(), "c".to_string()]); - table.next(); - table.next(); - assert_eq!(table.scroll.selected(), Some(1), "selected b"); - assert_eq!(table.selected(), Some("b"), "selected b"); - // start c which moves it to "running" which is before "planned" - table.start_task("c").unwrap(); - assert_eq!(table.scroll.selected(), Some(2), "selection stays on b"); - assert_eq!(table.selected(), Some("b"), "selected b"); - table.start_task("a").unwrap(); - assert_eq!(table.scroll.selected(), Some(2), "selection stays on b"); - assert_eq!(table.selected(), Some("b"), "selected b"); - // c - // a - // b <- - table.previous(); - table.previous(); - assert_eq!(table.scroll.selected(), Some(0), "selected c"); - assert_eq!(table.selected(), Some("c"), "selected c"); - table.finish_task("a", TaskResult::Success).unwrap(); - assert_eq!(table.scroll.selected(), Some(1), "c stays selected"); - assert_eq!(table.selected(), Some("c"), "selected c"); - table.previous(); - table.finish_task("c", TaskResult::Success).unwrap(); - assert_eq!(table.scroll.selected(), Some(0), "a stays selected"); - assert_eq!(table.selected(), Some("a"), "selected a"); - } -} diff --git a/crates/turborepo-ui/src/tui/task.rs b/crates/turborepo-ui/src/tui/task.rs index 9f514bc7b8697..44f67a117e522 100644 --- a/crates/turborepo-ui/src/tui/task.rs +++ b/crates/turborepo-ui/src/tui/task.rs @@ -24,6 +24,12 @@ pub struct Task { state: S, } +pub enum TaskType { + Planned, + Running, + Finished, +} + impl Task { pub fn name(&self) -> &str { &self.name @@ -82,3 +88,54 @@ impl Task { self.state.result } } + +pub struct TaskNamesByStatus { + pub running: Vec, + pub planned: Vec, + pub finished: Vec, +} + +#[derive(Clone)] +pub struct TasksByStatus { + pub running: Vec>, + pub planned: Vec>, + pub finished: Vec>, +} + +impl TasksByStatus { + pub fn all_empty(&self) -> bool { + self.planned.is_empty() && self.finished.is_empty() && self.running.is_empty() + } + + pub fn count_all(&self) -> usize { + self.task_names_in_displayed_order().count() + } + + pub fn task_names_in_displayed_order(&self) -> impl Iterator + '_ { + let running_names = self.running.iter().map(|task| task.name()); + let planned_names = self.planned.iter().map(|task| task.name()); + let finished_names = self.finished.iter().map(|task| task.name()); + + running_names.chain(planned_names).chain(finished_names) + } + + pub fn task_name(&self, index: usize) -> &str { + self.task_names_in_displayed_order().nth(index).unwrap() + } + + pub fn tasks_started(&self) -> Vec { + let (errors, success): (Vec<_>, Vec<_>) = self + .finished + .iter() + .partition(|task| matches!(task.result(), TaskResult::Failure)); + + // We return errors last as they most likely have information users want to see + success + .into_iter() + .map(|task| task.name()) + .chain(self.running.iter().map(|task| task.name())) + .chain(errors.into_iter().map(|task| task.name())) + .map(|task| task.to_string()) + .collect() + } +} diff --git a/crates/turborepo-ui/src/tui/term_output.rs b/crates/turborepo-ui/src/tui/term_output.rs new file mode 100644 index 0000000000000..765c130733204 --- /dev/null +++ b/crates/turborepo-ui/src/tui/term_output.rs @@ -0,0 +1,68 @@ +use std::io::Write; + +use turborepo_vt100 as vt100; + +use super::{app::Direction, Error}; + +pub struct TerminalOutput { + rows: u16, + cols: u16, + pub parser: vt100::Parser, + pub stdin: Option, + pub status: Option, +} + +impl TerminalOutput { + pub fn new(rows: u16, cols: u16, stdin: Option) -> Self { + Self { + parser: vt100::Parser::new(rows, cols, 1024), + stdin, + rows, + cols, + status: None, + } + } + + pub fn title(&self, task_name: &str) -> String { + match self.status.as_deref() { + Some(status) => format!(" {task_name} > {status} "), + None => format!(" {task_name} > "), + } + } + + pub fn resize(&mut self, rows: u16, cols: u16) { + if self.rows != rows || self.cols != cols { + self.parser.screen_mut().set_size(rows, cols); + } + self.rows = rows; + self.cols = cols; + } + + pub fn scroll(&mut self, direction: Direction) -> Result<(), Error> { + let scrollback = self.parser.screen().scrollback(); + let new_scrollback = match direction { + Direction::Up => scrollback + 1, + Direction::Down => scrollback.saturating_sub(1), + }; + self.parser.screen_mut().set_scrollback(new_scrollback); + Ok(()) + } + + #[tracing::instrument(skip(self))] + pub fn persist_screen(&self, task_name: &str) -> std::io::Result<()> { + let screen = self.parser.entire_screen(); + let title = self.title(task_name); + let mut stdout = std::io::stdout().lock(); + stdout.write_all("┌".as_bytes())?; + stdout.write_all(title.as_bytes())?; + stdout.write_all(b"\r\n")?; + for row in screen.rows_formatted(0, self.cols) { + stdout.write_all("│ ".as_bytes())?; + stdout.write_all(&row)?; + stdout.write_all(b"\r\n")?; + } + stdout.write_all("└────>\r\n".as_bytes())?; + + Ok(()) + } +}