1use std::collections::{BTreeMap, BTreeSet};
29use std::os::unix::process::CommandExt;
30use std::path::PathBuf;
31use std::sync::Arc;
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::time::Duration;
34
35use anyhow::Result;
36use cursive::Cursive;
37use cursive::event::{Event, EventResult, Key};
38use cursive::theme::{BaseColor, Color, Effect, Theme};
39use cursive::traits::*;
40use cursive::utils::markup::StyledString;
41use cursive::views::{Dialog, LinearLayout, OnEventView, SelectView, TextView};
42
43use crate::config::{self, Config};
44use crate::paths::Paths;
45use crate::repo::{NeedsClone, OnMissing};
46use crate::source::ReviewSource;
47use crate::stacks;
48use crate::state::{ReviewEntry, ReviewKey, State, Status};
49use crate::sync;
50
51const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
53
54const HELP: &str = "[?] Help";
55
56const HELP_DIALOG: &str = "↑/↓ or j/k move
57←/→ or h/l expand/collapse
58enter open locally
59f fetch
60o open in browser
61d delete workspace
62r reload
63q / esc quit
64? show this help";
65
66struct Ctx {
67 paths: Paths,
68 config: Config,
69 config_path: PathBuf,
70 sources: Arc<Vec<Box<dyn ReviewSource>>>,
71 handle: tokio::runtime::Handle,
72 all: bool,
73 key_w: usize,
74 author_w: usize,
75 expanded: BTreeSet<ReviewKey>,
77 pending_shell: Option<(ReviewKey, PathBuf)>,
80 fetching: BTreeMap<ReviewKey, Fetch>,
84}
85
86struct Fetch {
89 cancelled: Arc<AtomicBool>,
92 frame: usize,
93 deleting: bool,
96}
97
98#[derive(Debug, Clone, Copy)]
100struct Spin {
101 frame: usize,
102 cancelling: bool,
103}
104
105type Spinners = BTreeMap<ReviewKey, Spin>;
106
107#[derive(Debug, Clone, PartialEq, Eq)]
111enum Row {
112 Entry(ReviewKey),
113 Detail(ReviewKey),
114}
115
116impl Row {
117 fn key(&self) -> &ReviewKey {
118 match self {
119 Row::Entry(k) | Row::Detail(k) => k,
120 }
121 }
122}
123
124pub fn run(
128 paths: Paths,
129 config: Config,
130 config_path: PathBuf,
131 sources: Vec<Box<dyn ReviewSource>>,
132 all: bool,
133) -> Result<()> {
134 let entries = load_rows(&paths, all)?;
135 if entries.is_empty() {
136 println!("No reviews tracked yet. Run `rq sync` first.");
137 return Ok(());
138 }
139 let (key_w, author_w) = column_widths(&entries);
140
141 let mut siv = Cursive::new();
142 siv.set_theme(Theme::terminal_default());
145 siv.set_user_data(Ctx {
146 paths,
147 config,
148 config_path,
149 sources: Arc::new(sources),
150 handle: tokio::runtime::Handle::current(),
151 all,
152 key_w,
153 author_w,
154 expanded: BTreeSet::new(),
155 pending_shell: None,
156 fetching: BTreeMap::new(),
157 });
158
159 let mut select = SelectView::<Row>::new();
160 for (label, row) in build_rows(
161 &entries,
162 key_w,
163 author_w,
164 &BTreeSet::new(),
165 terminal_width(),
166 &Spinners::new(),
167 ) {
168 select.add_item(label, row);
169 }
170 select.set_on_submit(|s, row: &Row| open_locally_selected_key(s, row.key().clone()));
171
172 let select = OnEventView::new(select.with_name("reviews"))
178 .on_event(Key::Down, |s| move_selection(s, true))
179 .on_event('j', |s| move_selection(s, true))
180 .on_event(Key::Up, |s| move_selection(s, false))
181 .on_event('k', |s| move_selection(s, false))
182 .on_event(Key::Right, |s| set_expanded(s, true))
183 .on_event('l', |s| set_expanded(s, true))
184 .on_event(Key::Left, |s| set_expanded(s, false))
185 .on_event('h', |s| set_expanded(s, false));
186
187 let layout = LinearLayout::vertical()
188 .child(select.scrollable().full_height())
189 .child(TextView::new("").with_name("status"))
190 .child(TextView::new(StyledString::styled(HELP, Effect::Dim)));
191 siv.add_fullscreen_layer(layout);
192
193 siv.add_global_callback('q', |s| s.quit());
194 siv.add_global_callback('?', show_help);
195 siv.add_global_callback(Key::Esc, |s| s.quit());
196 siv.add_global_callback('o', open_in_browser_selected);
197 siv.add_global_callback('d', delete_workspace_selected);
198 siv.add_global_callback('f', fetch_selected);
199 siv.add_global_callback('r', reload);
200
201 run_event_loop(siv)
202}
203
204fn run_event_loop(mut siv: Cursive) -> Result<()> {
213 enum Outcome {
214 Quit,
215 OpenShell(ReviewKey, PathBuf),
216 }
217
218 loop {
219 let backend = cursive::backends::crossterm::Backend::init()?;
220 let outcome = {
221 let mut runner = siv.runner(backend);
222 runner.refresh();
223 loop {
224 runner.step();
225 if let Some((key, path)) = runner
226 .user_data::<Ctx>()
227 .and_then(|ctx| ctx.pending_shell.take())
228 {
229 break Outcome::OpenShell(key, path);
230 }
231 if !runner.is_running() {
232 break Outcome::Quit;
233 }
234 }
235 };
238
239 match outcome {
240 Outcome::Quit => return Ok(()),
241 Outcome::OpenShell(key, path) => {
242 let open_command = siv
243 .user_data::<Ctx>()
244 .and_then(|ctx| ctx.config.open_command.clone());
245 let (label, result) = run_open_command(open_command.as_deref(), &key, &path);
246 match result {
247 Ok(status) if status.success() => {
248 set_status(&mut siv, format!("back from {key} ({})", path.display()))
249 }
250 Ok(status) => set_status(
251 &mut siv,
252 format!("`{label}` exited with {status} in {}", path.display()),
253 ),
254 Err(e) => set_status(&mut siv, format!("failed to launch `{label}`: {e}")),
255 }
256 }
257 }
258 }
259}
260
261fn build_open_command(
264 open_command: Option<&str>,
265 key: &ReviewKey,
266 path: &std::path::Path,
267) -> (String, std::process::Command) {
268 let mut cmd = match open_command {
269 Some(script) => {
270 let mut cmd = std::process::Command::new("sh");
271 cmd.args(["-c", script]);
272 cmd
273 }
274 None => {
275 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
276 std::process::Command::new(shell)
277 }
278 };
279 cmd.current_dir(path)
280 .env("RQ_REVIEW", key.slug())
281 .env("RQ_SOURCE", &key.source)
282 .env("RQ_ID", &key.id)
283 .env("RQ_WORKSPACE", path);
284 let label = open_command
285 .map(str::to_string)
286 .unwrap_or_else(|| cmd.get_program().to_string_lossy().into_owned());
287 (label, cmd)
288}
289
290fn run_open_command(
291 open_command: Option<&str>,
292 key: &ReviewKey,
293 path: &std::path::Path,
294) -> (String, std::io::Result<std::process::ExitStatus>) {
295 let (label, mut cmd) = build_open_command(open_command, key, path);
296 let result = cmd.status();
297 (label, result)
298}
299
300fn open_workspace(s: &mut Cursive, key: ReviewKey, path: PathBuf) {
303 let Some(ctx) = s.user_data::<Ctx>() else {
304 return;
305 };
306 let detached = match &ctx.config.open_command {
307 Some(cmd) if !ctx.config.open_command_wait => cmd.clone(),
308 _ => {
309 ctx.pending_shell = Some((key, path));
310 return;
311 }
312 };
313
314 let (label, mut cmd) = build_open_command(Some(&detached), &key, &path);
315 cmd.stdin(std::process::Stdio::null())
316 .stdout(std::process::Stdio::null())
317 .stderr(std::process::Stdio::null())
318 .process_group(0);
320 match cmd.spawn() {
321 Ok(mut child) => {
322 set_status(s, format!("opened {key} ({})", path.display()));
323 let sink = s.cb_sink().clone();
325 std::thread::spawn(move || {
326 if let Ok(status) = child.wait() {
327 if !status.success() {
328 let _ = sink.send(Box::new(move |s| {
329 set_status(s, format!("`{label}` exited with {status} for {key}"))
330 }));
331 }
332 }
333 });
334 }
335 Err(e) => set_status(s, format!("failed to launch `{label}`: {e}")),
336 }
337}
338
339fn load_rows(paths: &Paths, all: bool) -> Result<Vec<ReviewEntry>> {
340 let state = State::load(&paths.state_file())?;
341 Ok(order_by_stack(
342 state
343 .iter()
344 .filter(|e| all || e.in_queue)
345 .cloned()
346 .collect(),
347 ))
348}
349
350fn order_by_stack(entries: Vec<ReviewEntry>) -> Vec<ReviewEntry> {
353 let stacks = stacks::group(entries.iter());
354 let mut by_key: BTreeMap<ReviewKey, ReviewEntry> =
355 entries.iter().map(|e| (e.key.clone(), e.clone())).collect();
356 let mut emitted: BTreeSet<ReviewKey> = BTreeSet::new();
357 let mut out = Vec::with_capacity(entries.len());
358 for e in &entries {
359 if emitted.contains(&e.key) {
360 continue;
361 }
362 let members = stacks::stack_containing(&stacks, &e.key)
363 .map(|s| s.members.clone())
364 .unwrap_or_else(|| vec![e.key.clone()]);
365 for key in members {
366 if emitted.insert(key.clone())
367 && let Some(entry) = by_key.remove(&key)
368 {
369 out.push(entry);
370 }
371 }
372 }
373 out
374}
375
376fn column_widths(entries: &[ReviewEntry]) -> (usize, usize) {
377 let key_w = entries
378 .iter()
379 .map(|e| e.key.slug().len())
380 .max()
381 .unwrap_or(3)
382 .max(3);
383 let author_w = entries
384 .iter()
385 .map(|e| e.author.chars().count())
386 .max()
387 .unwrap_or(6)
388 .clamp(6, 20);
389 (key_w, author_w)
390}
391
392fn truncate(s: &str, width: usize) -> String {
393 if s.chars().count() <= width {
394 format!("{s:<width$}")
395 } else {
396 let head: String = s.chars().take(width.saturating_sub(1)).collect();
397 format!("{head}\u{2026}")
398 }
399}
400
401const LINK_W: usize = 2;
403
404#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407enum Link {
408 Bottom,
409 Middle,
410 Top,
411}
412
413impl Link {
414 fn glyph(self) -> &'static str {
415 match self {
416 Link::Bottom => "\u{2510}",
417 Link::Middle => "\u{2524}",
418 Link::Top => "\u{2518}",
419 }
420 }
421}
422
423fn ansi(c: BaseColor) -> Color {
424 Color::Dark(c)
425}
426
427fn row_label(
428 e: &ReviewEntry,
429 key_w: usize,
430 author_w: usize,
431 expanded: bool,
432 link: Option<Link>,
433 spin: Option<Spin>,
434) -> StyledString {
435 let marker = if expanded { '\u{25be}' } else { '\u{25b8}' };
436
437 let mut out = StyledString::new();
438 out.append_styled(format!("{marker} "), Effect::Dim);
439 if let Some(spin) = spin {
440 let color = if spin.cancelling {
441 BaseColor::Red
442 } else {
443 BaseColor::Yellow
444 };
445 out.append_styled(
446 SPINNER_FRAMES[spin.frame % SPINNER_FRAMES.len()],
447 ansi(color),
448 );
449 } else if e.stack_id.is_some() {
450 out.append_styled("\u{2913}", ansi(BaseColor::Cyan));
451 } else {
452 out.append_plain(" ");
453 }
454 out.append_plain(" ");
455 let slug = e.key.slug();
456 out.append_styled(&slug, ansi(BaseColor::Cyan));
457 let mut pad = key_w.saturating_sub(slug.chars().count()) + LINK_W;
460 if let Some(link) = link {
461 out.append_plain(" ");
462 out.append_styled(link.glyph(), ansi(BaseColor::Magenta));
463 pad -= LINK_W;
464 }
465 out.append_plain(" ".repeat(pad));
466 out.append_plain(" ");
467 out.append_plain(truncate(&e.author, author_w));
468 out.append_plain(" ");
469 out.append_plain(&e.title);
470 out
471}
472
473fn style_detail(line: &str) -> StyledString {
476 let mut out = StyledString::new();
477 if let Some((name, graph)) = line.split_once('|') {
478 out.append_plain(format!("{name}|"));
479 if graph.contains("Bin ") {
480 out.append_plain(graph);
481 return out;
482 }
483 for c in graph.chars() {
484 match c {
485 '+' => out.append_styled(c.to_string(), ansi(BaseColor::Green)),
486 '-' => out.append_styled(c.to_string(), ansi(BaseColor::Red)),
487 _ => out.append_plain(c.to_string()),
488 }
489 }
490 } else {
491 for (i, part) in line.split(',').enumerate() {
492 if i > 0 {
493 out.append_plain(",");
494 }
495 if part.ends_with("(+)") {
496 out.append_styled(part, ansi(BaseColor::Green));
497 } else if part.ends_with("(-)") {
498 out.append_styled(part, ansi(BaseColor::Red));
499 } else {
500 out.append_plain(part);
501 }
502 }
503 }
504 out
505}
506
507fn detail_lines(e: &ReviewEntry) -> Vec<String> {
510 match &e.diff_stat {
511 None => vec![" (no diffstat - run `rq sync`)".to_string()],
512 Some(stat) if stat.trim().is_empty() => vec![" (no changes)".to_string()],
513 Some(stat) => stat.lines().map(|l| format!(" {l}")).collect(),
514 }
515}
516
517const DETAIL_INDENT: &str = " ";
519
520const DEFAULT_WIDTH: usize = 100;
522
523fn terminal_width() -> usize {
526 cursive::backends::crossterm::crossterm::terminal::size()
527 .map(|(w, _)| w as usize)
528 .unwrap_or(DEFAULT_WIDTH)
529}
530
531fn wrap_text(text: &str, width: usize) -> Vec<String> {
533 let mut lines = Vec::new();
534 for raw in text.lines() {
535 let raw = raw.trim_end();
536 let indent = raw.len() - raw.trim_start().len();
537 let mut line = " ".repeat(indent);
538 let mut has_word = false;
539 for word in raw.split_whitespace() {
540 if has_word && line.chars().count() + 1 + word.chars().count() > width {
541 lines.push(std::mem::replace(&mut line, " ".repeat(indent)));
542 has_word = false;
543 }
544 if has_word {
545 line.push(' ');
546 }
547 line.push_str(word);
548 has_word = true;
549 }
550 lines.push(line);
551 }
552 lines
553}
554
555fn description_lines(e: &ReviewEntry, width: usize) -> Vec<String> {
559 let wrap = width.saturating_sub(DETAIL_INDENT.len() + 2).max(20);
560 let mut lines = wrap_text(&e.title, wrap);
561 lines.push(String::new());
562 if let Some(desc) = e
563 .description
564 .as_deref()
565 .map(str::trim)
566 .filter(|d| !d.is_empty())
567 {
568 lines.extend(wrap_text(desc, wrap));
569 lines.push(String::new());
570 }
571 lines
572 .into_iter()
573 .map(|l| format!("{DETAIL_INDENT}{l}"))
574 .collect()
575}
576
577fn build_rows(
578 entries: &[ReviewEntry],
579 key_w: usize,
580 author_w: usize,
581 expanded: &BTreeSet<ReviewKey>,
582 width: usize,
583 spinners: &Spinners,
584) -> Vec<(StyledString, Row)> {
585 let stacks: Vec<_> = stacks::group(entries.iter())
588 .into_iter()
589 .filter(|s| s.members.len() > 1)
590 .collect();
591 let stack_of: BTreeMap<&ReviewKey, usize> = stacks
592 .iter()
593 .enumerate()
594 .flat_map(|(i, s)| s.members.iter().map(move |k| (k, i)))
595 .collect();
596
597 let mut rows = Vec::new();
598 for e in entries {
599 let stack = stack_of.get(&e.key).map(|&i| &stacks[i]);
600 let link = stack.map(|st| {
601 if st.members.first() == Some(&e.key) {
602 Link::Bottom
603 } else if st.members.last() == Some(&e.key) {
604 Link::Top
605 } else {
606 Link::Middle
607 }
608 });
609 let is_expanded = expanded.contains(&e.key);
610 let spin = spinners.get(stack.map_or(&e.key, |st| &st.tip)).copied();
612 rows.push((
613 row_label(e, key_w, author_w, is_expanded, link, spin),
614 Row::Entry(e.key.clone()),
615 ));
616 if is_expanded {
617 for line in description_lines(e, width) {
618 rows.push((StyledString::plain(line), Row::Detail(e.key.clone())));
619 }
620 for line in detail_lines(e) {
621 rows.push((style_detail(&line), Row::Detail(e.key.clone())));
622 }
623 }
624 }
625 rows
626}
627
628fn set_status(s: &mut Cursive, msg: impl Into<String>) {
629 s.call_on_name("status", |v: &mut TextView| v.set_content(msg.into()));
630}
631
632fn move_selection(s: &mut Cursive, down: bool) {
635 s.call_on_name("reviews", |v: &mut SelectView<Row>| {
636 loop {
637 let before = v.selected_id();
638 let _ = if down {
639 v.select_down(1)
640 } else {
641 v.select_up(1)
642 };
643 if v.selected_id() == before {
644 break; }
646 if !matches!(v.selection().as_deref(), Some(Row::Detail(_))) {
647 break;
648 }
649 }
650 });
651}
652
653fn selected_row(s: &mut Cursive) -> Option<Row> {
654 s.call_on_name("reviews", |v: &mut SelectView<Row>| v.selection())
655 .flatten()
656 .map(|row| (*row).clone())
657}
658
659fn selected_key(s: &mut Cursive) -> Option<ReviewKey> {
660 selected_row(s).map(|row| row.key().clone())
661}
662
663fn set_expanded(s: &mut Cursive, expand: bool) {
666 let Some(row) = selected_row(s) else {
667 return;
668 };
669 if let Some(ctx) = s.user_data::<Ctx>() {
670 match row {
671 Row::Entry(key) | Row::Detail(key) => {
672 if expand {
673 ctx.expanded.insert(key);
674 } else {
675 ctx.expanded.remove(&key);
676 }
677 }
678 }
679 }
680 reload(s);
681}
682
683fn vim_keys(dialog: Dialog) -> OnEventView<Dialog> {
687 OnEventView::new(dialog)
688 .on_pre_event_inner(Event::Char('h'), |d, _| {
689 Some(d.on_event(Event::Key(Key::Left)))
690 })
691 .on_pre_event_inner(Event::Char('l'), |d, _| {
692 Some(d.on_event(Event::Key(Key::Right)))
693 })
694 .on_pre_event_inner(Event::Char('j'), |_, _| Some(EventResult::consumed()))
695 .on_pre_event_inner(Event::Char('k'), |_, _| Some(EventResult::consumed()))
696}
697
698fn show_help(s: &mut Cursive) {
699 let dialog = Dialog::text(HELP_DIALOG)
700 .title("Shortcuts")
701 .button("Close", |s| {
702 s.pop_layer();
703 });
704 s.add_layer(
706 vim_keys(dialog)
707 .on_event(Key::Esc, |s| {
708 s.pop_layer();
709 })
710 .on_event('?', |s| {
711 s.pop_layer();
712 }),
713 );
714}
715
716fn open_in_browser_selected(s: &mut Cursive) {
717 let Some(key) = selected_key(s) else {
718 return;
719 };
720 open_in_browser(s, &key);
721}
722
723fn open_in_browser(s: &mut Cursive, key: &ReviewKey) {
724 let url = s.user_data::<Ctx>().and_then(|ctx| {
725 State::load(&ctx.paths.state_file())
726 .ok()
727 .and_then(|st| st.get(key).map(|e| e.url.clone()))
728 });
729 match url {
730 Some(url) => match open::that(&url) {
731 Ok(()) => set_status(s, format!("opened {url}")),
732 Err(e) => set_status(s, format!("failed to open {url}: {e}")),
733 },
734 None => set_status(s, format!("`{key}` is no longer tracked")),
735 }
736}
737
738fn delete_workspace_selected(s: &mut Cursive) {
739 let Some(key) = selected_key(s) else {
740 return;
741 };
742 let guard = fetch_guard(s, &key);
744 if s.user_data::<Ctx>()
745 .is_some_and(|ctx| ctx.fetching.contains_key(&guard))
746 {
747 cancel_fetch(s, &guard);
748 return;
749 }
750 let sharing = s
752 .user_data::<Ctx>()
753 .and_then(|ctx| State::load(&ctx.paths.state_file()).ok())
754 .and_then(|st| {
755 let id = st.get(&key)?.stack_id.clone()?;
756 st.workspace(&id)?;
757 Some(st.members_of(&id).len())
758 });
759 if let Some(sharing) = sharing {
760 prompt_delete_workspace(s, key, sharing);
761 } else {
762 set_status(s, format!("`{key}` has no local workspace"));
763 }
764}
765
766fn prompt_delete_workspace(s: &mut Cursive, key: ReviewKey, sharing: usize) {
769 let text = if sharing > 1 {
770 format!(
771 "Delete the workspace for {key} and the {} other reviews in its stack?",
772 sharing - 1
773 )
774 } else {
775 format!("Delete workspace for {key}?")
776 };
777 let dialog = Dialog::text(text)
778 .title("Delete workspace?")
779 .button("No", |s| {
780 s.pop_layer();
781 })
782 .button("Yes", move |s| {
783 s.pop_layer();
784 begin_delete(s, key.clone());
785 });
786 s.add_layer(vim_keys(dialog));
787}
788
789fn begin_delete(s: &mut Cursive, key: ReviewKey) {
793 let guard = fetch_guard(s, &key);
794 let Some(ctx) = s.user_data::<Ctx>() else {
795 return;
796 };
797 ctx.fetching.insert(
798 guard.clone(),
799 Fetch {
800 cancelled: Arc::new(AtomicBool::new(false)),
801 frame: 0,
802 deleting: true,
803 },
804 );
805 let paths = ctx.paths.clone();
806 reload(s);
807
808 let done = Arc::new(AtomicBool::new(false));
809 spawn_spinner_ticker(s, guard.clone(), done.clone());
810
811 let cb_sink = s.cb_sink().clone();
812 std::thread::spawn(move || {
813 let result = sync::remove_workspace(&paths, &key, true);
814 done.store(true, Ordering::SeqCst);
815 let _ = cb_sink.send(Box::new(move |s| finish_delete(s, key, guard, result)));
816 });
817}
818
819fn finish_delete(s: &mut Cursive, key: ReviewKey, guard: ReviewKey, result: Result<()>) {
820 if let Some(ctx) = s.user_data::<Ctx>() {
821 ctx.fetching.remove(&guard);
822 }
823 match result {
824 Ok(()) => set_status(s, format!("deleted workspace for {key}")),
825 Err(e) => set_status(s, format!("failed to delete workspace for {key}: {e:#}")),
826 }
827 reload(s);
828}
829
830fn fetch_selected(s: &mut Cursive) {
833 let Some(key) = selected_key(s) else {
834 return;
835 };
836 let guard = fetch_guard(s, &key);
837 let Some(ctx) = s.user_data::<Ctx>() else {
838 return;
839 };
840 let on_missing = if ctx.config.auto_clone {
841 OnMissing::Clone
842 } else {
843 OnMissing::Ask
844 };
845 if let Some(f) = ctx.fetching.get(&guard) {
846 let msg = busy_message(&guard, f);
847 set_status(s, msg);
848 return;
849 }
850 begin_fetch(s, key, on_missing, false);
851}
852
853fn open_locally_selected_key(s: &mut Cursive, key: ReviewKey) {
854 let guard = fetch_guard(s, &key);
855 let Some(ctx) = s.user_data::<Ctx>() else {
856 return;
857 };
858 let on_missing = if ctx.config.auto_clone {
859 OnMissing::Clone
860 } else {
861 OnMissing::Ask
862 };
863 if let Some(f) = ctx.fetching.get(&guard) {
864 let msg = busy_message(&guard, f);
865 set_status(s, msg);
866 return;
867 }
868 let has_workspace = State::load(&ctx.paths.state_file())
871 .ok()
872 .is_some_and(|st| {
873 st.workspace_for(&key)
874 .is_some_and(|ws| ws.status != Status::ApplyFailed)
875 });
876
877 if has_workspace {
878 do_open_locally(s, key, on_missing);
881 } else {
882 prompt_confirm_fetch(s, key, on_missing);
883 }
884}
885
886fn prompt_confirm_fetch(s: &mut Cursive, key: ReviewKey, on_missing: OnMissing) {
890 let dialog = Dialog::text(format!("Create workspace for {key}?"))
891 .title("Fetch review?")
892 .button("Cancel", |s| {
893 s.pop_layer();
894 })
895 .button("Proceed", move |s| {
896 s.pop_layer();
897 begin_fetch(s, key.clone(), on_missing, true);
898 });
899 s.add_layer(vim_keys(dialog));
900}
901
902fn begin_fetch(s: &mut Cursive, key: ReviewKey, on_missing: OnMissing, open_shell: bool) {
907 let cancelled = Arc::new(AtomicBool::new(false));
908 let guard = fetch_guard(s, &key);
909 let Some(ctx) = s.user_data::<Ctx>() else {
910 return;
911 };
912 ctx.fetching.insert(
913 guard.clone(),
914 Fetch {
915 cancelled: cancelled.clone(),
916 frame: 0,
917 deleting: false,
918 },
919 );
920 let sources = ctx.sources.clone();
921 let paths = ctx.paths.clone();
922 let config = ctx.config.clone();
923 let handle = ctx.handle.clone();
924 let had_workspace = State::load(&ctx.paths.state_file())
925 .ok()
926 .is_some_and(|st| st.workspace_for(&key).is_some());
927 reload(s);
928
929 let done = Arc::new(AtomicBool::new(false));
930 spawn_spinner_ticker(s, guard.clone(), done.clone());
931
932 let cb_sink = s.cb_sink().clone();
933 let worker_key = key.clone();
934 std::thread::spawn(move || {
935 let result = handle.block_on(sync::fetch_local(
936 &sources,
937 &paths,
938 &config,
939 &worker_key,
940 on_missing,
941 ));
942 done.store(true, Ordering::SeqCst);
943 let _ = cb_sink.send(Box::new(move |s| {
944 finish_fetch(
945 s,
946 worker_key,
947 guard,
948 result,
949 cancelled,
950 open_shell,
951 had_workspace,
952 )
953 }));
954 });
955}
956
957fn fetch_guard(s: &mut Cursive, key: &ReviewKey) -> ReviewKey {
961 s.user_data::<Ctx>()
962 .and_then(|ctx| State::load(&ctx.paths.state_file()).ok())
963 .and_then(|st| {
964 let stacks = stacks::group(st.iter());
965 stacks::stack_containing(&stacks, key).map(|stack| stack.tip.clone())
966 })
967 .unwrap_or_else(|| key.clone())
968}
969
970fn busy_message(key: &ReviewKey, f: &Fetch) -> String {
975 if f.deleting {
976 format!("{key} is being deleted")
977 } else {
978 format!("already fetching {key}")
979 }
980}
981
982fn cancel_fetch(s: &mut Cursive, key: &ReviewKey) {
983 if s.user_data::<Ctx>()
984 .and_then(|ctx| ctx.fetching.get(key))
985 .is_some_and(|f| f.deleting)
986 {
987 set_status(s, format!("{key} is being deleted - can't cancel"));
988 return;
989 }
990 let flagged = s
991 .user_data::<Ctx>()
992 .and_then(|ctx| ctx.fetching.get(key))
993 .map(|f| f.cancelled.swap(true, Ordering::SeqCst));
994 match flagged {
995 Some(false) => {
996 set_status(
997 s,
998 format!(
999 "cancelling {key} - it will be cleaned up once the running operation stops"
1000 ),
1001 );
1002 reload(s);
1003 }
1004 Some(true) => set_status(s, format!("already cancelling {key}")),
1005 None => set_status(s, format!("`{key}` isn't being fetched")),
1006 }
1007}
1008
1009fn spawn_spinner_ticker(s: &Cursive, key: ReviewKey, done: Arc<AtomicBool>) {
1012 let cb_sink = s.cb_sink().clone();
1013 std::thread::spawn(move || {
1014 loop {
1015 std::thread::sleep(Duration::from_millis(120));
1016 if done.load(Ordering::SeqCst) {
1017 break;
1018 }
1019 let tick_key = key.clone();
1020 if cb_sink
1021 .send(Box::new(move |s| {
1022 let ticked = s
1023 .user_data::<Ctx>()
1024 .and_then(|ctx| ctx.fetching.get_mut(&tick_key))
1025 .map(|f| f.frame = f.frame.wrapping_add(1))
1026 .is_some();
1027 if ticked {
1028 reload(s);
1029 }
1030 }))
1031 .is_err()
1032 {
1033 break; }
1035 }
1036 });
1037}
1038
1039fn finish_fetch(
1045 s: &mut Cursive,
1046 key: ReviewKey,
1047 guard: ReviewKey,
1048 result: Result<PathBuf>,
1049 cancelled: Arc<AtomicBool>,
1050 open_shell: bool,
1051 had_workspace: bool,
1052) {
1053 if let Some(ctx) = s.user_data::<Ctx>() {
1054 ctx.fetching.remove(&guard);
1055 }
1056
1057 if cancelled.load(Ordering::SeqCst) {
1058 let status = match &result {
1059 Ok(_) if had_workspace => format!("cancelled fetching {key}; existing workspace kept"),
1060 Ok(_) => match s
1061 .user_data::<Ctx>()
1062 .map(|ctx| sync::remove_workspace(&ctx.paths, &key, true))
1063 {
1064 Some(Ok(())) => format!("cancelled fetching {key}; workspace cleaned up"),
1065 Some(Err(e)) => format!("cancelled fetching {key}; cleanup failed: {e:#}"),
1066 None => format!("cancelled fetching {key}"),
1067 },
1068 Err(_) => format!("cancelled fetching {key}"),
1069 };
1070 set_status(s, status);
1071 reload(s);
1072 return;
1073 }
1074
1075 match result {
1076 Ok(path) => {
1077 if open_shell {
1078 open_workspace(s, key, path);
1079 } else {
1080 set_status(s, format!("fetched {key} ({})", path.display()));
1081 }
1082 reload(s);
1083 }
1084 Err(e) => match e.downcast::<NeedsClone>() {
1085 Ok(needs_clone) => prompt_clone(s, key, needs_clone.url, needs_clone.dest, open_shell),
1086 Err(e) => {
1087 set_status(s, format!("error fetching {key}: {e:#}"));
1088 reload(s);
1089 }
1090 },
1091 }
1092}
1093
1094fn do_open_locally(s: &mut Cursive, key: ReviewKey, on_missing: OnMissing) {
1095 let outcome = s.user_data::<Ctx>().map(|ctx| {
1096 tokio::task::block_in_place(|| {
1097 ctx.handle.clone().block_on(sync::fetch_local(
1098 &ctx.sources,
1099 &ctx.paths,
1100 &ctx.config,
1101 &key,
1102 on_missing,
1103 ))
1104 })
1105 });
1106
1107 match outcome {
1108 Some(Ok(path)) => {
1113 open_workspace(s, key, path);
1114 reload(s);
1115 }
1116 Some(Err(e)) => match e.downcast::<NeedsClone>() {
1117 Ok(needs_clone) => prompt_clone(s, key, needs_clone.url, needs_clone.dest, true),
1118 Err(e) => {
1119 set_status(s, format!("error fetching {key}: {e:#}"));
1120 reload(s);
1121 }
1122 },
1123 None => {}
1124 }
1125}
1126
1127fn prompt_clone(s: &mut Cursive, key: ReviewKey, url: String, dest: PathBuf, open_shell: bool) {
1130 let yes_key = key.clone();
1131 let always_key = key.clone();
1132 let dialog = Dialog::text(format!(
1133 "No local checkout of `{url}` found.\nClone into {}?",
1134 dest.display()
1135 ))
1136 .title("Clone repo?")
1137 .button("No", |s| {
1138 s.pop_layer();
1139 set_status(s, "skipped - not cloned");
1140 })
1141 .button("Yes", move |s| {
1142 s.pop_layer();
1143 begin_fetch(s, yes_key.clone(), OnMissing::Clone, open_shell);
1144 })
1145 .button("Always", move |s| {
1146 s.pop_layer();
1147 let saved = s.user_data::<Ctx>().map(|ctx| {
1148 let result = config::set_auto_clone(&ctx.config_path);
1149 if result.is_ok() {
1150 ctx.config.auto_clone = true;
1151 }
1152 result
1153 });
1154 if let Some(Err(e)) = saved {
1155 set_status(s, format!("failed to save auto_clone: {e:#}"));
1156 return;
1157 }
1158 begin_fetch(s, always_key.clone(), OnMissing::Clone, open_shell);
1159 });
1160 s.add_layer(vim_keys(dialog));
1161}
1162
1163fn reload(s: &mut Cursive) {
1164 let loaded = s.user_data::<Ctx>().map(|ctx| {
1165 (
1166 load_rows(&ctx.paths, ctx.all),
1167 ctx.key_w,
1168 ctx.author_w,
1169 ctx.expanded.clone(),
1170 ctx.fetching
1171 .iter()
1172 .map(|(k, f)| {
1173 let spin = Spin {
1174 frame: f.frame,
1175 cancelling: f.cancelled.load(Ordering::SeqCst),
1176 };
1177 (k.clone(), spin)
1178 })
1179 .collect::<Spinners>(),
1180 )
1181 });
1182 let Some((loaded, key_w, author_w, expanded, spinners)) = loaded else {
1183 return;
1184 };
1185 let entries = match loaded {
1186 Ok(entries) => entries,
1187 Err(e) => {
1188 set_status(s, format!("reload failed: {e:#}"));
1189 return;
1190 }
1191 };
1192
1193 s.call_on_name("reviews", |v: &mut SelectView<Row>| {
1194 let selected = v.selection().map(|row| (*row).clone());
1195 v.clear();
1196 let rows = build_rows(
1197 &entries,
1198 key_w,
1199 author_w,
1200 &expanded,
1201 terminal_width(),
1202 &spinners,
1203 );
1204 for (label, row) in rows {
1205 v.add_item(label, row);
1206 }
1207 if let Some(selected) = selected
1208 && let Some(idx) =
1209 (0..v.len()).find(|&i| v.get_item(i).is_some_and(|(_, row)| *row == selected))
1210 {
1211 v.set_selection(idx);
1212 }
1213 });
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 use super::*;
1219 use crate::source::{RepoRef, ReviewKind};
1220
1221 fn entry(id: &str, diff_stat: Option<&str>) -> ReviewEntry {
1222 ReviewEntry {
1223 key: ReviewKey::new("moz", id),
1224 title: "Fix the thing".into(),
1225 author: "someone".into(),
1226 url: format!("https://example.com/{id}"),
1227 repo: RepoRef {
1228 urls: vec!["https://example.com/o/r".into()],
1229 display_name: "o/r".into(),
1230 },
1231 kind: ReviewKind::Direct,
1232 version: "1".into(),
1233 in_queue: true,
1234 resolved: false,
1235 last_synced: chrono::Utc::now(),
1236 stack_id: None,
1237 ancestors: Vec::new(),
1238 diff_stat: diff_stat.map(String::from),
1239 description: None,
1240 }
1241 }
1242
1243 fn stacked(id: &str, ancestors: &[&str]) -> ReviewEntry {
1244 let mut e = entry(id, None);
1245 e.ancestors = ancestors
1246 .iter()
1247 .map(|a| ReviewKey::new("moz", *a))
1248 .collect();
1249 e
1250 }
1251
1252 #[test]
1253 fn a_stack_is_grouped_bottom_first_where_its_first_member_sat() {
1254 let entries = vec![
1256 stacked("D1", &[]),
1257 stacked("D2", &[]),
1258 stacked("D3", &["D2"]),
1259 stacked("D4", &[]),
1260 ];
1261 let ordered = order_by_stack(entries);
1262 let ids: Vec<_> = ordered.iter().map(|e| e.key.id.as_str()).collect();
1263 assert_eq!(ids, ["D1", "D2", "D3", "D4"]);
1264
1265 let entries = vec![
1266 stacked("D1", &["D9"]),
1267 stacked("D2", &[]),
1268 stacked("D9", &[]),
1269 ];
1270 let ordered = order_by_stack(entries);
1271 let ids: Vec<_> = ordered.iter().map(|e| e.key.id.as_str()).collect();
1272 assert_eq!(
1273 ids,
1274 ["D9", "D1", "D2"],
1275 "D9 is D1's parent, so it comes first"
1276 );
1277 }
1278
1279 #[test]
1280 fn stacked_members_are_flat_rows_linked_by_a_connector() {
1281 let entries = order_by_stack(vec![
1282 stacked("D1", &[]),
1283 stacked("D2", &["D1"]),
1284 stacked("D3", &["D1", "D2"]),
1285 stacked("D9", &[]),
1286 ]);
1287 let spinners = Spinners::from([(
1288 ReviewKey::new("moz", "D3"),
1289 Spin {
1290 frame: 0,
1291 cancelling: false,
1292 },
1293 )]);
1294 let rows = build_rows(&entries, 6, 6, &BTreeSet::new(), 100, &spinners);
1295
1296 assert_eq!(rows.len(), 4);
1297 assert!(
1298 rows.iter().all(|(_, r)| matches!(r, Row::Entry(_))),
1299 "no header rows"
1300 );
1301
1302 let plain = |i: usize| rows[i].0.source().to_string();
1303 let id_len = "moz/D1".len();
1305 let slot = |i: usize| plain(i).chars().nth(4 + id_len + 1).unwrap().to_string();
1306 assert_eq!(slot(0), Link::Bottom.glyph());
1307 assert_eq!(slot(1), Link::Middle.glyph());
1308 assert_eq!(slot(2), Link::Top.glyph());
1309 assert!(plain(3).starts_with("▸ moz/D9 "), "{}", plain(3));
1310 for i in 0..3 {
1312 assert!(plain(i).contains(SPINNER_FRAMES[0]));
1313 }
1314 assert!(!plain(3).contains(SPINNER_FRAMES[0]));
1315 }
1316
1317 #[test]
1318 fn stacked_and_unstacked_rows_keep_author_and_title_aligned() {
1319 let entries = order_by_stack(vec![
1320 stacked("D1", &[]),
1321 stacked("D2", &["D1"]),
1322 stacked("D3", &[]),
1323 ]);
1324 let (key_w, author_w) = column_widths(&entries);
1325 let rows = build_rows(
1326 &entries,
1327 key_w,
1328 author_w,
1329 &BTreeSet::new(),
1330 100,
1331 &Spinners::new(),
1332 );
1333 let author_col: Vec<usize> = rows
1334 .iter()
1335 .filter(|(_, r)| matches!(r, Row::Entry(_)))
1336 .map(|(l, _)| {
1337 let src = l.source();
1338 src[..src.find("someone").unwrap()].chars().count()
1339 })
1340 .collect();
1341 assert_eq!(author_col.len(), 3);
1342 assert!(
1343 author_col.iter().all(|c| *c == author_col[0]),
1344 "{author_col:?}"
1345 );
1346 }
1347
1348 #[test]
1349 fn collapsed_entries_produce_one_row_each() {
1350 let entries = vec![entry("D1", None), entry("D2", None)];
1351 let rows = build_rows(&entries, 5, 6, &BTreeSet::new(), 100, &Spinners::new());
1352 assert_eq!(rows.len(), 2);
1353 assert!(matches!(&rows[0].1, Row::Entry(k) if k.id == "D1"));
1354 assert!(matches!(&rows[1].1, Row::Entry(k) if k.id == "D2"));
1355 }
1356
1357 #[test]
1358 fn expanding_a_review_inserts_detail_rows_owned_by_its_key() {
1359 let entries = vec![
1360 entry("D1", Some("a.rs | 1 +\nb.rs | 2 ++")),
1361 entry("D2", None),
1362 ];
1363 let expanded = ReviewKey::new("moz", "D1");
1364 let rows = build_rows(
1365 &entries,
1366 5,
1367 6,
1368 &BTreeSet::from([expanded.clone()]),
1369 100,
1370 &Spinners::new(),
1371 );
1372
1373 assert_eq!(rows.len(), 6);
1375 assert!(matches!(&rows[0].1, Row::Entry(k) if *k == expanded));
1376 for (_, row) in &rows[1..5] {
1377 assert_eq!(row, &Row::Detail(expanded.clone()));
1378 }
1379 assert!(matches!(&rows[5].1, Row::Entry(k) if k.id == "D2"));
1380 }
1381
1382 #[test]
1383 fn multiple_reviews_can_be_expanded_at_once() {
1384 let entries = vec![
1385 entry("D1", Some("a.rs | 1 +")),
1386 entry("D2", Some("b.rs | 2 ++")),
1387 ];
1388 let d1 = ReviewKey::new("moz", "D1");
1389 let d2 = ReviewKey::new("moz", "D2");
1390 let rows = build_rows(
1391 &entries,
1392 5,
1393 6,
1394 &BTreeSet::from([d1.clone(), d2.clone()]),
1395 100,
1396 &Spinners::new(),
1397 );
1398
1399 assert_eq!(rows.len(), 8);
1402 assert!(matches!(&rows[0].1, Row::Entry(k) if *k == d1));
1403 for (_, row) in &rows[1..4] {
1404 assert_eq!(row, &Row::Detail(d1.clone()));
1405 }
1406 assert!(matches!(&rows[4].1, Row::Entry(k) if *k == d2));
1407 for (_, row) in &rows[5..8] {
1408 assert_eq!(row, &Row::Detail(d2.clone()));
1409 }
1410 }
1411
1412 #[tokio::test]
1413 async fn set_expanded_can_expand_multiple_reviews_independently() {
1414 let tmp = tempfile::tempdir().unwrap();
1415 let mut siv = cursive::dummy();
1416 siv.set_user_data(ctx_with_state(
1417 tmp.path(),
1418 vec![entry("D1", None), entry("D2", None)],
1419 ));
1420 let mut select = SelectView::<Row>::new();
1421 select.add_item("D1", Row::Entry(ReviewKey::new("moz", "D1")));
1422 select.add_item("D2", Row::Entry(ReviewKey::new("moz", "D2")));
1423 siv.add_layer(select.with_name("reviews"));
1424
1425 set_expanded(&mut siv, true);
1428 move_selection(&mut siv, true);
1429 set_expanded(&mut siv, true);
1430
1431 let ctx = siv.user_data::<Ctx>().unwrap();
1432 assert_eq!(
1433 ctx.expanded,
1434 BTreeSet::from([ReviewKey::new("moz", "D1"), ReviewKey::new("moz", "D2")]),
1435 "expanding D2 must not collapse the already-expanded D1"
1436 );
1437
1438 move_selection(&mut siv, false);
1440 set_expanded(&mut siv, false);
1441
1442 let ctx = siv.user_data::<Ctx>().unwrap();
1443 assert_eq!(
1444 ctx.expanded,
1445 BTreeSet::from([ReviewKey::new("moz", "D2")]),
1446 "collapsing D1 must not touch D2"
1447 );
1448 }
1449
1450 #[test]
1451 fn expanding_a_review_shows_title_and_wrapped_description_before_the_diffstat() {
1452 let mut e = entry("D1", Some("a.rs | 1 +"));
1453 e.description = Some(format!("first para\n\n{}", "word ".repeat(60)));
1454 let lines = description_lines(&e, 100);
1455
1456 assert_eq!(lines[0], " Fix the thing");
1457 assert_eq!(lines[1], " ");
1458 assert_eq!(lines[2], " first para");
1459 assert_eq!(lines[3], " ");
1460 assert!(lines.len() > 6, "long paragraph should wrap: {lines:?}");
1461 assert!(lines.iter().all(|l| l.chars().count() <= 100));
1462 assert_eq!(lines.last().unwrap(), " ");
1463
1464 let expanded = BTreeSet::from([e.key.clone()]);
1465 let rows = build_rows(&[e], 5, 6, &expanded, 100, &Spinners::new());
1466 assert_eq!(rows.len(), 1 + lines.len() + 1);
1468 }
1469
1470 #[test]
1471 fn a_long_title_is_wrapped_in_full_to_the_given_width() {
1472 let mut e = entry("D1", None);
1473 e.title = "word ".repeat(30);
1474 let lines = description_lines(&e, 40);
1475 assert!(lines.len() > 4, "{lines:?}");
1476 assert!(lines.iter().all(|l| l.chars().count() <= 40));
1477 assert_eq!(
1478 lines[..lines.len() - 1]
1479 .join(" ")
1480 .split_whitespace()
1481 .count(),
1482 30
1483 );
1484 }
1485
1486 #[test]
1487 fn a_fetching_row_shows_a_spinner_instead_of_the_workspace_icon() {
1488 let e = entry("D1", None);
1489 let plain = row_label(&e, 5, 6, false, None, None).source().to_string();
1490 let spin = Spin {
1491 frame: 0,
1492 cancelling: false,
1493 };
1494 let spinning = row_label(&e, 5, 6, false, None, Some(spin))
1495 .source()
1496 .to_string();
1497 assert!(spinning.contains(SPINNER_FRAMES[0]));
1498 assert!(!plain.contains(SPINNER_FRAMES[0]));
1499 assert_eq!(plain.chars().count(), spinning.chars().count());
1500 }
1501
1502 #[tokio::test]
1503 async fn cancel_fetch_flags_only_a_review_that_is_being_fetched() {
1504 let tmp = tempfile::tempdir().unwrap();
1505 let mut siv = cursive::dummy();
1506 siv.set_user_data(ctx_with_state(tmp.path(), vec![entry("D1", None)]));
1507 let key = ReviewKey::new("moz", "D1");
1508 let flag = Arc::new(AtomicBool::new(false));
1509 siv.user_data::<Ctx>().unwrap().fetching.insert(
1510 key.clone(),
1511 Fetch {
1512 cancelled: flag.clone(),
1513 frame: 0,
1514 deleting: false,
1515 },
1516 );
1517
1518 cancel_fetch(&mut siv, &ReviewKey::new("moz", "D2"));
1519 assert!(!flag.load(Ordering::SeqCst));
1520 cancel_fetch(&mut siv, &key);
1521 assert!(flag.load(Ordering::SeqCst));
1522 }
1523
1524 #[test]
1525 fn h_and_l_move_between_dialog_buttons_without_touching_the_list() {
1526 use cursive::views::DialogFocus;
1527
1528 let mut siv = cursive::dummy();
1529 let mut select = SelectView::<Row>::new();
1530 select.add_item("D1", Row::Entry(ReviewKey::new("moz", "D1")));
1531 select.add_item("D2", Row::Entry(ReviewKey::new("moz", "D2")));
1532 siv.add_layer(select.with_name("reviews"));
1533 siv.add_global_callback('j', |s| move_selection(s, true));
1534 siv.add_global_callback('l', |s| set_expanded(s, true));
1535 siv.add_layer(
1536 vim_keys(
1537 Dialog::text("x")
1538 .button("No", |_| {})
1539 .button("Yes", |_| {})
1540 .button("Always", |_| {}),
1541 )
1542 .with_name("dlg"),
1543 );
1544 let focus = |siv: &mut Cursive| {
1545 siv.call_on_name("dlg", |v: &mut OnEventView<Dialog>| v.get_inner().focus())
1546 .unwrap()
1547 };
1548
1549 siv.runner().refresh(); assert_eq!(focus(&mut siv), DialogFocus::Button(0));
1551 siv.on_event(Event::Char('l'));
1552 assert_eq!(focus(&mut siv), DialogFocus::Button(1));
1553 siv.on_event(Event::Char('l'));
1554 assert_eq!(focus(&mut siv), DialogFocus::Button(2));
1555 siv.on_event(Event::Char('h'));
1556 assert_eq!(focus(&mut siv), DialogFocus::Button(1));
1557
1558 siv.on_event(Event::Char('j'));
1560 assert!(matches!(selected_row(&mut siv), Row::Entry(k) if k.id == "D1"));
1561 }
1562
1563 #[test]
1564 fn a_review_without_a_description_still_shows_its_title() {
1565 let mut e = entry("D1", None);
1566 e.description = Some(" \n".into());
1567 assert_eq!(
1568 description_lines(&e, 100),
1569 vec![" Fix the thing", " "]
1570 );
1571 }
1572
1573 #[test]
1574 fn detail_lines_with_no_diff_stat_say_to_run_sync() {
1575 let e = entry("D1", None);
1576 let lines = detail_lines(&e);
1577 assert_eq!(lines.len(), 1);
1578 assert!(lines[0].contains("run `rq sync`"));
1579 }
1580
1581 #[test]
1582 fn detail_lines_with_an_empty_diff_stat_say_no_changes() {
1583 let e = entry("D1", Some(""));
1584 let lines = detail_lines(&e);
1585 assert_eq!(lines.len(), 1);
1586 assert!(lines[0].contains("no changes"));
1587 }
1588
1589 #[test]
1590 fn detail_lines_render_each_line_of_the_stored_diff_stat() {
1591 let e = entry(
1592 "D1",
1593 Some("a.rs | 1 +\nb.rs | 2 ++\n2 files changed, 3 insertions(+)"),
1594 );
1595 let lines = detail_lines(&e);
1596 assert_eq!(lines.len(), 3);
1597 assert!(lines[0].contains("a.rs | 1 +"));
1598 assert!(lines[1].contains("b.rs | 2 ++"));
1599 assert!(lines[2].contains("2 files changed"));
1600 }
1601
1602 fn selected_row(siv: &mut Cursive) -> Row {
1603 (*siv
1604 .call_on_name("reviews", |v: &mut SelectView<Row>| v.selection())
1605 .flatten()
1606 .unwrap())
1607 .clone()
1608 }
1609
1610 #[test]
1611 fn move_selection_skips_detail_rows_in_both_directions() {
1612 let mut siv = cursive::dummy();
1613 let entries = vec![
1614 entry("D1", Some("a.rs | 1 +\nb.rs | 2 ++")),
1615 entry("D2", None),
1616 ];
1617 let mut select = SelectView::<Row>::new();
1618 let expanded = ReviewKey::new("moz", "D1");
1619 for (label, row) in build_rows(
1620 &entries,
1621 5,
1622 6,
1623 &BTreeSet::from([expanded]),
1624 100,
1625 &Spinners::new(),
1626 ) {
1627 select.add_item(label, row);
1628 }
1629 siv.add_layer(select.with_name("reviews"));
1630
1631 move_selection(&mut siv, true);
1634 assert!(matches!(selected_row(&mut siv), Row::Entry(k) if k.id == "D2"));
1635
1636 move_selection(&mut siv, false);
1638 assert!(matches!(selected_row(&mut siv), Row::Entry(k) if k.id == "D1"));
1639 }
1640
1641 #[test]
1642 fn move_selection_stops_at_the_last_row_even_if_it_is_a_detail_line() {
1643 let mut siv = cursive::dummy();
1644 let entries = vec![entry("D1", Some("a.rs | 1 +"))];
1645 let mut select = SelectView::<Row>::new();
1646 let expanded = ReviewKey::new("moz", "D1");
1647 for (label, row) in build_rows(
1648 &entries,
1649 5,
1650 6,
1651 &BTreeSet::from([expanded]),
1652 100,
1653 &Spinners::new(),
1654 ) {
1655 select.add_item(label, row);
1656 }
1657 siv.add_layer(select.with_name("reviews"));
1658
1659 move_selection(&mut siv, true); assert!(matches!(selected_row(&mut siv), Row::Detail(_)));
1661
1662 move_selection(&mut siv, true);
1664 assert!(matches!(selected_row(&mut siv), Row::Detail(_)));
1665 }
1666
1667 fn ctx_with_state(tmp: &std::path::Path, entries: Vec<ReviewEntry>) -> Ctx {
1671 let paths = crate::paths::Paths::discover()
1672 .unwrap()
1673 .with_overrides(Some(tmp.join("data")));
1674 let mut state = crate::state::State::default();
1675 for e in entries {
1676 state.insert(e);
1677 }
1678 state.save(&paths.state_file()).unwrap();
1679 Ctx {
1680 paths,
1681 config: Config::default(),
1682 config_path: PathBuf::new(),
1683 sources: Arc::new(Vec::new()),
1684 handle: tokio::runtime::Handle::current(),
1685 all: true,
1686 key_w: 5,
1687 author_w: 6,
1688 expanded: BTreeSet::new(),
1689 pending_shell: None,
1690 fetching: BTreeMap::new(),
1691 }
1692 }
1693
1694 #[tokio::test]
1695 async fn set_expanded_is_directional_not_a_toggle() {
1696 let tmp = tempfile::tempdir().unwrap();
1697 let mut siv = cursive::dummy();
1698 siv.set_user_data(ctx_with_state(tmp.path(), vec![entry("D1", None)]));
1699 let mut select = SelectView::<Row>::new();
1700 select.add_item("D1", Row::Entry(ReviewKey::new("moz", "D1")));
1701 siv.add_layer(select.with_name("reviews"));
1702
1703 set_expanded(&mut siv, true);
1705 set_expanded(&mut siv, true);
1706 assert!(
1707 siv.user_data::<Ctx>()
1708 .unwrap()
1709 .expanded
1710 .contains(&ReviewKey::new("moz", "D1"))
1711 );
1712
1713 set_expanded(&mut siv, false);
1715 set_expanded(&mut siv, false);
1716 assert!(
1717 !siv.user_data::<Ctx>()
1718 .unwrap()
1719 .expanded
1720 .contains(&ReviewKey::new("moz", "D1"))
1721 );
1722 }
1723
1724 #[tokio::test]
1725 async fn set_expanded_targets_whichever_review_owns_the_selected_row() {
1726 let tmp = tempfile::tempdir().unwrap();
1727 let mut siv = cursive::dummy();
1728 siv.set_user_data(ctx_with_state(
1729 tmp.path(),
1730 vec![entry("D1", Some("a.rs | 1 +")), entry("D2", None)],
1731 ));
1732 let mut select = SelectView::<Row>::new();
1733 select.add_item("D1", Row::Entry(ReviewKey::new("moz", "D1")));
1735 select.add_item(" a.rs | 1 +", Row::Detail(ReviewKey::new("moz", "D1")));
1736 select.add_item("D2", Row::Entry(ReviewKey::new("moz", "D2")));
1737 select.set_selection(1);
1738 siv.add_layer(select.with_name("reviews"));
1739
1740 set_expanded(&mut siv, true);
1741
1742 assert_eq!(
1743 siv.user_data::<Ctx>().unwrap().expanded,
1744 BTreeSet::from([ReviewKey::new("moz", "D1")]),
1745 "the detail row's owning key (D1) should be expanded, not D2"
1746 );
1747 }
1748}