Skip to content

Commit d248b68

Browse files
committed
update interactive actions
1 parent fdb8897 commit d248b68

File tree

4 files changed

+252
-44
lines changed

4 files changed

+252
-44
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
/target
22
.rsworktree/
3+
.codex/
4+
.specify/
35
.DS_Store
46
specs/
57
CLAUDE.md
8+
AGENTS.md

src/commands/interactive/command.rs

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use git2::{
88
use ratatui::{
99
Terminal,
1010
backend::Backend,
11+
layout::{Constraint, Direction, Layout},
1112
style::{Color, Modifier, Style},
1213
text::{Line, Span},
1314
widgets::ListState,
@@ -23,6 +24,69 @@ use super::{
2324
};
2425
use crate::commands::rm::{LocalBranchStatus, RemoveOutcome};
2526

27+
#[allow(dead_code)]
28+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29+
pub(crate) enum ActionPanelOrientation {
30+
Horizontal,
31+
Vertical,
32+
}
33+
34+
#[allow(dead_code)]
35+
#[derive(Clone, Debug)]
36+
pub(crate) struct ActionPanelState {
37+
pub orientation: ActionPanelOrientation,
38+
pub selected_index: usize,
39+
pub scroll_offset: usize,
40+
}
41+
42+
impl ActionPanelState {
43+
pub fn new(orientation: ActionPanelOrientation) -> Self {
44+
Self {
45+
orientation,
46+
selected_index: 0,
47+
scroll_offset: 0,
48+
}
49+
}
50+
51+
pub fn vertical() -> Self {
52+
Self::new(ActionPanelOrientation::Vertical)
53+
}
54+
55+
pub fn ensure_visible(&mut self, visible_rows: usize, total_items: usize) {
56+
if total_items == 0 {
57+
self.selected_index = 0;
58+
self.scroll_offset = 0;
59+
return;
60+
}
61+
62+
if self.selected_index >= total_items {
63+
self.selected_index = total_items.saturating_sub(1);
64+
}
65+
66+
if visible_rows == 0 {
67+
self.scroll_offset = 0;
68+
return;
69+
}
70+
71+
if self.selected_index < self.scroll_offset {
72+
self.scroll_offset = self.selected_index;
73+
} else {
74+
let viewport_end = self.scroll_offset + visible_rows;
75+
if self.selected_index >= viewport_end {
76+
self.scroll_offset = self
77+
.selected_index
78+
.saturating_add(1)
79+
.saturating_sub(visible_rows);
80+
}
81+
}
82+
83+
let max_offset = total_items.saturating_sub(visible_rows);
84+
if self.scroll_offset > max_offset {
85+
self.scroll_offset = max_offset;
86+
}
87+
}
88+
}
89+
2690
pub struct InteractiveCommand<B, E>
2791
where
2892
B: Backend,
@@ -34,7 +98,7 @@ where
3498
pub(crate) worktrees: Vec<WorktreeEntry>,
3599
pub(crate) selected: Option<usize>,
36100
pub(crate) focus: Focus,
37-
pub(crate) action_selected: usize,
101+
pub(crate) action_panel: ActionPanelState,
38102
pub(crate) global_action_selected: usize,
39103
pub(crate) branches: Vec<String>,
40104
pub(crate) default_branch: Option<String>,
@@ -67,7 +131,7 @@ where
67131
worktrees,
68132
selected,
69133
focus: Focus::Worktrees,
70-
action_selected: 0,
134+
action_panel: ActionPanelState::vertical(),
71135
global_action_selected: 0,
72136
branches,
73137
default_branch,
@@ -275,7 +339,7 @@ where
275339
}
276340
}
277341
Focus::Actions => {
278-
let action = Action::from_index(self.action_selected);
342+
let action = Action::from_index(self.action_panel.selected_index);
279343
match action {
280344
Action::Open => {
281345
if let Some(entry) = self.current_entry() {
@@ -864,9 +928,12 @@ where
864928

865929
fn move_action(&mut self, delta: isize) {
866930
let len = Action::ALL.len() as isize;
867-
let current = self.action_selected as isize;
931+
let current = self.action_panel.selected_index as isize;
868932
let next = (current + delta).rem_euclid(len);
869-
self.action_selected = next as usize;
933+
self.action_panel.selected_index = next as usize;
934+
let visible_rows = self.action_panel_visible_rows();
935+
self.action_panel
936+
.ensure_visible(visible_rows, Action::ALL.len());
870937
}
871938

872939
fn move_global_action(&mut self, delta: isize) {
@@ -879,6 +946,28 @@ where
879946
self.global_action_selected = next as usize;
880947
}
881948

949+
fn action_panel_visible_rows(&self) -> usize {
950+
let Ok(size) = self.terminal.size() else {
951+
return Action::ALL.len();
952+
};
953+
954+
let columns = Layout::default()
955+
.direction(Direction::Horizontal)
956+
.constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
957+
.split(size);
958+
959+
let panel_layout = Layout::default()
960+
.direction(Direction::Vertical)
961+
.constraints([
962+
Constraint::Min(5),
963+
Constraint::Length((Action::ALL.len() as u16).saturating_add(2).max(3)),
964+
Constraint::Length(3),
965+
])
966+
.split(columns[1]);
967+
968+
usize::from(panel_layout[1].height.saturating_sub(2))
969+
}
970+
882971
fn current_entry(&self) -> Option<&WorktreeEntry> {
883972
self.selected.and_then(|idx| self.worktrees.get(idx))
884973
}
@@ -938,7 +1027,7 @@ where
9381027
items,
9391028
detail,
9401029
self.focus,
941-
self.action_selected,
1030+
self.action_panel.clone(),
9421031
self.global_action_selected,
9431032
self.status.clone(),
9441033
dialog,

src/commands/interactive/tests.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use super::command::ActionPanelState;
12
use super::*;
23
use std::{collections::VecDeque, path::PathBuf};
34

@@ -142,6 +143,67 @@ fn selecting_pr_github_action_exits_with_pr_variant() -> Result<()> {
142143
Ok(())
143144
}
144145

146+
#[test]
147+
fn action_panel_wraps_when_navigating_up() -> Result<()> {
148+
let backend = TestBackend::new(40, 12);
149+
let terminal = Terminal::new(backend)?;
150+
let events = StubEvents::new(vec![
151+
key(KeyCode::Tab),
152+
key(KeyCode::Up),
153+
key(KeyCode::Up),
154+
key(KeyCode::Enter),
155+
]);
156+
let worktrees = entries(&["alpha"]);
157+
let command = InteractiveCommand::new(
158+
terminal,
159+
events,
160+
PathBuf::from("/tmp/worktrees"),
161+
worktrees,
162+
vec![String::from("main")],
163+
Some(String::from("main")),
164+
);
165+
166+
let result = command.run(
167+
|_, _| {
168+
Ok(RemoveOutcome {
169+
local_branch: None,
170+
repositioned: false,
171+
})
172+
},
173+
|_, _| panic!("create should not be called"),
174+
)?;
175+
176+
assert_eq!(result, Some(Selection::PrGithub(String::from("alpha"))));
177+
178+
Ok(())
179+
}
180+
181+
#[test]
182+
fn action_panel_scroll_offset_adjusts_to_keep_selection_visible() {
183+
let mut state = ActionPanelState::vertical();
184+
state.selected_index = 0;
185+
state.scroll_offset = 0;
186+
state.ensure_visible(3, Action::ALL.len());
187+
assert_eq!(
188+
state.scroll_offset, 0,
189+
"initial selection should be visible"
190+
);
191+
192+
state.selected_index = 3;
193+
state.ensure_visible(3, Action::ALL.len());
194+
assert_eq!(
195+
state.scroll_offset, 1,
196+
"scroll offset should advance when selection passes viewport"
197+
);
198+
199+
state.selected_index = 0;
200+
state.ensure_visible(3, Action::ALL.len());
201+
assert_eq!(
202+
state.scroll_offset, 0,
203+
"scroll offset should reset when selection moves back to top"
204+
);
205+
}
206+
145207
#[test]
146208
fn selecting_merge_action_collects_cleanup_choices() -> Result<()> {
147209
let backend = TestBackend::new(40, 12);

0 commit comments

Comments
 (0)