From 99e5c73b90656562e048209e354a5292da721838 Mon Sep 17 00:00:00 2001 From: ywenhao <37856060+ywenhao@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:51:57 +0800 Subject: [PATCH 1/2] fix(select): wrap arrow-key navigation at list boundaries Co-authored-by: GPT-5 Codex --- crates/vt_select/src/interactive.rs | 116 +++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 4 deletions(-) diff --git a/crates/vt_select/src/interactive.rs b/crates/vt_select/src/interactive.rs index 12069099e..dc6aeb750 100644 --- a/crates/vt_select/src/interactive.rs +++ b/crates/vt_select/src/interactive.rs @@ -189,15 +189,16 @@ impl<'a> State<'a> { } fn move_up(&mut self) { - if self.selected > 0 { - self.selected -= 1; + if self.item_count > 0 { + self.selected = + if self.selected == 0 { self.item_count - 1 } else { self.selected - 1 }; self.ensure_selected_visible(); } } fn move_down(&mut self) { - if self.item_count > 0 && self.selected < self.item_count - 1 { - self.selected += 1; + if self.item_count > 0 { + self.selected = (self.selected + 1) % self.item_count; self.ensure_selected_visible(); } } @@ -598,6 +599,113 @@ mod tests { .collect() } + #[test] + fn navigation_wraps_up_from_first_to_last() { + let items = make_items(&[("build", ""), ("lint", ""), ("test", "")]); + let mut state = State::new(&items, None, 2); + + state.move_up(); + + assert_eq!(state.selected_item_index(), Some(2)); + assert_eq!(state.visible_display_rows(), 1..3); + } + + #[test] + fn navigation_wraps_down_from_last_to_first() { + let items = make_items(&[("build", ""), ("lint", ""), ("test", "")]); + let mut state = State::new(&items, None, 2); + + state.move_down(); + assert_eq!(state.selected_item_index(), Some(1)); + state.move_down(); + assert_eq!(state.selected_item_index(), Some(2)); + assert_eq!(state.visible_display_rows(), 1..3); + state.move_down(); + + assert_eq!(state.selected_item_index(), Some(0)); + assert_eq!(state.visible_display_rows(), 0..2); + } + + #[test] + fn navigation_wraps_within_filtered_results() { + let items = make_items(&[("build-a", ""), ("lint", ""), ("build-b", ""), ("test", "")]); + let mut state = State::new(&items, None, 2); + state.move_up(); + state.query.push_str("build"); + state.refilter(); + assert_eq!(state.item_count, 2); + assert_eq!(state.selected_item_index(), Some(0)); + assert_eq!(state.scroll_offset, 0); + + state.move_up(); + assert_eq!(state.selected_item_index(), Some(2)); + state.move_down(); + assert_eq!(state.selected_item_index(), Some(0)); + assert_eq!(state.query, "build"); + } + + #[test] + fn navigation_wraps_past_group_headers_and_scrolls_to_the_selected_item() { + let items = make_grouped_items(&[ + ("lib#build", "build", "", Some("lib")), + ("lib#lint", "lint", "", Some("lib")), + ("app#build", "build", "", Some("app")), + ]); + let mut state = State::new(&items, None, 3); + + state.move_up(); + assert_eq!(state.selected_item_index(), Some(2)); + assert_eq!(state.visible_display_rows(), 2..5); + state.move_down(); + assert_eq!(state.selected_item_index(), Some(0)); + assert_eq!(state.visible_display_rows(), 0..3); + assert!(matches!(state.display_rows[0], DisplayRow::Header(_))); + + state.move_down(); + assert_eq!(state.selected_item_index(), Some(1)); + state.move_down(); + assert_eq!(state.selected_item_index(), Some(2)); + state.move_up(); + assert_eq!(state.selected_item_index(), Some(1)); + } + + #[test] + fn navigation_with_no_items_is_a_noop() { + let mut state = State::new(&[], None, 2); + + state.move_up(); + state.move_down(); + + assert_eq!(state.selected, 0); + assert_eq!(state.selected_item_index(), None); + assert_eq!(state.visible_display_rows(), 0..0); + } + + #[test] + fn navigation_with_no_matching_items_is_a_noop() { + let items = make_items(&[("build", ""), ("lint", "")]); + let mut state = State::new(&items, Some("zzz"), 2); + + state.move_up(); + state.move_down(); + + assert_eq!(state.selected, 0); + assert_eq!(state.selected_item_index(), None); + assert_eq!(state.visible_display_rows(), 0..0); + } + + #[test] + fn navigation_with_one_matching_item_stays_on_that_item() { + let items = make_items(&[("build", ""), ("lint", "")]); + let mut state = State::new(&items, Some("lint"), 2); + + state.move_up(); + assert_eq!(state.selected_item_index(), Some(1)); + state.move_down(); + assert_eq!(state.selected_item_index(), Some(1)); + assert_eq!(state.visible_display_rows(), 0..1); + } + /// Strip ANSI escape sequences from output for easier assertions. #[expect(clippy::disallowed_types, reason = "test helper building arbitrary output string")] fn strip_ansi(s: &str) -> String { From 6ceceba92d0af26171d2cada6dde656d53ce67b0 Mon Sep 17 00:00:00 2001 From: ywenhao <37856060+ywenhao@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:54:18 +0800 Subject: [PATCH 2/2] docs(changelog): note task selector wrap-around navigation Co-authored-by: GPT-5 Codex --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0d93dcf2..41681766f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Changed** The `vp run` / `vpr` task selector now wraps from the first task to the last with Up, and from the last task to the first with Down, including filtered results ([#715](https://github.com/voidzero-dev/vite-task/pull/715)). - **Fixed** `vp run` no longer hangs or fails when a task leaves a process running behind it, such as a dev server or a background helper, or when one of a task's processes is killed. The run finishes as soon as the task itself does, and the files the task used are still recorded ([#544](https://github.com/voidzero-dev/vite-task/issues/544), [#675](https://github.com/voidzero-dev/vite-task/pull/675)). - **Fixed** A task that reads or writes an unusually large number of files now runs to the end instead of being killed partway through. Vite+ reports the run as not cached, because it could not record every file the task used ([#533](https://github.com/voidzero-dev/vite-task/issues/533), [#675](https://github.com/voidzero-dev/vite-task/pull/675)). - **Fixed** Vite+ diagnostics now display individual paths and working directories without Rust debug formatting such as quoted paths or escaped Windows backslashes ([#534](https://github.com/voidzero-dev/vite-task/pull/534)).