diff --git a/.changeset/kan-209-multi-select-cards.md b/.changeset/kan-209-multi-select-cards.md new file mode 100644 index 00000000..1cbc3b95 --- /dev/null +++ b/.changeset/kan-209-multi-select-cards.md @@ -0,0 +1,21 @@ +--- +bump: patch +--- + +- feat(tui): add bulk priority popup rendering +- feat(tui): add selection mode indicator to footer +- feat(tui): handle SetMultipleCardsPriority dialog in event loop +- feat(tui): add keyboard shortcuts for multi-select +- feat(tui): wire keybinding actions in execute_action +- feat(tui): add bulk priority popup handler +- feat(tui): update escape handler for selection mode +- feat(tui): add auto-select on navigation in selection mode +- feat(tui): implement vim-style selection mode toggle +- feat(tui): add bulk move for selected cards +- feat(tui): add card selection handler functions +- feat(tui): add card list keybindings for bulk operations +- feat(tui): register bulk priority dialog provider +- feat(tui): add BulkPriorityDialog component +- feat(tui): add keybinding actions for multi-select operations +- feat(tui): add SetMultipleCardsPriority dialog mode +- feat(tui): add selection_mode_active field to App diff --git a/README.md b/README.md index e97aec95..b209b267 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ Switch between view modes with `V`: **Card list** - `n` - New card, `e` - Edit, `r` - Rename - `d` - Archive, `c` - Toggle done, `p` - Set priority +- `v` - Toggle selection mode, `Ctrl+a` - Select all **Views & Search** - `V` - Toggle view mode, `/` - Search diff --git a/crates/kanban-tui/README.md b/crates/kanban-tui/README.md index e8b83b2b..9ef4558e 100644 --- a/crates/kanban-tui/README.md +++ b/crates/kanban-tui/README.md @@ -76,7 +76,14 @@ kanban myboard.json # Load a board from file | Key | Action | |-----|--------| -| `v` | Toggle selection on current card | +| `v` | Toggle selection mode (vim-style visual select) | +| `j` / `k` | Auto-select cards while navigating in selection mode | +| `Ctrl+a` | Select all cards in current view | +| `Esc` | Clear selections and exit selection mode | +| `P` | Set priority on all selected cards | +| `H` / `L` | Move all selected cards left/right | +| `c` | Toggle completion on all selected cards | +| `d` | Archive all selected cards | | `V` | Toggle view mode (Flat/Grouped/Kanban) | ### Search & Filter diff --git a/crates/kanban-tui/src/app.rs b/crates/kanban-tui/src/app.rs index 6e47d661..22644fbf 100644 --- a/crates/kanban-tui/src/app.rs +++ b/crates/kanban-tui/src/app.rs @@ -65,6 +65,7 @@ pub struct App { pub current_sort_field: Option, pub current_sort_order: Option, pub selected_cards: std::collections::HashSet, + pub selection_mode_active: bool, pub priority_selection: SelectionState, pub column_selection: SelectionState, pub task_list_view_selection: SelectionState, @@ -146,6 +147,7 @@ pub enum DialogMode { ImportBoard, SetCardPoints, SetCardPriority, + SetMultipleCardsPriority, SetBranchPrefix, OrderCards, CreateSprint, @@ -213,6 +215,7 @@ impl App { current_sort_field: None, current_sort_order: None, selected_cards: std::collections::HashSet::new(), + selection_mode_active: false, priority_selection: SelectionState::new(), column_selection: SelectionState::new(), task_list_view_selection: SelectionState::new(), @@ -422,6 +425,9 @@ impl App { KeybindingAction::ToggleArchivedView => self.handle_toggle_archived_cards_view(), KeybindingAction::ToggleTaskListView => self.handle_toggle_task_list_view(), KeybindingAction::ToggleCardSelection => self.handle_card_selection_toggle(), + KeybindingAction::ClearCardSelection => self.handle_clear_card_selection(), + KeybindingAction::SelectAllCards => self.handle_select_all_cards_in_view(), + KeybindingAction::SetSelectedCardsPriority => self.handle_set_selected_cards_priority(), KeybindingAction::Search => { if self.focus == Focus::Cards { self.search.activate(); @@ -510,6 +516,18 @@ impl App { return false; } + // Handle Ctrl+a for select all cards + if matches!(self.mode, AppMode::Normal) + && key + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('a')) + { + self.pending_key = None; + self.handle_select_all_cards_in_view(); + return false; + } + match self.mode { AppMode::Normal => match key.code { KeyCode::Char('/') => { @@ -618,6 +636,10 @@ impl App { self.pending_key = None; self.handle_toggle_task_list_view(); } + KeyCode::Char('P') => { + self.pending_key = None; + self.handle_set_selected_cards_priority(); + } KeyCode::Char('H') => { self.pending_key = None; self.handle_move_card_left(); @@ -726,6 +748,9 @@ impl App { should_restart_events = self.handle_set_card_points_dialog(key.code); } DialogMode::SetCardPriority => self.handle_set_card_priority_popup(key.code), + DialogMode::SetMultipleCardsPriority => { + self.handle_set_multiple_cards_priority_popup(key.code) + } DialogMode::SetBranchPrefix => self.handle_set_branch_prefix_dialog(key.code), DialogMode::SetSprintPrefix => self.handle_set_sprint_prefix_dialog(key.code), DialogMode::SetSprintCardPrefix => { diff --git a/crates/kanban-tui/src/components/selection_dialog.rs b/crates/kanban-tui/src/components/selection_dialog.rs index c5c8f137..bf2b4c3c 100644 --- a/crates/kanban-tui/src/components/selection_dialog.rs +++ b/crates/kanban-tui/src/components/selection_dialog.rs @@ -56,6 +56,56 @@ impl SelectionDialog for PriorityDialog { } } +pub struct BulkPriorityDialog { + pub count: usize, +} + +impl SelectionDialog for BulkPriorityDialog { + fn title(&self) -> &str { + "Set Priority (Bulk)" + } + + fn get_current_selection(&self, _app: &App) -> usize { + 0 + } + + fn options_count(&self, _app: &App) -> usize { + 4 // Low, Medium, High, Critical + } + + fn render(&self, app: &App, frame: &mut Frame) { + use crate::components::render_selection_popup_with_list_items; + use crate::theme::*; + use kanban_domain::CardPriority; + use ratatui::widgets::ListItem; + + let priorities = [ + CardPriority::Low, + CardPriority::Medium, + CardPriority::High, + CardPriority::Critical, + ]; + + let selected = app.priority_selection.get(); + + let items: Vec = priorities + .iter() + .enumerate() + .map(|(idx, priority)| { + let style = if Some(idx) == selected { + bold_highlight() + } else { + normal_text() + }; + ListItem::new(format!("{:?}", priority)).style(style) + }) + .collect(); + + let title = format!("Set Priority ({} cards)", self.count); + render_selection_popup_with_list_items(frame, &title, items, 35, 40); + } +} + pub struct SortFieldDialog; impl SelectionDialog for SortFieldDialog { diff --git a/crates/kanban-tui/src/handlers/card_handlers.rs b/crates/kanban-tui/src/handlers/card_handlers.rs index c281b428..47f80406 100644 --- a/crates/kanban-tui/src/handlers/card_handlers.rs +++ b/crates/kanban-tui/src/handlers/card_handlers.rs @@ -37,17 +37,47 @@ impl App { pub fn handle_card_selection_toggle(&mut self) { if self.focus == Focus::Cards { - if let Some(card) = self.get_selected_card_in_context() { - let card_id = card.id; - if self.selected_cards.contains(&card_id) { - self.selected_cards.remove(&card_id); - } else { - self.selected_cards.insert(card_id); + if self.selection_mode_active { + // Exit selection mode (keep selections) + self.selection_mode_active = false; + } else { + // Enter selection mode and select current card + self.selection_mode_active = true; + if let Some(card) = self.get_selected_card_in_context() { + self.selected_cards.insert(card.id); } } } } + pub fn handle_clear_card_selection(&mut self) { + self.selected_cards.clear(); + } + + pub fn handle_select_all_cards_in_view(&mut self) { + if self.focus != Focus::Cards { + return; + } + + if let Some(task_list) = self.view_strategy.get_active_task_list() { + for card_id in &task_list.cards { + self.selected_cards.insert(*card_id); + } + if !task_list.cards.is_empty() { + self.selection_mode_active = true; + } + } + } + + pub fn handle_set_selected_cards_priority(&mut self) { + if self.focus != Focus::Cards || self.selected_cards.is_empty() { + return; + } + + self.priority_selection.set(Some(0)); + self.open_dialog(DialogMode::SetMultipleCardsPriority); + } + pub fn handle_assign_to_sprint_key(&mut self) { if self.focus != Focus::Cards { return; @@ -273,6 +303,7 @@ impl App { tracing::info!("Toggled {} cards completion status", toggled_count); self.selected_cards.clear(); + self.selection_mode_active = false; self.refresh_view(); if let Some(card_id) = first_card_id { self.select_card_by_id(card_id); @@ -396,6 +427,11 @@ impl App { return; } + if !self.selected_cards.is_empty() { + self.move_selected_cards(direction); + return; + } + if let Some(card) = self.get_selected_card_in_context() { let board = self .active_board_index @@ -479,6 +515,78 @@ impl App { } } + fn move_selected_cards(&mut self, direction: kanban_domain::card_lifecycle::MoveDirection) { + let board = self + .active_board_index + .and_then(|idx| self.ctx.boards.get(idx)); + let board = match board { + Some(b) => b, + None => return, + }; + + let card_ids: Vec = self.selected_cards.iter().copied().collect(); + let first_card_id = card_ids.first().copied(); + let mut commands: Vec> = Vec::new(); + let mut moved_count = 0; + + for card_id in &card_ids { + let card = match self.ctx.cards.iter().find(|c| c.id == *card_id) { + Some(c) => c, + None => continue, + }; + + let move_result = kanban_domain::card_lifecycle::compute_card_column_move( + card, + board, + &self.ctx.columns, + &self.ctx.cards, + direction, + ); + + let move_result = match move_result { + Some(r) => r, + None => continue, + }; + + commands.push(Box::new(MoveCard { + card_id: *card_id, + new_column_id: move_result.target_column_id, + new_position: move_result.new_position, + })); + + if let Some(new_status) = move_result.new_status { + commands.push(Box::new(UpdateCard { + card_id: *card_id, + updates: CardUpdate { + status: Some(new_status), + ..Default::default() + }, + })); + } + + moved_count += 1; + } + + if !commands.is_empty() { + if let Err(e) = self.execute_commands_batch(commands) { + let dir = match direction { + kanban_domain::card_lifecycle::MoveDirection::Left => "left", + kanban_domain::card_lifecycle::MoveDirection::Right => "right", + }; + tracing::error!("Failed to move cards {}: {}", dir, e); + return; + } + } + + tracing::info!("Moved {} cards", moved_count); + self.selected_cards.clear(); + self.selection_mode_active = false; + self.refresh_view(); + if let Some(card_id) = first_card_id { + self.select_card_by_id(card_id); + } + } + pub fn handle_archive_card(&mut self) { if self.focus != Focus::Cards { return; @@ -497,6 +605,7 @@ impl App { self.start_delete_animation(card_id); } self.selected_cards.clear(); + self.selection_mode_active = false; } fn start_delete_animation(&mut self, card_id: uuid::Uuid) { @@ -563,6 +672,7 @@ impl App { self.start_restore_animation(card_id); } self.selected_cards.clear(); + self.selection_mode_active = false; } fn start_restore_animation(&mut self, card_id: uuid::Uuid) { @@ -639,6 +749,7 @@ impl App { self.start_permanent_delete_animation(card_id); } self.selected_cards.clear(); + self.selection_mode_active = false; } fn start_permanent_delete_animation(&mut self, card_id: uuid::Uuid) { diff --git a/crates/kanban-tui/src/handlers/navigation_handlers.rs b/crates/kanban-tui/src/handlers/navigation_handlers.rs index 62106a19..b5834890 100644 --- a/crates/kanban-tui/src/handlers/navigation_handlers.rs +++ b/crates/kanban-tui/src/handlers/navigation_handlers.rs @@ -381,6 +381,13 @@ impl App { } } + // If selection mode active, add the new current card to selection + if self.selection_mode_active { + if let Some(card) = self.get_selected_card_in_context() { + self.selected_cards.insert(card.id); + } + } + // Check for bottom navigation: only switch columns if we were ALREADY at bottom if was_at_bottom { self.view_strategy.navigate_right(false); @@ -419,6 +426,13 @@ impl App { } } + // If selection mode active, add the new current card to selection + if self.selection_mode_active { + if let Some(card) = self.get_selected_card_in_context() { + self.selected_cards.insert(card.id); + } + } + // Check for top navigation: only switch columns if we were ALREADY at top if was_at_top { self.view_strategy.navigate_left(true); @@ -482,6 +496,13 @@ impl App { } pub fn handle_escape_key(&mut self) { + // Clear selection mode first (only when actively in selection mode) + if self.selection_mode_active { + self.selection_mode_active = false; + self.selected_cards.clear(); + return; + } + if self.active_board_index.is_some() { self.active_board_index = None; self.focus = Focus::Boards; diff --git a/crates/kanban-tui/src/handlers/popup_handlers.rs b/crates/kanban-tui/src/handlers/popup_handlers.rs index 75a15f7d..157cb423 100644 --- a/crates/kanban-tui/src/handlers/popup_handlers.rs +++ b/crates/kanban-tui/src/handlers/popup_handlers.rs @@ -5,6 +5,8 @@ use kanban_domain::{ dependencies::CardGraphExt, FieldUpdate, Snapshot, SortField, SortOrder, Sprint, }; +const PRIORITY_COUNT: usize = 4; + impl App { pub fn handle_import_board_popup(&mut self, key_code: KeyCode) { match key_code { @@ -39,7 +41,7 @@ impl App { self.pop_mode(); } KeyCode::Char('j') | KeyCode::Down => { - self.priority_selection.next(4); + self.priority_selection.next(PRIORITY_COUNT); } KeyCode::Char('k') | KeyCode::Up => { self.priority_selection.prev(); @@ -76,6 +78,66 @@ impl App { } } + pub fn handle_set_multiple_cards_priority_popup(&mut self, key_code: KeyCode) { + match key_code { + KeyCode::Esc => { + self.pop_mode(); + self.priority_selection.clear(); + } + KeyCode::Char('j') | KeyCode::Down => { + self.priority_selection.next(PRIORITY_COUNT); + } + KeyCode::Char('k') | KeyCode::Up => { + self.priority_selection.prev(); + } + KeyCode::Enter => { + if let Some(priority_idx) = self.priority_selection.get() { + use kanban_domain::{CardPriority, CardUpdate}; + let priority = match priority_idx { + 0 => CardPriority::Low, + 1 => CardPriority::Medium, + 2 => CardPriority::High, + 3 => CardPriority::Critical, + _ => CardPriority::Medium, + }; + + let card_ids: Vec = self.selected_cards.iter().copied().collect(); + let mut commands: Vec> = Vec::new(); + + for card_id in &card_ids { + let cmd = Box::new(kanban_domain::commands::UpdateCard { + card_id: *card_id, + updates: CardUpdate { + priority: Some(priority), + ..Default::default() + }, + }) + as Box; + commands.push(cmd); + } + + if !commands.is_empty() { + if let Err(e) = self.execute_commands_batch(commands) { + tracing::error!("Failed to update cards priority: {}", e); + } else { + tracing::info!( + "Set priority to {:?} for {} cards", + priority, + card_ids.len() + ); + } + } + + self.selected_cards.clear(); + self.selection_mode_active = false; + } + self.pop_mode(); + self.priority_selection.clear(); + } + _ => {} + } + } + pub fn handle_order_cards_popup(&mut self, key_code: KeyCode) -> bool { match key_code { KeyCode::Esc => { @@ -283,6 +345,7 @@ impl App { self.pop_mode(); self.sprint_assign_selection.clear(); self.selected_cards.clear(); + self.selection_mode_active = false; } KeyCode::Char('j') | KeyCode::Down => { if let Some(board_idx) = self.active_board_index { @@ -391,6 +454,7 @@ impl App { self.pop_mode(); self.sprint_assign_selection.clear(); self.selected_cards.clear(); + self.selection_mode_active = false; } _ => {} } diff --git a/crates/kanban-tui/src/keybindings/card_list.rs b/crates/kanban-tui/src/keybindings/card_list.rs index 4051f108..5185fe72 100644 --- a/crates/kanban-tui/src/keybindings/card_list.rs +++ b/crates/kanban-tui/src/keybindings/card_list.rs @@ -52,6 +52,24 @@ impl KeybindingProvider for CardListProvider { "Select task for bulk operation", KeybindingAction::ToggleCardSelection, ), + Keybinding::new( + "Ctrl+a", + "select all", + "Select all visible tasks", + KeybindingAction::SelectAllCards, + ), + Keybinding::new( + "Esc", + "clear", + "Clear selection", + KeybindingAction::ClearCardSelection, + ), + Keybinding::new( + "P", + "priority", + "Set priority (bulk)", + KeybindingAction::SetSelectedCardsPriority, + ), Keybinding::new( "V", "view", diff --git a/crates/kanban-tui/src/keybindings/mod.rs b/crates/kanban-tui/src/keybindings/mod.rs index 8d572e15..c85e6bff 100644 --- a/crates/kanban-tui/src/keybindings/mod.rs +++ b/crates/kanban-tui/src/keybindings/mod.rs @@ -43,6 +43,9 @@ pub enum KeybindingAction { ToggleArchivedView, ToggleTaskListView, ToggleCardSelection, + ClearCardSelection, + SelectAllCards, + SetSelectedCardsPriority, Search, ShowHelp, Escape, diff --git a/crates/kanban-tui/src/keybindings/registry.rs b/crates/kanban-tui/src/keybindings/registry.rs index 5a34ab46..04a5db43 100644 --- a/crates/kanban-tui/src/keybindings/registry.rs +++ b/crates/kanban-tui/src/keybindings/registry.rs @@ -66,6 +66,9 @@ impl KeybindingRegistry { DialogMode::SetCardPriority => { Box::new(DialogSelectionProvider::new("Set Priority")) } + DialogMode::SetMultipleCardsPriority => { + Box::new(DialogSelectionProvider::new("Set Priority (Bulk)")) + } DialogMode::OrderCards => Box::new(DialogSelectionProvider::new("Sort Tasks")), DialogMode::AssignCardToSprint => { Box::new(DialogSelectionProvider::new("Assign to Sprint")) diff --git a/crates/kanban-tui/src/ui.rs b/crates/kanban-tui/src/ui.rs index ca8e2e7c..897a90b3 100644 --- a/crates/kanban-tui/src/ui.rs +++ b/crates/kanban-tui/src/ui.rs @@ -54,6 +54,9 @@ pub fn render(app: &mut App, frame: &mut Frame) { DialogMode::ImportBoard => render_import_board_popup(app, frame), DialogMode::SetCardPoints => render_set_card_points_popup(app, frame), DialogMode::SetCardPriority => render_set_card_priority_popup(app, frame), + DialogMode::SetMultipleCardsPriority => { + render_set_multiple_cards_priority_popup(app, frame) + } DialogMode::SetBranchPrefix => render_set_branch_prefix_popup(app, frame), DialogMode::SetSprintPrefix => render_set_sprint_prefix_popup(app, frame), DialogMode::SetSprintCardPrefix => render_set_sprint_card_prefix_popup(app, frame), @@ -506,6 +509,14 @@ fn render_footer(app: &App, frame: &mut Frame, area: Rect) { use crate::keybindings::KeybindingRegistry; + let selection_prefix = if app.selection_mode_active { + format!("-- SELECT ({}) -- | ", app.selected_cards.len()) + } else if !app.selected_cards.is_empty() { + format!("({} selected) | ", app.selected_cards.len()) + } else { + String::new() + }; + let help_text: String = if let AppMode::SprintDetail = app.mode { let component = match app.sprint_task_panel { crate::app::SprintTaskPanel::Uncompleted => &app.sprint_uncompleted_component, @@ -520,16 +531,17 @@ fn render_footer(app: &App, frame: &mut Frame, area: Rect) { .collect::>() .join(" | "); let component_help = component.help_text(); - format!("{} | {}", keybindings, component_help) + format!("{}{} | {}", selection_prefix, keybindings, component_help) } else { let provider = KeybindingRegistry::get_provider(app); let context = provider.get_context(); - context + let keybindings = context .bindings .iter() .map(|b| format!("{}: {}", b.key, b.short_description)) .collect::>() - .join(" | ") + .join(" | "); + format!("{}{}", selection_prefix, keybindings) }; let help = Paragraph::new(help_text) .style(label_text()) @@ -583,6 +595,14 @@ fn render_set_card_priority_popup(app: &App, frame: &mut Frame) { dialog.render(app, frame); } +fn render_set_multiple_cards_priority_popup(app: &App, frame: &mut Frame) { + use crate::components::{BulkPriorityDialog, SelectionDialog}; + let dialog = BulkPriorityDialog { + count: app.selected_cards.len(), + }; + dialog.render(app, frame); +} + fn render_relationship_boxes( app: &App, frame: &mut Frame,