Skip to main content

tix/app/
triage.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5    /// Open the triage inbox (`t`).
6    ///
7    /// A refusal — no `[triage]` section in this project, or an I/O failure —
8    /// is reported on the status line and **nothing else changes**: a project
9    /// that does not use triage leaves the board exactly where it was, which is
10    /// T029's "navigation is unchanged when the tab is not active" from the
11    /// other side.
12    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    /// The inbox's keys (WP06, T029–T032).
24    ///
25    /// Two layers, exactly as the batch modal has one: an open decision modal
26    /// takes the key first, and the list has it otherwise. Every branch here
27    /// either moves a cursor that belongs to *this* view or calls a shared
28    /// service — no `IssuePatch`, no `store.update`, and no status compared
29    /// against a literal. Cancellation drops the modal and changes nothing else;
30    /// the queue is re-read only after an apply has actually run.
31    #[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        // Priority: picker, then create prompt, then decision modal, then list.
52        // A picker and the create prompt can never coexist with a modal, so a
53        // letter typed into a decline reason is never stolen (tui-triage-mode).
54        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                // `esc` while a field is being typed reverts the draft rather
83                // than closing — the footer says "esc revert", and a revert
84                // that closed the modal would take the row it reverted with it.
85                KeyCode::Esc if editing => {
86                    if let Some(modal) = state.modal.as_mut() {
87                        modal.abort_edit();
88                    }
89                }
90                // Cancels from every state: the plan and the draft go, the
91                // committed fields stay, and no file moved (T032). The report
92                // goes with the user when it is a refusal — a refusal nobody
93                // can re-read after pressing `esc` is not a report.
94                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                // The cursor does not move while a field is being typed:
145                // `commit_edit` writes the draft to the row the cursor is on,
146                // so a cursor that could travel mid-edit would file half a
147                // reason under the wrong label.
148                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                // The target row, when the decision has one — the same `t` the
159                // batch modal gives its pair target.
160                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                // Cycle the verb. The selection and the already-typed fields
174                // survive it, for the reason the batch modal's `a` keeps the
175                // selection: a user who marked ten does not re-mark them to
176                // change their mind about what to do with them.
177                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                // Oldest first, the TUI's `--age`. Toggling re-reads the queue
199                // rather than re-sorting a snapshot, so the ages cannot be
200                // computed against two different `today`s.
201                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                // The batch modal over everything marked; `a` inside it picks
233                // the decision, exactly as the batch modal's `a` picks its
234                // action.
235                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                // The status picker: exactly the active board's columns. The
240                // direct key moves the focus onto the control it opened, the
241                // same as a click or `enter` does.
242                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                // The assignee picker: storage folders + team members + current.
252                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                // Create an issue straight into the inbox's entry column.
260                KeyCode::Char('n') => {
261                    state.focus = TriageControl::New;
262                    state.creating = true;
263                    state.create_buffer.clear();
264                }
265                // The control bar's own axis (tui-triage-mode). `h`/`l` are
266                // **raw** keys, never bindings in this context: the inbox is a
267                // text-entry context (`allows_text_fallback`), so a binding
268                // would rewrite those letters while a decline reason or a
269                // duplicate target is being typed. This branch is reached only
270                // with no modal, picker, or prompt open — the same "nothing is
271                // being typed" state `!editing` names in the modal branch.
272                KeyCode::Left | KeyCode::Char('h') => state.move_focus(-1),
273                KeyCode::Right | KeyCode::Char('l') => state.move_focus(1),
274                // `enter` activates the focused control. On `queue` it is the
275                // no-op it has always been: the queue's keys act on the list
276                // directly and need no activation step.
277                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            // The note goes to the status line rather than being dropped with
305            // the view — the batch modal's handler does the same thing for the
306            // same reason.
307            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        // `enter` on the focused control runs the very same path a click on it
320        // does, so the two can never drift.
321        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    /// Open the status picker: exactly the active board's columns, in board
336    /// order, with the focused issue's current column preselected. The status
337    /// control becomes the focused one, so `enter` reopens what a click opened
338    /// (tui-triage-mode).
339    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    /// Open the assignee picker: `docs/issues/<username>/` folders, configured
352    /// team members, and the focused issue's current value (design D6). The
353    /// assignee control becomes the focused one.
354    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    /// Move the focused triage issue to `target` through the **shared** move
368    /// path, so the blocked-move and terminal-transition guards still apply. A
369    /// refusal (an unacknowledged blocked move, a terminal exit without a
370    /// reason) is reported on the mode's note rather than bypassed.
371    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        // WP05 T023/T024: the inbox's move is a move like any other, so it asks the
381        // same policy. There is no room for a modal in the inbox, so a blocking
382        // decision is reported on the inbox's note and the move does not happen —
383        // the same rule the acknowledgement below follows: nothing is written
384        // without the decision it requires.
385        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            // No acknowledgement is offered inline: a blocked move stays blocked
403            // rather than being written without the decision it requires.
404            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    /// Set the focused triage issue's assignee through `tix edit --assignee`'s
424    /// own core, so the field and the file relocation are exactly the CLI's.
425    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    /// Create an issue from inside the Triage mode, straight into the
474    /// configured entry column, through the shared create core (so the entry
475    /// stamp is recorded and it appears in the queue).
476    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    /// Run a Triage-mode pointer action recorded during the draw. Public so the
519    /// hit-region tests drive the same path the run loop does.
520    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    /// Open the decision modal for one issue directly, bypassing the inbox list
577    /// but not its service. The row is located by number, then `open_single`
578    /// builds the same plan the `a`/`d`/`u`/`f` keys build.
579    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}