1use std::cell::Cell;
41
42use rich::{Segment, Style};
43
44use crate::component::{Component, Context, Flow, View};
45use crate::components::{PreviewLayout, Theme, fit, pad, pasted, plain, question, text};
46use crate::event::{Button, Event, KeyCode, MouseKind};
47use crate::item::Item;
48use crate::keymap::{Keymap, keys};
49use crate::kit::{ListState, TextBuffer};
50use crate::policy::{LineIo, NotInteractive};
51
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
54pub enum Verdict {
55 #[default]
57 Pending,
58 Approved,
59 Rejected,
60 Skipped,
61}
62
63impl Verdict {
64 pub fn as_str(self) -> &'static str {
66 match self {
67 Verdict::Pending => "pending",
68 Verdict::Approved => "approved",
69 Verdict::Rejected => "rejected",
70 Verdict::Skipped => "skipped",
71 }
72 }
73
74 pub fn parse(name: &str) -> Option<Verdict> {
77 Some(match name {
78 "pending" => Verdict::Pending,
79 "approved" | "approve" => Verdict::Approved,
80 "rejected" | "reject" => Verdict::Rejected,
81 "skipped" | "skip" => Verdict::Skipped,
82 _ => return None,
83 })
84 }
85
86 pub fn mark(self) -> &'static str {
88 match self {
89 Verdict::Pending => "·",
90 Verdict::Approved => "✓",
91 Verdict::Rejected => "✗",
92 Verdict::Skipped => "↷",
93 }
94 }
95
96 pub fn theme_key(self) -> &'static str {
98 match self {
99 Verdict::Pending => "approve.pending",
100 Verdict::Approved => "approve.approved",
101 Verdict::Rejected => "approve.rejected",
102 Verdict::Skipped => "approve.skipped",
103 }
104 }
105
106 fn default_style(self) -> &'static str {
108 match self {
109 Verdict::Pending => "dim",
110 Verdict::Approved => "bold green",
111 Verdict::Rejected => "bold red",
112 Verdict::Skipped => "yellow",
113 }
114 }
115
116 fn verb(self) -> &'static str {
118 match self {
119 Verdict::Pending => "undo",
120 Verdict::Approved => "approve",
121 Verdict::Rejected => "reject",
122 Verdict::Skipped => "skip",
123 }
124 }
125}
126
127impl std::fmt::Display for Verdict {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 f.write_str(self.as_str())
130 }
131}
132
133#[derive(Clone, Debug, PartialEq, Eq)]
135pub struct Decision<T> {
136 pub index: usize,
138 pub value: T,
139 pub label: String,
140 pub verdict: Verdict,
141 pub reason: Option<String>,
142}
143
144#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct ApprovalReport<T> {
148 pub decisions: Vec<Decision<T>>,
149}
150
151impl<T> ApprovalReport<T> {
152 pub fn verdicts(&self) -> Vec<Verdict> {
154 self.decisions.iter().map(|d| d.verdict).collect()
155 }
156
157 pub fn with(&self, verdict: Verdict) -> impl Iterator<Item = &Decision<T>> {
159 self.decisions.iter().filter(move |d| d.verdict == verdict)
160 }
161
162 pub fn approved(&self) -> Vec<&T> {
164 self.with(Verdict::Approved).map(|d| &d.value).collect()
165 }
166
167 pub fn rejected(&self) -> Vec<&T> {
169 self.with(Verdict::Rejected).map(|d| &d.value).collect()
170 }
171
172 pub fn count(&self, verdict: Verdict) -> usize {
174 self.with(verdict).count()
175 }
176
177 pub fn summary(&self) -> String {
179 summary(&self.verdicts())
180 }
181}
182
183fn summary(verdicts: &[Verdict]) -> String {
184 let count = |verdict| verdicts.iter().filter(|v| **v == verdict).count();
185 format!(
186 "{} approved · {} rejected · {} skipped · {} pending",
187 count(Verdict::Approved),
188 count(Verdict::Rejected),
189 count(Verdict::Skipped),
190 count(Verdict::Pending)
191 )
192}
193
194pub fn approve_keymap() -> Keymap {
196 Keymap::new("approve")
197 .bind("approve", keys("a"), "approve")
198 .bind("reject", keys("r"), "reject")
199 .bind("skip", keys("s"), "skip")
200 .bind("approve-reason", keys("A"), "approve, with a reason")
201 .bind("reject-reason", keys("R"), "reject, with a reason")
202 .bind("skip-reason", keys("S"), "skip, with a reason")
203 .bind("undo", keys("u"), "back to pending")
204 .bind("mark", keys("space"), "mark for a bulk decision")
205 .bind("mark-all", keys("ctrl+a"), "mark every pending item")
206 .bind("next-pending", keys("n tab"), "next pending item")
207 .bind("up", keys("up k ctrl+p"), "move up")
208 .bind("down", keys("down j ctrl+n"), "move down")
209 .bind("page-up", keys("pageup"), "page up")
210 .bind("page-down", keys("pagedown"), "page down")
211 .bind("first", keys("home g"), "first")
212 .bind("last", keys("end G"), "last")
213 .bind("preview", keys("p"), "show or hide the details")
214 .bind("submit", keys("enter"), "submit the decisions")
215 .bind("cancel", keys("escape"), "cancel")
216}
217
218const BUTTONS: [(&str, &str); 5] = [
220 ("[a] approve", "approve"),
221 ("[r] reject", "reject"),
222 ("[s] skip", "skip"),
223 ("[u] undo", "undo"),
224 ("[enter] submit", "submit"),
225];
226
227struct ReasonEditor {
229 verdict: Verdict,
230 targets: Vec<usize>,
231 buffer: TextBuffer,
232 error: Option<String>,
233}
234
235#[must_use = "a component does nothing until it is run or mounted"]
238pub struct ApprovalQueue<T> {
239 prompt: String,
240 items: Vec<Item<T>>,
241 verdicts: Vec<Verdict>,
242 reasons: Vec<Option<String>>,
243 list: ListState,
245 height: usize,
246 preview: PreviewLayout,
247 preview_height: usize,
248 theme: Theme,
249 mouse: bool,
250 keymap: Keymap,
251 required: Vec<Verdict>,
253 advance: bool,
255 auto_submit: bool,
257 reason: Option<ReasonEditor>,
258 status: Option<String>,
260 answer: Option<Option<String>>,
262 space: Cell<usize>,
264}
265
266impl<T> ApprovalQueue<T> {
267 pub fn new<I, V>(prompt: impl Into<String>, items: I) -> ApprovalQueue<T>
268 where
269 I: IntoIterator<Item = V>,
270 V: Into<Item<T>>,
271 {
272 let items: Vec<Item<T>> = items.into_iter().map(Into::into).collect();
273 let mut list = ListState::new();
274 list.set_len(items.len());
275 list.clear_selection(items.len());
276 ApprovalQueue {
277 prompt: prompt.into(),
278 verdicts: vec![Verdict::Pending; items.len()],
279 reasons: vec![None; items.len()],
280 items,
281 list,
282 height: 10,
283 preview: PreviewLayout::Auto,
284 preview_height: 8,
285 theme: Theme::default(),
286 mouse: false,
287 keymap: approve_keymap(),
288 required: Vec::new(),
289 advance: true,
290 auto_submit: false,
291 reason: None,
292 status: None,
293 answer: None,
294 space: Cell::new(usize::MAX),
295 }
296 }
297
298 pub fn height(mut self, rows: usize) -> Self {
300 self.height = rows.max(1);
301 self
302 }
303
304 pub fn preview(mut self, layout: PreviewLayout) -> Self {
307 self.preview = layout;
308 self
309 }
310
311 pub fn preview_height(mut self, rows: usize) -> Self {
313 self.preview_height = rows.max(1);
314 self
315 }
316
317 pub fn theme(mut self, theme: Theme) -> Self {
318 self.theme = theme;
319 self
320 }
321
322 pub fn with_mouse(mut self, on: bool) -> Self {
325 self.mouse = on;
326 self
327 }
328
329 pub fn rebind(
332 mut self,
333 action: &str,
334 keys: impl IntoIterator<Item = crate::event::Key>,
335 ) -> Self {
336 self.keymap.rebind(action, keys);
337 self
338 }
339
340 pub fn require_reason(mut self, verdict: Verdict) -> Self {
343 if verdict != Verdict::Pending && !self.required.contains(&verdict) {
344 self.required.push(verdict);
345 }
346 self
347 }
348
349 pub fn advance(mut self, on: bool) -> Self {
352 self.advance = on;
353 self
354 }
355
356 pub fn auto_submit(mut self, on: bool) -> Self {
359 self.auto_submit = on;
360 self
361 }
362
363 pub fn decided(mut self, index: usize, verdict: Verdict, reason: Option<String>) -> Self {
365 if index < self.items.len() {
366 self.verdicts[index] = verdict;
367 self.reasons[index] = reason;
368 }
369 self
370 }
371
372 pub fn items(&self) -> &[Item<T>] {
373 &self.items
374 }
375
376 pub fn verdicts(&self) -> &[Verdict] {
378 &self.verdicts
379 }
380
381 pub fn reason(&self, index: usize) -> Option<&str> {
383 self.reasons.get(index)?.as_deref()
384 }
385
386 pub fn focused(&self) -> Option<usize> {
388 (!self.items.is_empty()).then(|| self.list.cursor())
389 }
390
391 pub fn marked(&self) -> Vec<usize> {
393 self.list.selected()
394 }
395
396 pub fn is_asking_reason(&self) -> bool {
398 self.reason.is_some()
399 }
400
401 pub fn approve_keymap(&self) -> &Keymap {
403 &self.keymap
404 }
405
406 pub fn summary(&self) -> String {
408 summary(&self.verdicts)
409 }
410
411 fn rows(&self) -> usize {
414 self.height
415 .min(self.items.len().max(1))
416 .min(self.space.get().saturating_sub(2).max(1))
417 }
418
419 fn step(&mut self, delta: isize) {
420 self.list.step(delta, self.rows());
421 }
422
423 fn next_pending(&self, from: usize) -> Option<usize> {
426 let len = self.items.len();
427 (1..=len)
428 .map(|offset| (from + offset) % len)
429 .find(|&index| self.verdicts[index] == Verdict::Pending)
430 }
431
432 fn targets(&self) -> Vec<usize> {
434 let marked = self.list.selected();
435 if marked.is_empty() {
436 self.focused().into_iter().collect()
437 } else {
438 marked
439 }
440 }
441
442 fn apply(&mut self, verdict: Verdict, targets: &[usize], reason: Option<String>) {
444 for &index in targets {
445 self.verdicts[index] = verdict;
446 self.reasons[index] = reason.clone();
447 }
448 self.list.clear_selection(self.items.len());
449 if targets.len() > 1 {
450 self.status = Some(format!("{} {} items", verdict.as_str(), targets.len()));
451 }
452 if self.advance
453 && verdict != Verdict::Pending
454 && let Some(next) = self.next_pending(self.list.cursor())
455 {
456 self.list.select_row(next, self.rows());
457 }
458 }
459
460 fn decide(&mut self, verdict: Verdict, ask: bool) -> Flow<Vec<usize>> {
462 let targets = self.targets();
463 if targets.is_empty() {
464 return Flow::Continue;
465 }
466 if ask || self.required.contains(&verdict) {
467 let current = match targets[..] {
469 [index] => self.reasons[index].clone().unwrap_or_default(),
470 _ => String::new(),
471 };
472 self.reason = Some(ReasonEditor {
473 verdict,
474 targets,
475 buffer: TextBuffer::with_text(current),
476 error: None,
477 });
478 return Flow::Continue;
479 }
480 self.apply(verdict, &targets, None);
481 self.maybe_submit()
482 }
483
484 fn maybe_submit(&mut self) -> Flow<Vec<usize>> {
485 if self.auto_submit && !self.verdicts.contains(&Verdict::Pending) {
486 return self.submit();
487 }
488 Flow::Continue
489 }
490
491 fn submit(&mut self) -> Flow<Vec<usize>> {
492 self.reason = None;
493 self.answer = Some(Some(self.summary()));
494 Flow::Done((0..self.items.len()).collect())
495 }
496
497 fn reason_event(&mut self, event: &Event) -> Flow<Vec<usize>> {
498 let Some(editor) = self.reason.as_mut() else {
499 return Flow::Continue;
500 };
501 if let Event::Paste(text) = event {
502 editor.buffer.insert(&pasted(text, " "));
503 editor.error = None;
504 return Flow::Continue;
505 }
506 let Some(key) = event.key() else {
507 return Flow::Ignored;
508 };
509 let typing = !key.modifiers.ctrl && !key.modifiers.alt;
510 match key.code {
511 KeyCode::Enter => {
512 let reason = editor.buffer.text().trim().to_string();
513 if reason.is_empty() && self.required.contains(&editor.verdict) {
514 editor.error = Some(format!("a reason is needed to {}", editor.verdict.verb()));
515 return Flow::Continue;
516 }
517 let Some(editor) = self.reason.take() else {
518 return Flow::Continue;
519 };
520 let reason = (!reason.is_empty()).then_some(reason);
521 self.apply(editor.verdict, &editor.targets, reason);
522 return self.maybe_submit();
523 }
524 KeyCode::Escape => self.reason = None,
525 KeyCode::Backspace => {
526 editor.buffer.backspace();
527 }
528 KeyCode::Delete => {
529 editor.buffer.delete();
530 }
531 KeyCode::Left => editor.buffer.left(),
532 KeyCode::Right => editor.buffer.right(),
533 KeyCode::Home => editor.buffer.home(),
534 KeyCode::End => editor.buffer.end(),
535 KeyCode::Char('u') if key.modifiers.ctrl => editor.buffer.delete_to_start(),
536 KeyCode::Char('w') if key.modifiers.ctrl => editor.buffer.delete_word(),
537 KeyCode::Char(c) if typing => {
538 editor.buffer.insert(c.encode_utf8(&mut [0; 4]));
539 editor.error = None;
540 }
541 _ => {}
542 }
543 Flow::Continue
544 }
545
546 fn act(&mut self, action: &str) -> Flow<Vec<usize>> {
547 let page = self.rows() as isize;
548 match action {
549 "approve" => return self.decide(Verdict::Approved, false),
550 "reject" => return self.decide(Verdict::Rejected, false),
551 "skip" => return self.decide(Verdict::Skipped, false),
552 "approve-reason" => return self.decide(Verdict::Approved, true),
553 "reject-reason" => return self.decide(Verdict::Rejected, true),
554 "skip-reason" => return self.decide(Verdict::Skipped, true),
555 "undo" => {
556 let targets = self.targets();
557 self.apply(Verdict::Pending, &targets, None);
558 }
559 "mark" => {
560 if let Some(index) = self.focused() {
561 self.list.toggle(index);
562 self.step(1);
563 }
564 }
565 "mark-all" => {
566 let pending: Vec<usize> = (0..self.items.len())
567 .filter(|&index| self.verdicts[index] == Verdict::Pending)
568 .collect();
569 let all = !pending.is_empty()
570 && pending.iter().all(|&index| self.list.is_selected(index));
571 self.list.clear_selection(self.items.len());
572 if !all {
573 for index in pending {
574 self.list.set_selected(index, true);
575 }
576 }
577 }
578 "next-pending" => {
579 if let Some(next) = self.next_pending(self.list.cursor()) {
580 self.list.select_row(next, self.rows());
581 }
582 }
583 "up" => self.step(-1),
584 "down" => self.step(1),
585 "page-up" => self.step(-page),
586 "page-down" => self.step(page),
587 "first" => self.step(isize::MIN / 2),
588 "last" => self.step(isize::MAX / 2),
589 "preview" => {
590 self.preview = match self.preview {
591 PreviewLayout::Hidden => PreviewLayout::Auto,
592 _ => PreviewLayout::Hidden,
593 };
594 }
595 "submit" => return self.submit(),
596 "cancel" => {
597 self.answer = Some(None);
598 return Flow::Cancel;
599 }
600 _ => return Flow::Ignored,
601 }
602 Flow::Continue
603 }
604
605 fn layout(&self, width: usize) -> (PreviewLayout, usize) {
607 if self.items.is_empty() {
608 return (PreviewLayout::Hidden, width);
609 }
610 let layout = match self.preview {
611 PreviewLayout::Auto if width >= 72 => PreviewLayout::Right,
612 PreviewLayout::Auto => PreviewLayout::Below,
613 layout => layout,
614 };
615 let left = (width * 45 / 100).max(20).min(width);
616 (
617 layout,
618 if layout == PreviewLayout::Right {
619 left
620 } else {
621 width
622 },
623 )
624 }
625
626 fn button_spans(&self) -> Vec<(usize, usize, &'static str)> {
628 let mut at = 2;
629 BUTTONS
630 .iter()
631 .map(|(label, action)| {
632 let span = (at, at + label.chars().count(), *action);
633 at = span.1 + 2;
634 span
635 })
636 .collect()
637 }
638
639 fn mouse_event(
640 &mut self,
641 kind: MouseKind,
642 column: usize,
643 row: usize,
644 width: usize,
645 ) -> Flow<Vec<usize>> {
646 let rows = self.rows();
647 let shown = self.list.visible(rows).len();
648 let (layout, left) = self.layout(width);
649 match kind {
650 MouseKind::ScrollUp => self.step(-1),
651 MouseKind::ScrollDown => self.step(1),
652 MouseKind::Down(Button::Left) => {
653 if row >= 1 && row < 1 + shown {
654 if layout == PreviewLayout::Right && column >= left {
655 return Flow::Continue;
656 }
657 let position = self.list.offset() + row - 1;
658 self.list.select_row(position, rows);
659 if (2..4).contains(&column) {
661 self.list.toggle(position);
662 }
663 } else if row == 1 + rows && self.reason.is_none() {
664 let hit = self
665 .button_spans()
666 .into_iter()
667 .find(|(from, to, _)| (*from..*to).contains(&column));
668 if let Some((_, _, action)) = hit {
669 return self.act(action);
670 }
671 }
672 }
673 _ => return Flow::Ignored,
674 }
675 Flow::Continue
676 }
677
678 fn event(&mut self, event: &Event, context: &Context<'_>) -> Flow<Vec<usize>> {
679 self.space.set(context.height);
680 if matches!(event, Event::Key(_) | Event::Mouse(_) | Event::Paste(_)) {
681 self.status = None;
682 }
683 if let Some(mouse) = event.mouse() {
684 return self.mouse_event(
685 mouse.kind,
686 mouse.column as usize,
687 mouse.row as usize,
688 context.width,
689 );
690 }
691 if self.reason.is_some() {
692 return self.reason_event(event);
693 }
694 let Some(key) = event.key() else {
695 return Flow::Ignored;
696 };
697 match self.keymap.action(key).map(str::to_string) {
698 Some(action) => self.act(&action),
699 None => Flow::Ignored,
700 }
701 }
702
703 fn style(&self, context: &Context<'_>, verdict: Verdict) -> Style {
704 context
705 .console
706 .theme()
707 .get(verdict.theme_key())
708 .cloned()
709 .unwrap_or_else(|| Style::parse(verdict.default_style()).expect("a built-in style"))
710 }
711
712 fn row(&self, context: &Context<'_>, position: usize, width: usize) -> Vec<Segment> {
713 let theme = &self.theme;
714 let item = &self.items[position];
715 let verdict = self.verdicts[position];
716 let focused = position == self.list.cursor();
717 let mut line = if focused {
718 vec![text(format!("{} ", theme.pointer), &theme.pointer_style)]
719 } else {
720 vec![plain(" ")]
721 };
722 line.push(if self.list.is_selected(position) {
723 text(format!("{} ", theme.checked), &theme.checked_style)
724 } else {
725 plain(" ")
726 });
727 let style = self.style(context, verdict);
728 line.push(text(format!("{} ", verdict.mark()), &style));
729 line.push(if focused {
730 text(item.label.clone(), &theme.focused)
731 } else {
732 plain(item.label.clone())
733 });
734 match (&self.reasons[position], &item.description) {
735 (Some(reason), _) => line.push(text(format!(" — {reason}"), &style)),
736 (None, Some(description)) => line.push(text(format!(" {description}"), &theme.hint)),
737 _ => {}
738 }
739 fit(line, width)
740 }
741
742 fn detail(&self, context: &Context<'_>, width: usize) -> Vec<Vec<Segment>> {
745 let Some(index) = self.focused() else {
746 return Vec::new();
747 };
748 let theme = &self.theme;
749 let item = &self.items[index];
750 let verdict = self.verdicts[index];
751 let mut lines = vec![fit(vec![text(item.label.clone(), &theme.focused)], width)];
752 if let Some(description) = &item.description {
753 lines.extend(context.lines_at(&rich::Text::new(description.clone()), width));
754 }
755 for (key, value) in &item.metadata {
756 lines.push(fit(
757 vec![text(format!("{key}: "), &theme.hint), plain(value.clone())],
758 width,
759 ));
760 }
761 let mut decided = vec![text(
762 format!("{} {}", verdict.mark(), verdict.as_str()),
763 &self.style(context, verdict),
764 )];
765 if let Some(reason) = &self.reasons[index] {
766 decided.push(plain(format!(" — {reason}")));
767 }
768 lines.push(fit(decided, width));
769 if let Some(preview) = &item.preview {
770 lines.push(Vec::new());
771 lines.extend(context.lines_at(&*preview.renderable(), width));
772 }
773 lines
774 }
775
776 fn footer(&self, width: usize) -> (Vec<Segment>, Option<usize>) {
779 let theme = &self.theme;
780 if let Some(editor) = &self.reason {
781 let count = editor.targets.len();
782 let what = if count == 1 {
783 String::new()
784 } else {
785 format!(" {count} items")
786 };
787 let mut line = vec![text(
788 format!(" Reason to {}{what} › ", editor.verdict.verb()),
789 &theme.prompt,
790 )];
791 let used = crate::components::width(&line);
792 if let Some(error) = &editor.error {
793 line.push(text(error.clone(), &theme.error));
794 return (fit(line, width), None);
795 }
796 let (shown, caret) = editor.buffer.window(width.saturating_sub(used + 1), None);
797 line.push(plain(shown));
798 return (
799 fit(line, width),
800 Some((used + caret).min(width.saturating_sub(1))),
801 );
802 }
803 if let Some(status) = &self.status {
804 return (
805 fit(vec![text(format!(" {status}"), &theme.hint)], width),
806 None,
807 );
808 }
809 let mut line = vec![plain(" ")];
810 for (at, (label, _)) in BUTTONS.iter().enumerate() {
811 if at > 0 {
812 line.push(plain(" "));
813 }
814 line.push(text(*label, &theme.hint));
815 }
816 let marked = self.list.selected_count();
817 if marked > 0 {
818 line.push(text(format!(" · {marked} marked"), &theme.checked_style));
819 }
820 (fit(line, width), None)
821 }
822
823 fn render_view(&self, context: &Context<'_>) -> View {
824 self.space.set(context.height);
825 let width = context.width;
826 let theme = &self.theme;
827 let mut header = question(theme, &self.prompt);
828 if let Some(answer) = &self.answer {
829 header.push(match answer {
830 Some(answer) => text(answer.clone(), &theme.answer),
831 None => text("cancelled", &theme.hint),
832 });
833 return View::new(vec![fit(header, width)]);
834 }
835 let done = self
836 .verdicts
837 .iter()
838 .filter(|v| **v != Verdict::Pending)
839 .count();
840 header.push(text(
841 format!("{done}/{} decided · {}", self.items.len(), self.summary()),
842 &theme.hint,
843 ));
844 let mut lines = vec![fit(header, width)];
845 let rows = self.rows();
846 let (layout, left) = self.layout(width);
847 let mut list: Vec<Vec<Segment>> = self
848 .list
849 .visible(rows)
850 .map(|position| self.row(context, position, left))
851 .collect();
852 if self.items.is_empty() {
853 list.push(vec![text(" nothing to review", &theme.hint)]);
854 }
855 list.resize_with(rows, Vec::new);
856 let (footer, caret) = self.footer(width);
857 match layout {
858 PreviewLayout::Right => {
859 let right = width.saturating_sub(left + 3).max(1);
860 let detail = self.detail(context, right);
861 for (row, line) in list.into_iter().enumerate() {
862 let mut line = pad(line, left);
863 line.push(text(" │ ", &theme.border));
864 if let Some(detail) = detail.get(row) {
865 line.extend(fit(detail.clone(), right));
866 }
867 lines.push(line);
868 }
869 lines.push(footer);
870 }
871 PreviewLayout::Below => {
872 lines.extend(list);
873 lines.push(footer);
874 lines.push(vec![text("─".repeat(width), &theme.border)]);
875 lines.extend(
876 self.detail(context, width)
877 .into_iter()
878 .take(self.preview_height),
879 );
880 }
881 _ => {
882 lines.extend(list);
883 lines.push(footer);
884 }
885 }
886 let view = View::new(lines);
887 match caret {
888 Some(column) => view.with_cursor(1 + rows, column),
889 None => view,
890 }
891 }
892
893 fn ask(&mut self, io: &mut dyn LineIo) -> Result<Option<Vec<usize>>, NotInteractive> {
895 io.write(&format!("{}\n", self.prompt));
896 let total = self.items.len();
897 for index in 0..total {
898 if self.verdicts[index] != Verdict::Pending {
899 continue;
900 }
901 let item = &self.items[index];
902 let mut about = format!("[{}/{total}] {}", index + 1, item.label);
903 if let Some(description) = &item.description {
904 about.push_str(&format!(" — {description}"));
905 }
906 io.write(&format!("{about}\n"));
907 for (key, value) in &item.metadata {
908 io.write(&format!(" {key}: {value}\n"));
909 }
910 loop {
911 io.write("Approve, reject or skip? [a/r/s, a reason after a colon, q stops]: ");
912 let Some(line) = io.read_line() else {
913 return Ok(Some((0..total).collect()));
914 };
915 match parse_answer(&line) {
916 Ok(None) => return Ok(Some((0..total).collect())),
917 Ok(Some((verdict, reason))) => {
918 if reason.is_none() && self.required.contains(&verdict) {
919 io.write(&format!("A reason is needed to {}.\n", verdict.verb()));
920 continue;
921 }
922 self.verdicts[index] = verdict;
923 self.reasons[index] = reason;
924 break;
925 }
926 Err(message) => io.write(&format!("{message}\n")),
927 }
928 }
929 }
930 Ok(Some((0..total).collect()))
931 }
932}
933
934pub(crate) fn parse_answer(line: &str) -> Result<Option<(Verdict, Option<String>)>, String> {
938 let (word, reason) = match line.split_once(':') {
939 Some((word, reason)) => (word, Some(reason.trim().to_string())),
940 None => (line, None),
941 };
942 let reason = reason.filter(|reason| !reason.is_empty());
943 let verdict = match word.trim().to_lowercase().as_str() {
944 "a" | "approve" | "approved" | "y" | "yes" => Verdict::Approved,
945 "r" | "reject" | "rejected" | "n" | "no" => Verdict::Rejected,
946 "s" | "skip" | "skipped" | "" => Verdict::Skipped,
947 "q" | "quit" => return Ok(None),
948 other => return Err(format!("{other:?} is not a, r, s or q")),
949 };
950 Ok(Some((verdict, reason)))
951}
952
953impl<T: Clone> ApprovalQueue<T> {
954 pub fn report(&self) -> ApprovalReport<T> {
956 ApprovalReport {
957 decisions: self
958 .items
959 .iter()
960 .enumerate()
961 .map(|(index, item)| Decision {
962 index,
963 value: item.value.clone(),
964 label: item.label.clone(),
965 verdict: self.verdicts[index],
966 reason: self.reasons[index].clone(),
967 })
968 .collect(),
969 }
970 }
971}
972
973impl<T: Clone> Component for ApprovalQueue<T> {
974 type Output = ApprovalReport<T>;
975
976 fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<ApprovalReport<T>> {
977 match self.event(event, context) {
978 Flow::Done(_) => Flow::Done(self.report()),
979 Flow::Cancel => Flow::Cancel,
980 Flow::Ignored => Flow::Ignored,
981 _ => Flow::Continue,
982 }
983 }
984
985 fn render(&self, context: &Context<'_>) -> View {
986 self.render_view(context)
987 }
988
989 fn mouse(&self) -> bool {
990 self.mouse
991 }
992
993 fn keymap(&self) -> Keymap {
994 if self.reason.is_some() {
995 return Keymap::new("approve-reason")
996 .bind("confirm", keys("enter"), "decide with this reason")
997 .bind("back", keys("escape"), "back, deciding nothing");
998 }
999 self.keymap.clone()
1000 }
1001
1002 fn default_value(&self) -> Option<ApprovalReport<T>> {
1005 Some(self.report())
1006 }
1007
1008 fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<ApprovalReport<T>>, NotInteractive> {
1009 Ok(self.ask(io)?.map(|_| self.report()))
1010 }
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015 use super::*;
1016
1017 #[test]
1018 fn answers_parse_with_reasons() {
1019 assert_eq!(parse_answer("a"), Ok(Some((Verdict::Approved, None))));
1020 assert_eq!(
1021 parse_answer("r: not now "),
1022 Ok(Some((Verdict::Rejected, Some("not now".into()))))
1023 );
1024 assert_eq!(parse_answer(""), Ok(Some((Verdict::Skipped, None))));
1025 assert_eq!(parse_answer("YES:"), Ok(Some((Verdict::Approved, None))));
1026 assert_eq!(parse_answer("q"), Ok(None));
1027 assert!(parse_answer("maybe").is_err());
1028 assert_eq!(Verdict::parse("reject"), Some(Verdict::Rejected));
1029 assert_eq!(Verdict::Skipped.to_string(), "skipped");
1030 }
1031}