diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index 9f99eac7e7729..a72d9e0e2fbf1 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -1,6 +1,7 @@ use std::{ collections::BTreeMap, io::{self, Stdout, Write}, + mem, sync::mpsc, time::{Duration, Instant}, }; @@ -17,18 +18,24 @@ const FRAMERATE: Duration = Duration::from_millis(3); const RESIZE_DEBOUNCE_DELAY: Duration = Duration::from_millis(10); use super::{ - event::{CacheResult, OutputLogs, TaskResult}, - input, AppReceiver, Debouncer, Error, Event, InputOptions, SizeInfo, TaskTable, TerminalPane, + event::{CacheResult, Direction, OutputLogs, TaskResult}, + input, + search::SearchResults, + AppReceiver, Debouncer, Error, Event, InputOptions, SizeInfo, TaskTable, TerminalPane, }; use crate::tui::{ task::{Task, TasksByStatus}, term_output::TerminalOutput, }; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub enum LayoutSections { Pane, TaskList, + Search { + previous_selection: String, + results: SearchResults, + }, } pub struct App { @@ -43,11 +50,6 @@ pub struct App { done: bool, } -pub enum Direction { - Up, - Down, -} - impl App { pub fn new(rows: u16, cols: u16, tasks: Vec) -> Self { debug!("tasks: {tasks:?}"); @@ -94,10 +96,11 @@ impl App { } } - pub fn is_focusing_pane(&self) -> bool { + #[cfg(test)] + fn is_focusing_pane(&self) -> bool { match self.focus { LayoutSections::Pane => true, - LayoutSections::TaskList => false, + LayoutSections::TaskList | LayoutSections::Search { .. } => false, } } @@ -108,7 +111,7 @@ impl App { fn input_options(&self) -> Result { let has_selection = self.get_full_task()?.has_selection(); Ok(InputOptions { - focus: self.focus, + focus: &self.focus, tty_stdin: self.tty_stdin, has_selection, }) @@ -158,6 +161,105 @@ impl App { Ok(()) } + pub fn enter_search(&mut self) -> Result<(), Error> { + self.focus = LayoutSections::Search { + previous_selection: self.active_task()?.to_string(), + results: SearchResults::new(&self.tasks_by_status), + }; + // We set scroll as we want to keep the current selection + self.has_user_scrolled = true; + Ok(()) + } + + pub fn exit_search(&mut self, restore_scroll: bool) { + let mut prev_focus = LayoutSections::TaskList; + mem::swap(&mut self.focus, &mut prev_focus); + if let LayoutSections::Search { + previous_selection, .. + } = prev_focus + { + if restore_scroll && self.select_task(&previous_selection).is_err() { + // If the task that was selected is no longer in the task list we reset + // scrolling. + self.reset_scroll(); + } + } + } + + pub fn search_scroll(&mut self, direction: Direction) -> Result<(), Error> { + let LayoutSections::Search { results, .. } = &self.focus else { + debug!("scrolling search while not searching"); + return Ok(()); + }; + let new_selection = match direction { + Direction::Up => results.first_match( + self.tasks_by_status + .task_names_in_displayed_order() + .rev() + // We skip all of the tasks that are at or after the current selection + .skip(self.tasks_by_status.count_all() - self.selected_task_index), + ), + Direction::Down => results.first_match( + self.tasks_by_status + .task_names_in_displayed_order() + .skip(self.selected_task_index + 1), + ), + }; + if let Some(new_selection) = new_selection { + let new_selection = new_selection.to_owned(); + self.select_task(&new_selection)?; + } + Ok(()) + } + + pub fn search_enter_char(&mut self, c: char) -> Result<(), Error> { + let LayoutSections::Search { results, .. } = &mut self.focus else { + debug!("modifying search query while not searching"); + return Ok(()); + }; + results.modify_query(|s| s.push(c)); + self.update_search_results(); + Ok(()) + } + + pub fn search_remove_char(&mut self) -> Result<(), Error> { + let LayoutSections::Search { results, .. } = &mut self.focus else { + debug!("modified search query while not searching"); + return Ok(()); + }; + let mut query_was_empty = false; + results.modify_query(|s| { + query_was_empty = s.pop().is_none(); + }); + if query_was_empty { + self.exit_search(true); + } else { + self.update_search_results(); + } + Ok(()) + } + + fn update_search_results(&mut self) { + let LayoutSections::Search { results, .. } = &self.focus else { + return; + }; + + // if currently selected task is in results stay on it + // if not we go forward looking for a task in results + if let Some(result) = results + .first_match( + self.tasks_by_status + .task_names_in_displayed_order() + .skip(self.selected_task_index), + ) + .or_else(|| results.first_match(self.tasks_by_status.task_names_in_displayed_order())) + { + let new_selection = result.to_owned(); + self.has_user_scrolled = true; + self.select_task(&new_selection).expect("todo"); + } + } + /// 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. @@ -282,6 +384,11 @@ impl App { self.reset_scroll(); } + if let LayoutSections::Search { results, .. } = &mut self.focus { + results.update_tasks(&self.tasks_by_status); + } + self.update_search_results(); + Ok(()) } @@ -299,6 +406,10 @@ impl App { self.tasks_by_status .restart_tasks(tasks.iter().map(|s| s.as_str())); + if let LayoutSections::Search { results, .. } = &mut self.focus { + results.update_tasks(&self.tasks_by_status); + } + if self.select_task(&highlighted_task).is_err() { debug!("was unable to find {highlighted_task} after restart"); self.reset_scroll(); @@ -645,6 +756,21 @@ fn update( Event::Resize { rows, cols } => { app.resize(rows, cols); } + Event::SearchEnter => { + app.enter_search()?; + } + Event::SearchExit { restore_scroll } => { + app.exit_search(restore_scroll); + } + Event::SearchScroll { direction } => { + app.search_scroll(direction)?; + } + Event::SearchEnterChar(c) => { + app.search_enter_char(c)?; + } + Event::SearchBackspace => { + app.search_remove_char()?; + } } Ok(None) } @@ -657,8 +783,7 @@ fn view(app: &mut App, f: &mut Frame) { let active_task = app.active_task().unwrap().to_string(); 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 pane_to_render: TerminalPane = TerminalPane::new(output_logs, &active_task, &app.focus); let table_to_render = TaskTable::new(&app.tasks_by_status); @@ -1017,4 +1142,147 @@ mod test { app.start_task("d", OutputLogs::Full)?; Ok(()) } + + #[test] + fn test_search_backspace_exits_search() -> Result<(), Error> { + let mut app: App<()> = App::new( + 100, + 100, + vec!["a".to_string(), "b".to_string(), "c".to_string()], + ); + app.enter_search()?; + assert!(matches!(app.focus, LayoutSections::Search { .. })); + app.search_remove_char()?; + assert!(matches!(app.focus, LayoutSections::TaskList)); + app.enter_search()?; + app.search_enter_char('a')?; + assert!(matches!(app.focus, LayoutSections::Search { .. })); + app.search_remove_char()?; + assert!(matches!(app.focus, LayoutSections::Search { .. })); + app.search_remove_char()?; + assert!(matches!(app.focus, LayoutSections::TaskList)); + Ok(()) + } + + #[test] + fn test_search_moves_with_typing() -> Result<(), Error> { + let mut app: App<()> = App::new( + 100, + 100, + vec!["a".to_string(), "ab".to_string(), "abc".to_string()], + ); + app.enter_search()?; + app.search_enter_char('a')?; + assert_eq!(app.active_task()?, "a"); + app.search_enter_char('b')?; + assert_eq!(app.active_task()?, "ab"); + app.search_enter_char('c')?; + assert_eq!(app.active_task()?, "abc"); + app.search_remove_char()?; + assert_eq!( + app.active_task()?, + "abc", + "should not move off of a search result if still a match" + ); + Ok(()) + } + + #[test] + fn test_search_scroll() -> Result<(), Error> { + let mut app: App<()> = App::new( + 100, + 100, + vec!["a".to_string(), "ab".to_string(), "abc".to_string()], + ); + app.enter_search()?; + app.search_enter_char('b')?; + assert_eq!(app.active_task()?, "ab"); + app.start_task("ab", OutputLogs::Full)?; + assert_eq!( + app.active_task()?, + "ab", + "starting the selected task keeps selection" + ); + app.search_scroll(Direction::Down)?; + assert_eq!(app.active_task()?, "abc"); + app.search_scroll(Direction::Down)?; + assert_eq!(app.active_task()?, "abc"); + app.search_scroll(Direction::Up)?; + assert_eq!(app.active_task()?, "ab"); + app.search_scroll(Direction::Up)?; + assert_eq!(app.active_task()?, "ab"); + Ok(()) + } + + #[test] + fn test_exit_search_restore_selection() -> Result<(), Error> { + let mut app: App<()> = App::new( + 100, + 100, + vec!["a".to_string(), "abc".to_string(), "b".to_string()], + ); + app.next(); + assert_eq!(app.active_task()?, "abc"); + app.enter_search()?; + app.search_enter_char('b')?; + assert_eq!(app.active_task()?, "abc"); + app.search_scroll(Direction::Down)?; + assert_eq!(app.active_task()?, "b"); + app.exit_search(true); + assert_eq!(app.active_task()?, "abc"); + Ok(()) + } + + #[test] + fn test_exit_search_keep_selection() -> Result<(), Error> { + let mut app: App<()> = App::new( + 100, + 100, + vec!["a".to_string(), "abc".to_string(), "b".to_string()], + ); + app.next(); + assert_eq!(app.active_task()?, "abc"); + app.enter_search()?; + app.search_enter_char('b')?; + assert_eq!(app.active_task()?, "abc"); + app.search_scroll(Direction::Down)?; + assert_eq!(app.active_task()?, "b"); + app.exit_search(false); + assert_eq!(app.active_task()?, "b"); + Ok(()) + } + + #[test] + fn test_select_update_task_removes_task() -> Result<(), Error> { + let mut app: App<()> = App::new( + 100, + 100, + vec!["a".to_string(), "ab".to_string(), "abc".to_string()], + ); + app.enter_search()?; + app.search_enter_char('b')?; + assert_eq!(app.active_task()?, "ab"); + // Remove selected task ab + app.update_tasks(vec!["a".into(), "abc".into()])?; + assert_eq!(app.active_task()?, "abc"); + Ok(()) + } + + #[test] + fn test_select_restart_tasks_reorders_tasks() -> Result<(), Error> { + let mut app: App<()> = App::new( + 100, + 100, + vec!["a".to_string(), "ab".to_string(), "abc".to_string()], + ); + app.enter_search()?; + app.search_enter_char('b')?; + assert_eq!(app.active_task()?, "ab"); + app.start_task("ab", OutputLogs::Full)?; + assert_eq!(app.active_task()?, "ab"); + // Restart ab + app.restart_tasks(vec!["ab".into()])?; + assert_eq!(app.active_task()?, "ab"); + Ok(()) + } } diff --git a/crates/turborepo-ui/src/tui/event.rs b/crates/turborepo-ui/src/tui/event.rs index 3719610b3b4cf..757be75017ca9 100644 --- a/crates/turborepo-ui/src/tui/event.rs +++ b/crates/turborepo-ui/src/tui/event.rs @@ -45,6 +45,20 @@ pub enum Event { rows: u16, cols: u16, }, + SearchEnter, + SearchExit { + restore_scroll: bool, + }, + SearchScroll { + direction: Direction, + }, + SearchEnterChar(char), + SearchBackspace, +} + +pub enum Direction { + Up, + Down, } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] diff --git a/crates/turborepo-ui/src/tui/input.rs b/crates/turborepo-ui/src/tui/input.rs index accaf94c6c3ec..0dcc585bf00c3 100644 --- a/crates/turborepo-ui/src/tui/input.rs +++ b/crates/turborepo-ui/src/tui/input.rs @@ -2,14 +2,19 @@ use std::time::Duration; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use super::{app::LayoutSections, event::Event, Error}; +use super::{ + app::LayoutSections, + event::{Direction, Event}, + Error, +}; #[derive(Debug, Clone, Copy)] -pub struct InputOptions { - pub focus: LayoutSections, +pub struct InputOptions<'a> { + pub focus: &'a LayoutSections, pub tty_stdin: bool, pub has_selection: bool, } + /// Return any immediately available event pub fn input(options: InputOptions) -> Result, Error> { // If stdin is not a tty, then we do not attempt to read from it @@ -63,6 +68,36 @@ fn translate_key_event(options: InputOptions, key_event: KeyEvent) -> Option Some(Event::Input { bytes: encode_key(key_event), }), + // If we're on the list and user presses `/` enter search mode + KeyCode::Char('/') if matches!(options.focus, LayoutSections::TaskList) => { + Some(Event::SearchEnter) + } + KeyCode::Esc if matches!(options.focus, LayoutSections::Search { .. }) => { + Some(Event::SearchExit { + restore_scroll: true, + }) + } + KeyCode::Enter if matches!(options.focus, LayoutSections::Search { .. }) => { + Some(Event::SearchExit { + restore_scroll: false, + }) + } + KeyCode::Up if matches!(options.focus, LayoutSections::Search { .. }) => { + Some(Event::SearchScroll { + direction: Direction::Up, + }) + } + KeyCode::Down if matches!(options.focus, LayoutSections::Search { .. }) => { + Some(Event::SearchScroll { + direction: Direction::Down, + }) + } + KeyCode::Backspace if matches!(options.focus, LayoutSections::Search { .. }) => { + Some(Event::SearchBackspace) + } + KeyCode::Char(c) if matches!(options.focus, LayoutSections::Search { .. }) => { + Some(Event::SearchEnterChar(c)) + } // Fall through if we aren't in interactive mode KeyCode::Char('p') if key_event.modifiers == KeyModifiers::CONTROL => Some(Event::ScrollUp), KeyCode::Char('n') if key_event.modifiers == KeyModifiers::CONTROL => { diff --git a/crates/turborepo-ui/src/tui/mod.rs b/crates/turborepo-ui/src/tui/mod.rs index dd7678448e8ab..99735be667c01 100644 --- a/crates/turborepo-ui/src/tui/mod.rs +++ b/crates/turborepo-ui/src/tui/mod.rs @@ -5,6 +5,7 @@ pub mod event; mod handle; mod input; mod pane; +mod search; mod size; mod spinner; mod table; diff --git a/crates/turborepo-ui/src/tui/pane.rs b/crates/turborepo-ui/src/tui/pane.rs index c7a26b361f543..a1c5e584477b2 100644 --- a/crates/turborepo-ui/src/tui/pane.rs +++ b/crates/turborepo-ui/src/tui/pane.rs @@ -5,7 +5,7 @@ use ratatui::{ }; use tui_term::widget::PseudoTerminal; -use super::TerminalOutput; +use super::{app::LayoutSections, TerminalOutput}; const FOOTER_TEXT_ACTIVE: &str = "Press`Ctrl-Z` to stop interacting."; const FOOTER_TEXT_INACTIVE: &str = "Press `Enter` to interact."; @@ -14,21 +14,41 @@ const HAS_SELECTION: &str = "Press `c` to copy selection"; pub struct TerminalPane<'a, W> { terminal_output: &'a TerminalOutput, task_name: &'a str, - highlight: bool, + section: &'a LayoutSections, } impl<'a, W> TerminalPane<'a, W> { pub fn new( terminal_output: &'a TerminalOutput, task_name: &'a str, - highlight: bool, + section: &'a LayoutSections, ) -> Self { Self { terminal_output, - highlight, + section, task_name, } } + + fn highlight(&self) -> bool { + matches!(self.section, LayoutSections::Pane) + } + + fn footer(&self) -> Line { + match self.section { + LayoutSections::Pane if self.terminal_output.has_selection() => { + Line::from(format!("{FOOTER_TEXT_ACTIVE} {HAS_SELECTION}")).centered() + } + LayoutSections::Pane => Line::from(FOOTER_TEXT_ACTIVE.to_owned()).centered(), + LayoutSections::TaskList if self.terminal_output.has_selection() => { + Line::from(format!("{FOOTER_TEXT_INACTIVE} {HAS_SELECTION}")).centered() + } + LayoutSections::TaskList => Line::from(FOOTER_TEXT_INACTIVE.to_owned()).centered(), + LayoutSections::Search { results, .. } => { + Line::from(format!("/ {}", results.query())).left_aligned() + } + } + } } impl<'a, W> Widget for &TerminalPane<'a, W> { @@ -37,22 +57,11 @@ impl<'a, W> Widget for &TerminalPane<'a, W> { Self: Sized, { let screen = self.terminal_output.parser.screen(); - let mut help_text = if self.highlight { - FOOTER_TEXT_ACTIVE - } else { - FOOTER_TEXT_INACTIVE - } - .to_owned(); - - if self.terminal_output.has_selection() { - help_text.push(' '); - help_text.push_str(HAS_SELECTION); - } let block = Block::default() .borders(Borders::LEFT) .title(self.terminal_output.title(self.task_name)) - .title_bottom(Line::from(help_text).centered()) - .style(if self.highlight { + .title_bottom(self.footer()) + .style(if self.highlight() { Style::new().fg(ratatui::style::Color::Yellow) } else { Style::new() diff --git a/crates/turborepo-ui/src/tui/search.rs b/crates/turborepo-ui/src/tui/search.rs new file mode 100644 index 0000000000000..eabced60bc0e5 --- /dev/null +++ b/crates/turborepo-ui/src/tui/search.rs @@ -0,0 +1,130 @@ +use std::{collections::HashSet, rc::Rc}; + +use super::task::TasksByStatus; + +#[derive(Debug, Clone)] +pub struct SearchResults { + query: String, + // We use Rc instead of String here for two reasons: + // - Rc for cheap clones since elements in `matches` will always be in `tasks` as well + // - Rc implements Borrow meaning we can query a `HashSet>` using a `&str` + // We do not modify the provided task names so we do not need the capabilities of String. + tasks: Vec>, + matches: HashSet>, +} + +impl SearchResults { + pub fn new(tasks: &TasksByStatus) -> Self { + Self { + tasks: tasks + .task_names_in_displayed_order() + .map(Rc::from) + .collect(), + query: String::new(), + matches: HashSet::new(), + } + } + + /// Updates search results with new search body + pub fn update_tasks(&mut self, tasks: &TasksByStatus) { + self.tasks.clear(); + self.tasks + .extend(tasks.task_names_in_displayed_order().map(Rc::from)); + self.update_matches(); + } + + /// Updates the query and the matches + pub fn modify_query(&mut self, modification: impl FnOnce(&mut String)) { + modification(&mut self.query); + self.update_matches(); + } + + fn update_matches(&mut self) { + self.matches.clear(); + if self.query.is_empty() { + return; + } + for task in self.tasks.iter().filter(|task| task.contains(&self.query)) { + self.matches.insert(task.clone()); + } + } + + /// Given an iterator it returns the first task that is in the search + /// results + pub fn first_match<'a>(&self, mut tasks: impl Iterator) -> Option<&'a str> { + tasks.find(|task| self.matches.contains(*task)) + } + + /// Returns if there are any matches for the query + pub fn has_matches(&self) -> bool { + !self.matches.is_empty() + } + + /// Returns query + pub fn query(&self) -> &str { + &self.query + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::tui::task::Task; + + fn basic_task_list() -> TasksByStatus { + TasksByStatus { + planned: vec![ + Task::new("app-a".into()), + Task::new("app-b".into()), + Task::new("pkg-a".into()), + ], + ..Default::default() + } + } + + #[test] + fn test_no_query_no_matches() { + let task_list = basic_task_list(); + let results = SearchResults::new(&task_list); + assert!(!results.has_matches()); + } + + #[test] + fn test_matches_first_result() { + let task_list = basic_task_list(); + let mut results = SearchResults::new(&task_list); + results.modify_query(|s| s.push_str("app")); + let result = results.first_match(task_list.task_names_in_displayed_order()); + assert_eq!(result, Some("app-a")); + let result = results.first_match(task_list.task_names_in_displayed_order().skip(1)); + assert_eq!(result, Some("app-b")); + let result = results.first_match(task_list.task_names_in_displayed_order().skip(2)); + assert_eq!(result, None); + } + + #[test] + fn test_update_task_rebuilds_matches() { + let mut task_list = basic_task_list(); + let mut results = SearchResults::new(&task_list); + results.modify_query(|s| s.push_str("app")); + assert!(results.has_matches()); + task_list.planned.remove(0); + task_list.planned.push(Task::new("app-c".into())); + results.update_tasks(&task_list); + assert!(results.has_matches()); + let result = results.first_match(task_list.task_names_in_displayed_order()); + assert_eq!(result, Some("app-b")); + let result = results.first_match(task_list.task_names_in_displayed_order().skip(1)); + assert_eq!(result, Some("app-c")); + } + + #[test] + fn test_no_match_on_empty_list() { + let task_list = basic_task_list(); + let mut results = SearchResults::new(&task_list); + results.modify_query(|s| s.push_str("app")); + assert!(results.has_matches()); + let result = results.first_match(std::iter::empty()); + assert_eq!(result, None); + } +} diff --git a/crates/turborepo-ui/src/tui/task.rs b/crates/turborepo-ui/src/tui/task.rs index 50076b3ac3891..996077784bc14 100644 --- a/crates/turborepo-ui/src/tui/task.rs +++ b/crates/turborepo-ui/src/tui/task.rs @@ -109,7 +109,7 @@ pub struct TaskNamesByStatus { pub finished: Vec, } -#[derive(Clone)] +#[derive(Clone, Debug, Default)] pub struct TasksByStatus { pub running: Vec>, pub planned: Vec>, @@ -125,7 +125,7 @@ impl TasksByStatus { self.task_names_in_displayed_order().count() } - pub fn task_names_in_displayed_order(&self) -> impl Iterator + '_ { + pub fn task_names_in_displayed_order(&self) -> impl DoubleEndedIterator + '_ { 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()); diff --git a/crates/turborepo-ui/src/tui/term_output.rs b/crates/turborepo-ui/src/tui/term_output.rs index 2eb2e52cf7da3..42966f1456a3b 100644 --- a/crates/turborepo-ui/src/tui/term_output.rs +++ b/crates/turborepo-ui/src/tui/term_output.rs @@ -3,8 +3,7 @@ use std::{io::Write, mem}; use turborepo_vt100 as vt100; use super::{ - app::Direction, - event::{CacheResult, OutputLogs, TaskResult}, + event::{CacheResult, Direction, OutputLogs, TaskResult}, Error, };