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};
46
47use crate::bulk::{Selection, SelectionScope, SelectionSource};
48use crate::commands::triage as cli_triage;
49use crate::error::{Result, TixError};
50use crate::model::Issue;
51use crate::storage::Store;
52use crate::triage::inbox::{self, AgeBucket, InboxScope, QueueMetrics};
53use crate::triage::{DecisionKind, TriageDecision, TriagePlan, batch};
54use crate::ui::bulk::BulkSelection;
55
56#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct QueueRow {
68 pub issue: Issue,
70 pub age_days: Option<i64>,
74 pub last_decision: Option<String>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct TriageSummary {
83 pub total: usize,
85 pub awaiting: usize,
87 pub deferred: usize,
89 pub accepted: usize,
91 pub declined: usize,
93 pub duplicated: usize,
95 pub unknown_age: usize,
97 pub age: Vec<AgeBucket>,
99}
100
101impl TriageSummary {
102 #[must_use]
104 pub fn of(metrics: &QueueMetrics<'_>) -> Self {
105 Self {
106 total: metrics.total,
107 awaiting: metrics.awaiting,
108 deferred: metrics.deferred,
109 accepted: metrics.accepted,
110 declined: metrics.declined,
111 duplicated: metrics.duplicated,
112 unknown_age: metrics.unknown_age,
113 age: metrics.age_distribution.clone(),
114 }
115 }
116
117 #[must_use]
125 pub fn header(&self) -> String {
126 format!(
127 "inbox: {} entr{} ({} awaiting, {} deferred)",
128 self.total,
129 if self.total == 1 { "y" } else { "ies" },
130 self.awaiting,
131 self.deferred
132 )
133 }
134
135 #[must_use]
137 pub fn age_line(&self) -> String {
138 let cells: Vec<String> = self
139 .age
140 .iter()
141 .map(|bucket| format!("{} {}", bucket.label, bucket.count))
142 .collect();
143 format!("age: {}", cells.join(" · "))
144 }
145
146 #[must_use]
150 pub fn decisions_line(&self) -> String {
151 format!(
152 "decisions: {} accepted · {} declined · {} duplicated · {} unknown age",
153 self.accepted, self.declined, self.duplicated, self.unknown_age
154 )
155 }
156}
157
158#[must_use]
160pub fn decision_summary(row: &QueueRow) -> String {
161 let mut out = format!(
162 "#{number} \"{title}\" — {status}",
163 number = row.issue.number,
164 title = row.issue.title,
165 status = row.issue.status.display(),
166 );
167 match &row.last_decision {
168 Some(last) => {
169 let _ = write!(out, " — last decision: {last}");
170 }
171 None => out.push_str(" — last decision: none"),
172 }
173 out
174}
175
176fn last_decision(issue: &Issue) -> Option<String> {
182 let record = crate::triage::history::last_decision(issue)?;
183 let mut out = format!("{} -> {}", record.kind.as_str(), record.result);
184 if let Some(reason) = &record.reason {
185 let _ = write!(out, " — {reason}");
186 }
187 let when = record.timestamp.get(..10).unwrap_or(&record.timestamp);
188 let _ = write!(out, " ({}, {})", record.actor, when);
189 Some(out)
190}
191
192fn scope_of(store: &Store) -> Result<InboxScope> {
200 inbox::scope(&store.config)?.ok_or_else(|| {
201 TixError::InvalidInput(
202 "the triage inbox needs a `[triage]` section in tix.toml, and this project has none"
203 .to_string(),
204 )
205 })
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum DecisionField {
215 Reason,
216 Actor,
217 Target,
220 DuplicateOf,
223}
224
225impl DecisionField {
226 #[must_use]
227 pub fn label(self) -> &'static str {
228 match self {
229 Self::Reason => "reason",
230 Self::Actor => "actor",
231 Self::Target => "to",
232 Self::DuplicateOf => "of",
233 }
234 }
235}
236
237#[derive(Debug, Clone, PartialEq, Eq)]
239pub enum ModalScope {
240 One(u32),
244 Many(BulkSelection),
246}
247
248#[derive(Debug, Clone, PartialEq, Eq)]
251pub enum ModalState {
252 Editing,
254 NeedsInput(String),
256 Preview(Box<TriagePlan>),
258 Confirming(Box<TriagePlan>),
260 Done(String),
262 Refused(String),
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct TriageModal {
269 pub kind: DecisionKind,
271 pub scope: ModalScope,
273 pub heading: String,
275 pub fields: Vec<DecisionField>,
277 pub cursor: usize,
279 pub editing: bool,
281 pub draft: String,
283 pub reason: String,
284 pub actor: String,
285 pub target: String,
286 pub duplicate_of: String,
287 pub timestamp: String,
290 pub state: ModalState,
291 pub note: Option<String>,
293 pub plan_stale: bool,
295}
296
297impl TriageModal {
298 #[must_use]
304 pub fn new(kind: DecisionKind, scope: ModalScope, heading: String) -> Self {
305 let fields = Self::fields_for(kind);
306 Self {
307 kind,
308 scope,
309 heading,
310 fields,
311 cursor: 0,
312 editing: false,
313 draft: String::new(),
314 reason: String::new(),
315 actor: String::new(),
316 target: String::new(),
317 duplicate_of: String::new(),
318 timestamp: crate::model::utc_now_rfc3339(),
319 state: ModalState::Editing,
320 note: None,
321 plan_stale: false,
322 }
323 }
324
325 #[must_use]
326 pub fn field(&self, index: usize) -> DecisionField {
327 self.fields
328 .get(index)
329 .copied()
330 .unwrap_or(DecisionField::Reason)
331 }
332
333 #[must_use]
334 pub fn field_value(&self, field: DecisionField) -> &str {
335 match field {
336 DecisionField::Reason => &self.reason,
337 DecisionField::Actor => &self.actor,
338 DecisionField::Target => &self.target,
339 DecisionField::DuplicateOf => &self.duplicate_of,
340 }
341 }
342
343 fn field_mut(&mut self, field: DecisionField) -> &mut String {
344 match field {
345 DecisionField::Reason => &mut self.reason,
346 DecisionField::Actor => &mut self.actor,
347 DecisionField::Target => &mut self.target,
348 DecisionField::DuplicateOf => &mut self.duplicate_of,
349 }
350 }
351
352 pub fn begin_edit(&mut self) {
353 let field = self.field(self.cursor);
354 self.draft = self.field_value(field).to_string();
355 self.editing = true;
356 }
357
358 pub fn push_char(&mut self, c: char) {
359 if self.editing {
360 self.draft.push(c);
361 }
362 }
363
364 pub fn pop_char(&mut self) {
365 if self.editing {
366 self.draft.pop();
367 }
368 }
369
370 pub fn commit_edit(&mut self) {
371 if !self.editing {
372 return;
373 }
374 let field = self.field(self.cursor);
375 let draft = std::mem::take(&mut self.draft);
376 *self.field_mut(field) = draft;
377 self.editing = false;
378 self.plan_stale = true;
385 }
386
387 pub fn abort_edit(&mut self) {
389 self.draft.clear();
390 self.editing = false;
391 }
392
393 pub fn move_cursor(&mut self, delta: isize) {
394 if self.fields.is_empty() {
395 return;
396 }
397 let len = self.fields.len() as isize;
398 let next = (self.cursor as isize + delta).rem_euclid(len);
399 self.cursor = next as usize;
400 }
401
402 pub fn cycle_kind(&mut self) {
411 self.kind = match self.kind {
412 DecisionKind::Accept => DecisionKind::Decline,
413 DecisionKind::Decline => DecisionKind::Duplicate,
414 DecisionKind::Duplicate => DecisionKind::Defer,
415 DecisionKind::Defer => DecisionKind::Accept,
416 };
417 self.fields = Self::fields_for(self.kind);
418 if self.cursor >= self.fields.len() {
419 self.cursor = self.fields.len() - 1;
420 }
421 self.state = ModalState::Editing;
422 self.plan_stale = true;
423 }
424
425 fn fields_for(kind: DecisionKind) -> Vec<DecisionField> {
432 let mut fields = vec![DecisionField::Reason, DecisionField::Actor];
433 match kind {
434 DecisionKind::Accept => fields.push(DecisionField::Target),
435 DecisionKind::Duplicate => fields.push(DecisionField::DuplicateOf),
436 DecisionKind::Decline | DecisionKind::Defer => {}
437 }
438 fields
439 }
440
441 #[must_use]
450 pub fn request(&self, store: &Store) -> TriageDecision {
451 let actor = if self.actor.trim().is_empty() {
452 crate::storage::actor_identity()
453 } else {
454 self.actor.trim().to_string()
455 };
456 let mut decision = TriageDecision::new(
457 self.kind,
458 self.timestamp.clone(),
459 actor,
460 self.reason.clone(),
461 );
462 if self.kind == DecisionKind::Accept {
463 let target = self.target.trim();
464 decision.target_status = if target.is_empty() {
465 None
466 } else {
467 Some(target.to_string())
468 };
469 if decision.target_status.is_none() {
470 decision.target_status = store
471 .config
472 .triage
473 .as_ref()
474 .and_then(|triage| triage.accepted_status.clone());
475 }
476 }
477 decision
478 }
479
480 #[must_use]
482 pub fn duplicate_target(&self) -> Option<String> {
483 let raw = self.duplicate_of.trim();
484 (!raw.is_empty() && self.kind == DecisionKind::Duplicate).then(|| raw.to_string())
485 }
486
487 pub fn plan(&self, store: &Store, issues: &[(u32, Issue)]) -> Result<TriagePlan> {
501 let selection = match &self.scope {
502 ModalScope::One(number) => Selection::from_issues(
503 [store.resolve_issue(&number.to_string())?],
504 SelectionSource::Explicit,
505 SelectionScope::Active,
506 ),
507 ModalScope::Many(marked) => marked.to_selection(issues).0,
508 };
509 batch::build_plan(
510 store,
511 selection,
512 self.request(store),
513 self.duplicate_target().as_deref(),
514 )
515 }
516
517 pub fn preview(&mut self, store: &Store, issues: &[(u32, Issue)]) -> Result<()> {
523 if let ModalScope::Many(marked) = &self.scope {
524 if marked.is_empty() {
525 self.state =
526 ModalState::NeedsInput("press `space` on a card to mark it".to_string());
527 return Ok(());
528 }
529 let (_selection, dropped) = marked.to_selection(issues);
533 self.note = (!dropped.is_empty()).then(|| {
534 format!(
535 "{} marked issue(s) left the inbox and were left out: #{}",
536 dropped.len(),
537 dropped
538 .iter()
539 .map(u32::to_string)
540 .collect::<Vec<_>>()
541 .join(", #")
542 )
543 });
544 }
545 match self.plan(store, issues) {
546 Ok(plan) => {
547 self.state = ModalState::Preview(Box::new(plan));
548 self.plan_stale = false;
549 }
550 Err(e) => {
551 self.state = ModalState::Refused(e.to_string());
552 self.note = Some(e.to_string());
553 }
554 }
555 Ok(())
556 }
557
558 pub fn confirm(&mut self, store: &Store) -> Result<()> {
574 if self.plan_stale
575 && matches!(
576 self.state,
577 ModalState::Preview(_) | ModalState::Confirming(_)
578 )
579 {
580 self.state = ModalState::Refused(
581 "the fields changed since the preview — press `p` again to see what would \
582 happen now"
583 .to_string(),
584 );
585 return Ok(());
586 }
587 match &self.state {
588 ModalState::Preview(plan) => {
589 self.state = ModalState::Confirming(plan.clone());
590 Ok(())
591 }
592 ModalState::Confirming(plan) => {
593 let mut plan = (**plan).clone();
594 plan.confirm();
595 match batch::apply(store, &plan) {
596 Ok(report) => {
597 let text = cli_triage::render_report(&report, &plan);
598 self.state = ModalState::Done(text);
599 self.plan_stale = false;
600 }
601 Err(e) => {
602 self.state = ModalState::Refused(e.to_string());
603 self.note = Some(e.to_string());
604 }
605 }
606 Ok(())
607 }
608 _ => Ok(()),
609 }
610 }
611
612 #[must_use]
613 pub fn is_armed(&self) -> bool {
614 matches!(self.state, ModalState::Confirming(_))
615 }
616
617 #[must_use]
618 pub fn state_label(&self) -> &'static str {
619 match &self.state {
620 ModalState::Editing => "editing",
621 ModalState::NeedsInput(_) => "needs input",
622 ModalState::Preview(_) | ModalState::Confirming(_) if self.plan_stale => {
623 "stale — preview again"
624 }
625 ModalState::Preview(_) => "preview — nothing written",
626 ModalState::Confirming(_) => "confirming — y writes",
627 ModalState::Done(_) => "done",
628 ModalState::Refused(_) => "refused",
629 }
630 }
631
632 #[must_use]
635 pub fn footer(&self) -> String {
636 if self.editing {
637 return "enter commit · esc revert".to_string();
638 }
639 if self.plan_stale
640 && matches!(
641 self.state,
642 ModalState::Preview(_) | ModalState::Confirming(_)
643 )
644 {
645 return "press p to re-preview · esc to cancel".to_string();
646 }
647 match &self.state {
648 ModalState::Preview(_) => "y to confirm · p to re-preview · esc to cancel".to_string(),
649 ModalState::Confirming(_) => "y APPLIES THIS WRITE · esc to cancel".to_string(),
650 ModalState::Done(_) | ModalState::Refused(_) | ModalState::NeedsInput(_) => {
651 "p to re-preview · esc to close".to_string()
652 }
653 ModalState::Editing => {
654 "a decision · j/k field · enter edit · t target · p preview · esc cancel"
655 .to_string()
656 }
657 }
658 }
659
660 #[must_use]
662 pub fn rows(&self) -> Vec<(bool, &'static str, String)> {
663 self.fields
664 .iter()
665 .enumerate()
666 .map(|(index, field)| {
667 let value = if self.editing && index == self.cursor {
668 format!("{}█", self.draft)
669 } else {
670 let value = self.field_value(*field);
671 if value.is_empty() {
672 "—".to_string()
673 } else {
674 value.to_string()
675 }
676 };
677 (index == self.cursor, field.label(), value)
678 })
679 .collect()
680 }
681
682 #[must_use]
685 pub fn body(&self) -> String {
686 match &self.state {
687 ModalState::Editing => {
688 let mut out = format!("decision: {}\n", self.kind);
689 if self.kind.requires_reason() {
690 out.push_str("a reason is required\n");
691 }
692 out.push_str("fields (enter to edit):\n");
693 for (on, name, value) in self.rows() {
694 let cursor = if on { ">" } else { " " };
695 out.push_str(&format!("{cursor} {name}: {value}\n"));
696 }
697 if let Some(note) = &self.note {
698 out.push_str(&format!("\nnote: {note}\n"));
699 }
700 out.push('\n');
701 out.push_str(&self.footer());
702 out
703 }
704 ModalState::NeedsInput(what) => format!("{what}\n\n{}", self.footer()),
705 ModalState::Preview(plan) => {
708 let mut out = cli_triage::render_plan(plan);
709 if let Some(note) = &self.note {
710 out.push_str(&format!("\nnote: {note}\n"));
711 }
712 out.push('\n');
713 out.push_str(&self.footer());
714 out
715 }
716 ModalState::Confirming(plan) => {
717 let mut out = cli_triage::render_plan(plan);
718 out.push('\n');
719 out.push_str(&self.footer());
720 out
721 }
722 ModalState::Done(text) | ModalState::Refused(text) => text.clone(),
723 }
724 }
725}
726
727#[derive(Debug, Clone, PartialEq, Eq)]
735pub struct TriageState {
736 pub rows: Vec<QueueRow>,
738 pub summary: TriageSummary,
740 pub cursor: usize,
742 pub selection: BulkSelection,
745 pub by_age: bool,
747 pub modal: Option<TriageModal>,
749 pub note: Option<String>,
751}
752
753impl TriageState {
754 pub fn open(store: &Store) -> Result<Self> {
762 let mut state = Self {
763 rows: Vec::new(),
764 summary: TriageSummary {
765 total: 0,
766 awaiting: 0,
767 deferred: 0,
768 accepted: 0,
769 declined: 0,
770 duplicated: 0,
771 unknown_age: 0,
772 age: Vec::new(),
773 },
774 cursor: 0,
775 selection: BulkSelection::new(),
776 by_age: false,
777 modal: None,
778 note: None,
779 };
780 state.refresh(store)?;
781 Ok(state)
782 }
783
784 pub fn refresh(&mut self, store: &Store) -> Result<()> {
791 let scope = scope_of(store)?;
796 let corpus = crate::search::Corpus::build(store)?;
797 let metrics = inbox::collect(&corpus.issues, &scope, &crate::model::today());
800 self.summary = TriageSummary::of(&metrics);
801 self.rows = metrics
802 .entries
803 .iter()
804 .map(|entry| QueueRow {
805 issue: (*entry.issue).clone(),
806 age_days: entry.age_days,
807 last_decision: last_decision(entry.issue),
808 })
809 .collect();
810 if self.by_age {
811 self.sort_by_age();
812 }
813 self.clamp_cursor();
814 Ok(())
815 }
816
817 fn sort_by_age(&mut self) {
821 self.rows.sort_by(|a, b| match (a.age_days, b.age_days) {
822 (Some(x), Some(y)) => y.cmp(&x),
823 (Some(_), None) => std::cmp::Ordering::Less,
824 (None, Some(_)) => std::cmp::Ordering::Greater,
825 (None, None) => std::cmp::Ordering::Equal,
826 });
827 }
828
829 fn clamp_cursor(&mut self) {
830 if self.rows.is_empty() {
831 self.cursor = 0;
832 } else if self.cursor >= self.rows.len() {
833 self.cursor = self.rows.len() - 1;
834 }
835 }
836
837 pub fn move_cursor(&mut self, delta: isize) {
838 if self.rows.is_empty() {
839 return;
840 }
841 let len = self.rows.len() as isize;
842 let next = (self.cursor as isize + delta).rem_euclid(len);
843 self.cursor = next as usize;
844 }
845
846 #[must_use]
847 pub fn current(&self) -> Option<&QueueRow> {
848 self.rows.get(self.cursor)
849 }
850
851 pub fn toggle_current(&mut self) {
854 let Some(row) = self.rows.get(self.cursor) else {
855 return;
856 };
857 match self.selection.toggle(row.issue.number) {
858 Ok(_) => {}
859 Err(problem) => self.note = Some(problem.to_string()),
860 }
861 }
862
863 pub fn clear_selection(&mut self) {
864 self.selection.clear();
865 }
866
867 pub fn open_single(&mut self, kind: DecisionKind) -> bool {
869 let Some(row) = self.rows.get(self.cursor) else {
870 self.note = Some("the inbox is empty — there is nothing to decide".to_string());
871 return false;
872 };
873 let heading = format!("#{} {}", row.issue.number, row.issue.title);
874 self.modal = Some(TriageModal::new(
875 kind,
876 ModalScope::One(row.issue.number),
877 heading,
878 ));
879 true
880 }
881
882 pub fn open_batch(&mut self, kind: DecisionKind) -> bool {
884 if self.selection.is_empty() {
885 self.note = Some("press `space` on a card to mark it first".to_string());
886 return false;
887 }
888 let heading = format!("{} marked issue(s)", self.selection.len());
889 self.modal = Some(TriageModal::new(
890 kind,
891 ModalScope::Many(self.selection.clone()),
892 heading,
893 ));
894 true
895 }
896
897 pub fn close_modal(&mut self) {
901 self.modal = None;
902 }
903
904 fn issues(&self) -> Vec<(u32, Issue)> {
906 self.rows
907 .iter()
908 .map(|row| (row.issue.number, row.issue.clone()))
909 .collect()
910 }
911
912 pub fn preview(&mut self, store: &Store) -> Result<()> {
918 let issues = self.issues();
919 if let Some(modal) = self.modal.as_mut() {
920 modal.preview(store, &issues)?;
921 }
922 Ok(())
923 }
924
925 pub fn confirm(&mut self, store: &Store) -> Result<()> {
930 if let Some(modal) = self.modal.as_mut() {
934 modal.confirm(store)?;
935 }
936 Ok(())
937 }
938
939 #[must_use]
942 pub fn modal_finished(&self) -> bool {
943 matches!(
944 self.modal.as_ref().map(|modal| &modal.state),
945 Some(ModalState::Done(_))
946 )
947 }
948
949 #[must_use]
952 pub fn display_note(&self) -> Option<String> {
953 match self.modal.as_ref().map(|modal| &modal.state) {
954 Some(ModalState::Done(text)) | Some(ModalState::Refused(text)) => Some(text.clone()),
955 _ => self.note.clone(),
956 }
957 }
958
959 #[must_use]
961 pub fn footer(&self) -> String {
962 if self.selection.is_empty() {
963 "j/k move · space mark · a accept · d decline · u duplicate · f defer · p batch · A age · esc back"
964 .to_string()
965 } else {
966 format!(
967 "{} marked — p batch · X clear · a accept · d decline · u duplicate · f defer · esc back",
968 self.selection.len()
969 )
970 }
971 }
972
973 #[must_use]
975 pub fn body(&self) -> String {
976 let mut out = String::new();
977 let _ = writeln!(out, "{}", self.summary.header());
978 let _ = writeln!(out, "{}", self.summary.age_line());
979 let _ = writeln!(out, "{}", self.summary.decisions_line());
980 out.push('\n');
981 if self.rows.is_empty() {
982 out.push_str("the inbox is empty\n");
983 }
984 for (index, row) in self.rows.iter().enumerate() {
985 let cursor = if index == self.cursor { ">" } else { " " };
986 let mark = if self.selection.contains(row.issue.number) {
987 "*"
988 } else {
989 " "
990 };
991 let _ = writeln!(
992 out,
993 "{cursor}{mark}{:<4} {:<8} {}",
994 row.issue.number,
995 age_cell(row.age_days),
996 row.issue.title
997 );
998 }
999 if let Some(row) = self.current() {
1000 let _ = writeln!(out, "\n{}", decision_summary(row));
1001 }
1002 if let Some(note) = self.display_note() {
1003 let _ = writeln!(out, "\n{note}");
1004 }
1005 let _ = write!(out, "\n{}", self.footer());
1006 out
1007 }
1008}
1009
1010fn age_cell(age: Option<i64>) -> String {
1020 match age {
1021 Some(0) => "today".to_string(),
1022 Some(days) => format!("{days}d"),
1023 None => "unknown".to_string(),
1024 }
1025}
1026
1027pub fn render(frame: &mut Frame, area: Rect, state: &TriageState) {
1035 let rect = crate::ui::modal::modal_rect(area);
1036 frame.render_widget(Clear, rect);
1037 let block = Block::default()
1038 .borders(Borders::ALL)
1039 .title(" triage inbox ")
1040 .border_style(Style::default().fg(Color::Cyan));
1041 let inner = block.inner(rect);
1042 frame.render_widget(block, rect);
1043
1044 let body = state.body();
1045 frame.render_widget(Paragraph::new(body), inner);
1046
1047 if let Some(modal) = state.modal.as_ref() {
1048 let modal_rect = crate::ui::modal::modal_rect(rect);
1049 frame.render_widget(Clear, modal_rect);
1050 let modal_block = Block::default()
1051 .borders(Borders::ALL)
1052 .title(format!(" {} — {} ", modal.kind, modal.state_label()))
1053 .border_style(Style::default().fg(Color::Magenta));
1054 let modal_inner = modal_block.inner(modal_rect);
1055 frame.render_widget(modal_block, modal_rect);
1056
1057 let chunks = Layout::default()
1058 .direction(Direction::Vertical)
1059 .constraints([Constraint::Length(1), Constraint::Min(1)])
1060 .split(modal_inner);
1061 frame.render_widget(
1062 Paragraph::new(Line::from(Span::styled(
1063 modal.heading.clone(),
1064 Style::default().add_modifier(Modifier::BOLD),
1065 ))),
1066 chunks[0],
1067 );
1068 frame.render_widget(Paragraph::new(modal.body()), chunks[1]);
1069 }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075
1076 #[test]
1080 fn triage_tui_fields_exist_only_for_the_decision_that_can_use_them() {
1081 let one = ModalScope::One(1);
1082 let accept = TriageModal::new(DecisionKind::Accept, one.clone(), "a".into());
1083 assert_eq!(
1084 accept.fields,
1085 vec![
1086 DecisionField::Reason,
1087 DecisionField::Actor,
1088 DecisionField::Target
1089 ]
1090 );
1091 let decline = TriageModal::new(DecisionKind::Decline, one.clone(), "d".into());
1092 assert_eq!(
1093 decline.fields,
1094 vec![DecisionField::Reason, DecisionField::Actor],
1095 "`--to` has nowhere to be typed on a decline, so it cannot be"
1096 );
1097 let duplicate = TriageModal::new(DecisionKind::Duplicate, one.clone(), "u".into());
1098 assert_eq!(
1099 duplicate.fields,
1100 vec![
1101 DecisionField::Reason,
1102 DecisionField::Actor,
1103 DecisionField::DuplicateOf
1104 ]
1105 );
1106 let defer = TriageModal::new(DecisionKind::Defer, one, "f".into());
1107 assert_eq!(
1108 defer.fields,
1109 vec![DecisionField::Reason, DecisionField::Actor]
1110 );
1111 }
1112
1113 #[test]
1114 fn triage_tui_the_cursor_only_moves_over_the_fields_the_decision_has() {
1115 let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1116 modal.move_cursor(1);
1117 assert_eq!(modal.field(modal.cursor), DecisionField::Actor);
1118 modal.move_cursor(1);
1119 assert_eq!(
1120 modal.field(modal.cursor),
1121 DecisionField::Reason,
1122 "two fields, so the third step wraps back to the first"
1123 );
1124 modal.move_cursor(-1);
1125 assert_eq!(modal.field(modal.cursor), DecisionField::Actor);
1126 }
1127
1128 #[test]
1129 fn triage_tui_a_cancelled_draft_never_reaches_the_row_it_was_typed_into() {
1130 let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1131 modal.begin_edit();
1132 for c in "half a reason".chars() {
1133 modal.push_char(c);
1134 }
1135 modal.abort_edit();
1136 assert!(!modal.editing);
1137 assert_eq!(modal.reason, "", "the row never saw the draft");
1138 }
1139
1140 #[test]
1141 fn triage_tui_a_committed_draft_lands_on_the_row_under_the_cursor() {
1142 let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1143 modal.begin_edit();
1144 for c in "no budget".chars() {
1145 modal.push_char(c);
1146 }
1147 modal.commit_edit();
1148 assert_eq!(modal.reason, "no budget");
1149 assert_eq!(modal.field_value(modal.field(0)), "no budget");
1150 }
1151
1152 #[test]
1153 fn triage_tui_an_accept_without_a_configured_target_names_none() {
1154 let config = crate::config::Config::default();
1155 let modal = TriageModal::new(DecisionKind::Accept, ModalScope::One(1), "a".into());
1156 let request = modal.request(&Store::open(&config).expect("an empty store opens"));
1157 assert_eq!(
1158 request.target_status, None,
1159 "no `--to` and no `[triage].accepted_status` means the service \
1160 refuses `missing_target`, exactly as the CLI does"
1161 );
1162 }
1163
1164 #[test]
1165 fn triage_tui_the_header_is_the_cli_headers_wording() {
1166 let summary = TriageSummary {
1167 total: 3,
1168 awaiting: 2,
1169 deferred: 1,
1170 accepted: 0,
1171 declined: 0,
1172 duplicated: 0,
1173 unknown_age: 1,
1174 age: Vec::new(),
1175 };
1176 assert_eq!(
1177 summary.header(),
1178 "inbox: 3 entries (2 awaiting, 1 deferred)"
1179 );
1180 }
1181
1182 #[test]
1183 fn triage_tui_an_unknown_age_is_unknown_and_never_zero() {
1184 assert_eq!(age_cell(None), "unknown");
1185 assert_eq!(age_cell(Some(0)), "today");
1186 assert_eq!(age_cell(Some(7)), "7d");
1187 }
1188
1189 #[test]
1190 fn triage_tui_the_footer_names_only_keys_that_mean_something_now() {
1191 let mut modal = TriageModal::new(DecisionKind::Decline, ModalScope::One(1), "d".into());
1192 assert!(modal.footer().contains("p preview"), "editing: preview");
1193 modal.begin_edit();
1194 assert!(
1195 modal.footer().contains("enter commit"),
1196 "a letter while editing must not be advertised as a command"
1197 );
1198 assert!(!modal.footer().contains("p preview"));
1199 }
1200}