1use std::fmt::Write as _;
40
41use ratatui::Frame;
42use ratatui::layout::{Constraint, Direction, Layout, Rect};
43use ratatui::style::{Color, Modifier, Style};
44use ratatui::text::{Line, Span};
45use ratatui::widgets::{Block, Borders, Clear, Paragraph};
46use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
47
48use crate::app::{Action, App};
49use crate::bulk::{Selection, SelectionScope, SelectionSource};
50use crate::commands::triage as cli_triage;
51use crate::error::{Result, TixError};
52use crate::model::Issue;
53use crate::storage::Store;
54use crate::triage::inbox::{self, AgeBucket, InboxScope, QueueMetrics};
55use crate::triage::{DecisionKind, TriageDecision, TriagePlan, batch};
56use crate::ui::bulk::BulkSelection;
57use crate::ui::pickers::{DropdownState, PickerEvent, PickerItem};
58
59#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct QueueRow {
71 pub issue: Issue,
73 pub age_days: Option<i64>,
77 pub last_decision: crate::triage::history::LastDecision,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct TriageSummary {
87 pub total: usize,
89 pub awaiting: usize,
91 pub deferred: usize,
93 pub accepted: usize,
95 pub declined: usize,
97 pub duplicated: usize,
99 pub unknown_age: usize,
101 pub age: Vec<AgeBucket>,
103}
104
105impl TriageSummary {
106 #[must_use]
108 pub fn of(metrics: &QueueMetrics<'_>) -> Self {
109 Self {
110 total: metrics.total,
111 awaiting: metrics.awaiting,
112 deferred: metrics.deferred,
113 accepted: metrics.accepted,
114 declined: metrics.declined,
115 duplicated: metrics.duplicated,
116 unknown_age: metrics.unknown_age,
117 age: metrics.age_distribution.clone(),
118 }
119 }
120
121 #[must_use]
129 pub fn header(&self) -> String {
130 format!(
131 "inbox: {} entr{} ({} awaiting, {} deferred)",
132 self.total,
133 if self.total == 1 { "y" } else { "ies" },
134 self.awaiting,
135 self.deferred
136 )
137 }
138
139 #[must_use]
141 pub fn age_line(&self) -> String {
142 let cells: Vec<String> = self
143 .age
144 .iter()
145 .map(|bucket| format!("{} {}", bucket.label, bucket.count))
146 .collect();
147 format!("age: {}", cells.join(" · "))
148 }
149
150 #[must_use]
154 pub fn decisions_line(&self) -> String {
155 format!(
156 "decisions: {} accepted · {} declined · {} duplicated · {} unknown age",
157 self.accepted, self.declined, self.duplicated, self.unknown_age
158 )
159 }
160}
161
162#[must_use]
164pub fn decision_summary(row: &QueueRow) -> String {
165 let mut out = format!(
166 "#{number} \"{title}\" — {status}",
167 number = row.issue.number,
168 title = row.issue.title,
169 status = row.issue.status.display(),
170 );
171 match &row.last_decision {
172 crate::triage::history::LastDecision::Readable(record) => {
173 let _ = write!(out, " — last decision: {}", format_decision(record));
174 }
175 crate::triage::history::LastDecision::Unreadable => {
176 out.push_str(" — last decision: unreadable");
177 }
178 crate::triage::history::LastDecision::None => {
179 out.push_str(" — last decision: none");
180 }
181 }
182 out
183}
184
185fn last_decision(issue: &Issue) -> crate::triage::history::LastDecision {
192 crate::triage::history::last_decision_state(issue)
193}
194
195fn format_decision(record: &crate::triage::history::TriageDecisionRecord) -> String {
201 let mut out = format!("{} -> {}", record.kind.as_str(), record.result);
202 if let Some(reason) = &record.reason {
203 let _ = write!(out, " — {reason}");
204 }
205 let when = record.timestamp.get(..10).unwrap_or(&record.timestamp);
206 let _ = write!(out, " ({}, {})", record.actor, when);
207 out
208}
209
210fn scope_of(store: &Store) -> Result<InboxScope> {
218 inbox::scope(&store.config)?.ok_or_else(|| {
219 TixError::InvalidInput(
220 "the triage inbox needs a `[triage]` section in tix.toml, and this project has none"
221 .to_string(),
222 )
223 })
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum DecisionField {
233 Reason,
234 Actor,
235 Target,
238 Assignee,
240 DuplicateOf,
243}
244
245impl DecisionField {
246 #[must_use]
247 pub fn label(self, kind: DecisionKind) -> &'static str {
248 match (kind, self) {
249 (DecisionKind::Accept, Self::Target) => "column (required)",
250 (DecisionKind::Accept, Self::Actor) => "actor (required)",
251 (DecisionKind::Accept, Self::Reason) => "reason (optional)",
252 (DecisionKind::Accept, Self::Assignee) => "assignee (optional)",
253 (_, Self::Reason) => "reason (required)",
254 (_, Self::Actor) => "actor (required)",
255 (DecisionKind::Duplicate, Self::DuplicateOf) => "duplicate of (required)",
256 (_, Self::Target) => "column (required)",
257 (_, Self::Assignee) => "assignee (optional)",
258 (_, Self::DuplicateOf) => "duplicate of (required)",
259 }
260 }
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
265pub enum ModalScope {
266 One(u32),
270 Many(BulkSelection),
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
277pub enum ModalState {
278 Editing,
280 NeedsInput(String),
282 Preview(Box<TriagePlan>),
284 Confirming(Box<TriagePlan>),
286 Done(String),
288 Refused(String),
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct TriageModal {
295 pub kind: DecisionKind,
297 pub scope: ModalScope,
299 pub heading: String,
301 pub fields: Vec<DecisionField>,
303 pub cursor: usize,
305 pub ok_focused: bool,
308 pub editing: bool,
310 pub draft: String,
312 pub reason: String,
313 pub actor: String,
314 pub target: String,
315 pub assignee: String,
316 pub duplicate_of: String,
317 pub timestamp: String,
320 pub state: ModalState,
321 pub note: Option<String>,
323 pub plan_stale: bool,
325}
326
327impl TriageModal {
328 #[must_use]
334 pub fn new(kind: DecisionKind, scope: ModalScope, heading: String) -> Self {
335 let fields = Self::fields_for(kind);
336 Self {
337 kind,
338 scope,
339 heading,
340 fields,
341 cursor: 0,
342 ok_focused: true,
343 editing: false,
344 draft: String::new(),
345 reason: String::new(),
346 actor: String::new(),
347 target: String::new(),
348 assignee: String::new(),
349 duplicate_of: String::new(),
350 timestamp: crate::model::utc_now_rfc3339(),
351 state: ModalState::Editing,
352 note: None,
353 plan_stale: false,
354 }
355 }
356
357 #[must_use]
358 pub fn field(&self, index: usize) -> DecisionField {
359 self.fields
360 .get(index)
361 .copied()
362 .unwrap_or(DecisionField::Reason)
363 }
364
365 #[must_use]
366 pub fn field_value(&self, field: DecisionField) -> &str {
367 match field {
368 DecisionField::Reason => &self.reason,
369 DecisionField::Actor => &self.actor,
370 DecisionField::Target => &self.target,
371 DecisionField::Assignee => &self.assignee,
372 DecisionField::DuplicateOf => &self.duplicate_of,
373 }
374 }
375
376 fn field_mut(&mut self, field: DecisionField) -> &mut String {
377 match field {
378 DecisionField::Reason => &mut self.reason,
379 DecisionField::Actor => &mut self.actor,
380 DecisionField::Target => &mut self.target,
381 DecisionField::Assignee => &mut self.assignee,
382 DecisionField::DuplicateOf => &mut self.duplicate_of,
383 }
384 }
385
386 pub fn begin_edit(&mut self) {
387 let field = self.field(self.cursor);
388 self.draft = self.field_value(field).to_string();
389 self.ok_focused = false;
390 self.editing = true;
391 }
392
393 pub fn push_char(&mut self, c: char) {
394 if self.editing {
395 self.draft.push(c);
396 }
397 }
398
399 pub fn pop_char(&mut self) {
400 if self.editing {
401 self.draft.pop();
402 }
403 }
404
405 pub fn commit_edit(&mut self) {
406 if !self.editing {
407 return;
408 }
409 let field = self.field(self.cursor);
410 let draft = std::mem::take(&mut self.draft);
411 *self.field_mut(field) = draft;
412 self.editing = false;
413 self.plan_stale = true;
420 }
421
422 pub fn abort_edit(&mut self) {
424 self.draft.clear();
425 self.editing = false;
426 }
427
428 pub fn move_cursor(&mut self, delta: isize) {
429 if self.fields.is_empty() {
430 return;
431 }
432 self.ok_focused = false;
433 let len = self.fields.len() as isize;
434 let next = (self.cursor as isize + delta).rem_euclid(len);
435 self.cursor = next as usize;
436 }
437
438 pub fn move_focus(&mut self, delta: isize) {
444 if self.fields.is_empty() {
445 self.ok_focused = true;
446 return;
447 }
448 if self.ok_focused {
449 self.ok_focused = false;
450 self.cursor = if delta < 0 { self.fields.len() - 1 } else { 0 };
451 return;
452 }
453 let next = self.cursor as isize + delta;
454 if next < 0 || next >= self.fields.len() as isize {
455 self.ok_focused = true;
456 } else {
457 self.cursor = next as usize;
458 }
459 }
460
461 pub fn cycle_kind(&mut self) {
470 self.kind = match self.kind {
471 DecisionKind::Accept => DecisionKind::Decline,
472 DecisionKind::Decline => DecisionKind::Duplicate,
473 DecisionKind::Duplicate => DecisionKind::Defer,
474 DecisionKind::Defer => DecisionKind::Accept,
475 };
476 self.fields = Self::fields_for(self.kind);
477 if self.cursor >= self.fields.len() {
478 self.cursor = self.fields.len() - 1;
479 }
480 self.state = ModalState::Editing;
481 self.plan_stale = true;
482 }
483
484 fn fields_for(kind: DecisionKind) -> Vec<DecisionField> {
491 match kind {
492 DecisionKind::Accept => vec![
493 DecisionField::Target,
494 DecisionField::Actor,
495 DecisionField::Reason,
496 DecisionField::Assignee,
497 ],
498 DecisionKind::Duplicate => vec![
499 DecisionField::DuplicateOf,
500 DecisionField::Reason,
501 DecisionField::Actor,
502 ],
503 DecisionKind::Decline | DecisionKind::Defer => {
504 vec![DecisionField::Reason, DecisionField::Actor]
505 }
506 }
507 }
508
509 pub fn prefill_defaults(&mut self, store: &Store, columns: &[String]) {
516 if self.kind == DecisionKind::Accept && self.target.trim().is_empty() {
517 if let Some(configured) = store
518 .config
519 .triage
520 .as_ref()
521 .and_then(|triage| triage.accepted_status.clone())
522 .filter(|target| !target.trim().is_empty())
523 {
524 self.target = configured;
525 } else if let Some(todo) = columns
526 .iter()
527 .find(|column| column.eq_ignore_ascii_case("todo"))
528 {
529 self.target.clone_from(todo);
530 }
531 }
532 if self.actor.trim().is_empty() {
533 self.actor = crate::storage::actor_identity();
534 }
535 }
536
537 pub fn choose_target(&mut self, target: String) {
539 self.target = target;
540 self.plan_stale = true;
541 }
542
543 #[must_use]
545 pub fn confirmation_question(&self) -> Option<String> {
546 let ModalScope::Many(marked) = &self.scope else {
547 return None;
548 };
549 Some(format!(
550 "Are you sure you want to apply {} to {} selected issues?",
551 self.kind,
552 marked.len()
553 ))
554 }
555
556 #[must_use]
565 pub fn request(&self, store: &Store) -> TriageDecision {
566 let actor = if self.actor.trim().is_empty() {
567 crate::storage::actor_identity()
568 } else {
569 self.actor.trim().to_string()
570 };
571 let mut decision = TriageDecision::new(
572 self.kind,
573 self.timestamp.clone(),
574 actor,
575 self.reason.clone(),
576 );
577 if self.kind == DecisionKind::Accept {
578 let target = self.target.trim();
579 decision.target_status = if target.is_empty() {
580 None
581 } else {
582 Some(target.to_string())
583 };
584 if decision.target_status.is_none() {
585 decision.target_status = store
586 .config
587 .triage
588 .as_ref()
589 .and_then(|triage| triage.accepted_status.clone());
590 }
591 decision.assignee =
592 Some(self.assignee.trim().to_string()).filter(|assignee| !assignee.is_empty());
593 }
594 decision
595 }
596
597 #[must_use]
599 pub fn duplicate_target(&self) -> Option<String> {
600 let raw = self.duplicate_of.trim();
601 (!raw.is_empty() && self.kind == DecisionKind::Duplicate).then(|| raw.to_string())
602 }
603
604 pub fn plan(&self, store: &Store, issues: &[(u32, Issue)]) -> Result<TriagePlan> {
618 let selection = match &self.scope {
619 ModalScope::One(number) => Selection::from_issues(
620 [store.resolve_issue(&number.to_string())?],
621 SelectionSource::Explicit,
622 SelectionScope::Active,
623 ),
624 ModalScope::Many(marked) => marked.to_selection(issues).0,
625 };
626 batch::build_plan(
627 store,
628 selection,
629 self.request(store),
630 self.duplicate_target().as_deref(),
631 )
632 }
633
634 pub fn preview(&mut self, store: &Store, issues: &[(u32, Issue)]) -> Result<()> {
640 if let ModalScope::Many(marked) = &self.scope {
641 if marked.is_empty() {
642 self.state =
643 ModalState::NeedsInput("press `space` on a card to mark it".to_string());
644 return Ok(());
645 }
646 let (_selection, dropped) = marked.to_selection(issues);
650 self.note = (!dropped.is_empty()).then(|| {
651 format!(
652 "{} marked issue(s) left the inbox and were left out: #{}",
653 dropped.len(),
654 dropped
655 .iter()
656 .map(u32::to_string)
657 .collect::<Vec<_>>()
658 .join(", #")
659 )
660 });
661 }
662 match self.plan(store, issues) {
663 Ok(plan) => {
664 self.state = ModalState::Preview(Box::new(plan));
665 self.plan_stale = false;
666 }
667 Err(e) => {
668 self.state = ModalState::Refused(e.to_string());
669 self.note = Some(e.to_string());
670 }
671 }
672 Ok(())
673 }
674
675 pub fn confirm(&mut self, store: &Store) -> Result<()> {
691 if self.plan_stale
692 && matches!(
693 self.state,
694 ModalState::Preview(_) | ModalState::Confirming(_)
695 )
696 {
697 self.state = ModalState::Refused(
698 "the fields changed since the preview — press `p` again to see what would \
699 happen now"
700 .to_string(),
701 );
702 return Ok(());
703 }
704 match &self.state {
705 ModalState::Preview(plan) => {
706 self.state = ModalState::Confirming(plan.clone());
707 Ok(())
708 }
709 ModalState::Confirming(plan) => {
710 let mut plan = (**plan).clone();
711 plan.confirm();
712 match batch::apply(store, &plan) {
713 Ok(report) => {
714 let text = cli_triage::render_report(&report, &plan);
715 self.state = ModalState::Done(text);
716 self.plan_stale = false;
717 }
718 Err(e) => {
719 self.state = ModalState::Refused(e.to_string());
720 self.note = Some(e.to_string());
721 }
722 }
723 Ok(())
724 }
725 _ => Ok(()),
726 }
727 }
728
729 #[must_use]
730 pub fn is_armed(&self) -> bool {
731 matches!(self.state, ModalState::Confirming(_))
732 }
733
734 #[must_use]
735 pub fn state_label(&self) -> &'static str {
736 match &self.state {
737 ModalState::Editing => "editing",
738 ModalState::NeedsInput(_) => "needs input",
739 ModalState::Preview(_) | ModalState::Confirming(_) if self.plan_stale => {
740 "stale — preview again"
741 }
742 ModalState::Preview(_) => "preview — nothing written",
743 ModalState::Confirming(_) => "confirming — y writes",
744 ModalState::Done(_) => "done",
745 ModalState::Refused(_) => "refused",
746 }
747 }
748
749 #[must_use]
752 pub fn footer(&self) -> String {
753 if self.editing {
754 return "enter commit · esc revert".to_string();
755 }
756 if self.plan_stale
757 && matches!(
758 self.state,
759 ModalState::Preview(_) | ModalState::Confirming(_)
760 )
761 {
762 return "press p to re-preview · esc to cancel".to_string();
763 }
764 match &self.state {
765 ModalState::Preview(_) => "y to confirm · p to re-preview · esc to cancel".to_string(),
766 ModalState::Confirming(_) => "y APPLIES THIS WRITE · esc to cancel".to_string(),
767 ModalState::Done(_) | ModalState::Refused(_) | ModalState::NeedsInput(_) => {
768 "p to re-preview · esc to close".to_string()
769 }
770 ModalState::Editing if self.ok_focused => {
771 "enter OK · tab fields · t target · p preview · esc cancel".to_string()
772 }
773 ModalState::Editing => {
774 "a decision · j/k field · enter edit/select · t target · p preview · esc cancel"
775 .to_string()
776 }
777 }
778 }
779
780 #[must_use]
782 pub fn rows(&self) -> Vec<(bool, &'static str, String)> {
783 self.fields
784 .iter()
785 .enumerate()
786 .map(|(index, field)| {
787 let mut value = if self.editing && index == self.cursor {
788 format!("{}█", self.draft)
789 } else {
790 let value = self.field_value(*field);
791 if value.is_empty() {
792 "—".to_string()
793 } else {
794 value.to_string()
795 }
796 };
797 if *field == DecisionField::Target {
798 value.push_str(" ▾");
799 }
800 (
801 !self.ok_focused && index == self.cursor,
802 field.label(self.kind),
803 value,
804 )
805 })
806 .collect()
807 }
808
809 #[must_use]
812 pub fn body(&self) -> String {
813 match &self.state {
814 ModalState::Editing => {
815 let mut out = format!("decision: {}\n", self.kind);
816 if let Some(question) = self.confirmation_question() {
817 out.push_str(&question);
818 out.push('\n');
819 }
820 out.push_str("fields (required first; enter to edit):\n");
821 for (on, name, value) in self.rows() {
822 let cursor = if on { ">" } else { " " };
823 out.push_str(&format!("{cursor} {name}: {value}\n"));
824 }
825 if let Some(note) = &self.note {
826 out.push_str(&format!("\nnote: {note}\n"));
827 }
828 out.push('\n');
829 out.push_str(&self.footer());
830 out
831 }
832 ModalState::NeedsInput(what) => format!("{what}\n\n{}", self.footer()),
833 ModalState::Preview(plan) => {
836 let mut out = String::new();
837 if let Some(question) = self.confirmation_question() {
838 out.push_str(&question);
839 out.push_str("\n\n");
840 }
841 out.push_str(&cli_triage::render_plan(plan));
842 if let Some(note) = &self.note {
843 out.push_str(&format!("\nnote: {note}\n"));
844 }
845 out.push('\n');
846 out.push_str(&self.footer());
847 out
848 }
849 ModalState::Confirming(plan) => {
850 let mut out = String::new();
851 if let Some(question) = self.confirmation_question() {
852 out.push_str(&question);
853 out.push_str("\n\n");
854 }
855 out.push_str(&cli_triage::render_plan(plan));
856 out.push('\n');
857 out.push_str(&self.footer());
858 out
859 }
860 ModalState::Done(text) | ModalState::Refused(text) => text.clone(),
861 }
862 }
863}
864
865#[derive(Debug, Clone, Copy, PartialEq, Eq)]
871pub enum TriageDropdownKind {
872 Status,
874 DecisionTarget,
876 Assignee,
878}
879
880#[derive(Debug, Clone, PartialEq, Eq)]
883pub struct TriageDropdown {
884 pub kind: TriageDropdownKind,
885 pub state: DropdownState,
886}
887
888#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
897pub enum TriageControl {
898 #[default]
900 Queue,
901 Status,
903 Assignee,
905 New,
907}
908
909impl TriageControl {
910 pub const ALL: [Self; 4] = [Self::Queue, Self::Status, Self::Assignee, Self::New];
913}
914
915#[derive(Debug, Clone, PartialEq, Eq)]
919pub struct TriageState {
920 pub rows: Vec<QueueRow>,
922 pub summary: TriageSummary,
924 pub cursor: usize,
926 pub focus: TriageControl,
929 pub selection: BulkSelection,
932 pub by_age: bool,
934 pub modal: Option<TriageModal>,
936 pub dropdown: Option<TriageDropdown>,
940 pub creating: bool,
942 pub create_buffer: String,
944 pub note: Option<String>,
946}
947
948#[must_use]
951pub fn status_options(columns: &[String]) -> Vec<PickerItem> {
952 columns
953 .iter()
954 .map(|column| PickerItem {
955 id: column.clone(),
956 label: column.clone(),
957 })
958 .collect()
959}
960
961#[must_use]
968pub fn assignee_options(store: &Store, current: Option<&str>) -> Vec<PickerItem> {
969 let mut seen = std::collections::BTreeSet::new();
970 let mut out = Vec::new();
971 let mut push = |value: &str, out: &mut Vec<PickerItem>| {
972 let value = value.trim();
973 if value.is_empty() || !seen.insert(value.to_string()) {
974 return;
975 }
976 out.push(PickerItem {
977 id: value.to_string(),
978 label: value.to_string(),
979 });
980 };
981 if let Ok(folders) = store.assignee_segments() {
982 for folder in folders {
983 push(&folder, &mut out);
984 }
985 }
986 for team in store.config.teams.values() {
987 for member in &team.members {
988 push(member, &mut out);
989 }
990 }
991 if let Some(current) = current {
992 push(current, &mut out);
993 }
994 out
995}
996
997impl TriageState {
998 pub fn open(store: &Store) -> Result<Self> {
1006 let mut state = Self {
1007 rows: Vec::new(),
1008 summary: TriageSummary {
1009 total: 0,
1010 awaiting: 0,
1011 deferred: 0,
1012 accepted: 0,
1013 declined: 0,
1014 duplicated: 0,
1015 unknown_age: 0,
1016 age: Vec::new(),
1017 },
1018 cursor: 0,
1019 focus: TriageControl::Queue,
1020 selection: BulkSelection::new(),
1021 by_age: false,
1022 modal: None,
1023 dropdown: None,
1024 creating: false,
1025 create_buffer: String::new(),
1026 note: None,
1027 };
1028 state.refresh(store)?;
1029 Ok(state)
1030 }
1031
1032 pub fn refresh(&mut self, store: &Store) -> Result<()> {
1039 let scope = scope_of(store)?;
1044 let corpus = crate::search::Corpus::build(store)?;
1045 let metrics = inbox::collect(&corpus.issues, &scope, &crate::model::today());
1048 self.summary = TriageSummary::of(&metrics);
1049 self.rows = metrics
1050 .entries
1051 .iter()
1052 .map(|entry| QueueRow {
1053 issue: (*entry.issue).clone(),
1054 age_days: entry.age_days,
1055 last_decision: last_decision(entry.issue),
1056 })
1057 .collect();
1058 if self.by_age {
1059 self.sort_by_age();
1060 }
1061 self.clamp_cursor();
1062 Ok(())
1063 }
1064
1065 fn sort_by_age(&mut self) {
1069 self.rows.sort_by(|a, b| match (a.age_days, b.age_days) {
1070 (Some(x), Some(y)) => y.cmp(&x),
1071 (Some(_), None) => std::cmp::Ordering::Less,
1072 (None, Some(_)) => std::cmp::Ordering::Greater,
1073 (None, None) => std::cmp::Ordering::Equal,
1074 });
1075 }
1076
1077 fn clamp_cursor(&mut self) {
1078 if self.rows.is_empty() {
1079 self.cursor = 0;
1080 } else if self.cursor >= self.rows.len() {
1081 self.cursor = self.rows.len() - 1;
1082 }
1083 }
1084
1085 pub fn move_cursor(&mut self, delta: isize) {
1086 if self.rows.is_empty() {
1087 return;
1088 }
1089 let len = self.rows.len() as isize;
1090 let next = (self.cursor as isize + delta).rem_euclid(len);
1091 self.cursor = next as usize;
1092 }
1093
1094 pub fn move_focus(&mut self, delta: isize) {
1102 let index = TriageControl::ALL
1103 .iter()
1104 .position(|control| *control == self.focus)
1105 .unwrap_or_default() as isize;
1106 let last = TriageControl::ALL.len() as isize - 1;
1107 self.focus = TriageControl::ALL[(index + delta).clamp(0, last) as usize];
1108 }
1109
1110 #[must_use]
1111 pub fn current(&self) -> Option<&QueueRow> {
1112 self.rows.get(self.cursor)
1113 }
1114
1115 pub fn open_dropdown(
1119 &mut self,
1120 kind: TriageDropdownKind,
1121 options: Vec<PickerItem>,
1122 current: Option<&str>,
1123 ) {
1124 let mut state = DropdownState::single(options);
1125 if let Some(current) = current
1126 && let Some(index) = state.options.iter().position(|option| option.id == current)
1127 && let Some(visible) = state
1128 .visible_indices()
1129 .iter()
1130 .position(|visible| *visible == index)
1131 {
1132 state.cursor = visible;
1133 }
1134 self.dropdown = Some(TriageDropdown { kind, state });
1135 }
1136
1137 pub fn close_dropdown(&mut self) {
1139 self.dropdown = None;
1140 }
1141
1142 #[must_use]
1146 pub fn handle_dropdown_key(
1147 &mut self,
1148 key: crossterm::event::KeyCode,
1149 ) -> Option<(TriageDropdownKind, String)> {
1150 let dropdown = self.dropdown.as_mut()?;
1151 match dropdown.state.handle_key(key) {
1152 PickerEvent::Cancelled => {
1153 self.dropdown = None;
1154 None
1155 }
1156 PickerEvent::Selected(ids) => {
1157 let kind = dropdown.kind;
1158 let id = ids.first().cloned();
1159 self.dropdown = None;
1160 id.map(|id| (kind, id))
1161 }
1162 PickerEvent::Created(_) | PickerEvent::None => None,
1166 }
1167 }
1168
1169 pub fn toggle_current(&mut self) {
1174 let Some(row) = self.rows.get(self.cursor) else {
1175 return;
1176 };
1177 if self.selection.contains(row.issue.number) {
1178 self.selection.remove(row.issue.number);
1179 return;
1180 }
1181 match self.selection.toggle(row.issue.number) {
1182 Ok(_) => {}
1183 Err(problem) => self.note = Some(problem.to_string()),
1184 }
1185 }
1186
1187 pub fn open_single(&mut self, kind: DecisionKind, store: &Store, columns: &[String]) -> bool {
1189 let Some(row) = self.rows.get(self.cursor) else {
1190 self.note = Some("the inbox is empty — there is nothing to decide".to_string());
1191 return false;
1192 };
1193 let heading = format!("#{} {}", row.issue.number, row.issue.title);
1194 let mut modal = TriageModal::new(kind, ModalScope::One(row.issue.number), heading);
1195 modal.prefill_defaults(store, columns);
1196 self.modal = Some(modal);
1197 true
1198 }
1199
1200 pub fn open_action(&mut self, kind: DecisionKind, store: &Store, columns: &[String]) -> bool {
1203 if self.selection.is_empty() {
1204 self.open_single(kind, store, columns)
1205 } else {
1206 self.open_batch(kind, store, columns)
1207 }
1208 }
1209
1210 pub fn open_batch(&mut self, kind: DecisionKind, store: &Store, columns: &[String]) -> bool {
1212 if self.selection.is_empty() {
1213 self.note = Some("press `space` on a card to mark it first".to_string());
1214 return false;
1215 }
1216 let heading = format!("{} marked issue(s)", self.selection.len());
1217 let mut modal = TriageModal::new(kind, ModalScope::Many(self.selection.clone()), heading);
1218 modal.prefill_defaults(store, columns);
1219 self.modal = Some(modal);
1220 true
1221 }
1222
1223 pub fn close_modal(&mut self) {
1227 self.modal = None;
1228 }
1229
1230 fn issues(&self) -> Vec<(u32, Issue)> {
1232 self.rows
1233 .iter()
1234 .map(|row| (row.issue.number, row.issue.clone()))
1235 .collect()
1236 }
1237
1238 pub fn preview(&mut self, store: &Store) -> Result<()> {
1244 let issues = self.issues();
1245 if let Some(modal) = self.modal.as_mut() {
1246 modal.preview(store, &issues)?;
1247 }
1248 Ok(())
1249 }
1250
1251 pub fn confirm(&mut self, store: &Store) -> Result<()> {
1256 if let Some(modal) = self.modal.as_mut() {
1260 modal.confirm(store)?;
1261 }
1262 if self.modal_finished() {
1263 let report = self.modal.as_ref().and_then(|modal| match &modal.state {
1264 ModalState::Done(text) => Some(text.clone()),
1265 _ => None,
1266 });
1267 self.refresh(store)?;
1268 self.modal = None;
1269 self.note = report;
1270 }
1271 Ok(())
1272 }
1273
1274 pub fn modal_ok(&mut self, store: &Store) -> Result<()> {
1281 match self.modal.as_ref().map(|modal| &modal.state) {
1282 Some(ModalState::Editing | ModalState::NeedsInput(_) | ModalState::Refused(_)) => {
1283 self.preview(store)?
1284 }
1285 Some(ModalState::Preview(_)) => self.confirm(store)?,
1286 Some(ModalState::Confirming(_)) => self.confirm(store)?,
1287 Some(ModalState::Done(_)) => self.close_modal(),
1288 None => {}
1289 }
1290 Ok(())
1291 }
1292
1293 #[must_use]
1296 pub fn modal_finished(&self) -> bool {
1297 matches!(
1298 self.modal.as_ref().map(|modal| &modal.state),
1299 Some(ModalState::Done(_))
1300 )
1301 }
1302
1303 #[must_use]
1306 pub fn display_note(&self) -> Option<String> {
1307 match self.modal.as_ref().map(|modal| &modal.state) {
1308 Some(ModalState::Done(text)) | Some(ModalState::Refused(text)) => Some(text.clone()),
1309 _ => self.note.clone(),
1310 }
1311 }
1312
1313 #[must_use]
1315 pub fn footer(&self) -> String {
1316 if self.creating {
1317 return "enter create · esc cancel".to_string();
1318 }
1319 if self.selection.is_empty() {
1320 "h/l focus · enter activate · j/k move · space toggle · a accept · d decline · u duplicate · f defer · p batch · s status · @ assignee · n new · A age · esc back"
1321 .to_string()
1322 } else {
1323 format!(
1324 "{} marked — space toggles · a/d/u/f apply to all · p batch accept · s status · @ assignee · esc back",
1325 self.selection.len()
1326 )
1327 }
1328 }
1329
1330 #[must_use]
1332 pub fn body(&self) -> String {
1333 self.body_with_width(usize::MAX)
1334 }
1335
1336 fn body_with_width(&self, width: usize) -> String {
1337 let mut out = String::new();
1338 let _ = writeln!(out, "{}", self.summary.header());
1339 let _ = writeln!(out, "{}", self.summary.age_line());
1340 let _ = writeln!(out, "{}", self.summary.decisions_line());
1341 out.push('\n');
1342 if self.rows.is_empty() {
1343 out.push_str("the inbox is empty\n");
1344 }
1345 for (index, row) in self.rows.iter().enumerate() {
1346 let cursor = if index == self.cursor { ">" } else { " " };
1347 let mark = if self.selection.contains(row.issue.number) {
1348 "*"
1349 } else {
1350 " "
1351 };
1352 let prefix = format!(
1353 "{cursor}{mark}{:<4} {:<8} ",
1354 row.issue.number,
1355 age_cell(row.age_days),
1356 );
1357 let prefix_width = UnicodeWidthStr::width(prefix.as_str());
1358 let title_lines =
1359 wrap_title(&row.issue.title, width.saturating_sub(prefix_width).max(1));
1360 let indent = " ".repeat(prefix_width);
1361 for (line_index, title_line) in title_lines.iter().enumerate() {
1362 if line_index == 0 {
1363 let _ = writeln!(out, "{prefix}{title_line}");
1364 } else {
1365 let _ = writeln!(out, "{indent}{title_line}");
1366 }
1367 }
1368 }
1369 if let Some(row) = self.current() {
1370 let _ = writeln!(out, "\n{}", decision_summary(row));
1371 }
1372 if let Some(note) = self.display_note() {
1373 let _ = writeln!(out, "\n{note}");
1374 }
1375 let _ = write!(out, "\n{}", self.footer());
1376 out
1377 }
1378}
1379
1380fn wrap_title(title: &str, width: usize) -> Vec<String> {
1386 if title.is_empty() || width == 0 {
1387 return vec![String::new()];
1388 }
1389 let mut lines = Vec::new();
1390 let mut line = String::new();
1391 for word in title.split_whitespace() {
1392 let word_width = UnicodeWidthStr::width(word);
1393 let candidate_width =
1394 UnicodeWidthStr::width(line.as_str()) + usize::from(!line.is_empty()) + word_width;
1395 if !line.is_empty() && candidate_width > width {
1396 lines.push(std::mem::take(&mut line));
1397 }
1398 if word_width <= width {
1399 if !line.is_empty() {
1400 line.push(' ');
1401 }
1402 line.push_str(word);
1403 } else {
1404 let mut chunk = String::new();
1405 for character in word.chars() {
1406 let character_width = UnicodeWidthChar::width(character).unwrap_or(0);
1407 if !chunk.is_empty()
1408 && UnicodeWidthStr::width(chunk.as_str()) + character_width > width
1409 {
1410 lines.push(std::mem::take(&mut chunk));
1411 }
1412 chunk.push(character);
1413 }
1414 line = chunk;
1415 }
1416 }
1417 if !line.is_empty() || lines.is_empty() {
1418 lines.push(line);
1419 }
1420 lines
1421}
1422
1423fn age_cell(age: Option<i64>) -> String {
1433 match age {
1434 Some(0) => "today".to_string(),
1435 Some(days) => format!("{days}d"),
1436 None => "unknown".to_string(),
1437 }
1438}
1439
1440pub fn render(app: &App, frame: &mut Frame, area: Rect, state: &TriageState) {
1450 let block = Block::default()
1451 .borders(Borders::ALL)
1452 .title(" triage ")
1453 .border_style(Style::default().fg(Color::Cyan));
1454 let inner = block.inner(area);
1455 frame.render_widget(block, area);
1456
1457 let chunks = Layout::default()
1458 .direction(Direction::Vertical)
1459 .constraints([Constraint::Length(1), Constraint::Min(1)])
1460 .split(inner);
1461
1462 let labels = control_labels(state);
1467 let mut spans: Vec<Span> = Vec::new();
1468 for (index, (control, label)) in labels.iter().enumerate() {
1469 if index > 0 {
1470 spans.push(Span::raw(" "));
1471 }
1472 spans.push(Span::styled(
1473 label.clone(),
1474 control_style(*control, state.focus),
1475 ));
1476 }
1477 frame.render_widget(Paragraph::new(Line::from(spans)), chunks[0]);
1478
1479 if state.modal.is_none() && !state.creating {
1480 for (control, rect) in control_rects(area, state) {
1481 app.record_region(rect, control_action(control));
1482 }
1483 }
1484
1485 if state.creating {
1486 let line = Line::from(vec![
1487 Span::styled(
1488 "new issue title: ",
1489 Style::default().add_modifier(Modifier::BOLD),
1490 ),
1491 Span::raw(format!("{}█", state.create_buffer)),
1492 ]);
1493 frame.render_widget(Paragraph::new(line), chunks[1]);
1494 return;
1495 }
1496
1497 frame.render_widget(
1498 Paragraph::new(state.body_with_width(usize::from(chunks[1].width))),
1499 chunks[1],
1500 );
1501
1502 if let Some(modal) = state.modal.as_ref() {
1503 render_modal(app, frame, area, modal);
1504 }
1505 if state.dropdown.is_some() {
1506 render_dropdown(app, frame, area, state);
1507 }
1508}
1509
1510fn control_labels(state: &TriageState) -> [(TriageControl, String); 4] {
1516 let status = state
1517 .current()
1518 .map(|row| row.issue.status.display().to_string())
1519 .unwrap_or_else(|| "—".to_string());
1520 let assignee = state
1521 .current()
1522 .and_then(|row| row.issue.assignee.clone())
1523 .unwrap_or_else(|| "unassigned".to_string());
1524 [
1525 (TriageControl::Queue, "queue".to_string()),
1526 (TriageControl::Status, format!("[s] status: {status}")),
1527 (TriageControl::Assignee, format!("[@] assignee: {assignee}")),
1528 (TriageControl::New, "[n] new issue".to_string()),
1529 ]
1530}
1531
1532fn control_style(control: TriageControl, focus: TriageControl) -> Style {
1538 let color = match control {
1539 TriageControl::Queue => Color::White,
1540 TriageControl::Status => Color::Cyan,
1541 TriageControl::Assignee => Color::Green,
1542 TriageControl::New => Color::Yellow,
1543 };
1544 let style = Style::default().fg(color);
1545 if control == focus {
1546 style.add_modifier(Modifier::REVERSED | Modifier::BOLD)
1547 } else {
1548 style
1549 }
1550}
1551
1552fn control_action(control: TriageControl) -> Action {
1555 match control {
1556 TriageControl::Queue => Action::TriageFocusQueue,
1557 TriageControl::Status => Action::TriageOpenStatus,
1558 TriageControl::Assignee => Action::TriageOpenAssignee,
1559 TriageControl::New => Action::TriageCreate,
1560 }
1561}
1562
1563fn label_width(label: &str) -> u16 {
1564 label.chars().count() as u16
1565}
1566
1567#[must_use]
1571pub fn control_rects(area: Rect, state: &TriageState) -> [(TriageControl, Rect); 4] {
1572 let inner = Block::default().borders(Borders::ALL).inner(area);
1573 let mut x = inner.x;
1574 control_labels(state).map(|(control, label)| {
1575 let width = label_width(&label).min(inner.width.saturating_sub(x.saturating_sub(inner.x)));
1576 let rect = Rect::new(x, inner.y, width, 1);
1577 x = x.saturating_add(width).saturating_add(3);
1578 (control, rect)
1579 })
1580}
1581
1582#[must_use]
1585pub fn dropdown_layout(area: Rect, state: &TriageState) -> Option<(Rect, Vec<Rect>)> {
1586 let dropdown = state.dropdown.as_ref()?;
1587 let visible = dropdown.state.visible_indices();
1588 if visible.is_empty() {
1589 return None;
1590 }
1591 let container = if dropdown.kind == TriageDropdownKind::DecisionTarget {
1592 crate::ui::modal::modal_rect(area)
1593 } else {
1594 area
1595 };
1596 let inner = Block::default().borders(Borders::ALL).inner(container);
1597 let inner = if dropdown.kind == TriageDropdownKind::DecisionTarget {
1598 Rect {
1599 y: inner.y.saturating_add(2),
1600 height: inner.height.saturating_sub(2),
1601 ..inner
1602 }
1603 } else {
1604 inner
1605 };
1606 let widest = visible
1607 .iter()
1608 .map(|index| label_width(&dropdown.state.options[*index].label))
1609 .max()
1610 .unwrap_or(0);
1611 let height = (visible.len() as u16 + 2).min(inner.height.max(3));
1612 if height < 3 || inner.width == 0 {
1613 return None;
1614 }
1615 let title_width = label_width(dropdown_title(dropdown.kind)) + 2;
1616 let width = (widest + 4).max(title_width).min(inner.width);
1617 let rect = Rect::new(inner.x, inner.y, width, height);
1618 let rows = (0..visible.len() as u16)
1619 .take(height as usize - 2)
1620 .map(|row| Rect::new(rect.x, rect.y + 1 + row, rect.width, 1))
1621 .collect();
1622 Some((rect, rows))
1623}
1624
1625fn dropdown_title(kind: TriageDropdownKind) -> &'static str {
1626 match kind {
1627 TriageDropdownKind::Status => " choose status ",
1628 TriageDropdownKind::DecisionTarget => " choose column ",
1629 TriageDropdownKind::Assignee => " choose assignee ",
1630 }
1631}
1632
1633#[must_use]
1635pub fn modal_ok_rect(area: Rect) -> Rect {
1636 let inner = Block::default()
1637 .borders(Borders::ALL)
1638 .inner(crate::ui::modal::modal_rect(area));
1639 let width = 8.min(inner.width);
1640 let x = inner.x + inner.width.saturating_sub(width) / 2;
1641 Rect::new(
1642 x,
1643 inner
1644 .y
1645 .saturating_add(1)
1646 .min(inner.bottom().saturating_sub(1)),
1647 width,
1648 1,
1649 )
1650}
1651
1652fn render_modal(app: &App, frame: &mut Frame, area: Rect, modal: &TriageModal) {
1653 let modal_rect = crate::ui::modal::modal_rect(area);
1654 frame.render_widget(Clear, modal_rect);
1655 let modal_block = Block::default()
1656 .borders(Borders::ALL)
1657 .title(format!(" {} — {} ", modal.kind, modal.state_label()))
1658 .border_style(Style::default().fg(Color::Magenta));
1659 let modal_inner = modal_block.inner(modal_rect);
1660 frame.render_widget(modal_block, modal_rect);
1661
1662 let chunks = Layout::default()
1663 .direction(Direction::Vertical)
1664 .constraints([
1665 Constraint::Length(1),
1666 Constraint::Length(1),
1667 Constraint::Min(1),
1668 ])
1669 .split(modal_inner);
1670 frame.render_widget(
1671 Paragraph::new(Line::from(Span::styled(
1672 modal.heading.clone(),
1673 Style::default().add_modifier(Modifier::BOLD),
1674 ))),
1675 chunks[0],
1676 );
1677 let ok = modal_ok_rect(area);
1678 let style = if modal.ok_focused || app.is_hovered(&Action::TriageModalOk) {
1679 Style::default()
1680 .fg(Color::Black)
1681 .bg(Color::Cyan)
1682 .add_modifier(Modifier::BOLD)
1683 } else {
1684 Style::default()
1685 .fg(Color::Yellow)
1686 .add_modifier(Modifier::BOLD)
1687 };
1688 if ok.width > 0 && ok.height > 0 {
1689 frame.render_widget(
1690 Paragraph::new(Line::from(Span::styled("[ OK ]", style))),
1691 ok,
1692 );
1693 app.record_region(ok, Action::TriageModalOk);
1694 }
1695 frame.render_widget(Paragraph::new(modal.body()), chunks[2]);
1696}
1697
1698fn render_dropdown(app: &App, frame: &mut Frame, area: Rect, state: &TriageState) {
1699 let Some(dropdown) = state.dropdown.as_ref() else {
1700 return;
1701 };
1702 let Some((rect, rows)) = dropdown_layout(area, state) else {
1703 return;
1704 };
1705 app.record_region(area, Action::TriageDropdownClose);
1708 frame.render_widget(Clear, rect);
1709 let title = dropdown_title(dropdown.kind);
1710 let block = Block::default().borders(Borders::ALL).title(title);
1711 let inner = block.inner(rect);
1712 frame.render_widget(block, rect);
1713
1714 let visible = dropdown.state.visible_indices();
1715 let lines: Vec<Line> = visible
1716 .iter()
1717 .enumerate()
1718 .map(|(row, index)| {
1719 let option = &dropdown.state.options[*index];
1720 let cursor = if row == dropdown.state.cursor {
1721 ">"
1722 } else {
1723 " "
1724 };
1725 Line::from(format!("{cursor} {}", option.label))
1726 })
1727 .collect();
1728 frame.render_widget(Paragraph::new(lines), inner);
1729 for (row, row_rect) in rows.iter().enumerate() {
1730 app.record_region(*row_rect, Action::TriagePickOption(row));
1731 }
1732}
1733
1734#[cfg(test)]
1735mod tests {
1736 #[allow(clippy::wildcard_imports)]
1737 use super::*;
1738
1739 #[test]
1743 fn triage_tui_fields_exist_only_for_the_decision_that_can_use_them() {
1744 let one = ModalScope::One(1);
1745 let accept = TriageModal::new(DecisionKind::Accept, one.clone(), "a".into());
1746 assert_eq!(
1747 accept.fields,
1748 vec![
1749 DecisionField::Target,
1750 DecisionField::Actor,
1751 DecisionField::Reason,
1752 DecisionField::Assignee,
1753 ]
1754 );
1755 let decline = TriageModal::new(DecisionKind::Decline, one.clone(), "d".into());
1756 assert_eq!(
1757 decline.fields,
1758 vec![DecisionField::Reason, DecisionField::Actor],
1759 "`--to` has nowhere to be typed on a decline, so it cannot be"
1760 );
1761 let duplicate = TriageModal::new(DecisionKind::Duplicate, one.clone(), "u".into());
1762 assert_eq!(
1763 duplicate.fields,
1764 vec![
1765 DecisionField::DuplicateOf,
1766 DecisionField::Reason,
1767 DecisionField::Actor,
1768 ]
1769 );
1770 let defer = TriageModal::new(DecisionKind::Defer, one, "f".into());
1771 assert_eq!(
1772 defer.fields,
1773 vec![DecisionField::Reason, DecisionField::Actor]
1774 );
1775 assert_eq!(
1776 DecisionField::Target.label(DecisionKind::Accept),
1777 "column (required)"
1778 );
1779 assert_eq!(
1780 DecisionField::Actor.label(DecisionKind::Accept),
1781 "actor (required)"
1782 );
1783 assert_eq!(
1784 DecisionField::Reason.label(DecisionKind::Accept),
1785 "reason (optional)"
1786 );
1787 assert_eq!(
1788 DecisionField::Assignee.label(DecisionKind::Accept),
1789 "assignee (optional)"
1790 );
1791 }
1792
1793 #[test]
1794 fn triage_tui_the_cursor_only_moves_over_the_fields_the_decision_has() {
1795 let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1796 modal.move_cursor(1);
1797 assert_eq!(modal.field(modal.cursor), DecisionField::Actor);
1798 modal.move_cursor(1);
1799 assert_eq!(
1800 modal.field(modal.cursor),
1801 DecisionField::Reason,
1802 "two fields, so the third step wraps back to the first"
1803 );
1804 modal.move_cursor(-1);
1805 assert_eq!(modal.field(modal.cursor), DecisionField::Actor);
1806 }
1807
1808 #[test]
1809 fn triage_tui_a_cancelled_draft_never_reaches_the_row_it_was_typed_into() {
1810 let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1811 modal.begin_edit();
1812 for c in "half a reason".chars() {
1813 modal.push_char(c);
1814 }
1815 modal.abort_edit();
1816 assert!(!modal.editing);
1817 assert_eq!(modal.reason, "", "the row never saw the draft");
1818 }
1819
1820 #[test]
1821 fn triage_tui_a_committed_draft_lands_on_the_row_under_the_cursor() {
1822 let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1823 modal.begin_edit();
1824 for c in "no budget".chars() {
1825 modal.push_char(c);
1826 }
1827 modal.commit_edit();
1828 assert_eq!(modal.reason, "no budget");
1829 assert_eq!(modal.field_value(modal.field(0)), "no budget");
1830 }
1831
1832 #[test]
1833 fn triage_tui_an_accept_without_a_configured_target_names_none() {
1834 let config = crate::config::Config::default();
1835 let modal = TriageModal::new(DecisionKind::Accept, ModalScope::One(1), "a".into());
1836 let request = modal.request(&Store::open(&config).expect("an empty store opens"));
1837 assert_eq!(
1838 request.target_status, None,
1839 "no `--to` and no `[triage].accepted_status` means the service \
1840 refuses `missing_target`, exactly as the CLI does"
1841 );
1842 }
1843
1844 #[test]
1845 fn triage_tui_ok_is_in_the_keyboard_focus_ring() {
1846 let mut modal = TriageModal::new(DecisionKind::Accept, ModalScope::One(1), "a".into());
1847 assert!(modal.ok_focused);
1848
1849 modal.move_focus(1);
1850 assert!(!modal.ok_focused);
1851 assert_eq!(modal.field(modal.cursor), DecisionField::Target);
1852
1853 modal.move_focus(-1);
1854 assert!(modal.ok_focused);
1855
1856 modal.move_focus(-1);
1857 assert!(!modal.ok_focused);
1858 assert_eq!(modal.field(modal.cursor), DecisionField::Assignee);
1859
1860 modal.move_focus(1);
1861 assert!(modal.ok_focused);
1862 }
1863
1864 #[test]
1865 fn triage_tui_accept_prefills_todo_and_the_actor_without_configuration() {
1866 let config = crate::config::Config::default();
1867 let store = Store::open(&config).expect("an empty store opens");
1868 let mut modal = TriageModal::new(DecisionKind::Accept, ModalScope::One(1), "a".into());
1869 let columns = vec!["triage".to_string(), "todo".to_string()];
1870
1871 modal.prefill_defaults(&store, &columns);
1872
1873 assert_eq!(modal.target, "todo");
1874 assert_eq!(modal.actor, crate::storage::actor_identity());
1875 }
1876
1877 #[test]
1878 fn triage_tui_the_header_is_the_cli_headers_wording() {
1879 let summary = TriageSummary {
1880 total: 3,
1881 awaiting: 2,
1882 deferred: 1,
1883 accepted: 0,
1884 declined: 0,
1885 duplicated: 0,
1886 unknown_age: 1,
1887 age: Vec::new(),
1888 };
1889 assert_eq!(
1890 summary.header(),
1891 "inbox: 3 entries (2 awaiting, 1 deferred)"
1892 );
1893 }
1894
1895 #[test]
1896 fn triage_tui_an_unknown_age_is_unknown_and_never_zero() {
1897 assert_eq!(age_cell(None), "unknown");
1898 assert_eq!(age_cell(Some(0)), "today");
1899 assert_eq!(age_cell(Some(7)), "7d");
1900 }
1901
1902 #[test]
1903 fn triage_tui_a_long_unbroken_title_is_split_to_the_title_width() {
1904 let lines = wrap_title("alpha-beta-gamma-delta", 10);
1905 assert_eq!(lines, vec!["alpha-beta", "-gamma-del", "ta"]);
1906 assert!(
1907 lines
1908 .iter()
1909 .all(|line| UnicodeWidthStr::width(line.as_str()) <= 10)
1910 );
1911 }
1912
1913 #[test]
1914 fn triage_tui_the_footer_names_only_keys_that_mean_something_now() {
1915 let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1916 assert!(modal.footer().contains("p preview"), "editing: preview");
1917 modal.begin_edit();
1918 assert!(
1919 modal.footer().contains("enter commit"),
1920 "a letter while editing must not be advertised as a command"
1921 );
1922 assert!(!modal.footer().contains("p preview"));
1923 }
1924
1925 #[test]
1926 fn triage_tui_status_options_are_the_board_columns_in_order() {
1927 let columns = vec!["triage".to_string(), "todo".to_string(), "done".to_string()];
1928 let labels: Vec<String> = status_options(&columns)
1929 .into_iter()
1930 .map(|option| option.label)
1931 .collect();
1932 assert_eq!(labels, vec!["triage", "todo", "done"]);
1933 }
1934
1935 #[test]
1936 fn triage_tui_assignee_options_are_folders_teams_and_current() {
1937 let tmp = tempfile::TempDir::new().unwrap();
1938 let mut config = crate::config::Config {
1939 issues_dir: tmp.path().to_path_buf(),
1940 ..crate::config::Config::default()
1941 };
1942 std::fs::create_dir_all(config.issues_dir.join("alice")).unwrap();
1943 std::fs::create_dir_all(config.issues_dir.join("bob")).unwrap();
1944 std::fs::create_dir_all(config.issues_dir.join("unassigned")).unwrap();
1946 config.teams.insert(
1947 "core".to_string(),
1948 crate::config::Team {
1949 members: vec!["carol".to_string()],
1950 },
1951 );
1952 let store = Store::open(&config).unwrap();
1953
1954 let labels: Vec<String> = assignee_options(&store, Some("zoe"))
1955 .into_iter()
1956 .map(|option| option.label)
1957 .collect();
1958
1959 assert_eq!(
1960 labels,
1961 vec!["alice", "bob", "carol", "zoe"],
1962 "folders first, then team members, then the current value"
1963 );
1964 }
1965}