Skip to content

Commit 915364a

Browse files
authored
KAN-209/multi-select-cards (#168)
* feat(tui): add selection_mode_active field to App * feat(tui): add SetMultipleCardsPriority dialog mode * feat(tui): add keybinding actions for multi-select operations * feat(tui): add BulkPriorityDialog component * feat(tui): register bulk priority dialog provider * feat(tui): add card list keybindings for bulk operations * feat(tui): add card selection handler functions * feat(tui): add bulk move for selected cards * feat(tui): implement vim-style selection mode toggle * feat(tui): add auto-select on navigation in selection mode * feat(tui): update escape handler for selection mode * feat(tui): add bulk priority popup handler * feat(tui): wire keybinding actions in execute_action * feat(tui): add keyboard shortcuts for multi-select * feat(tui): handle SetMultipleCardsPriority dialog in event loop * feat(tui): add selection mode indicator to footer * feat(tui): add bulk priority popup rendering * chore: add changeset * refactor(tui): extract PRIORITY_COUNT constant * fix(tui): only intercept escape when in selection mode * fix(tui): clear selection_mode_active after bulk operations * docs(tui): document multi-select and bulk operations * docs: add bulk operations to usage section * fix(tui): activate selection mode when selecting all cards
1 parent 61c02d8 commit 915364a

File tree

12 files changed

+355
-11
lines changed

12 files changed

+355
-11
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
bump: patch
3+
---
4+
5+
- feat(tui): add bulk priority popup rendering
6+
- feat(tui): add selection mode indicator to footer
7+
- feat(tui): handle SetMultipleCardsPriority dialog in event loop
8+
- feat(tui): add keyboard shortcuts for multi-select
9+
- feat(tui): wire keybinding actions in execute_action
10+
- feat(tui): add bulk priority popup handler
11+
- feat(tui): update escape handler for selection mode
12+
- feat(tui): add auto-select on navigation in selection mode
13+
- feat(tui): implement vim-style selection mode toggle
14+
- feat(tui): add bulk move for selected cards
15+
- feat(tui): add card selection handler functions
16+
- feat(tui): add card list keybindings for bulk operations
17+
- feat(tui): register bulk priority dialog provider
18+
- feat(tui): add BulkPriorityDialog component
19+
- feat(tui): add keybinding actions for multi-select operations
20+
- feat(tui): add SetMultipleCardsPriority dialog mode
21+
- feat(tui): add selection_mode_active field to App

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ Switch between view modes with `V`:
126126
**Card list**
127127
- `n` - New card, `e` - Edit, `r` - Rename
128128
- `d` - Archive, `c` - Toggle done, `p` - Set priority
129+
- `v` - Toggle selection mode, `Ctrl+a` - Select all
129130

130131
**Views & Search**
131132
- `V` - Toggle view mode, `/` - Search

crates/kanban-tui/README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,14 @@ kanban myboard.json # Load a board from file
7676

7777
| Key | Action |
7878
|-----|--------|
79-
| `v` | Toggle selection on current card |
79+
| `v` | Toggle selection mode (vim-style visual select) |
80+
| `j` / `k` | Auto-select cards while navigating in selection mode |
81+
| `Ctrl+a` | Select all cards in current view |
82+
| `Esc` | Clear selections and exit selection mode |
83+
| `P` | Set priority on all selected cards |
84+
| `H` / `L` | Move all selected cards left/right |
85+
| `c` | Toggle completion on all selected cards |
86+
| `d` | Archive all selected cards |
8087
| `V` | Toggle view mode (Flat/Grouped/Kanban) |
8188

8289
### Search & Filter

crates/kanban-tui/src/app.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ pub struct App {
6565
pub current_sort_field: Option<SortField>,
6666
pub current_sort_order: Option<SortOrder>,
6767
pub selected_cards: std::collections::HashSet<uuid::Uuid>,
68+
pub selection_mode_active: bool,
6869
pub priority_selection: SelectionState,
6970
pub column_selection: SelectionState,
7071
pub task_list_view_selection: SelectionState,
@@ -146,6 +147,7 @@ pub enum DialogMode {
146147
ImportBoard,
147148
SetCardPoints,
148149
SetCardPriority,
150+
SetMultipleCardsPriority,
149151
SetBranchPrefix,
150152
OrderCards,
151153
CreateSprint,
@@ -213,6 +215,7 @@ impl App {
213215
current_sort_field: None,
214216
current_sort_order: None,
215217
selected_cards: std::collections::HashSet::new(),
218+
selection_mode_active: false,
216219
priority_selection: SelectionState::new(),
217220
column_selection: SelectionState::new(),
218221
task_list_view_selection: SelectionState::new(),
@@ -422,6 +425,9 @@ impl App {
422425
KeybindingAction::ToggleArchivedView => self.handle_toggle_archived_cards_view(),
423426
KeybindingAction::ToggleTaskListView => self.handle_toggle_task_list_view(),
424427
KeybindingAction::ToggleCardSelection => self.handle_card_selection_toggle(),
428+
KeybindingAction::ClearCardSelection => self.handle_clear_card_selection(),
429+
KeybindingAction::SelectAllCards => self.handle_select_all_cards_in_view(),
430+
KeybindingAction::SetSelectedCardsPriority => self.handle_set_selected_cards_priority(),
425431
KeybindingAction::Search => {
426432
if self.focus == Focus::Cards {
427433
self.search.activate();
@@ -510,6 +516,18 @@ impl App {
510516
return false;
511517
}
512518

519+
// Handle Ctrl+a for select all cards
520+
if matches!(self.mode, AppMode::Normal)
521+
&& key
522+
.modifiers
523+
.contains(crossterm::event::KeyModifiers::CONTROL)
524+
&& matches!(key.code, KeyCode::Char('a'))
525+
{
526+
self.pending_key = None;
527+
self.handle_select_all_cards_in_view();
528+
return false;
529+
}
530+
513531
match self.mode {
514532
AppMode::Normal => match key.code {
515533
KeyCode::Char('/') => {
@@ -618,6 +636,10 @@ impl App {
618636
self.pending_key = None;
619637
self.handle_toggle_task_list_view();
620638
}
639+
KeyCode::Char('P') => {
640+
self.pending_key = None;
641+
self.handle_set_selected_cards_priority();
642+
}
621643
KeyCode::Char('H') => {
622644
self.pending_key = None;
623645
self.handle_move_card_left();
@@ -726,6 +748,9 @@ impl App {
726748
should_restart_events = self.handle_set_card_points_dialog(key.code);
727749
}
728750
DialogMode::SetCardPriority => self.handle_set_card_priority_popup(key.code),
751+
DialogMode::SetMultipleCardsPriority => {
752+
self.handle_set_multiple_cards_priority_popup(key.code)
753+
}
729754
DialogMode::SetBranchPrefix => self.handle_set_branch_prefix_dialog(key.code),
730755
DialogMode::SetSprintPrefix => self.handle_set_sprint_prefix_dialog(key.code),
731756
DialogMode::SetSprintCardPrefix => {

crates/kanban-tui/src/components/selection_dialog.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,56 @@ impl SelectionDialog for PriorityDialog {
5656
}
5757
}
5858

59+
pub struct BulkPriorityDialog {
60+
pub count: usize,
61+
}
62+
63+
impl SelectionDialog for BulkPriorityDialog {
64+
fn title(&self) -> &str {
65+
"Set Priority (Bulk)"
66+
}
67+
68+
fn get_current_selection(&self, _app: &App) -> usize {
69+
0
70+
}
71+
72+
fn options_count(&self, _app: &App) -> usize {
73+
4 // Low, Medium, High, Critical
74+
}
75+
76+
fn render(&self, app: &App, frame: &mut Frame) {
77+
use crate::components::render_selection_popup_with_list_items;
78+
use crate::theme::*;
79+
use kanban_domain::CardPriority;
80+
use ratatui::widgets::ListItem;
81+
82+
let priorities = [
83+
CardPriority::Low,
84+
CardPriority::Medium,
85+
CardPriority::High,
86+
CardPriority::Critical,
87+
];
88+
89+
let selected = app.priority_selection.get();
90+
91+
let items: Vec<ListItem> = priorities
92+
.iter()
93+
.enumerate()
94+
.map(|(idx, priority)| {
95+
let style = if Some(idx) == selected {
96+
bold_highlight()
97+
} else {
98+
normal_text()
99+
};
100+
ListItem::new(format!("{:?}", priority)).style(style)
101+
})
102+
.collect();
103+
104+
let title = format!("Set Priority ({} cards)", self.count);
105+
render_selection_popup_with_list_items(frame, &title, items, 35, 40);
106+
}
107+
}
108+
59109
pub struct SortFieldDialog;
60110

61111
impl SelectionDialog for SortFieldDialog {

crates/kanban-tui/src/handlers/card_handlers.rs

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,47 @@ impl App {
3737

3838
pub fn handle_card_selection_toggle(&mut self) {
3939
if self.focus == Focus::Cards {
40-
if let Some(card) = self.get_selected_card_in_context() {
41-
let card_id = card.id;
42-
if self.selected_cards.contains(&card_id) {
43-
self.selected_cards.remove(&card_id);
44-
} else {
45-
self.selected_cards.insert(card_id);
40+
if self.selection_mode_active {
41+
// Exit selection mode (keep selections)
42+
self.selection_mode_active = false;
43+
} else {
44+
// Enter selection mode and select current card
45+
self.selection_mode_active = true;
46+
if let Some(card) = self.get_selected_card_in_context() {
47+
self.selected_cards.insert(card.id);
4648
}
4749
}
4850
}
4951
}
5052

53+
pub fn handle_clear_card_selection(&mut self) {
54+
self.selected_cards.clear();
55+
}
56+
57+
pub fn handle_select_all_cards_in_view(&mut self) {
58+
if self.focus != Focus::Cards {
59+
return;
60+
}
61+
62+
if let Some(task_list) = self.view_strategy.get_active_task_list() {
63+
for card_id in &task_list.cards {
64+
self.selected_cards.insert(*card_id);
65+
}
66+
if !task_list.cards.is_empty() {
67+
self.selection_mode_active = true;
68+
}
69+
}
70+
}
71+
72+
pub fn handle_set_selected_cards_priority(&mut self) {
73+
if self.focus != Focus::Cards || self.selected_cards.is_empty() {
74+
return;
75+
}
76+
77+
self.priority_selection.set(Some(0));
78+
self.open_dialog(DialogMode::SetMultipleCardsPriority);
79+
}
80+
5181
pub fn handle_assign_to_sprint_key(&mut self) {
5282
if self.focus != Focus::Cards {
5383
return;
@@ -273,6 +303,7 @@ impl App {
273303

274304
tracing::info!("Toggled {} cards completion status", toggled_count);
275305
self.selected_cards.clear();
306+
self.selection_mode_active = false;
276307
self.refresh_view();
277308
if let Some(card_id) = first_card_id {
278309
self.select_card_by_id(card_id);
@@ -396,6 +427,11 @@ impl App {
396427
return;
397428
}
398429

430+
if !self.selected_cards.is_empty() {
431+
self.move_selected_cards(direction);
432+
return;
433+
}
434+
399435
if let Some(card) = self.get_selected_card_in_context() {
400436
let board = self
401437
.active_board_index
@@ -479,6 +515,78 @@ impl App {
479515
}
480516
}
481517

518+
fn move_selected_cards(&mut self, direction: kanban_domain::card_lifecycle::MoveDirection) {
519+
let board = self
520+
.active_board_index
521+
.and_then(|idx| self.ctx.boards.get(idx));
522+
let board = match board {
523+
Some(b) => b,
524+
None => return,
525+
};
526+
527+
let card_ids: Vec<uuid::Uuid> = self.selected_cards.iter().copied().collect();
528+
let first_card_id = card_ids.first().copied();
529+
let mut commands: Vec<Box<dyn kanban_domain::commands::Command>> = Vec::new();
530+
let mut moved_count = 0;
531+
532+
for card_id in &card_ids {
533+
let card = match self.ctx.cards.iter().find(|c| c.id == *card_id) {
534+
Some(c) => c,
535+
None => continue,
536+
};
537+
538+
let move_result = kanban_domain::card_lifecycle::compute_card_column_move(
539+
card,
540+
board,
541+
&self.ctx.columns,
542+
&self.ctx.cards,
543+
direction,
544+
);
545+
546+
let move_result = match move_result {
547+
Some(r) => r,
548+
None => continue,
549+
};
550+
551+
commands.push(Box::new(MoveCard {
552+
card_id: *card_id,
553+
new_column_id: move_result.target_column_id,
554+
new_position: move_result.new_position,
555+
}));
556+
557+
if let Some(new_status) = move_result.new_status {
558+
commands.push(Box::new(UpdateCard {
559+
card_id: *card_id,
560+
updates: CardUpdate {
561+
status: Some(new_status),
562+
..Default::default()
563+
},
564+
}));
565+
}
566+
567+
moved_count += 1;
568+
}
569+
570+
if !commands.is_empty() {
571+
if let Err(e) = self.execute_commands_batch(commands) {
572+
let dir = match direction {
573+
kanban_domain::card_lifecycle::MoveDirection::Left => "left",
574+
kanban_domain::card_lifecycle::MoveDirection::Right => "right",
575+
};
576+
tracing::error!("Failed to move cards {}: {}", dir, e);
577+
return;
578+
}
579+
}
580+
581+
tracing::info!("Moved {} cards", moved_count);
582+
self.selected_cards.clear();
583+
self.selection_mode_active = false;
584+
self.refresh_view();
585+
if let Some(card_id) = first_card_id {
586+
self.select_card_by_id(card_id);
587+
}
588+
}
589+
482590
pub fn handle_archive_card(&mut self) {
483591
if self.focus != Focus::Cards {
484592
return;
@@ -497,6 +605,7 @@ impl App {
497605
self.start_delete_animation(card_id);
498606
}
499607
self.selected_cards.clear();
608+
self.selection_mode_active = false;
500609
}
501610

502611
fn start_delete_animation(&mut self, card_id: uuid::Uuid) {
@@ -563,6 +672,7 @@ impl App {
563672
self.start_restore_animation(card_id);
564673
}
565674
self.selected_cards.clear();
675+
self.selection_mode_active = false;
566676
}
567677

568678
fn start_restore_animation(&mut self, card_id: uuid::Uuid) {
@@ -639,6 +749,7 @@ impl App {
639749
self.start_permanent_delete_animation(card_id);
640750
}
641751
self.selected_cards.clear();
752+
self.selection_mode_active = false;
642753
}
643754

644755
fn start_permanent_delete_animation(&mut self, card_id: uuid::Uuid) {

crates/kanban-tui/src/handlers/navigation_handlers.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,13 @@ impl App {
381381
}
382382
}
383383

384+
// If selection mode active, add the new current card to selection
385+
if self.selection_mode_active {
386+
if let Some(card) = self.get_selected_card_in_context() {
387+
self.selected_cards.insert(card.id);
388+
}
389+
}
390+
384391
// Check for bottom navigation: only switch columns if we were ALREADY at bottom
385392
if was_at_bottom {
386393
self.view_strategy.navigate_right(false);
@@ -419,6 +426,13 @@ impl App {
419426
}
420427
}
421428

429+
// If selection mode active, add the new current card to selection
430+
if self.selection_mode_active {
431+
if let Some(card) = self.get_selected_card_in_context() {
432+
self.selected_cards.insert(card.id);
433+
}
434+
}
435+
422436
// Check for top navigation: only switch columns if we were ALREADY at top
423437
if was_at_top {
424438
self.view_strategy.navigate_left(true);
@@ -482,6 +496,13 @@ impl App {
482496
}
483497

484498
pub fn handle_escape_key(&mut self) {
499+
// Clear selection mode first (only when actively in selection mode)
500+
if self.selection_mode_active {
501+
self.selection_mode_active = false;
502+
self.selected_cards.clear();
503+
return;
504+
}
505+
485506
if self.active_board_index.is_some() {
486507
self.active_board_index = None;
487508
self.focus = Focus::Boards;

0 commit comments

Comments
 (0)