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(()) + } +}