From 1cb31384171179b3a9f08458ddf1a7da1afac426 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 24 Feb 2026 11:08:55 +0100 Subject: [PATCH] feat(tui): add search filtering to RebaseBranchPicker modal Add TextArea-based substring search to the branch picker, matching the existing archive search pattern. Users can now type to filter branches, with matching segments highlighted in yellow/bold. - Add rebase_branch_search TextArea field and filtered_indices method - Rewrite event handler: typed chars go to search, arrow keys navigate - Rewrite renderer: 3-chunk layout with filter input and highlights - Update status bar hints to reflect new interaction model Co-Authored-By: Claude Opus 4.6 --- src/tui/app.rs | 79 +++++++++++++++++++++++++++------- src/tui/ui.rs | 112 ++++++++++++++++++++++++++++++++++--------------- 2 files changed, 141 insertions(+), 50 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 32ecfa5..500f08e 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -550,6 +550,7 @@ pub struct App { pub selected_rebase_branch_index: usize, pub rebase_branch_list_state: ListState, pub pending_branch_command: Option, + pub rebase_branch_search: TextArea<'static>, // Delete mode chooser pub archive_mode_index: usize, // Restart task wizard @@ -687,6 +688,7 @@ impl App { selected_rebase_branch_index: 0, rebase_branch_list_state: ListState::default(), pending_branch_command: None, + rebase_branch_search: Self::create_plain_editor(), archive_mode_index: 0, restart_wizard: None, restart_pending: false, @@ -1492,6 +1494,7 @@ impl App { self.rebase_branches = branches; self.selected_rebase_branch_index = preselect_index; self.rebase_branch_list_state.select(Some(preselect_index)); + self.rebase_branch_search = Self::create_plain_editor(); self.view = View::RebaseBranchPicker; } Err(e) => { @@ -2533,6 +2536,24 @@ impl App { .collect() } + /// Return indices into `self.rebase_branches` that match the current search query. + pub fn rebase_branch_filtered_indices(&self) -> Vec { + let query: String = self.rebase_branch_search.lines().join("").to_lowercase(); + let terms: Vec<&str> = query.split_whitespace().collect(); + if terms.is_empty() { + return (0..self.rebase_branches.len()).collect(); + } + self.rebase_branches + .iter() + .enumerate() + .filter(|(_, branch)| { + let branch_lower = branch.to_lowercase(); + terms.iter().all(|term| branch_lower.contains(term)) + }) + .map(|(i, _)| i) + .collect() + } + fn handle_archive_event(&mut self, event: Event) -> Result { if let Event::Key(key) = event { if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { @@ -3276,32 +3297,58 @@ impl App { } match key.code { - KeyCode::Esc | KeyCode::Char('q') => { + KeyCode::Esc => { self.view = View::Preview; } - KeyCode::Char('j') | KeyCode::Down => { - if !self.rebase_branches.is_empty() { - self.selected_rebase_branch_index = - (self.selected_rebase_branch_index + 1) % self.rebase_branches.len(); - self.rebase_branch_list_state.select(Some(self.selected_rebase_branch_index)); + KeyCode::Up | KeyCode::Down => { + let filtered = self.rebase_branch_filtered_indices(); + if !filtered.is_empty() { + if key.code == KeyCode::Up { + self.selected_rebase_branch_index = + self.selected_rebase_branch_index.saturating_sub(1); + } else { + self.selected_rebase_branch_index = + (self.selected_rebase_branch_index + 1).min(filtered.len() - 1); + } } } - KeyCode::Char('k') | KeyCode::Up => { - if !self.rebase_branches.is_empty() { + KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => { + let filtered = self.rebase_branch_filtered_indices(); + if !filtered.is_empty() { self.selected_rebase_branch_index = - if self.selected_rebase_branch_index == 0 { - self.rebase_branches.len() - 1 - } else { - self.selected_rebase_branch_index - 1 - }; - self.rebase_branch_list_state.select(Some(self.selected_rebase_branch_index)); + (self.selected_rebase_branch_index + 1).min(filtered.len() - 1); } } + KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.selected_rebase_branch_index = + self.selected_rebase_branch_index.saturating_sub(1); + } KeyCode::Enter => { - if let Some(branch) = self.rebase_branches.get(self.selected_rebase_branch_index).cloned() { - self.run_branch_command(&branch)?; + let filtered = self.rebase_branch_filtered_indices(); + if let Some(&real_idx) = filtered.get(self.selected_rebase_branch_index) { + if let Some(branch) = self.rebase_branches.get(real_idx).cloned() { + self.run_branch_command(&branch)?; + } } } + KeyCode::Backspace => { + self.rebase_branch_search.input(Input { + key: Key::Backspace, + ctrl: false, + alt: false, + shift: false, + }); + self.selected_rebase_branch_index = 0; + } + KeyCode::Char(c) => { + self.rebase_branch_search.input(Input { + key: Key::Char(c), + ctrl: false, + alt: false, + shift: false, + }); + self.selected_rebase_branch_index = 0; + } _ => {} } } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index d75f625..0e9653f 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1488,7 +1488,19 @@ fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) { Span::styled(" close", Style::default().fg(Color::DarkGray)), ] } - View::RebaseBranchPicker | View::SessionPicker => { + View::RebaseBranchPicker => { + vec![ + Span::styled("type", Style::default().fg(Color::LightCyan)), + Span::styled(" to filter ", Style::default().fg(Color::DarkGray)), + Span::styled("↑/↓", Style::default().fg(Color::LightCyan)), + Span::styled(" nav ", Style::default().fg(Color::DarkGray)), + Span::styled("Enter", Style::default().fg(Color::LightGreen)), + Span::styled(" select ", Style::default().fg(Color::DarkGray)), + Span::styled("Esc", Style::default().fg(Color::LightRed)), + Span::styled(" cancel", Style::default().fg(Color::DarkGray)), + ] + } + View::SessionPicker => { vec![ Span::styled("j/k", Style::default().fg(Color::LightCyan)), Span::styled(" nav ", Style::default().fg(Color::DarkGray)), @@ -2141,32 +2153,24 @@ fn draw_rebase_branch_picker(f: &mut Frame, app: &mut App) { .unwrap_or_else(|| "unknown".to_string()); // Dynamic title and labels based on the pending command - let (picker_title, header_label, list_title) = match app + let (picker_title, header_label) = match app .pending_branch_command .as_ref() .map(|c| c.id.as_str()) { - Some("local-merge") => ( - " Merge Branch Picker ", - "Merge task into: ", - " Select branch to merge into (Enter to select, Esc to cancel) ", - ), - Some("rebase") => ( - " Rebase Branch Picker ", - "Rebase task: ", - " Select branch to rebase onto (Enter to select, Esc to cancel) ", - ), - _ => ( - " Branch Picker ", - "Task: ", - " Select branch (Enter to select, Esc to cancel) ", - ), + Some("local-merge") => (" Merge Branch Picker ", "Merge task into: "), + Some("rebase") => (" Rebase Branch Picker ", "Rebase task: "), + _ => (" Branch Picker ", "Task: "), }; - // Split into header and list + // Split into header, search input, and list let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(5)]) + .constraints([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(5), + ]) .split(area); // Header @@ -2192,13 +2196,34 @@ fn draw_rebase_branch_picker(f: &mut Frame, app: &mut App) { ); f.render_widget(header, chunks[0]); - // Branch list - let items: Vec = app - .rebase_branches + // Search input + let search_block = Block::default() + .title(" Filter ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::LightCyan)); + let search_inner = search_block.inner(chunks[1]); + f.render_widget(search_block, chunks[1]); + f.render_widget(&app.rebase_branch_search, search_inner); + + // Filtered results + let filtered = app.rebase_branch_filtered_indices(); + let query: String = app.rebase_branch_search.lines().join("").to_lowercase(); + let terms: Vec<&str> = query.split_whitespace().collect(); + + // Clamp selection + if !filtered.is_empty() && app.selected_rebase_branch_index >= filtered.len() { + app.selected_rebase_branch_index = filtered.len() - 1; + } + + let items: Vec = filtered .iter() .enumerate() - .map(|(i, branch)| { - let style = if i == app.selected_rebase_branch_index { + .map(|(i, &real_idx)| { + let branch = &app.rebase_branches[real_idx]; + let is_selected = i == app.selected_rebase_branch_index; + + let prefix = if is_selected { "▸ " } else { " " }; + let prefix_style = if is_selected { Style::default() .fg(Color::White) .bg(Color::Rgb(40, 40, 60)) @@ -2206,18 +2231,31 @@ fn draw_rebase_branch_picker(f: &mut Frame, app: &mut App) { } else { Style::default().fg(Color::Gray) }; - let prefix = if i == app.selected_rebase_branch_index { - "▸ " - } else { - " " - }; - ListItem::new(Line::from(vec![ - Span::styled(prefix, style), - Span::styled(branch, style), - ])) + + let mut spans: Vec = vec![Span::styled(prefix, prefix_style)]; + + // Branch name with match highlighting + for (seg, is_match) in highlight_segments(branch, &terms) { + let style = if is_match { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else if is_selected { + Style::default() + .fg(Color::White) + .bg(Color::Rgb(40, 40, 60)) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + spans.push(Span::styled(seg.to_string(), style)); + } + + ListItem::new(Line::from(spans)) }) .collect(); + let list_title = format!(" Branches ({}) ", filtered.len()); let list = List::new(items) .block( Block::default() @@ -2230,7 +2268,13 @@ fn draw_rebase_branch_picker(f: &mut Frame, app: &mut App) { ) .highlight_style(Style::default()); - f.render_stateful_widget(list, chunks[1], &mut app.rebase_branch_list_state); + app.rebase_branch_list_state.select(if filtered.is_empty() { + None + } else { + Some(app.selected_rebase_branch_index) + }); + + f.render_stateful_widget(list, chunks[2], &mut app.rebase_branch_list_state); } fn draw_session_picker(f: &mut Frame, app: &App) {