Skip to main content

review_queue/
tui.rs

1//! `rq show`'s interactive TUI: a `cursive` (crossterm backend) `SelectView` of tracked reviews.
2//! Up/down and `j`/`k` move the cursor between *reviews*, skipping over an expanded review's
3//! diffstat lines rather than stepping into them (see `move_selection`) - arrow keys are
4//! intercepted via an `OnEventView` since `SelectView`'s own built-in handling would otherwise
5//! land on those lines like any other row. Left/right (and `h`/`l`) expand/collapse the selected
6//! review's diffstat (fetched by `rq sync` and read straight out of `state.json` - no network
7//! calls here). `o` opens the review in a browser. Enter opens it locally: fetching it on demand
8//! if needed (the same `sync::fetch_local` a plain `rq fetch <id>` runs), then suspending the TUI
9//! to drop the user into a subshell in its worktree (or run the configured `open_command`), resuming once they exit it - see
10//! `run_event_loop` for why that means tearing down and recreating the whole backend rather than
11//! just toggling raw mode. See `crate::sync`'s module docs for why `rq sync` itself never creates
12//! workspaces. `d` deletes the selected review's workspace (`sync::remove_workspace`), confirming
13//! first and always discarding any local changes. Once confirmed the
14//! dialog closes and the deletion runs on a background thread (`begin_delete`) with the same row
15//! spinner as a fetch.
16//!
17//! Fetching a review with no local worktree yet asks to confirm first (`prompt_confirm_fetch`) -
18//! `fetch_local` may clone a repo or shell out to a source's checkout command, either of which
19//! can take a while. Once confirmed the dialog closes and `begin_fetch` runs it on a background
20//! thread so the event loop stays responsive: the review's row shows a spinner (ticked via
21//! `Cursive::cb_sink`) where its workspace icon goes, and `d` on that row cancels. The underlying
22//! `git`/`jj` calls have no cancellation points of their own, so cancelling doesn't interrupt
23//! them - it just detaches from the operation, which keeps running (spinner turns red);
24//! `finish_fetch` still runs when it completes, and removes whatever workspace it created instead
25//! of opening a shell into it. `f` skips both the confirmation and the subshell: it just fetches
26//! (or refreshes) the selected review in the background.
27
28use 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
51/// Frames for the spinner shown while a review is fetched on a background thread.
52const 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    /// Reviews currently expanded to show their diffstat - any number at once, independently.
76    expanded: BTreeSet<ReviewKey>,
77    /// Set by `do_open_locally` on a successful fetch; drained by `run_event_loop`, which is the
78    /// only place actually allowed to touch the terminal/backend to suspend into a subshell.
79    pending_shell: Option<(ReviewKey, PathBuf)>,
80    /// Reviews `begin_fetch` currently has a background fetch running for - guards against
81    /// pressing `enter` on the same review again (and racing two `fetch_local` calls against the
82    /// same workspace path) while its own confirm/progress dialog is already up.
83    fetching: BTreeMap<ReviewKey, Fetch>,
84}
85
86/// A background fetch `begin_fetch` has running for a review, shown as a spinner in that review's
87/// row until it finishes.
88struct Fetch {
89    /// Flipped by `cancel_fetch`. The underlying `fetch_local` can't be interrupted, so this only
90    /// tells `finish_fetch` to clean up instead of opening the workspace.
91    cancelled: Arc<AtomicBool>,
92    frame: usize,
93    /// A workspace deletion (`begin_delete`) rather than a fetch - shares the spinner but can't
94    /// be cancelled.
95    deleting: bool,
96}
97
98/// What `row_label` needs to draw a fetch's spinner in place of the workspace icon.
99#[derive(Debug, Clone, Copy)]
100struct Spin {
101    frame: usize,
102    cancelling: bool,
103}
104
105type Spinners = BTreeMap<ReviewKey, Spin>;
106
107/// A row in the `reviews` `SelectView`: either a review itself, or one of the diffstat lines
108/// shown underneath it while expanded. Both carry the owning review's key so opening/collapsing
109/// act on the right review regardless of which line the cursor happens to sit on.
110#[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
124/// Run the TUI until the user quits. Blocks the current thread; call from within a Tokio runtime
125/// (needed for the open-locally hotkey, which drives `sync::fetch_local` to completion
126/// synchronously).
127pub 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    // Inherit the terminal's own colors. The palette's `Highlight` styles already use reverse video,
143    // so the selected row stays visible without hardcoding any colors.
144    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    // `OnEventView` intercepts the arrow keys before `SelectView`'s own built-in handling sees
173    // them, so they go through `move_selection`/`set_expanded` too instead of stopping on a
174    // diffstat line or falling through to `SelectView`'s own (unwanted) left/right handling.
175    // `j`/`k`/`h`/`l` must be bound here rather than as global callbacks: those run outside the
176    // view tree, so the enclosing `ScrollView` never scrolls to keep the selection visible.
177    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
204/// Drives the event loop by hand instead of the usual `siv.run()`, so an open-locally request can
205/// tear the whole backend down before dropping the user into a subshell, then build a fresh one
206/// on return. `Cursive`'s screen diffing has no way to know the subshell scribbled all over the
207/// terminal, so patching the existing backend back to raw/alternate-screen mode leaves stale
208/// content behind wherever the next frame doesn't happen to differ from the last one drawn before
209/// suspending. Recreating the backend (and with it, a `CursiveRunner` with a blank diff buffer)
210/// sidesteps that entirely - the next `refresh()` is indistinguishable from a fresh start, so it
211/// draws every cell instead of only the ones it thinks changed.
212fn 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            // `runner` drops here, tearing the backend fully down (leaves the alternate screen,
236            // disables raw mode, shows the cursor) before we touch the terminal for anything else.
237        };
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
261/// Builds the configured `open_command` (run via `sh -c`) or, if there is none, an interactive
262/// `$SHELL`, to run in the review's workspace. Returns the command's display name with it.
263fn 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
300/// Opens a fetched workspace: queues it for `run_event_loop` to suspend the TUI around, or - with
301/// `open_command_wait = false` - just starts the command in the background and keeps going.
302fn 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        // Own process group, so a Ctrl-C aimed at rq doesn't also hit the command.
319        .process_group(0);
320    match cmd.spawn() {
321        Ok(mut child) => {
322            set_status(s, format!("opened {key} ({})", path.display()));
323            // Reap the child so it doesn't linger as a zombie, and surface a failure.
324            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
350/// Reorders `entries` so every stack's reviews sit together, bottom-most first. A stack shows up
351/// where its first member did, so unstacked reviews keep their relative order.
352fn 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
401/// Columns a connector takes after the id: a space and the glyph.
402const LINK_W: usize = 2;
403
404/// Where a review sits in its stack, drawn as a connector in the gutter so the stack's members
405/// read as one linked chain even though the rows are flat. Members are bottom-most first.
406#[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    // The connector hangs off the id; the padding keeps authors aligned across stacked and
458    // unstacked rows.
459    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
473/// Colors a diffstat line: the `+`/`-` graph after a file's `|` becomes green/red, and in the
474/// `N insertions(+), M deletions(-)` summary the matching segments get the same treatment.
475fn 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
507/// The diffstat lines shown under an expanded review - `rq sync` fetches this from the review's
508/// source and stores it on the `ReviewEntry`, so this is a plain read with no network call.
509fn 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
517/// Indent for the expanded lines under a review (matches the diffstat).
518const DETAIL_INDENT: &str = "      ";
519
520/// Width to wrap at when the terminal size can't be read.
521const DEFAULT_WIDTH: usize = 100;
522
523/// The terminal's current width. `SelectView` rows are single-line and can't wrap themselves, so
524/// expanded text is wrapped up front to fit; a resize is picked up the next time rows are rebuilt.
525fn terminal_width() -> usize {
526    cursive::backends::crossterm::crossterm::terminal::size()
527        .map(|(w, _)| w as usize)
528        .unwrap_or(DEFAULT_WIDTH)
529}
530
531/// Word-wraps `text` to `width` columns, keeping blank lines and each line's leading indent.
532fn 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
555/// The full title, then the PR description / commit message, shown under an expanded review.
556/// The title is repeated in full since the review's own row truncates it to the terminal width.
557/// Wrapped to `width` and each block followed by a blank line.
558fn 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    // Which multi-review stack (by index into `stacks`) each review belongs to. A lone review
586    // isn't drawn as a stack.
587    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        // A stack shares one workspace, so its spinner runs on every member.
611        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
632/// Moves to the next/previous review, skipping over any diffstat detail lines in between so the
633/// cursor only ever lands on a `Row::Entry` - backs both the arrow keys and `j`/`k`.
634fn 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; // hit the top/bottom of the list; nowhere left to go
645            }
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
663/// `set_expanded(s, true/false)` backs left/right and the `l`/`h` vim-style bindings - directional
664/// rather than a toggle, so repeating one is idempotent instead of flipping back and forth.
665fn 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
683/// Lets `h`/`l` move between a dialog's buttons like Left/Right do. Without this the dialog
684/// ignores them and they fall through to the global callbacks, moving the review list behind it.
685/// `j`/`k` are swallowed for the same reason - a dialog has nothing to scroll.
686fn 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    // Esc would otherwise fall through to the global quit callback.
705    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    // Nothing to delete yet while it's still being fetched - `d` cancels it instead.
743    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    // A workspace is shared by every review in its stack, so deleting it takes them all along.
751    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
766/// Confirms before deleting a workspace. Local changes are always discarded - there's no second
767/// prompt for a dirty workspace.
768fn 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
789/// Runs `sync::remove_workspace` (forced, discarding any local changes) on a background thread,
790/// showing the same row spinner as a fetch while it works - deleting a big worktree can take a
791/// while.
792fn 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
830/// `f`: fetches (or refreshes) the selected review in the background with no confirmation and
831/// without opening a shell - unlike Enter, which confirms first and then opens the workspace.
832fn 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    // A workspace left `ApplyFailed` doesn't count: `fetch_local` tears it down and rebuilds it,
869    // which on a large repo takes long enough to freeze the UI if run inline.
870    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        // Already fetched - `fetch_local` is a fast, local no-op in this case, so there's
879        // nothing worth showing a confirmation or progress dialog for.
880        do_open_locally(s, key, on_missing);
881    } else {
882        prompt_confirm_fetch(s, key, on_missing);
883    }
884}
885
886/// Confirms before fetching a review that has no local worktree yet, since `fetch_local` may
887/// clone a repo or run a source's checkout command - either can take a while. `Proceed` closes
888/// the dialog and hands off to `begin_fetch`, which shows progress in the review's own row.
889fn 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
902/// Kicks off `sync::fetch_local` on a background thread; `open_shell` says whether to drop into a
903/// subshell in the workspace once it's done (Enter) or just leave it fetched (`f`). Progress is a spinner in the review's
904/// row where the workspace icon goes (see `row_label`), so the rest of the UI stays usable; `d`
905/// on that row cancels (`cancel_fetch`).
906fn 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
957/// The `Ctx::fetching` key for `key`'s operations: its stack's tip, since a stack shares one
958/// workspace and so one in-flight fetch/delete - starting a second on another member of the same
959/// stack would race the first.
960fn 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
970/// Cancels a review's in-flight fetch (`d` on its row, see `delete_workspace_selected`).
971/// `fetch_local`'s `git`/`jj` calls have no cancellation points, so the operation keeps running in
972/// the background: the spinner turns red until it completes, and `finish_fetch` then removes
973/// whatever workspace it created.
974fn 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
1009/// Advances `key`'s row spinner roughly every 120ms until `done` is set, so `begin_fetch`'s
1010/// background fetch has some visible sign of life.
1011fn 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; // UI gone.
1034            }
1035        }
1036    });
1037}
1038
1039/// Runs once `begin_fetch`'s background thread finishes, back on the main thread via `cb_sink`.
1040/// If `cancelled` was flipped in the meantime, the operation itself was never interrupted (see the
1041/// module docs), so a successful fetch's workspace is removed here instead of being opened -
1042/// unless it already existed before the fetch (`had_workspace`), in which case it's the user's and
1043/// is left alone; nothing needs cleaning up on a failed one.
1044fn 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        // Either way `fetch_local` may have written a workspace to state.json (even a failed
1109        // apply is recorded, left in place for inspection) - reload to reflect that. The actual
1110        // subshell only gets launched by `run_event_loop`, which alone is allowed to tear down
1111        // the backend.
1112        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
1127/// Raw stdin can't be read while cursive owns the screen, so the CLI's `Y/n/always` prompt
1128/// becomes a dialog here instead.
1129fn 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        // D2 sorts between D1 and D3 but belongs with D3's stack; D1 is on its own.
1255        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        // The connector hangs directly off the id: marker, icon, space, then the id.
1304        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        // The stack's shared spinner shows on every member, not the unstacked review.
1311        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        // D1's entry row, its title + blank line, its two diffstat lines, then D2's entry row.
1374        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        // D1's entry + title/blank + its diffstat line, then the same for D2 - expanding D2 must
1400        // not have collapsed D1.
1401        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        // Expand D1, then move to D2 (skipping over D1's now-visible detail line, same as any
1426        // other navigation) and expand it too - must not collapse D1.
1427        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        // Collapse D1 - must not touch D2.
1439        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        // entry + title/description lines + diffstat line
1467        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(); // lay the dialog out so its buttons have areas to move between
1550        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        // `j` must not reach the global callback and move the list underneath.
1559        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        // Starts on D1's entry row; moving down must skip both of its detail lines and land
1632        // directly on D2, not stop partway through the diffstat.
1633        move_selection(&mut siv, true);
1634        assert!(matches!(selected_row(&mut siv), Row::Entry(k) if k.id == "D2"));
1635
1636        // And back up, skipping the same detail lines in the other direction.
1637        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); // land on the one detail row
1660        assert!(matches!(selected_row(&mut siv), Row::Detail(_)));
1661
1662        // No review below it to skip forward to - must not hang looping at the boundary.
1663        move_selection(&mut siv, true);
1664        assert!(matches!(selected_row(&mut siv), Row::Detail(_)));
1665    }
1666
1667    /// A `Ctx` whose `paths` point at a fresh tempdir seeded with `entries` in `state.json`, so
1668    /// `reload` (which `set_expanded` calls) has real, stable rows to rebuild the `reviews` view
1669    /// from instead of reading whatever's on the real machine's disk.
1670    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        // Pressing `l` twice must stay expanded, not toggle back to collapsed.
1704        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        // Pressing `h` twice must stay collapsed.
1714        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        // Selection starts on D1's lone detail line, not its entry row.
1734        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}