1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5 pub(in crate::app) fn open_triage_inbox(&mut self) -> Result<Action> {
13 match crate::ui::triage::TriageState::open(&self.store) {
14 Ok(state) => {
15 self.triage = Some(state);
16 self.mode = Mode::TriageInbox;
17 }
18 Err(e) => self.status_msg = Some(e.to_string()),
19 }
20 Ok(Action::None)
21 }
22
23 #[allow(clippy::too_many_lines)]
32 pub(in crate::app) fn handle_triage_key(&mut self, key: KeyEvent) -> Result<Action> {
33 use crate::ui::triage::ModalState;
34
35 let Some(mut state) = self.triage.take() else {
36 self.mode = Mode::Board;
37 return Ok(Action::None);
38 };
39
40 let editing = state.modal.as_ref().is_some_and(|m| m.editing);
41 let armed = state.modal.as_ref().is_some_and(|m| m.is_armed());
42 let previewed = matches!(
43 state.modal.as_ref().map(|m| &m.state),
44 Some(ModalState::Preview(_))
45 );
46 let mut closed = false;
47 let mut chosen: Option<(TriageDropdownKind, String)> = None;
48 let mut create_title: Option<String> = None;
49 let mut activate: Option<TriageControl> = None;
50
51 if state.dropdown.is_some() {
55 if let Some(choice) = state.handle_dropdown_key(key.code) {
56 chosen = Some(choice);
57 }
58 } else if state.creating {
59 match key.code {
60 KeyCode::Esc => {
61 state.creating = false;
62 state.create_buffer.clear();
63 }
64 KeyCode::Enter => {
65 let title = state.create_buffer.trim().to_string();
66 state.creating = false;
67 state.create_buffer.clear();
68 if title.is_empty() {
69 state.note = Some("a title is required".to_string());
70 } else {
71 create_title = Some(title);
72 }
73 }
74 KeyCode::Backspace => {
75 state.create_buffer.pop();
76 }
77 KeyCode::Char(c) => state.create_buffer.push(c),
78 _ => {}
79 }
80 } else if state.modal.is_some() {
81 match key.code {
82 KeyCode::Esc if editing => {
86 if let Some(modal) = state.modal.as_mut() {
87 modal.abort_edit();
88 }
89 }
90 KeyCode::Esc => {
95 let carried = match state.modal.as_ref().map(|m| &m.state) {
96 Some(ModalState::Done(text)) | Some(ModalState::Refused(text)) => {
97 Some(text.lines().next().unwrap_or(text.as_str()).to_string())
98 }
99 _ => None,
100 };
101 state.close_modal();
102 if carried.is_some() {
103 state.note = carried;
104 }
105 }
106 KeyCode::Enter if editing => {
107 if let Some(modal) = state.modal.as_mut() {
108 modal.commit_edit();
109 }
110 }
111 KeyCode::Enter => {
112 let ok_focused = state.modal.as_ref().is_some_and(|modal| modal.ok_focused);
113 if ok_focused {
114 state.modal_ok(&self.store)?;
115 } else if state.modal.as_ref().is_some_and(|modal| {
116 matches!(&modal.state, ModalState::Editing)
117 && modal.field(modal.cursor) == crate::ui::triage::DecisionField::Target
118 }) {
119 let current = state
120 .modal
121 .as_ref()
122 .map(|modal| modal.target.trim().to_string())
123 .unwrap_or_default();
124 let columns = self.active_board_config().columns.clone();
125 state.open_dropdown(
126 TriageDropdownKind::DecisionTarget,
127 crate::ui::triage::status_options(&columns),
128 (!current.is_empty()).then_some(current.as_str()),
129 );
130 } else if let Some(modal) = state.modal.as_mut() {
131 modal.begin_edit();
132 }
133 }
134 KeyCode::Backspace if editing => {
135 if let Some(modal) = state.modal.as_mut() {
136 modal.pop_char();
137 }
138 }
139 KeyCode::Char(c) if editing => {
140 if let Some(modal) = state.modal.as_mut() {
141 modal.push_char(c);
142 }
143 }
144 KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') if !editing => {
149 if let Some(modal) = state.modal.as_mut() {
150 modal.move_focus(1);
151 }
152 }
153 KeyCode::BackTab | KeyCode::Up | KeyCode::Char('k') if !editing => {
154 if let Some(modal) = state.modal.as_mut() {
155 modal.move_focus(-1);
156 }
157 }
158 KeyCode::Char('t') if !editing => {
161 if let Some(modal) = state.modal.as_mut() {
162 let wanted = if modal.kind == crate::triage::DecisionKind::Accept {
163 crate::ui::triage::DecisionField::Target
164 } else {
165 crate::ui::triage::DecisionField::DuplicateOf
166 };
167 if let Some(index) = modal.fields.iter().position(|f| *f == wanted) {
168 modal.cursor = index;
169 modal.ok_focused = false;
170 }
171 }
172 }
173 KeyCode::Char('a') => {
178 if let Some(modal) = state.modal.as_mut() {
179 modal.cycle_kind();
180 }
181 }
182 KeyCode::Char('p') => state.preview(&self.store)?,
183 KeyCode::Char('y') => {
184 if armed || previewed {
185 state.confirm(&self.store)?;
186 } else {
187 state.note = Some("press `p` to preview first".to_string());
188 }
189 }
190 _ => {}
191 }
192 } else {
193 match key.code {
194 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => closed = true,
195 KeyCode::Up | KeyCode::Char('k') => state.move_cursor(-1),
196 KeyCode::Down | KeyCode::Char('j') => state.move_cursor(1),
197 KeyCode::Char(' ') => state.toggle_current(),
198 KeyCode::Char('A') => {
202 if state.rows.is_empty() {
203 self.action_feedback = Some(super::ActionFeedback::new(
204 "sort triage by age",
205 "the triage inbox has no issues to sort",
206 "press n to create one in the entry column",
207 ));
208 }
209 state.by_age = !state.by_age;
210 state.refresh(&self.store)?;
211 }
212 KeyCode::Char('a') => {
213 let columns = self.active_board_config().columns.clone();
214 state.open_action(crate::triage::DecisionKind::Accept, &self.store, &columns);
215 }
216 KeyCode::Char('d') => {
217 let columns = self.active_board_config().columns.clone();
218 state.open_action(crate::triage::DecisionKind::Decline, &self.store, &columns);
219 }
220 KeyCode::Char('u') => {
221 let columns = self.active_board_config().columns.clone();
222 state.open_action(
223 crate::triage::DecisionKind::Duplicate,
224 &self.store,
225 &columns,
226 );
227 }
228 KeyCode::Char('f') => {
229 let columns = self.active_board_config().columns.clone();
230 state.open_action(crate::triage::DecisionKind::Defer, &self.store, &columns);
231 }
232 KeyCode::Char('p') => {
236 let columns = self.active_board_config().columns.clone();
237 state.open_batch(crate::triage::DecisionKind::Accept, &self.store, &columns);
238 }
239 KeyCode::Char('s') => {
243 let columns = self.active_board_config().columns.clone();
244 let options = crate::ui::triage::status_options(&columns);
245 let current = state
246 .current()
247 .map(|row| row.issue.status.display().to_string());
248 state.focus = TriageControl::Status;
249 state.open_dropdown(TriageDropdownKind::Status, options, current.as_deref());
250 }
251 KeyCode::Char('@') => {
253 let current = state.current().and_then(|row| row.issue.assignee.clone());
254 let options =
255 crate::ui::triage::assignee_options(&self.store, current.as_deref());
256 state.focus = TriageControl::Assignee;
257 state.open_dropdown(TriageDropdownKind::Assignee, options, current.as_deref());
258 }
259 KeyCode::Char('n') => {
261 state.focus = TriageControl::New;
262 state.creating = true;
263 state.create_buffer.clear();
264 }
265 KeyCode::Left | KeyCode::Char('h') => state.move_focus(-1),
273 KeyCode::Right | KeyCode::Char('l') => state.move_focus(1),
274 KeyCode::Enter => activate = Some(state.focus),
278 _ => {}
279 }
280 }
281
282 if let Some(title) = create_title {
283 self.triage = Some(state);
284 self.create_triage_issue(&title)?;
285 return Ok(Action::None);
286 }
287 if let Some((kind, id)) = chosen {
288 self.triage = Some(state);
289 match kind {
290 TriageDropdownKind::Status => self.move_focused_triage_issue(&id)?,
291 TriageDropdownKind::DecisionTarget => {
292 if let Some(state) = self.triage.as_mut()
293 && let Some(modal) = state.modal.as_mut()
294 {
295 modal.choose_target(id);
296 }
297 }
298 TriageDropdownKind::Assignee => self.assign_focused_triage_issue(&id)?,
299 }
300 return Ok(Action::None);
301 }
302
303 if closed {
304 let note = state.note.clone();
308 self.triage = None;
309 self.mode = Mode::Board;
310 self.refresh_corpus()?;
311 if note.is_some() {
312 self.status_msg = note;
313 }
314 return Ok(Action::None);
315 }
316
317 self.triage = Some(state);
318 self.mode = Mode::TriageInbox;
319 if let Some(control) = activate {
322 match control {
323 TriageControl::Queue => {}
324 TriageControl::Status => self.open_triage_status_dropdown(),
325 TriageControl::Assignee => self.open_triage_assignee_dropdown(),
326 TriageControl::New => {
327 self.apply_triage_action(Action::TriageCreate)?;
328 }
329 }
330 return Ok(Action::None);
331 }
332 Ok(Action::None)
333 }
334
335 pub(in crate::app) fn open_triage_status_dropdown(&mut self) {
340 let columns = self.active_board_config().columns.clone();
341 let options = crate::ui::triage::status_options(&columns);
342 if let Some(state) = self.triage.as_mut() {
343 state.focus = TriageControl::Status;
344 let current = state
345 .current()
346 .map(|row| row.issue.status.display().to_string());
347 state.open_dropdown(TriageDropdownKind::Status, options, current.as_deref());
348 }
349 }
350
351 pub(in crate::app) fn open_triage_assignee_dropdown(&mut self) {
355 let current = self
356 .triage
357 .as_ref()
358 .and_then(|state| state.current())
359 .and_then(|row| row.issue.assignee.clone());
360 let options = crate::ui::triage::assignee_options(&self.store, current.as_deref());
361 if let Some(state) = self.triage.as_mut() {
362 state.focus = TriageControl::Assignee;
363 state.open_dropdown(TriageDropdownKind::Assignee, options, current.as_deref());
364 }
365 }
366
367 pub(in crate::app) fn move_focused_triage_issue(&mut self, target: &str) -> Result<()> {
372 let Some(number) = self
373 .triage
374 .as_ref()
375 .and_then(|state| state.current())
376 .map(|row| row.issue.number)
377 else {
378 return Ok(());
379 };
380 let wip_event = match self.wip_decision(number, target, None, None) {
386 Some(decision) if !decision.is_committable() => {
387 if let Some(state) = self.triage.as_mut() {
388 state.note = Some(crate::wip::render_decision(&decision));
389 }
390 return Ok(());
391 }
392 Some(decision) => decision.history,
393 None => None,
394 };
395 match crate::commands::mv::apply_guarded(
396 &self.store.config,
397 &self.store,
398 &number.to_string(),
399 target,
400 None,
401 None,
402 false,
405 wip_event.as_ref(),
406 ) {
407 Ok(moved) => {
408 if let Some(state) = self.triage.as_mut() {
409 state.refresh(&self.store)?;
410 state.note = Some(format!("Moved #{} to {target}", moved.issue.number));
411 }
412 self.refresh_corpus()?;
413 }
414 Err(e) => {
415 if let Some(state) = self.triage.as_mut() {
416 state.note = Some(e.to_string());
417 }
418 }
419 }
420 Ok(())
421 }
422
423 pub(in crate::app) fn assign_focused_triage_issue(&mut self, assignee: &str) -> Result<()> {
426 let Some(number) = self
427 .triage
428 .as_ref()
429 .and_then(|state| state.current())
430 .map(|row| row.issue.number)
431 else {
432 return Ok(());
433 };
434 match crate::commands::edit::apply(
435 &self.store.config,
436 &self.store,
437 &number.to_string(),
438 None,
439 None,
440 None,
441 None,
442 Some(assignee),
443 None,
444 None,
445 None,
446 None,
447 None,
448 None,
449 None,
450 None,
451 None,
452 None,
453 None,
454 None,
455 None,
456 ) {
457 Ok(issue) => {
458 if let Some(state) = self.triage.as_mut() {
459 state.refresh(&self.store)?;
460 state.note = Some(format!("Assigned #{} to {assignee}", issue.number));
461 }
462 self.refresh_corpus()?;
463 }
464 Err(e) => {
465 if let Some(state) = self.triage.as_mut() {
466 state.note = Some(e.to_string());
467 }
468 }
469 }
470 Ok(())
471 }
472
473 pub(in crate::app) fn create_triage_issue(&mut self, title: &str) -> Result<()> {
477 let entry = self
478 .store
479 .config
480 .triage
481 .as_ref()
482 .and_then(|triage| triage.entry_status.clone())
483 .ok_or_else(|| {
484 TixError::InvalidInput(
485 "the triage inbox needs a `[triage]` section in tix.toml, and this project \
486 has none"
487 .to_string(),
488 )
489 })?;
490 let issue = crate::commands::create::create(
491 &self.store.config,
492 &self.store,
493 title,
494 None,
495 Some(crate::model::Status::new(entry.clone())),
496 None,
497 None,
498 None,
499 None,
500 None,
501 None,
502 None,
503 None,
504 None,
505 None,
506 None,
507 None,
508 None,
509 )?;
510 if let Some(state) = self.triage.as_mut() {
511 state.refresh(&self.store)?;
512 state.note = Some(format!("Created #{} in {entry}", issue.number));
513 }
514 self.refresh_corpus()?;
515 Ok(())
516 }
517
518 pub fn apply_triage_action(&mut self, action: Action) -> Result<Action> {
521 match action {
522 Action::TriageOpenStatus => self.open_triage_status_dropdown(),
523 Action::TriageOpenAssignee => self.open_triage_assignee_dropdown(),
524 Action::TriageCreate => {
525 if let Some(state) = self.triage.as_mut() {
526 state.dropdown = None;
527 state.focus = TriageControl::New;
528 state.creating = true;
529 state.create_buffer.clear();
530 }
531 }
532 Action::TriageFocusQueue => {
533 if let Some(state) = self.triage.as_mut() {
534 state.focus = TriageControl::Queue;
535 }
536 }
537 Action::TriageDropdownClose => {
538 if let Some(state) = self.triage.as_mut() {
539 state.close_dropdown();
540 }
541 }
542 Action::TriageModalOk => {
543 if let Some(state) = self.triage.as_mut() {
544 state.modal_ok(&self.store)?;
545 }
546 }
547 Action::TriagePickOption(row) => {
548 let chosen = self.triage.as_mut().and_then(|state| {
549 let (kind, id) = {
550 let dropdown = state.dropdown.as_ref()?;
551 let index = *dropdown.state.visible_indices().get(row)?;
552 (dropdown.kind, dropdown.state.options.get(index)?.id.clone())
553 };
554 state.dropdown = None;
555 Some((kind, id))
556 });
557 if let Some((kind, id)) = chosen {
558 match kind {
559 TriageDropdownKind::Status => self.move_focused_triage_issue(&id)?,
560 TriageDropdownKind::DecisionTarget => {
561 if let Some(state) = self.triage.as_mut()
562 && let Some(modal) = state.modal.as_mut()
563 {
564 modal.choose_target(id);
565 }
566 }
567 TriageDropdownKind::Assignee => self.assign_focused_triage_issue(&id)?,
568 }
569 }
570 }
571 _ => {}
572 }
573 Ok(Action::None)
574 }
575
576 pub(in crate::app) fn open_triage_decision(
580 &mut self,
581 number: u32,
582 decision: crate::triage::DecisionKind,
583 ) -> Result<Action> {
584 let mut state = match crate::ui::triage::TriageState::open(&self.store) {
585 Ok(state) => state,
586 Err(error) => {
587 self.status_msg = Some(error.to_string());
588 return Ok(Action::None);
589 }
590 };
591 let Some(index) = state.rows.iter().position(|row| row.issue.number == number) else {
592 self.status_msg = Some(format!(
593 "#{number} is not in the triage inbox (does it sit in the entry column?)"
594 ));
595 return Ok(Action::None);
596 };
597 state.cursor = index;
598 let columns = self.active_board_config().columns.clone();
599 if !state.open_single(decision, &self.store, &columns) {
600 let note = state.note.clone();
601 self.triage = Some(state);
602 self.mode = Mode::TriageInbox;
603 self.status_msg = note;
604 return Ok(Action::None);
605 }
606 self.triage = Some(state);
607 self.mode = Mode::TriageInbox;
608 Ok(Action::None)
609 }
610}