Skip to content

Commit 4dc178d

Browse files
committed
Add scrolling support to branch selection dialog
Implements viewport-based scrolling for the create worktree dialog to support browsing through large branch lists in small terminals. Features: - Viewport rendering with automatic scroll adjustment during navigation - Visual scroll indicators ("▲ more above" / "▼ more below") - Terminal resize detection with scroll position recalculation - Minimum height validation (15 lines) with error messaging - Selection always visible with 2-line margin for smooth scrolling Technical changes: - Added LineType enum to represent flattened line structure - Added scroll state tracking (scroll_offset, last_known_height, last_known_content_height) - Implemented ensure_selected_visible() with proper indicator space accounting - Added comprehensive unit tests for scroll logic (8 new tests) - Dialog auto-closes if terminal resized below minimum height All 24 interactive tests pass. Supports repositories with 100+ branches in terminals as small as 15 lines.
1 parent dfaaa6b commit 4dc178d

File tree

5 files changed

+405
-21
lines changed

5 files changed

+405
-21
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
/target
22
.rsworktree/
33
.DS_Store
4+
specs/
5+
CLAUDE.md

src/commands/interactive/command.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,17 @@ where
312312
}
313313
Focus::GlobalActions => match self.global_action_selected {
314314
0 => {
315+
// Check minimum terminal height before opening create dialog
316+
let (_, height) = crossterm::terminal::size()
317+
.wrap_err("failed to query terminal size")?;
318+
319+
if height < 15 {
320+
self.status = Some(StatusMessage::error(
321+
"Terminal too small (minimum 15 lines). Please resize your terminal.",
322+
));
323+
return Ok(LoopControl::Continue);
324+
}
325+
315326
let dialog =
316327
CreateDialog::new(&self.branches, &self.worktrees, self.default_branch());
317328
self.dialog = Some(Dialog::Create(dialog));
@@ -479,6 +490,41 @@ where
479490
where
480491
G: FnMut(&str, Option<&str>) -> Result<()>,
481492
{
493+
// Update content height calculation before processing input
494+
let current_height = self
495+
.terminal
496+
.size()
497+
.wrap_err("failed to query terminal size")?
498+
.height;
499+
500+
if let Some(Dialog::Create(dialog)) = self.dialog.as_mut() {
501+
// Check if terminal became too small
502+
if current_height < 15 {
503+
self.dialog = None;
504+
self.status = Some(StatusMessage::error(
505+
"Terminal too small (minimum 15 lines). Dialog closed.",
506+
));
507+
return Ok(());
508+
}
509+
510+
// Calculate content height matching the rendering logic:
511+
// 70% of terminal height for popup, minus 3+3+3=9 lines for layout sections,
512+
// minus 2 for borders, minus 2 for scroll indicators
513+
let popup_height = ((current_height as f64) * 0.70) as u16;
514+
let base_section_height = popup_height.saturating_sub(9);
515+
let available_height = base_section_height.saturating_sub(2);
516+
let content_height = (available_height.saturating_sub(2)) as usize; // Reserve 2 for indicators
517+
518+
// Always update content height (handles both resize and first frame)
519+
dialog.last_known_content_height = content_height;
520+
521+
// Adjust scroll if terminal was resized
522+
if dialog.last_known_height != current_height {
523+
dialog.last_known_height = current_height;
524+
dialog.ensure_selected_visible(content_height);
525+
}
526+
}
527+
482528
let mut close_dialog = false;
483529
let mut status_message: Option<StatusMessage> = None;
484530
let mut submit_requested = false;

src/commands/interactive/dialog.rs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
use super::WorktreeEntry;
22

3+
/// Calculates initial scroll position to center default branch
4+
fn calculate_initial_scroll(
5+
selected_line: Option<usize>,
6+
total_lines: usize,
7+
visible_height: usize,
8+
) -> usize {
9+
let selected = selected_line.unwrap_or(0);
10+
let ideal_center = selected.saturating_sub(visible_height / 2);
11+
let max_scroll = total_lines.saturating_sub(visible_height);
12+
ideal_center.min(max_scroll)
13+
}
14+
15+
/// Represents different types of lines in the scrollable branch list
16+
#[derive(Debug, Clone, PartialEq)]
17+
pub(crate) enum LineType {
18+
/// A group header like "Branches" or "Worktrees"
19+
GroupHeader { title: String },
20+
21+
/// A selectable branch option
22+
BranchOption {
23+
/// Index into base_groups
24+
group_idx: usize,
25+
/// Index into base_groups[group_idx].options
26+
option_idx: usize,
27+
},
28+
29+
/// Empty spacing line between groups
30+
EmptyLine,
31+
}
32+
333
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
434
pub(crate) enum CreateDialogFocus {
535
Name,
@@ -28,6 +58,19 @@ pub(crate) struct CreateDialog {
2858
pub(crate) base_indices: Vec<(usize, usize)>,
2959
pub(crate) base_selected: usize,
3060
pub(crate) error: Option<String>,
61+
62+
// Scroll state fields
63+
/// Flattened list of all renderable lines (headers + branches + spacing)
64+
pub(crate) flat_lines: Vec<LineType>,
65+
66+
/// Index of the first visible line in the viewport
67+
pub(crate) scroll_offset: usize,
68+
69+
/// Last known viewport height (for detecting resize)
70+
pub(crate) last_known_height: u16,
71+
72+
/// Last known content height (viewport minus indicators)
73+
pub(crate) last_known_content_height: usize,
3174
}
3275

3376
impl CreateDialog {
@@ -107,6 +150,44 @@ impl CreateDialog {
107150
base_selected = 0;
108151
}
109152

153+
// Build flat_lines from groups
154+
let mut flat_lines = Vec::new();
155+
for (group_idx, group) in groups.iter().enumerate() {
156+
flat_lines.push(LineType::GroupHeader {
157+
title: group.title.clone(),
158+
});
159+
160+
for (option_idx, _) in group.options.iter().enumerate() {
161+
flat_lines.push(LineType::BranchOption {
162+
group_idx,
163+
option_idx,
164+
});
165+
}
166+
167+
// Add spacing between groups (but not after last)
168+
if group_idx < groups.len() - 1 {
169+
flat_lines.push(LineType::EmptyLine);
170+
}
171+
}
172+
173+
// Calculate initial scroll position to center default branch
174+
let selected_line = base_indices
175+
.get(base_selected)
176+
.and_then(|(target_group, target_option)| {
177+
flat_lines.iter().position(|line| {
178+
matches!(
179+
line,
180+
LineType::BranchOption { group_idx, option_idx }
181+
if group_idx == target_group && option_idx == target_option
182+
)
183+
})
184+
});
185+
186+
// Use reasonable default for initial visible height
187+
let initial_content_height = 6; // Conservative estimate
188+
let scroll_offset = calculate_initial_scroll(selected_line, flat_lines.len(), initial_content_height);
189+
let last_known_height = 0; // Will be set on first render
190+
110191
Self {
111192
name_input: String::new(),
112193
focus: CreateDialogFocus::Name,
@@ -115,6 +196,10 @@ impl CreateDialog {
115196
base_indices,
116197
base_selected,
117198
error: None,
199+
flat_lines,
200+
scroll_offset,
201+
last_known_height,
202+
last_known_content_height: initial_content_height,
118203
}
119204
}
120205

@@ -149,6 +234,53 @@ impl CreateDialog {
149234
let current = self.base_selected as isize;
150235
let next = (current + delta).rem_euclid(len);
151236
self.base_selected = next as usize;
237+
238+
// Update scroll position to keep selection visible
239+
// Use last known content height from rendering
240+
self.ensure_selected_visible(self.last_known_content_height);
241+
}
242+
243+
pub(crate) fn find_selected_line(&self) -> Option<usize> {
244+
let (target_group, target_option) = self.base_indices.get(self.base_selected)?;
245+
246+
self.flat_lines.iter().position(|line| {
247+
matches!(
248+
line,
249+
LineType::BranchOption { group_idx, option_idx }
250+
if group_idx == target_group && option_idx == target_option
251+
)
252+
})
253+
}
254+
255+
pub(crate) fn ensure_selected_visible(&mut self, visible_height: usize) {
256+
const MARGIN: usize = 2;
257+
258+
// If viewport is too small, just ensure selection is in range
259+
if visible_height == 0 {
260+
return;
261+
}
262+
263+
let Some(selected_line) = self.find_selected_line() else {
264+
return;
265+
};
266+
267+
let max_scroll = self.flat_lines.len().saturating_sub(visible_height);
268+
269+
// Calculate safe lower bound (handle case where visible_height < MARGIN)
270+
let viewport_end = self.scroll_offset + visible_height;
271+
let safe_bottom = viewport_end.saturating_sub(MARGIN);
272+
273+
// Scroll down if selection below viewport
274+
if selected_line >= safe_bottom {
275+
self.scroll_offset = (selected_line + MARGIN + 1)
276+
.saturating_sub(visible_height)
277+
.min(max_scroll);
278+
}
279+
280+
// Scroll up if selection above viewport
281+
if selected_line < self.scroll_offset + MARGIN {
282+
self.scroll_offset = selected_line.saturating_sub(MARGIN);
283+
}
152284
}
153285
}
154286

@@ -161,6 +293,8 @@ pub(crate) struct CreateDialogView {
161293
pub(crate) base_selected: usize,
162294
pub(crate) base_indices: Vec<(usize, usize)>,
163295
pub(crate) error: Option<String>,
296+
pub(crate) flat_lines: Vec<LineType>,
297+
pub(crate) scroll_offset: usize,
164298
}
165299

166300
impl From<&CreateDialog> for CreateDialogView {
@@ -173,6 +307,8 @@ impl From<&CreateDialog> for CreateDialogView {
173307
base_selected: dialog.base_selected,
174308
base_indices: dialog.base_indices.clone(),
175309
error: dialog.error.clone(),
310+
flat_lines: dialog.flat_lines.clone(),
311+
scroll_offset: dialog.scroll_offset,
176312
}
177313
}
178314
}

0 commit comments

Comments
 (0)