diff --git a/crates/vite_select/src/interactive.rs b/crates/vite_select/src/interactive.rs index c5022d30..b56c6350 100644 --- a/crates/vite_select/src/interactive.rs +++ b/crates/vite_select/src/interactive.rs @@ -3,12 +3,18 @@ use std::io::{Write, stdout}; use crossterm::{ cursor::{self, MoveToColumn}, event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, - style::{Attribute, Color, ResetColor, SetAttribute, SetForegroundColor}, + style::{Attribute, Color, SetAttribute, SetForegroundColor}, terminal::{self, Clear, ClearType}, }; +use vite_str::Str; use crate::{RenderState, SelectItem, fuzzy::fuzzy_match}; +/// Prefix width for root-level items (`" › "` or `" "`). +const ROOT_PREFIX_WIDTH: usize = 4; +/// Prefix width for grouped items (`" › "` or `" "`). +const GROUP_PREFIX_WIDTH: usize = 6; + struct RawModeGuard; impl RawModeGuard { @@ -24,19 +30,93 @@ impl Drop for RawModeGuard { } } +/// A row in the flattened display list. +pub enum DisplayRow { + /// Non-selectable group header line. + Header(Str), + /// Selectable item. `item_index` is the index into the original `items` slice. + Item { item_index: usize }, +} + +impl DisplayRow { + pub const fn is_item(&self) -> bool { + matches!(self, Self::Item { .. }) + } +} + +/// Filter, group, and flatten items into display rows. +/// +/// Pipeline: fuzzy match → group by `SelectItem::group` (current-package first) +/// → insert header rows at group boundaries. +pub fn build_display_rows(items: &[SelectItem], query: &str) -> Vec { + let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect(); + let mut filtered = fuzzy_match(query, &labels); + group_filtered(items, &mut filtered); + + let mut rows = Vec::new(); + let mut current_group: Option> = None; + for &item_idx in &filtered { + let group = items[item_idx].group.as_deref(); + if current_group != Some(group) { + current_group = Some(group); + if let Some(g) = group { + rows.push(DisplayRow::Header(Str::from(g))); + } + } + rows.push(DisplayRow::Item { item_index: item_idx }); + } + rows +} + +/// Reorder `filtered` so items are grouped by `SelectItem::group`. +/// +/// Groups are ordered by the position of their best-matching item in the +/// original fuzzy-scored `filtered` list. Items with `group: None` +/// (current-package tasks) are always placed first. +fn group_filtered(items: &[SelectItem], filtered: &mut Vec) { + // Collect group ordering: first-seen order preserves fuzzy rank. + let mut group_order: Vec> = Vec::new(); + for &idx in filtered.iter() { + let group = items[idx].group.as_deref(); + if !group_order.contains(&group) { + group_order.push(group); + } + } + // Always put current-package group (None) first. + if let Some(pos) = group_order.iter().position(Option::is_none) + && pos != 0 + { + let g = group_order.remove(pos); + group_order.insert(0, g); + } + let mut new_filtered = Vec::with_capacity(filtered.len()); + for &group in &group_order { + for &idx in filtered.iter() { + if items[idx].group.as_deref() == group { + new_filtered.push(idx); + } + } + } + *filtered = new_filtered; +} + struct State<'a> { items: &'a [SelectItem], - /// Indices into `items` that match the current query, in score order. - filtered: Vec, + /// Flattened display rows (headers + items): the single source of truth + /// after filtering + grouping. + display_rows: Vec, + /// Cached count of selectable items in `display_rows`. + item_count: usize, #[expect( clippy::disallowed_types, reason = "crossterm key events push chars one at a time; String is natural here" )] query: String, - /// Index into `filtered`. + /// Index among selectable items (0 = first Item row, 1 = second, etc.). selected: usize, - /// First visible row in the filtered list (scroll offset). + /// Index into `display_rows` — first visible row. scroll_offset: usize, + /// Max visible lines (display rows) in the viewport. page_size: usize, /// Number of lines rendered in the last frame (for clearing). rendered_lines: usize, @@ -45,71 +125,112 @@ struct State<'a> { impl<'a> State<'a> { fn new(items: &'a [SelectItem], initial_query: Option<&str>, page_size: usize) -> Self { let query = initial_query.unwrap_or_default().to_owned(); - let mut state = Self { + let display_rows = build_display_rows(items, &query); + let item_count = display_rows.iter().filter(|r| r.is_item()).count(); + Self { items, - filtered: Vec::new(), + display_rows, + item_count, query, selected: 0, scroll_offset: 0, page_size, rendered_lines: 0, - }; - state.refilter(); - state + } } fn refilter(&mut self) { - let labels: Vec<&str> = self.items.iter().map(|i| i.label.as_str()).collect(); - self.filtered = fuzzy_match(&self.query, &labels); + self.display_rows = build_display_rows(self.items, &self.query); + self.item_count = self.display_rows.iter().filter(|r| r.is_item()).count(); self.selected = 0; self.scroll_offset = 0; } - const fn move_up(&mut self) { - if self.selected > 0 { - self.selected -= 1; - if self.selected < self.scroll_offset { - self.scroll_offset = self.selected; + /// Find the display row index for the Nth selectable item. + fn display_row_of_selected(&self) -> Option { + let mut count = 0; + for (i, row) in self.display_rows.iter().enumerate() { + if row.is_item() { + if count == self.selected { + return Some(i); + } + count += 1; } } + None } - const fn move_down(&mut self) { - if !self.filtered.is_empty() && self.selected < self.filtered.len() - 1 { - self.selected += 1; - if self.selected >= self.scroll_offset + self.page_size { - self.scroll_offset = self.selected + 1 - self.page_size; - } + /// Get the original item index for the currently selected item. + fn selected_item_index(&self) -> Option { + let row_idx = self.display_row_of_selected()?; + match &self.display_rows[row_idx] { + DisplayRow::Item { item_index } => Some(*item_index), + DisplayRow::Header(_) => None, + } + } + + /// Ensure the selected item is within the visible viewport. + fn ensure_selected_visible(&mut self) { + let Some(row_idx) = self.display_row_of_selected() else { + self.scroll_offset = 0; + return; + }; + if row_idx < self.scroll_offset { + // If the selected item is a first item in a group, also show the header above it + self.scroll_offset = + if row_idx > 0 && matches!(self.display_rows[row_idx - 1], DisplayRow::Header(_)) { + row_idx - 1 + } else { + row_idx + }; + } else if row_idx >= self.scroll_offset + self.page_size { + self.scroll_offset = row_idx + 1 - self.page_size; } } - fn selected_original_index(&self) -> Option { - self.filtered.get(self.selected).copied() + fn move_up(&mut self) { + if self.selected > 0 { + self.selected -= 1; + self.ensure_selected_visible(); + } } - fn visible_range(&self) -> std::ops::Range { - let end = (self.scroll_offset + self.page_size).min(self.filtered.len()); + fn move_down(&mut self) { + if self.item_count > 0 && self.selected < self.item_count - 1 { + self.selected += 1; + self.ensure_selected_visible(); + } + } + + fn visible_display_rows(&self) -> std::ops::Range { + let end = (self.scroll_offset + self.page_size).min(self.display_rows.len()); self.scroll_offset..end } - const fn hidden_count(&self) -> usize { - self.filtered.len().saturating_sub(self.scroll_offset + self.page_size) + /// Count selectable items (not headers) beyond the visible window. + fn hidden_item_count(&self) -> usize { + let visible_end = (self.scroll_offset + self.page_size).min(self.display_rows.len()); + self.display_rows[visible_end..].iter().filter(|r| r.is_item()).count() } } /// Parameters for rendering a task list. pub struct RenderParams<'a> { pub items: &'a [SelectItem], - pub filtered: &'a [usize], - /// Index into `filtered` of the highlighted item, or `None` for non-interactive. - pub selected_in_filtered: Option, - /// Which slice of `filtered` to display. - pub visible_range: std::ops::Range, - /// Number of items beyond the visible range. + pub display_rows: &'a [DisplayRow], + /// Which selectable item is highlighted (0-based among Item rows), + /// or `None` for non-interactive. + pub selected: Option, + /// Which slice of `display_rows` to show. + pub visible_row_range: std::ops::Range, + /// Number of selectable items beyond the visible range. pub hidden_count: usize, pub header: Option<&'a str>, /// Current search text. `Some` enables the prompt line (interactive only). pub query: Option<&'a str>, + /// Whether to render group header lines. When `false`, items are still + /// grouped/sorted by `SelectItem::group` but headers are hidden. + pub show_group_headers: bool, /// `"\r\n"` for raw mode, `"\n"` for normal. pub line_ending: &'a str, /// Maximum visible width per line. Descriptions are truncated to prevent @@ -118,19 +239,34 @@ pub struct RenderParams<'a> { pub max_line_width: usize, } +/// Truncate a description string to fit within `max_chars`, appending ellipsis if needed. +fn truncate_desc<'a>(desc: &'a str, max_chars: usize, buf: &'a mut Str) -> &'a str { + let char_count = desc.chars().count(); + if char_count <= max_chars { + return desc; + } + let take = max_chars.saturating_sub(1); // room for "…" + #[expect(clippy::disallowed_types, reason = "intermediate collect for char truncation")] + let prefix: std::string::String = desc.chars().take(take).collect(); + *buf = vite_str::format!("{prefix}\u{2026}"); + buf.as_str() +} + /// Render the item list. Shared rendering logic used by both interactive /// and non-interactive modes (via [`crate::non_interactive`]). /// /// Returns the number of lines written. +#[expect(clippy::too_many_lines, reason = "single rendering function with sequential layout logic")] pub fn render_items(writer: &mut impl Write, params: &RenderParams<'_>) -> anyhow::Result { let RenderParams { items, - filtered, - selected_in_filtered, - visible_range, + display_rows, + selected, + visible_row_range, hidden_count, header, query, + show_group_headers, line_ending, max_line_width: _, } = params; @@ -164,65 +300,130 @@ pub fn render_items(writer: &mut impl Write, params: &RenderParams<'_>) -> anyho lines += 2; } - // Items - for vi in visible_range.clone() { - let item_idx = filtered[vi]; - let item = &items[item_idx]; - let is_selected = *selected_in_filtered == Some(vi); - - // Truncate description to prevent line wrapping. - // Line layout: - // - interactive prefix is " › " or " " (4 chars) - // - non-interactive prefix is " " (2 chars) - // then label + ": " + description - let prefix_width = if is_interactive { 4 } else { 2 }; - let prefix_and_label_width = prefix_width + item.label.chars().count() + 2; - let max_desc_chars = params.max_line_width.saturating_sub(prefix_and_label_width); - let desc_str = item.description.as_str(); - let desc_char_count = desc_str.chars().count(); - let truncated; - let display_desc = if desc_char_count > max_desc_chars { - let take = max_desc_chars.saturating_sub(1); // room for "…" - #[expect(clippy::disallowed_types, reason = "intermediate collect for char truncation")] - let prefix: std::string::String = desc_str.chars().take(take).collect(); - truncated = vite_str::format!("{prefix}\u{2026}"); - truncated.as_str() - } else { - desc_str - }; + // Single pre-pass: compute has_groups, max_name_width, has_items, and + // item_ordinal (count of Item rows before the visible range) together. + let mut has_groups = false; + let mut max_name_width = 0usize; + let mut has_items = false; + let mut item_ordinal = 0usize; + if is_interactive { + for (i, row) in display_rows.iter().enumerate() { + match row { + DisplayRow::Header(_) => has_groups = *show_group_headers, + DisplayRow::Item { item_index } => { + has_items = true; + let w = items[*item_index].display_name.chars().count(); + if w > max_name_width { + max_name_width = w; + } + if i < visible_row_range.start { + item_ordinal += 1; + } + } + } + } + } else { + has_items = display_rows.iter().any(DisplayRow::is_item); + } - if is_selected && is_interactive { - write!( - writer, - "{blue}{bold} \u{203a} {label}: {desc}{reset}{line_ending}", - blue = SetForegroundColor(Color::Blue), - bold = SetAttribute(Attribute::Bold), - label = item.label, - desc = display_desc, - reset = SetAttribute(Attribute::Reset), - )?; - } else if is_interactive { - write!( - writer, - "{marker_color} {reset_color}{}:{command_color} {display_desc}{reset_color}{line_ending}", - item.label, - marker_color = SetForegroundColor(Color::DarkGrey), - command_color = SetForegroundColor(Color::Grey), - reset_color = ResetColor, - )?; - } else if is_selected { - write!( - writer, - "{bold}> {label}: {desc}{reset}{line_ending}", - bold = SetAttribute(Attribute::Bold), - label = item.label, - desc = display_desc, - reset = SetAttribute(Attribute::Reset), - )?; - } else { - write!(writer, " {}: {display_desc}{line_ending}", item.label)?; + // Compute the absolute column where commands start (interactive only). + // All items — root and grouped — align their descriptions to the same column. + let max_prefix = if has_groups { GROUP_PREFIX_WIDTH } else { ROOT_PREFIX_WIDTH }; + // command_col = max_prefix + max_name_width + ": " + let command_col = if is_interactive { max_prefix + max_name_width + 2 } else { 0 }; + + // Render visible display rows + for ri in visible_row_range.clone() { + let row = &display_rows[ri]; + match row { + DisplayRow::Header(group_name) => { + if !show_group_headers { + continue; + } + if is_interactive { + write!( + writer, + " {dim}{name}{reset}{line_ending}", + dim = SetAttribute(Attribute::Dim), + name = group_name, + reset = SetAttribute(Attribute::Reset), + )?; + } else { + write!(writer, " {group_name}{line_ending}")?; + } + lines += 1; + } + DisplayRow::Item { item_index } => { + let item = &items[*item_index]; + let is_selected = *selected == Some(item_ordinal); + item_ordinal += 1; + let is_in_group = item.group.is_some(); + + let name = item.display_name.as_str(); + let name_width = name.chars().count(); + + let prefix_width = if is_interactive { + if is_in_group { GROUP_PREFIX_WIDTH } else { ROOT_PREFIX_WIDTH } + } else { + 2 + }; + + // Padding after colon to align all commands at `command_col`. + let name_padding = + if is_interactive { command_col - prefix_width - name_width - 2 } else { 0 }; + let max_desc_chars = params.max_line_width.saturating_sub(if is_interactive { + command_col + } else { + prefix_width + name_width + 2 + }); + + let mut truncated = Str::default(); + let display_desc = + truncate_desc(item.description.as_str(), max_desc_chars, &mut truncated); + + if is_interactive { + let prefix = match (is_selected, is_in_group) { + (true, true) => " \u{203a} ", + (true, false) => " \u{203a} ", + (false, true) => " ", + (false, false) => " ", + }; + let reset = SetAttribute(Attribute::Reset); + let dark_grey = SetForegroundColor(Color::DarkGrey); + if is_selected { + let blue = SetForegroundColor(Color::Blue); + let bold = SetAttribute(Attribute::Bold); + write!( + writer, + "{dark_grey}{prefix}{reset}{blue}{bold}{name}:{reset}{dark_grey}{:>pad$} {desc}{reset}{line_ending}", + "", + pad = name_padding, + desc = display_desc, + )?; + } else { + write!( + writer, + "{prefix}{name}:{dark_grey}{:>pad$} {desc}{reset}{line_ending}", + "", + pad = name_padding, + desc = display_desc, + )?; + } + } else if is_selected { + let bold = SetAttribute(Attribute::Bold); + let reset = SetAttribute(Attribute::Reset); + write!( + writer, + "{bold}> {name}: {desc}{reset}{line_ending}", + name = item.display_name, + desc = display_desc, + )?; + } else { + write!(writer, " {}: {display_desc}{line_ending}", item.display_name)?; + } + lines += 1; + } } - lines += 1; } // Footer: hidden items count @@ -232,7 +433,7 @@ pub fn render_items(writer: &mut impl Write, params: &RenderParams<'_>) -> anyho } // Empty state - if filtered.is_empty() { + if !has_items { write!(writer, " No matching tasks.{line_ending}")?; lines += 1; } @@ -265,12 +466,13 @@ fn render( stdout, &RenderParams { items: state.items, - filtered: &state.filtered, - selected_in_filtered: Some(state.selected), - visible_range: state.visible_range(), - hidden_count: state.hidden_count(), + display_rows: &state.display_rows, + selected: Some(state.selected), + visible_row_range: state.visible_display_rows(), + hidden_count: state.hidden_item_count(), header, query: Some(&state.query), + show_group_headers: true, line_ending: "\r\n", max_line_width, }, @@ -286,7 +488,6 @@ pub fn run( selected_index: &mut usize, header: Option<&str>, page_size: usize, - mut before_render: impl FnMut(&mut Vec, &str), mut after_render: impl FnMut(&RenderState<'_>), ) -> anyhow::Result<()> { if items.is_empty() { @@ -299,7 +500,6 @@ pub fn run( crossterm::execute!(out, cursor::Hide)?; let mut state = State::new(items, initial_query, page_size); - before_render(&mut state.filtered, &state.query); // Initial render render(&mut out, &mut state, header)?; @@ -313,14 +513,13 @@ pub fn run( // Clear the search query and reset the filter state.query.clear(); state.refilter(); - before_render(&mut state.filtered, &state.query); } KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { cleanup(&mut out, &state)?; std::process::exit(130); } KeyCode::Enter => { - let Some(idx) = state.selected_original_index() else { + let Some(idx) = state.selected_item_index() else { continue; }; *selected_index = idx; @@ -336,12 +535,10 @@ pub fn run( KeyCode::Char(c) => { state.query.push(c); state.refilter(); - before_render(&mut state.filtered, &state.query); } KeyCode::Backspace => { state.query.pop(); state.refilter(); - before_render(&mut state.filtered, &state.query); } _ => continue, }, @@ -377,7 +574,25 @@ mod tests { fn make_items(items: &[(&str, &str)]) -> Vec { items .iter() - .map(|(label, desc)| SelectItem { label: (*label).into(), description: (*desc).into() }) + .map(|(label, desc)| SelectItem { + label: (*label).into(), + display_name: (*label).into(), + description: (*desc).into(), + group: None, + }) + .collect() + } + + /// Create items with explicit groups: (label, `display_name`, description, group) + fn make_grouped_items(items: &[(&str, &str, &str, Option<&str>)]) -> Vec { + items + .iter() + .map(|(label, display_name, desc, group)| SelectItem { + label: (*label).into(), + display_name: (*display_name).into(), + description: (*desc).into(), + group: group.map(Str::from), + }) .collect() } @@ -403,19 +618,20 @@ mod tests { #[expect(clippy::disallowed_types, reason = "test helper building arbitrary output string")] fn render_to_string(items: &[SelectItem], max_line_width: usize) -> String { - let filtered: Vec = (0..items.len()).collect(); - let len = filtered.len(); + let display_rows = build_display_rows(items, ""); + let len = display_rows.len(); let mut buf = Vec::new(); render_items( &mut buf, &RenderParams { items, - filtered: &filtered, - selected_in_filtered: Some(0), - visible_range: 0..len, + display_rows: &display_rows, + selected: Some(0), + visible_row_range: 0..len, hidden_count: 0, header: None, query: None, + show_group_headers: false, line_ending: "\n", max_line_width, }, @@ -430,19 +646,20 @@ mod tests { query: &str, max_line_width: usize, ) -> String { - let filtered: Vec = (0..items.len()).collect(); - let len = filtered.len(); + let display_rows = build_display_rows(items, query); + let len = display_rows.len(); let mut buf = Vec::new(); render_items( &mut buf, &RenderParams { items, - filtered: &filtered, - selected_in_filtered: Some(0), - visible_range: 0..len, + display_rows: &display_rows, + selected: Some(0), + visible_row_range: 0..len, hidden_count: 0, header: None, query: Some(query), + show_group_headers: true, line_ending: "\n", max_line_width, }, @@ -453,7 +670,7 @@ mod tests { #[test] fn truncates_long_description() { - let items = make_items(&[("build", "a]really long command that exceeds the width limit")]); + let items = make_items(&[("build", "a really long command that exceeds the width limit")]); // " build: a really long..." = 2 + 5 + 2 + desc // max_line_width = 30 => max_desc = 30 - 9 = 21 chars let output = render_to_string(&items, 30); @@ -523,9 +740,156 @@ mod tests { let spacer = lines.next().unwrap(); let selected = lines.next().unwrap(); let unselected = lines.next().unwrap(); - assert_eq!(prompt, "Select a task (↑/↓, Enter to run, Esc to clear):"); + assert_eq!(prompt, "Select a task (\u{2191}/\u{2193}, Enter to run, Esc to clear):"); assert!(spacer.is_empty()); - assert_eq!(selected, " › build: echo build"); - assert_eq!(unselected, " lint: echo lint"); + assert_eq!(selected, " \u{203a} build: echo build"); + assert_eq!(unselected, " lint: echo lint"); + } + + #[test] + fn interactive_commands_are_aligned() { + let items = + make_items(&[("build", "echo build"), ("lint", "echo lint"), ("test", "vitest run")]); + let output = render_interactive_to_string(&items, "", 80); + let item_lines: Vec<&str> = output.lines().skip(2).collect(); + // max_name_width = 5 ("build") + // prefix(4) + max_name(5) + ":" + padding + " " + desc + assert_eq!(item_lines[0], " \u{203a} build: echo build"); + assert_eq!(item_lines[1], " lint: echo lint"); + assert_eq!(item_lines[2], " test: vitest run"); + } + + #[test] + fn interactive_truncation_accounts_for_padding() { + let items = make_items(&[ + ("build", "a really long command that exceeds the width limit"), + ("lint", "short"), + ]); + // max_name_width = 5, prefix(4) + max_name(5) + sep(2) = 11 + // max_line_width = 30 => max_desc = 30 - 11 = 19 chars + let output = render_interactive_to_string(&items, "", 30); + for line in output.lines().skip(2) { + assert!( + line.chars().count() <= 30, + "line exceeds 30 chars: ({}) {line:?}", + line.chars().count() + ); + } + let build_line = output.lines().nth(2).unwrap(); + assert!( + build_line.contains('\u{2026}'), + "long description should be truncated: {build_line:?}" + ); + } + + #[test] + fn interactive_tree_view_with_groups() { + let items = make_grouped_items(&[ + ("build", "build", "echo build app", None), + ("lint", "lint", "echo lint app", None), + ("lib#build", "build", "echo build lib", Some("lib (packages/lib)")), + ("lib#lint", "lint", "echo lint lib", Some("lib (packages/lib)")), + ]); + let output = render_interactive_to_string(&items, "", 80); + let item_lines: Vec<&str> = output.lines().skip(2).collect(); + // max_name=5, has_groups → max_prefix=6, command_col=13 + // Root items get extra padding to align with grouped items + assert_eq!(item_lines[0], " \u{203a} build: echo build app"); + assert_eq!(item_lines[1], " lint: echo lint app"); + // Group header + assert_eq!(item_lines[2], " lib (packages/lib)"); + // Grouped items (indented by 2 more, less padding) + assert_eq!(item_lines[3], " build: echo build lib"); + assert_eq!(item_lines[4], " lint: echo lint lib"); + } + + #[test] + fn interactive_tree_view_alignment_across_groups() { + let items = make_grouped_items(&[ + ("build", "build", "echo build", None), + ("typecheck", "typecheck", "echo tc", None), + ("lib#build", "build", "echo build lib", Some("lib")), + ]); + let output = render_interactive_to_string(&items, "", 80); + let item_lines: Vec<&str> = output.lines().skip(2).collect(); + // max_name=9, has_groups → max_prefix=6, command_col=17 + // All commands start at column 17 regardless of indent level + assert_eq!(item_lines[0], " \u{203a} build: echo build"); + assert_eq!(item_lines[1], " typecheck: echo tc"); + assert_eq!(item_lines[2], " lib"); + assert_eq!(item_lines[3], " build: echo build lib"); + } + + #[test] + fn interactive_tree_view_truncation_with_groups() { + let items = make_grouped_items(&[ + ("build", "build", "a really long command that exceeds the limit", None), + ( + "lib#build", + "build", + "another really long command that exceeds the limit", + Some("lib"), + ), + ]); + let output = render_interactive_to_string(&items, "", 30); + for line in output.lines().skip(2) { + if !line.is_empty() { + assert!( + line.chars().count() <= 30, + "line exceeds 30 chars: ({}) {line:?}", + line.chars().count() + ); + } + } + } + + #[test] + fn display_rows_built_correctly() { + let items = make_grouped_items(&[ + ("build", "build", "echo build", None), + ("lib#build", "build", "echo build lib", Some("lib")), + ("lib#lint", "lint", "echo lint lib", Some("lib")), + ("app#build", "build", "echo build app", Some("app")), + ]); + let rows = build_display_rows(&items, ""); + assert_eq!(rows.len(), 6); // 4 items + 2 headers ("lib", "app") + assert!(matches!(&rows[0], DisplayRow::Item { item_index: 0 })); + assert!(matches!(&rows[1], DisplayRow::Header(h) if h.as_str() == "lib")); + assert!(matches!(&rows[2], DisplayRow::Item { item_index: 1 })); + assert!(matches!(&rows[3], DisplayRow::Item { item_index: 2 })); + assert!(matches!(&rows[4], DisplayRow::Header(h) if h.as_str() == "app")); + assert!(matches!(&rows[5], DisplayRow::Item { item_index: 3 })); + } + + /// Mirrors the E2E scenario: items sorted alphabetically by package name + /// (app before lib), with lib being the current package (group: None). + /// Verifies that None-group items come first despite appearing later in input. + #[test] + fn display_rows_none_group_first_when_not_first_in_input() { + let items = make_grouped_items(&[ + // app items first (alphabetically before lib) + ("app#build", "app#build", "echo build app", Some("app (packages/app)")), + ("app#lint", "app#lint", "echo lint app", Some("app (packages/app)")), + // lib items (current package, group: None) + ("build", "build", "echo build lib", None), + ("lint", "lint", "echo lint lib", None), + // root items + ("root#check", "root#check", "echo check", Some("root (workspace root)")), + ]); + let rows = build_display_rows(&items, ""); + // None-group (lib) should come first, then app, then root + assert!( + matches!(&rows[0], DisplayRow::Item { item_index: 2 }), + "first item should be lib build (idx 2)" + ); + assert!( + matches!(&rows[1], DisplayRow::Item { item_index: 3 }), + "second item should be lib lint (idx 3)" + ); + assert!(matches!(&rows[2], DisplayRow::Header(h) if h.as_str() == "app (packages/app)")); + assert!(matches!(&rows[3], DisplayRow::Item { item_index: 0 })); + assert!(matches!(&rows[4], DisplayRow::Item { item_index: 1 })); + assert!(matches!(&rows[5], DisplayRow::Header(h) if h.as_str() == "root (workspace root)")); + assert!(matches!(&rows[6], DisplayRow::Item { item_index: 4 })); } } diff --git a/crates/vite_select/src/lib.rs b/crates/vite_select/src/lib.rs index 2082ce8c..228d737b 100644 --- a/crates/vite_select/src/lib.rs +++ b/crates/vite_select/src/lib.rs @@ -4,15 +4,20 @@ mod interactive; use std::io::Write; pub use fuzzy::fuzzy_match; -use interactive::{RenderParams, render_items}; +use interactive::{RenderParams, build_display_rows, render_items}; use vite_str::Str; /// An item in the selection list. pub struct SelectItem { - /// Display label, e.g. `"build"` or `"app#build"`. + /// Searchable label, e.g. `"build"` or `"app#build"`. Used for fuzzy matching. pub label: Str, - /// Description shown next to the label, e.g. `"echo build app"`. + /// Display name shown in the list, e.g. `"build"` (tree view) or `"app#build"` (flat). + pub display_name: Str, + /// Description shown next to the display name, e.g. `"echo build app"`. pub description: Str, + /// Group header text. Items sharing the same group render together under a + /// header line. `None` = top-level (no header). + pub group: Option, } /// Selection mode. @@ -63,7 +68,6 @@ pub fn select_list( writer: &mut impl Write, params: &SelectParams<'_>, mode: Mode<'_>, - before_render: impl FnMut(&mut Vec, &str), after_render: impl FnMut(&RenderState<'_>), ) -> anyhow::Result<()> { match mode { @@ -73,12 +77,9 @@ pub fn select_list( selected_index, params.header, params.page_size, - before_render, after_render, ), - Mode::NonInteractive => { - non_interactive(writer, params.items, params.query, params.header, before_render) - } + Mode::NonInteractive => non_interactive(writer, params.items, params.query, params.header), } } @@ -87,34 +88,33 @@ fn non_interactive( items: &[SelectItem], query: Option<&str>, header: Option<&str>, - mut before_render: impl FnMut(&mut Vec, &str), ) -> anyhow::Result<()> { - let labels: Vec<&str> = items.iter().map(|item| item.label.as_str()).collect(); - let mut filtered: Vec = - query.map_or_else(|| (0..items.len()).collect(), |q| fuzzy_match(q, &labels)); - before_render(&mut filtered, query.unwrap_or_default()); - let len = filtered.len(); + let display_rows = build_display_rows(items, query.unwrap_or_default()); // When there are no matching items, just print the header (if any) and // return early — avoids showing a redundant "No matching tasks." line // after a "not found" header. - if filtered.is_empty() { + let has_items = display_rows.iter().any(interactive::DisplayRow::is_item); + if !has_items { if let Some(h) = header { writeln!(writer, "{h}")?; } return Ok(()); } + let row_count = display_rows.len(); + render_items( writer, &RenderParams { items, - filtered: &filtered, - selected_in_filtered: None, - visible_range: 0..len, + display_rows: &display_rows, + selected: None, + visible_row_range: 0..row_count, hidden_count: 0, header, query: None, + show_group_headers: false, line_ending: "\n", max_line_width: usize::MAX, }, diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index dfbd2c83..9b3f3a05 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -21,14 +21,16 @@ use vite_select::SelectItem; use vite_str::Str; use vite_task_graph::{ IndexedTaskGraph, TaskGraph, TaskGraphLoadError, config::user::UserCacheConfig, - loader::UserConfigLoader, + loader::UserConfigLoader, query::TaskQuery, }; use vite_task_plan::{ ExecutionGraph, TaskGraphLoader, - plan_request::{PlanRequest, ScriptCommand, SyntheticPlanRequest}, + plan_request::{ + PlanOptions, PlanRequest, QueryPlanRequest, ScriptCommand, SyntheticPlanRequest, + }, prepend_path_env, }; -use vite_workspace::{WorkspaceRoot, find_workspace_root}; +use vite_workspace::{WorkspaceRoot, find_workspace_root, package_graph::PackageQuery}; use crate::cli::{CacheSubcommand, Command, ResolvedCommand, ResolvedRunCommand, RunCommand}; @@ -272,7 +274,7 @@ impl<'a> Session<'a> { match command.into_resolved() { ResolvedCommand::Cache { ref subcmd } => self.handle_cache_command(subcmd), ResolvedCommand::RunLastDetails => self.show_last_run_details(), - ResolvedCommand::Run(mut run_command) => { + ResolvedCommand::Run(run_command) => { let is_interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); @@ -286,9 +288,8 @@ impl<'a> Session<'a> { // No tasks matched. With is_cwd_only (no scope flags) the // task name is a typo — show the selector. Otherwise error. if is_cwd_only { - self.handle_no_task(is_interactive, &mut run_command).await?; - let cwd = Arc::clone(&self.cwd); - self.plan_from_cli_run_resolved(cwd, run_command.clone()).await?.0 + let qpr = self.handle_no_task(is_interactive, &run_command).await?; + self.plan_from_query(qpr).await? } else { return Err(vite_task_plan::Error::NoTasksMatched( task_specifier.clone(), @@ -307,9 +308,8 @@ impl<'a> Session<'a> { if run_command != bare { return Err(vite_task_plan::Error::MissingTaskSpecifier.into()); } - self.handle_no_task(is_interactive, &mut run_command).await?; - let cwd = Arc::clone(&self.cwd); - self.plan_from_cli_run_resolved(cwd, run_command.clone()).await?.0 + let qpr = self.handle_no_task(is_interactive, &run_command).await?; + self.plan_from_query(qpr).await? }; let builder = LabeledReporterBuilder::new( @@ -334,11 +334,11 @@ impl<'a> Session<'a> { Ok(()) } - /// Show the task selector or list, and update the run command with the selected task. + /// Show the task selector or list, and return a plan request for the selected task. /// /// In interactive mode, shows a fuzzy-searchable selection list. On selection, - /// updates `run_command.task_specifier` and returns `Ok(())` so the caller - /// can plan and execute the selected task. + /// returns `Ok(QueryPlanRequest)` using the selected entry's filesystem path + /// (not its display name) for package matching. /// /// In non-interactive mode, prints the task list (or "did you mean" suggestions) /// and returns `Err(SessionError::EarlyExit(_))` — no further execution needed. @@ -346,11 +346,15 @@ impl<'a> Session<'a> { clippy::future_not_send, reason = "session is single-threaded, futures do not need to be Send" )] + #[expect( + clippy::too_many_lines, + reason = "builds interactive/non-interactive select items and handles selection" + )] async fn handle_no_task( &mut self, is_interactive: bool, - run_command: &mut ResolvedRunCommand, - ) -> Result<(), SessionError> { + run_command: &ResolvedRunCommand, + ) -> Result { let not_found_name = run_command.task_specifier.as_deref(); let cwd = Arc::clone(&self.cwd); let task_graph = self.ensure_task_graph_loaded().await?; @@ -363,18 +367,48 @@ impl<'a> Session<'a> { .then_with(|| a.task_display.task_name.cmp(&b.task_display.task_name)) }); + let workspace_path = self.workspace_path(); + // Build items: current package tasks use unqualified names (no '#'), // other packages use qualified "package#task" names. + // Interactive mode uses tree view (grouped by package); non-interactive is flat. let select_items: Vec = entries .iter() .map(|entry| { - let label = - if current_package_path.as_ref() == Some(&entry.task_display.package_path) { - entry.task_display.task_name.clone() + let is_current = + current_package_path.as_ref() == Some(&entry.task_display.package_path); + let label = if is_current { + entry.task_display.task_name.clone() + } else { + vite_str::format!("{}", entry.task_display) + }; + + let group = if is_current { + None + } else { + let rel_path = entry + .task_display + .package_path + .strip_prefix(&*workspace_path) + .ok() + .flatten() + .map(|p| Str::from(p.as_str())) + .unwrap_or_default(); + let pkg_name = &entry.task_display.package_name; + let display_path = + if rel_path.is_empty() { Str::from("workspace root") } else { rel_path }; + Some(if pkg_name.is_empty() { + display_path } else { - vite_str::format!("{}", entry.task_display) - }; - SelectItem { label, description: entry.command.clone() } + vite_str::format!("{pkg_name} ({display_path})") + }) + }; + let display_name = if is_interactive { + entry.task_display.task_name.clone() + } else { + label.clone() + }; + SelectItem { label, display_name, description: entry.command.clone(), group } }) .collect(); @@ -410,28 +444,15 @@ impl<'a> Session<'a> { page_size: 12, }; - vite_select::select_list( - &mut stdout, - ¶ms, - mode, - |filtered, query| { - // When the query doesn't contain '#', move current-package tasks (those - // without '#' in their label) to the top. `sort_by_key` is a stable sort, - // so the fuzzy rating order is preserved within each group. - if !query.contains('#') { - filtered.sort_by_key(|&idx| select_items[idx].label.contains('#')); - } - }, - |state| { - use std::io::Write; - let milestone_name = - vite_str::format!("task-select:{}:{}", state.query, state.selected_index); - let milestone_bytes = pty_terminal_test_client::encoded_milestone(&milestone_name); - let mut out = std::io::stdout(); - let _ = out.write_all(&milestone_bytes); - let _ = out.flush(); - }, - )?; + vite_select::select_list(&mut stdout, ¶ms, mode, |state| { + use std::io::Write; + let milestone_name = + vite_str::format!("task-select:{}:{}", state.query, state.selected_index); + let milestone_bytes = pty_terminal_test_client::encoded_milestone(&milestone_name); + let mut out = std::io::stdout(); + let _ = out.write_all(&milestone_bytes); + let _ = out.flush(); + })?; let Some(selected_index) = selected_index else { // Non-interactive, the list was printed. @@ -444,7 +465,9 @@ impl<'a> Session<'a> { })); }; - // Interactive: print selected task and run it + // Interactive: print selected task and build a QueryPlanRequest using the + // entry's filesystem path (not its display name) for package matching. + let entry = &entries[selected_index]; let selected_label = &select_items[selected_index].label; { use std::io::Write as _; @@ -457,8 +480,20 @@ impl<'a> Session<'a> { selected_label, )?; } - run_command.task_specifier = Some(selected_label.clone()); - Ok(()) + + let package_query = + PackageQuery::containing_package(Arc::clone(&entry.task_display.package_path)); + Ok(QueryPlanRequest { + query: TaskQuery { + package_query, + task_name: entry.task_display.task_name.clone(), + include_explicit_deps: !run_command.flags.ignore_depends_on, + }, + plan_options: PlanOptions { + extra_args: run_command.additional_args.clone().into(), + cache_override: run_command.flags.cache_override(), + }, + }) } /// Lazily initializes and returns the execution cache. @@ -670,4 +705,28 @@ impl<'a> Session<'a> { .await?; Ok((graph, is_cwd_only)) } + + /// Plan execution from a pre-built [`QueryPlanRequest`]. + /// + /// Used by the interactive task selector, which constructs the request + /// directly (bypassing CLI specifier parsing). + #[expect( + clippy::future_not_send, + reason = "session is single-threaded, futures do not need to be Send" + )] + async fn plan_from_query( + &mut self, + request: QueryPlanRequest, + ) -> Result { + let cwd = Arc::clone(&self.cwd); + vite_task_plan::plan_query( + request, + &self.workspace_path, + &cwd, + &self.envs, + &mut self.plan_request_parser, + &mut self.lazy_task_graph, + ) + .await + } } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap index e85df174..d317431f 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap @@ -7,12 +7,14 @@ expression: e2e_outputs $ vp run ⊘ cache disabled Select a task (↑/↓, Enter to run, Esc to clear): - › hello: echo hello from root - list-tasks: vp run - app#build: echo build app - app#lint: echo lint app - app#test: echo test app - lib#build: echo build lib + › hello: echo hello from root + list-tasks: vp run + app (packages/app) + build: echo build app + lint: echo lint app + test: echo test app + lib (packages/lib) + build: echo build lib @ write-key: enter $ vp run ⊘ cache disabled Selected task: hello diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap index 0e7fe656..18b8e18c 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select-truncate/snapshots/interactive long command truncated.snap @@ -8,42 +8,42 @@ info: @ expect-milestone: task-select::0 Select a task (↑/↓, Enter to run, Esc to clear): - › build: echo build app - lint: echo lint app + › build: echo build app + lint: echo lint app long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… - test: echo test app + test: echo test app @ write-key: down @ expect-milestone: task-select::1 Select a task (↑/↓, Enter to run, Esc to clear): - build: echo build app - › lint: echo lint app + build: echo build app + › lint: echo lint app long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… - test: echo test app + test: echo test app @ write-key: down @ expect-milestone: task-select::2 Select a task (↑/↓, Enter to run, Esc to clear): - build: echo build app - lint: echo lint app + build: echo build app + lint: echo lint app › long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… - test: echo test app + test: echo test app @ write-key: down @ expect-milestone: task-select::3 Select a task (↑/↓, Enter to run, Esc to clear): - build: echo build app - lint: echo lint app + build: echo build app + lint: echo lint app long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… - › test: echo test app + › test: echo test app @ write-key: up @ expect-milestone: task-select::2 Select a task (↑/↓, Enter to run, Esc to clear): - build: echo build app - lint: echo lint app + build: echo build app + lint: echo lint app › long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… - test: echo test app + test: echo test app @ write-key: enter Selected task: long-cmd ~/packages/app$ echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ⊘ cache disabled diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml index 5d553a8e..ef863e98 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots.toml @@ -132,6 +132,14 @@ steps = [ { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write" = "zzzzz" }, { "expect-milestone" = "task-select:zzzzz:0" }, { "write-key" = "enter" }, { "write-key" = "escape" }, { "expect-milestone" = "task-select::0" }, { "write-key" = "enter" }] }, ] +# Interactive: navigate into a package group, select a non-current-package task +[[e2e]] +name = "interactive select from other package" +cwd = "packages/app" +steps = [ + { command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write-key" = "down" }, { "write-key" = "down" }, { "write-key" = "down" }, { "expect-milestone" = "task-select::3" }, { "write-key" = "enter" }] }, +] + # Typo inside a task script should fail with an error, NOT show a list [[e2e]] name = "typo in task script fails without list" diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive enter with no results does nothing.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive enter with no results does nothing.snap index 99bca642..7b92a3c6 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive enter with no results does nothing.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive enter with no results does nothing.snap @@ -8,19 +8,19 @@ info: @ expect-milestone: task-select::0 Select a task (↑/↓, Enter to run, Esc to clear): - › build: echo build app - lint: echo lint app - test: echo test app - lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) + › build: echo build app + lint: echo lint app + test: echo test app + lib (packages/lib) + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) @ write: zzzzz @ expect-milestone: task-select:zzzzz:0 Select a task (↑/↓, Enter to run, Esc to clear): zzzzz @@ -31,19 +31,19 @@ Select a task (↑/↓, Enter to run, Esc to clear): zzzzz @ expect-milestone: task-select::0 Select a task (↑/↓, Enter to run, Esc to clear): - › build: echo build app - lint: echo lint app - test: echo test app - lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) + › build: echo build app + lint: echo lint app + test: echo test app + lib (packages/lib) + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) @ write-key: enter Selected task: build ~/packages/app$ echo build app ⊘ cache disabled diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap index 505af762..f80a88e3 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive escape clears query.snap @@ -8,42 +8,43 @@ info: @ expect-milestone: task-select::0 Select a task (↑/↓, Enter to run, Esc to clear): - › build: echo build app - lint: echo lint app - test: echo test app - lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) + › build: echo build app + lint: echo lint app + test: echo test app + lib (packages/lib) + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) @ write: lin @ expect-milestone: task-select:lin:0 Select a task (↑/↓, Enter to run, Esc to clear): lin - › lint: echo lint app - lib#lint: echo lint lib + › lint: echo lint app + lib (packages/lib) + lint: echo lint lib @ write-key: escape @ expect-milestone: task-select::0 Select a task (↑/↓, Enter to run, Esc to clear): - › build: echo build app - lint: echo lint app - test: echo test app - lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) + › build: echo build app + lint: echo lint app + test: echo test app + lib (packages/lib) + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) @ write-key: enter Selected task: build ~/packages/app$ echo build app ⊘ cache disabled diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap index af033920..b9835651 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive scroll long list.snap @@ -8,19 +8,19 @@ info: @ expect-milestone: task-select::0 Select a task (↑/↓, Enter to run, Esc to clear): - › build: echo build app - lint: echo lint app - test: echo test app - lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) + › build: echo build app + lint: echo lint app + test: echo test app + lib (packages/lib) + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) @ write-key: down @ write-key: down @ write-key: down @@ -32,19 +32,19 @@ Select a task (↑/↓, Enter to run, Esc to clear): @ expect-milestone: task-select::8 Select a task (↑/↓, Enter to run, Esc to clear): - build: echo build app - lint: echo lint app - test: echo test app - lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib - task-select-test#check: echo check root - › task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) + build: echo build app + lint: echo lint app + test: echo test app + lib (packages/lib) + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + task-select-test (workspace root) + check: echo check root + › clean: echo clean root + deploy: echo deploy root + (…5 more) @ write-key: up @ write-key: up @ write-key: up @@ -56,19 +56,19 @@ Select a task (↑/↓, Enter to run, Esc to clear): @ expect-milestone: task-select::0 Select a task (↑/↓, Enter to run, Esc to clear): - › build: echo build app - lint: echo lint app - test: echo test app - lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) + › build: echo build app + lint: echo lint app + test: echo test app + lib (packages/lib) + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) @ write-key: enter Selected task: build ~/packages/app$ echo build app ⊘ cache disabled diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap index 23bc607c..e2e24041 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search other package task.snap @@ -8,24 +8,25 @@ info: @ expect-milestone: task-select::0 Select a task (↑/↓, Enter to run, Esc to clear): - › build: echo build app - lint: echo lint app - test: echo test app - lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) + › build: echo build app + lint: echo lint app + test: echo test app + lib (packages/lib) + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) @ write: typec @ expect-milestone: task-select:typec:0 Select a task (↑/↓, Enter to run, Esc to clear): typec - › lib#typecheck: echo typecheck lib + lib (packages/lib) + › typecheck: echo typecheck lib @ write-key: enter Selected task: lib#typecheck ~/packages/lib$ echo typecheck lib ⊘ cache disabled diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap index 53b1d1d6..35bbd984 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search preserves rating within package.snap @@ -8,36 +8,36 @@ info: @ expect-milestone: task-select::0 Select a task (↑/↓, Enter to run, Esc to clear): - › build: echo build lib - lint: echo lint lib - test: echo test lib - typecheck: echo typecheck lib - app#build: echo build app - app#lint: echo lint app - app#test: echo test app - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) + › build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + app (packages/app) + build: echo build app + lint: echo lint app + test: echo test app + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) @ write: t @ expect-milestone: task-select:t:0 Select a task (↑/↓, Enter to run, Esc to clear): t - › test: echo test lib - typecheck: echo typecheck lib - lint: echo lint lib - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - task-select-test#hello: echo hello from root - task-select-test#run-typo-task: vp run nonexistent-xyz - task-select-test#validate: echo validate root - app#test: echo test app - (…1 more) + › test: echo test lib + typecheck: echo typecheck lib + lint: echo lint lib + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + docs: echo docs root + format: echo format root + hello: echo hello from root + run-typo-task: vp run nonexistent-xyz + validate: echo validate root + (…2 more) @ write-key: enter Selected task: test ~/packages/lib$ echo test lib ⊘ cache disabled diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap index 754468a5..b130cbbe 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search then select.snap @@ -8,25 +8,26 @@ info: @ expect-milestone: task-select::0 Select a task (↑/↓, Enter to run, Esc to clear): - › build: echo build app - lint: echo lint app - test: echo test app - lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) + › build: echo build app + lint: echo lint app + test: echo test app + lib (packages/lib) + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) @ write: lin @ expect-milestone: task-select:lin:0 Select a task (↑/↓, Enter to run, Esc to clear): lin - › lint: echo lint app - lib#lint: echo lint lib + › lint: echo lint app + lib (packages/lib) + lint: echo lint lib @ write-key: enter Selected task: lint ~/packages/app$ echo lint app ⊘ cache disabled diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap index e796bd6e..0b18b0b7 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive search with hash skips reorder.snap @@ -8,27 +8,28 @@ info: @ expect-milestone: task-select::0 Select a task (↑/↓, Enter to run, Esc to clear): - › build: echo build app - lint: echo lint app - test: echo test app - lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) + › build: echo build app + lint: echo lint app + test: echo test app + lib (packages/lib) + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) @ write: lib# @ expect-milestone: task-select:lib#:0 Select a task (↑/↓, Enter to run, Esc to clear): lib# - › lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib + lib (packages/lib) + › build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib @ write-key: enter Selected task: lib#build ~/packages/lib$ echo build lib ⊘ cache disabled diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select from other package.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select from other package.snap new file mode 100644 index 00000000..57b88930 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select from other package.snap @@ -0,0 +1,46 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +info: + cwd: packages/app +--- +> vp run +@ expect-milestone: task-select::0 +Select a task (↑/↓, Enter to run, Esc to clear): + + › build: echo build app + lint: echo lint app + test: echo test app + lib (packages/lib) + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) +@ write-key: down +@ write-key: down +@ write-key: down +@ expect-milestone: task-select::3 +Select a task (↑/↓, Enter to run, Esc to clear): + + build: echo build app + lint: echo lint app + test: echo test app + lib (packages/lib) + › build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) +@ write-key: enter +Selected task: lib#build +~/packages/lib$ echo build lib ⊘ cache disabled +build lib diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap index a1b0dc28..b665f058 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task from lib.snap @@ -8,19 +8,19 @@ info: @ expect-milestone: task-select::0 Select a task (↑/↓, Enter to run, Esc to clear): - › build: echo build lib - lint: echo lint lib - test: echo test lib - typecheck: echo typecheck lib - app#build: echo build app - app#lint: echo lint app - app#test: echo test app - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) + › build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + app (packages/app) + build: echo build app + lint: echo lint app + test: echo test app + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) @ write-key: enter Selected task: build ~/packages/lib$ echo build lib ⊘ cache disabled diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap index d1a964e8..a44a45d2 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select task.snap @@ -8,36 +8,36 @@ info: @ expect-milestone: task-select::0 Select a task (↑/↓, Enter to run, Esc to clear): - › build: echo build app - lint: echo lint app - test: echo test app - lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) + › build: echo build app + lint: echo lint app + test: echo test app + lib (packages/lib) + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) @ write-key: down @ expect-milestone: task-select::1 Select a task (↑/↓, Enter to run, Esc to clear): - build: echo build app - › lint: echo lint app - test: echo test app - lib#build: echo build lib - lib#lint: echo lint lib - lib#test: echo test lib - lib#typecheck: echo typecheck lib - task-select-test#check: echo check root - task-select-test#clean: echo clean root - task-select-test#deploy: echo deploy root - task-select-test#docs: echo docs root - task-select-test#format: echo format root - (…3 more) + build: echo build app + › lint: echo lint app + test: echo test app + lib (packages/lib) + build: echo build lib + lint: echo lint lib + test: echo test lib + typecheck: echo typecheck lib + task-select-test (workspace root) + check: echo check root + clean: echo clean root + deploy: echo deploy root + (…5 more) @ write-key: enter Selected task: lint ~/packages/app$ echo lint app ⊘ cache disabled diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap index cd51be84..bbecae61 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/interactive select with typo.snap @@ -9,8 +9,9 @@ info: Task "buid" not found. Select a task (↑/↓, Enter to run, Esc to clear): buid - › build: echo build app - lib#build: echo build lib + › build: echo build app + lib (packages/lib) + build: echo build lib @ write-key: enter Selected task: build ~/packages/app$ echo build app ⊘ cache disabled diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/verbose with typo enters selector.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/verbose with typo enters selector.snap index 736f51e8..0cdb961a 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/verbose with typo enters selector.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select/snapshots/verbose with typo enters selector.snap @@ -9,8 +9,9 @@ info: Task "buid" not found. Select a task (↑/↓, Enter to run, Esc to clear): buid - › build: echo build app - lib#build: echo build lib + › build: echo build app + lib (packages/lib) + build: echo build lib @ write-key: enter Selected task: build ~/packages/app$ echo build app ⊘ cache disabled diff --git a/crates/vite_workspace/src/package_graph.rs b/crates/vite_workspace/src/package_graph.rs index 24c66011..69908d62 100644 --- a/crates/vite_workspace/src/package_graph.rs +++ b/crates/vite_workspace/src/package_graph.rs @@ -67,6 +67,20 @@ impl PackageQuery { pub(crate) const fn filters(filters: Vec1) -> Self { Self(PackageQueryKind::Filters(filters)) } + + /// Select the single package whose root is `path`. + /// + /// Used by the interactive task selector to match by filesystem path + /// instead of package name. + #[must_use] + pub fn containing_package(path: Arc) -> Self { + Self(PackageQueryKind::Filters(Vec1::new(PackageFilter { + exclude: false, + selector: PackageSelector::ContainingPackage(path), + traversal: None, + source: None, + }))) + } } // ────────────────────────────────────────────────────────────────────────────