1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5 pub(in crate::app) fn persist_card_order(&mut self) -> bool {
9 let board_name = self.active_board_name().to_string();
10 let mut order = Vec::new();
11 let active_config = self.active_board_config().clone();
12 order.extend(
13 self.board
14 .columns
15 .iter()
16 .flat_map(|col| board::sparse_card_order(&active_config, &col.cards)),
17 );
18 order.extend(
19 self.board
20 .hidden
21 .iter()
22 .flat_map(|col| board::sparse_card_order(&active_config, &col.cards)),
23 );
24 order.extend(board::sparse_card_order(
25 &active_config,
26 &self.board.mismatch.cards,
27 ));
28 let Some(board_config) = self.store.config.kanban.configurations.get_mut(&board_name)
29 else {
30 return false;
31 };
32 board_config.card_order = order;
33 self.persist_config()
34 }
35
36 pub(in crate::app) fn reorder_selected_within_column(
37 &mut self,
38 col: usize,
39 from_row: usize,
40 to_row: usize,
41 ) -> Result<Action> {
42 let (filename, number, column_name) = {
43 let Some(column) = self.board.columns.get_mut(col) else {
44 return Ok(Action::None);
45 };
46 if from_row >= column.cards.len() || to_row >= column.cards.len() || from_row == to_row
47 {
48 return Ok(Action::None);
49 }
50 let card = column.cards.remove(from_row);
51 let filename = card.filename.clone();
52 let number = card.number;
53 let insert_at = to_row;
54 column.cards.insert(insert_at, card);
55 (filename, number, column.name.clone())
56 };
57
58 if !self.persist_card_order() {
59 return Ok(Action::None);
60 }
61 self.rebuild_board();
62 self.select_card_by_filename(&filename);
63 self.status_msg = Some(format!("Reordered #{} in {}", number, column_name));
64 Ok(Action::None)
65 }
66
67 pub(in crate::app) fn move_selected_within_column(&mut self, delta: isize) -> Result<Action> {
69 let col = self.board.active_col;
70 let column_name = if col < self.board.columns.len() {
71 self.board.columns[col].name.clone()
72 } else if col == self.board.columns.len() && self.board.show_mismatch {
73 self.board.mismatch.name.clone()
74 } else {
75 return Ok(Action::None);
76 };
77 let (filename, number) = {
78 let cards = if col < self.board.columns.len() {
79 &mut self.board.columns[col].cards
80 } else {
81 &mut self.board.mismatch.cards
82 };
83 let len = cards.len();
84 if len < 2 {
85 return Ok(Action::None);
86 }
87 let from_row = self.board.selected;
88 if from_row >= len {
89 return Ok(Action::None);
90 }
91 let to_row = if delta < 0 {
92 from_row.saturating_sub(delta.unsigned_abs())
93 } else {
94 usize::min(from_row + delta as usize, len - 1)
95 };
96 if to_row == from_row {
97 return Ok(Action::None);
98 }
99 let card = cards.remove(from_row);
100 let filename = card.filename.clone();
101 let number = card.number;
102 let insert_at = to_row;
103 cards.insert(insert_at, card);
104 (filename, number)
105 };
106 if !self.persist_card_order() {
107 return Ok(Action::None);
108 }
109 self.rebuild_board();
110 self.select_card_by_filename(&filename);
111 self.status_msg = Some(format!(
112 "Moved #{} {} in {}",
113 number,
114 if delta < 0 { "up" } else { "down" },
115 column_name
116 ));
117 Ok(Action::None)
118 }
119
120 pub(in crate::app) fn open_card_action_menu(&mut self) -> Result<Action> {
129 let (col, row) = self
130 .board
131 .sole_visible_card()
132 .unwrap_or((self.board.active_col, self.board.selected));
133 self.mouse.last_down = None;
134 self.open_action_menu(Some(HitTarget::Card { col, row }), false)
135 }
136
137 #[allow(clippy::too_many_lines)]
138 pub(in crate::app) fn handle_board_key(&mut self, key: KeyEvent) -> Result<Action> {
139 if key
140 .modifiers
141 .contains(crossterm::event::KeyModifiers::CONTROL)
142 {
143 match key.code {
144 KeyCode::Up => return self.move_selected_within_column(-1),
145 KeyCode::Down => return self.move_selected_within_column(1),
146 _ => {}
147 }
148 }
149 match key.code {
150 KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
151 self.quit = true;
152 Ok(Action::Quit)
153 }
154 KeyCode::Tab => {
155 self.tabs.next();
156 self.active_config = self.tabs.selected;
157 self.switch_config();
158 Ok(Action::None)
159 }
160 KeyCode::BackTab => {
161 self.tabs.previous();
162 self.active_config = self.tabs.selected;
163 self.switch_config();
164 Ok(Action::None)
165 }
166 KeyCode::Left | KeyCode::Char('h') => {
167 self.board.move_left();
168 Ok(Action::None)
169 }
170 KeyCode::Right | KeyCode::Char('l') => {
171 self.board.move_right();
172 Ok(Action::None)
173 }
174 KeyCode::Up | KeyCode::Char('k') => {
175 self.board.move_up();
176 Ok(Action::None)
177 }
178 KeyCode::Down | KeyCode::Char('j') => {
179 self.board.move_down();
180 Ok(Action::None)
181 }
182 KeyCode::Enter => self.open_card_action_menu(),
183 KeyCode::Char('e') => {
184 if let Some(card) = self.board.get_selected_card() {
185 Ok(Action::OpenEditor(card.filename.clone()))
186 } else {
187 Ok(Action::None)
188 }
189 }
190 KeyCode::Char('i') => {
195 self.open_mentions();
196 Ok(Action::None)
197 }
198 KeyCode::Char('V') => {
203 if let Some(card) = self.board.get_selected_card() {
204 Ok(Action::OpenViewer(card.filename.clone()))
205 } else {
206 Ok(Action::None)
207 }
208 }
209 KeyCode::Char('A') => {
210 if let Some(card) = self.board.get_selected_card() {
211 Ok(Action::OpenAgent(card.filename.clone()))
212 } else {
213 Ok(Action::None)
214 }
215 }
216 KeyCode::Char('c') => {
217 self.toggle_column_visibility();
218 Ok(Action::None)
219 }
220 KeyCode::Char('f') => {
221 self.open_filter_modal();
222 Ok(Action::None)
223 }
224 KeyCode::Char(' ') => {
235 self.toggle_mark_selected();
236 Ok(Action::None)
237 }
238 KeyCode::Char('x') => {
239 self.unmark_selected_card();
240 Ok(Action::None)
241 }
242 KeyCode::Char('X') => {
243 self.clear_marks();
244 Ok(Action::None)
245 }
246 KeyCode::Char('B') => {
247 let action = self
248 .bulk
249 .as_ref()
250 .map(|m| m.action)
251 .unwrap_or(crate::ui::bulk::BatchAction::Edit);
252 self.open_bulk_modal(action);
253 Ok(Action::None)
254 }
255 KeyCode::Char('<') => {
256 let real_cols = self.board.real_cols();
257 if real_cols > 0 {
258 let target = if self.board.active_col == 0 || self.board.active_col >= real_cols
259 {
260 real_cols - 1
261 } else {
262 self.board.active_col - 1
263 };
264 self.move_selected_to(target)
265 } else {
266 Ok(Action::None)
267 }
268 }
269 KeyCode::Char('>') => {
270 let real_cols = self.board.real_cols();
271 if real_cols > 0 {
272 let target = if self.board.active_col >= real_cols {
273 0
274 } else {
275 (self.board.active_col + 1) % real_cols
276 };
277 self.move_selected_to(target)
278 } else {
279 Ok(Action::None)
280 }
281 }
282 KeyCode::Char('n') => {
283 self.open_new_issue_form();
284 Ok(Action::None)
285 }
286 KeyCode::Char('C') => {
287 self.clone_selected_into_form();
288 Ok(Action::None)
289 }
290 KeyCode::Char('a') => {
295 self.show_archived = !self.show_archived;
296 self.refresh_corpus()?;
297 self.status_msg = Some(
298 if self.show_archived {
299 "Showing archived issues"
300 } else {
301 "Hiding archived issues"
302 }
303 .to_string(),
304 );
305 Ok(Action::None)
306 }
307 KeyCode::Char('u') => {
312 self.show_children = !self.show_children;
313 self.rebuild_board();
314 self.status_msg = Some(
315 if self.show_children {
316 "Showing sub-issues"
317 } else {
318 "Hiding sub-issues"
319 }
320 .to_string(),
321 );
322 Ok(Action::None)
323 }
324 KeyCode::PageDown => {
333 self.move_selection_by_page(1);
334 Ok(Action::None)
335 }
336 KeyCode::PageUp => {
337 self.move_selection_by_page(-1);
338 Ok(Action::None)
339 }
340 KeyCode::Char('d') => {
341 if let Some(card) = self.board.get_selected_card().cloned() {
342 self.confirm_delete_target = Some(card.filename);
343 self.mode = Mode::ConfirmDelete;
344 }
345 Ok(Action::None)
346 }
347 KeyCode::Char('r') => {
348 self.refresh_corpus()?;
349 self.status_msg = Some("Refreshed".to_string());
350 Ok(Action::None)
351 }
352 KeyCode::Char('s') => {
353 self.open_settings(None);
354 Ok(Action::None)
355 }
356 KeyCode::Char('t') => self.open_triage_inbox(),
361 KeyCode::Char('v') => {
365 self.open_views();
366 Ok(Action::None)
367 }
368 KeyCode::Char('m') | KeyCode::Char('M') => {
369 self.open_planning_modal();
370 Ok(Action::None)
371 }
372 KeyCode::Char('b') => {
373 self.open_settings(Some(SettingsPrompt::NewBoard(String::new())));
374 Ok(Action::None)
375 }
376 KeyCode::Char('?') => {
377 self.open_help(Mode::Board);
378 Ok(Action::None)
379 }
380 KeyCode::Char('/') => {
381 let buffer = self.filter.clone().unwrap_or_default();
382 self.mode = Mode::Filter { buffer };
383 Ok(Action::None)
384 }
385 _ => Ok(Action::None),
386 }
387 }
388
389 pub(in crate::app) fn clone_selected_into_form(&mut self) {
393 let Some(card) = self.board.get_selected_card().cloned() else {
394 self.status_msg = Some("No issue selected to clone".to_string());
395 return;
396 };
397 match crate::commands::view::get(&self.store, &card.filename) {
398 Ok(issue) => {
399 self.status_msg = Some(format!("Cloning #{}", issue.number));
400 let entry = self.new_issue_target_status(NewIssueTarget::Triage);
401 let mut form = FormState::from_issue(&issue);
402 if issue.status.to_string().trim() == entry {
403 form.target = NewIssueTarget::Triage;
404 }
405 self.enter_form(form);
406 }
407 Err(err) => self.status_msg = Some(format!("Cannot clone: {err}")),
408 }
409 }
410
411 pub(in crate::app) fn move_selected_to(&mut self, target_col: usize) -> Result<Action> {
413 self.start_move(MoveTarget::Visible(target_col))
414 }
415
416 pub(in crate::app) fn start_move(&mut self, target: MoveTarget) -> Result<Action> {
423 let Some(card) = self.board.get_selected_card().cloned() else {
424 return Ok(Action::None);
425 };
426 let target_name = target.name(&self.board);
427 let numbers = self.move_numbers(card.number);
428 self.pending_move = Some(PendingMove {
429 numbers,
430 target,
431 target_name,
432 reason: None,
433 acknowledged: false,
434 commit: None,
435 });
436 self.advance_move()
437 }
438
439 fn move_numbers(&self, anchor: u32) -> Vec<u32> {
443 match &self.bulk {
444 Some(modal) if modal.selection.len() > 1 && modal.selection.contains(anchor) => {
445 modal.selection.numbers().to_vec()
446 }
447 _ => vec![anchor],
448 }
449 }
450
451 #[allow(clippy::too_many_lines)]
466 pub(in crate::app) fn advance_move(&mut self) -> Result<Action> {
467 use crate::wip::WipOutcome;
468 use crate::workflow::{TransitionOutcome, WorkflowCategory};
469
470 let Some(pending) = self.pending_move.clone() else {
471 return Ok(Action::None);
472 };
473 if pending.numbers.is_empty() {
474 self.finish_move();
475 return Ok(Action::None);
476 }
477
478 let target_name = pending.target_name.clone();
479 let (_, resolution) = crate::workflow::active_resolution(&self.store.config);
480 let is_done_target = resolution
481 .resolve(&target_name)
482 .is_some_and(WorkflowCategory::is_complete);
483
484 let mut issues = Vec::with_capacity(pending.numbers.len());
488 let mut missing = 0usize;
489 for number in &pending.numbers {
490 match self.store.get(IssueSelector::Number(*number)) {
491 Ok(issue) => issues.push(issue),
492 Err(_) => missing += 1,
493 }
494 }
495 if issues.is_empty() {
496 self.finish_move();
497 self.status_msg = Some("Move: none of the selected issues still exist".to_string());
498 return Ok(Action::None);
499 }
500
501 if !pending.acknowledged {
504 let mut blocked = Vec::new();
505 for issue in &issues {
506 let guard =
507 mv::blocked_move_guard(&self.store.config, &self.store, issue, &target_name)?;
508 if guard.needs_acknowledgement() {
509 blocked.push(guard.warning().unwrap_or_default());
510 }
511 }
512 if let Some(first) = blocked.first() {
513 let warning = if issues.len() > 1 {
514 format!(
515 "{} of {} selected issues are blocked. {first}",
516 blocked.len(),
517 issues.len()
518 )
519 } else {
520 first.clone()
521 };
522 self.mode = Mode::ConfirmBlockedMove { warning };
523 return Ok(Action::None);
524 }
525 }
526
527 if pending.reason.is_none() {
529 for issue in &issues {
530 if let TransitionOutcome::Rejected(diagnostic) =
531 crate::workflow::validate_transition(
532 issue,
533 &target_name,
534 &resolution,
535 &self.store.config,
536 None,
537 )
538 {
539 self.mode = Mode::TransitionReason {
540 reason: diagnostic.to_string(),
541 buffer: String::new(),
542 };
543 return Ok(Action::None);
544 }
545 }
546 }
547
548 if self.pending_wip_override.is_none() {
551 for issue in &issues {
552 if let Some(decision) =
553 self.wip_decision(issue.number, &target_name, pending.reason.as_deref(), None)
554 && decision.outcome == WipOutcome::Block
555 {
556 self.wip_override = Some(crate::ui::wip_override::WipOverridePrompt {
557 number: issue.number,
558 count: issues.len(),
559 target_move: pending.target.clone(),
560 target: target_name.clone(),
561 decision,
562 });
563 self.mode = Mode::WipOverride {
564 buffer: String::new(),
565 };
566 return Ok(Action::None);
567 }
568 }
569 }
570
571 if is_done_target && pending.commit.is_none() {
574 let needs_commit = issues.iter().any(|issue| {
575 let was_done = resolution
576 .resolve(&issue.status.to_string())
577 .is_some_and(WorkflowCategory::is_complete);
578 !was_done && issue.commit.is_none()
579 });
580 if needs_commit {
581 self.mode = Mode::MoveIntoDone {
582 buffer: String::new(),
583 };
584 return Ok(Action::None);
585 }
586 }
587
588 let mut moved = Vec::new();
590 let mut refusals: Vec<(u32, String)> = Vec::new();
591 let mut wip_note: Option<String> = None;
592 for issue in &issues {
593 let was_done = resolution
594 .resolve(&issue.status.to_string())
595 .is_some_and(WorkflowCategory::is_complete);
596 let commit = if was_done {
597 None
598 } else {
599 match &pending.commit {
600 Some(answered) => answered.clone(),
601 None => issue.commit.clone(),
602 }
603 };
604
605 let (wip_event, item_note) = match self.wip_decision(
606 issue.number,
607 &target_name,
608 pending.reason.as_deref(),
609 self.pending_wip_override.as_ref(),
610 ) {
611 Some(decision) => match decision.outcome {
612 WipOutcome::Allow => (None, None),
613 WipOutcome::Warn => (None, Some(crate::wip::render_decision(&decision))),
614 WipOutcome::Override => (decision.history.clone(), None),
615 WipOutcome::Rejected
616 if decision.diagnostics.first().is_some_and(|diagnostic| {
617 diagnostic.kind == crate::wip::WipDiagnosticKind::RejectedTransition
618 }) =>
619 {
620 (None, None)
621 }
622 WipOutcome::Block | WipOutcome::Rejected => {
623 refusals.push((issue.number, crate::wip::render_decision(&decision)));
624 continue;
625 }
626 },
627 None => (None, None),
628 };
629 if wip_note.is_none() {
630 wip_note = item_note;
631 }
632
633 match mv::apply_guarded(
634 &self.store.config,
635 &self.store,
636 &issue.filename,
637 &target_name,
638 commit.as_deref(),
639 pending.reason.as_deref(),
640 pending.acknowledged,
641 wip_event.as_ref(),
642 ) {
643 Ok(outcome) => {
644 moved.push((issue.number, outcome.acknowledged));
645 self.refresh_corpus()?;
649 }
650 Err(error) => refusals.push((issue.number, error.to_string())),
651 }
652 }
653
654 self.finish_move();
655 self.refresh_corpus()?;
656 if let Some(index) = pending.target.visible_index() {
657 self.board.select_column(index);
658 }
659
660 if pending.numbers.len() == 1 && refusals.is_empty() && missing == 0 {
661 let (number, acknowledged) = moved.first().copied().unwrap_or((0, false));
662 let ack = if acknowledged {
663 ", blocked move acknowledged"
664 } else {
665 ""
666 };
667 let commit_note = match &pending.commit {
668 Some(Some(hash)) => format!(" (commit: {hash}{ack})"),
669 Some(None) => format!(" (no commit recorded{ack})"),
670 None => String::new(),
671 };
672 self.status_msg = Some(match wip_note {
673 Some(note) => format!("{note}\nMoved #{number} to {target_name}{commit_note}"),
674 None => format!("Moved #{number} to {target_name}{commit_note}"),
675 });
676 } else {
677 let mut message = format!(
678 "Moved {} of {} to {target_name}",
679 moved.len(),
680 pending.numbers.len()
681 );
682 if let Some((number, reason)) = refusals.first() {
683 let head = reason.lines().next().unwrap_or_default();
684 message.push_str(&format!("; {} refused (#{number}: {head})", refusals.len()));
685 }
686 if missing > 0 {
687 message.push_str(&format!("; {missing} missing"));
688 }
689 self.status_msg = Some(message);
690 }
691 Ok(Action::None)
692 }
693
694 pub(in crate::app) fn finish_move(&mut self) {
697 self.pending_move = None;
698 self.pending_wip_override = None;
699 }
700
701 pub(in crate::app) fn handle_transition_reason_key(&mut self, key: KeyEvent) -> Result<Action> {
712 match key.code {
713 KeyCode::Enter => {
714 let reason = match &self.mode {
715 Mode::TransitionReason { buffer, .. } => buffer.clone(),
716 _ => String::new(),
717 };
718 if reason.trim().is_empty() {
719 self.status_msg = Some(
720 "A reason is required: the decision is recorded in the issue's \
721 history and has to say why"
722 .to_string(),
723 );
724 return Ok(Action::None);
725 }
726 if let Some(pending) = self.pending_move.as_mut() {
730 pending.reason = Some(reason);
731 }
732 self.mode = Mode::Board;
733 self.advance_move()
734 }
735 KeyCode::Esc => {
736 self.finish_move();
737 self.mode = Mode::Board;
738 Ok(Action::None)
739 }
740 KeyCode::Char(c) => {
741 if let Mode::TransitionReason { ref mut buffer, .. } = self.mode {
742 buffer.push(c);
743 }
744 Ok(Action::None)
745 }
746 KeyCode::Backspace => {
747 if let Mode::TransitionReason { ref mut buffer, .. } = self.mode {
748 buffer.pop();
749 }
750 Ok(Action::None)
751 }
752 _ => Ok(Action::None),
753 }
754 }
755
756 pub(in crate::app) fn handle_move_into_done_key(&mut self, key: KeyEvent) -> Result<Action> {
762 match key.code {
763 KeyCode::Enter => {
764 let buffer = match &self.mode {
765 Mode::MoveIntoDone { buffer, .. } => buffer.clone(),
766 _ => String::new(),
767 };
768 let trimmed = buffer.trim();
769 if let Some(pending) = self.pending_move.as_mut() {
770 pending.commit = Some(if trimmed.is_empty() {
771 None
772 } else {
773 Some(trimmed.to_string())
774 });
775 }
776 self.mode = Mode::Board;
777 self.advance_move()
778 }
779 KeyCode::Esc => {
780 self.finish_move();
784 self.mode = Mode::Board;
785 Ok(Action::None)
786 }
787 KeyCode::Char(c) => {
788 if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
789 buffer.push(c);
790 }
791 Ok(Action::None)
792 }
793 KeyCode::Backspace => {
794 if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
795 buffer.pop();
796 }
797 Ok(Action::None)
798 }
799 _ => Ok(Action::None),
800 }
801 }
802
803 pub(in crate::app) fn move_selection_by_page(&mut self, direction: i32) {
808 let page = self.board_page.get();
809 if page == 0 {
810 return;
811 }
812 let step = i32::try_from(page).unwrap_or(i32::MAX);
813 self.board.move_selection_by(step * direction);
814 }
815
816 pub(in crate::app) fn toggle_column_visibility(&mut self) {
819 let col_name = self.board.active_column_name().to_string();
820 let config_name = self.tabs.names[self.active_config].clone();
821 let board_config = self
822 .store
823 .config
824 .kanban
825 .configurations
826 .get_mut(&config_name);
827 let board_config = match board_config {
828 Some(bc) => bc,
829 None => {
830 self.status_msg = Some("toggle: config not found".into());
831 return;
832 }
833 };
834 let now_hidden = if let Some(pos) = board_config
835 .hidden_columns
836 .iter()
837 .position(|h| h == &col_name)
838 {
839 board_config.hidden_columns.remove(pos);
840 false
841 } else {
842 board_config.hidden_columns.push(col_name.clone());
843 true
844 };
845 match self.store.config_path() {
846 Ok(path) => {
847 if let Err(e) = self.store.config.save(&path) {
848 self.status_msg = Some(format!("toggle save: {e}"));
849 return;
850 }
851 }
852 Err(e) => {
853 self.status_msg = Some(format!("toggle path: {e}"));
854 return;
855 }
856 }
857 if let Some(session) = self.view_session.as_mut() {
864 session.visibility = false;
865 }
866 self.rebuild_board();
867 let visible = self.board.columns.len();
871 self.board.active_col = if now_hidden {
872 visible
873 + self
874 .board
875 .hidden
876 .iter()
877 .position(|c| c.name == col_name)
878 .unwrap_or_default()
879 } else {
880 self.board
881 .columns
882 .iter()
883 .position(|c| c.name == col_name)
884 .unwrap_or_default()
885 };
886 self.board.clamp_selection();
887 self.status_msg = Some(format!("Toggled '{col_name}' visibility"));
888 }
889
890 pub(in crate::app) fn switch_board(&mut self, index: usize) {
899 if index < self.tabs.len() {
900 self.tabs.selected = index;
901 self.tabs.hovered = None;
902 self.active_config = index;
903 self.switch_config();
904 }
905 }
906
907 pub(in crate::app) fn switch_config(&mut self) {
908 let selected_filename = self.board.get_selected_card().map(|c| c.filename.clone());
909 self.rebuild_board();
910 self.board.active_col = 0;
911 if let Some(filename) = selected_filename
912 && self
913 .board
914 .column_cards(0)
915 .iter()
916 .any(|c| c.filename == filename)
917 {
918 self.board.selected = self
919 .board
920 .column_cards(0)
921 .iter()
922 .position(|c| c.filename == filename)
923 .unwrap_or(0);
924 } else {
925 self.board.selected = 0;
926 }
927 self.board.clamp_selection();
928 self.status_msg = Some("Switched board".to_string());
929 }
930
931 pub fn activate_branch(&mut self, action: &Action) -> Result<Action> {
938 match action {
939 Action::SelectBoard(name) => self.select_board_branch(name),
940 Action::SelectCycle(name) => self.select_cycle_branch(name),
941 Action::SelectMilestone(name) => self.select_milestone_branch(name),
942 Action::NewFromTemplate(name) => self.new_from_template(name),
943 _ => Ok(Action::None),
944 }
945 }
946
947 pub(in crate::app) fn select_board_branch(&mut self, name: &str) -> Result<Action> {
951 let index = self
952 .store
953 .config
954 .configs()
955 .position(|(candidate, _)| candidate == name);
956 let Some(index) = index else {
957 self.status_msg = Some(format!("Board '{name}' not found"));
958 return Ok(Action::None);
959 };
960 self.switch_top_mode(TopMode::Board)?;
961 self.switch_board(index);
962 Ok(Action::None)
963 }
964
965 pub(in crate::app) fn select_cycle_branch(&mut self, name: &str) -> Result<Action> {
972 self.switch_top_mode(TopMode::Cycle)?;
973 let groups = self.cycle_groups();
974 let index = crate::ui::cycle::flat_entries(&groups)
975 .iter()
976 .position(|entry| entry.name == name);
977 if let (Some(index), Some(state)) = (index, self.cycle_state.as_mut()) {
978 state.selected = index;
979 }
980 Ok(Action::None)
981 }
982
983 pub(in crate::app) fn select_milestone_branch(&mut self, name: &str) -> Result<Action> {
985 self.switch_top_mode(TopMode::Milestones)?;
986 let (_cycles, milestones) = self.planning_rows();
987 let index = milestones
988 .iter()
989 .position(|row| row.name == name)
990 .unwrap_or(0);
991 if let Some(modal) = self.planning.as_mut() {
992 modal.segment = PlanningSegment::Milestones;
993 modal.selected = index;
994 modal.prompt = None;
995 }
996 Ok(Action::None)
997 }
998
999 pub(in crate::app) fn new_from_template(&mut self, name: &str) -> Result<Action> {
1003 self.switch_top_mode(TopMode::NewIssue)?;
1004 self.select_template(name);
1005 Ok(Action::None)
1006 }
1007}