use std::collections::{BTreeMap, BTreeSet};
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use anyhow::Result;
use cursive::Cursive;
use cursive::event::{Event, EventResult, Key};
use cursive::theme::{BaseColor, Color, Effect, Theme};
use cursive::traits::*;
use cursive::utils::markup::StyledString;
use cursive::views::{Dialog, LinearLayout, OnEventView, SelectView, TextView};
use crate::config::{self, Config};
use crate::paths::Paths;
use crate::repo::{NeedsClone, OnMissing};
use crate::source::ReviewSource;
use crate::stacks;
use crate::state::{ReviewEntry, ReviewKey, State, Status};
use crate::sync;
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const HELP: &str = "[?] Help";
const HELP_DIALOG: &str = "↑/↓ or j/k move
←/→ or h/l expand/collapse
enter open locally
f fetch
o open in browser
d delete workspace
r reload
q / esc quit
? show this help";
struct Ctx {
paths: Paths,
config: Config,
config_path: PathBuf,
sources: Arc<Vec<Box<dyn ReviewSource>>>,
handle: tokio::runtime::Handle,
all: bool,
key_w: usize,
author_w: usize,
expanded: BTreeSet<ReviewKey>,
pending_shell: Option<(ReviewKey, PathBuf)>,
fetching: BTreeMap<ReviewKey, Fetch>,
}
struct Fetch {
cancelled: Arc<AtomicBool>,
frame: usize,
deleting: bool,
}
#[derive(Debug, Clone, Copy)]
struct Spin {
frame: usize,
cancelling: bool,
}
type Spinners = BTreeMap<ReviewKey, Spin>;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Row {
Entry(ReviewKey),
Detail(ReviewKey),
}
impl Row {
fn key(&self) -> &ReviewKey {
match self {
Row::Entry(k) | Row::Detail(k) => k,
}
}
}
pub fn run(
paths: Paths,
config: Config,
config_path: PathBuf,
sources: Vec<Box<dyn ReviewSource>>,
all: bool,
) -> Result<()> {
let entries = load_rows(&paths, all)?;
if entries.is_empty() {
println!("No reviews tracked yet. Run `rq sync` first.");
return Ok(());
}
let (key_w, author_w) = column_widths(&entries);
let mut siv = Cursive::new();
siv.set_theme(Theme::terminal_default());
siv.set_user_data(Ctx {
paths,
config,
config_path,
sources: Arc::new(sources),
handle: tokio::runtime::Handle::current(),
all,
key_w,
author_w,
expanded: BTreeSet::new(),
pending_shell: None,
fetching: BTreeMap::new(),
});
let mut select = SelectView::<Row>::new();
for (label, row) in build_rows(
&entries,
key_w,
author_w,
&BTreeSet::new(),
terminal_width(),
&Spinners::new(),
) {
select.add_item(label, row);
}
select.set_on_submit(|s, row: &Row| open_locally_selected_key(s, row.key().clone()));
let select = OnEventView::new(select.with_name("reviews"))
.on_event(Key::Down, |s| move_selection(s, true))
.on_event('j', |s| move_selection(s, true))
.on_event(Key::Up, |s| move_selection(s, false))
.on_event('k', |s| move_selection(s, false))
.on_event(Key::Right, |s| set_expanded(s, true))
.on_event('l', |s| set_expanded(s, true))
.on_event(Key::Left, |s| set_expanded(s, false))
.on_event('h', |s| set_expanded(s, false));
let layout = LinearLayout::vertical()
.child(select.scrollable().full_height())
.child(TextView::new("").with_name("status"))
.child(TextView::new(StyledString::styled(HELP, Effect::Dim)));
siv.add_fullscreen_layer(layout);
siv.add_global_callback('q', |s| s.quit());
siv.add_global_callback('?', show_help);
siv.add_global_callback(Key::Esc, |s| s.quit());
siv.add_global_callback('o', open_in_browser_selected);
siv.add_global_callback('d', delete_workspace_selected);
siv.add_global_callback('f', fetch_selected);
siv.add_global_callback('r', reload);
run_event_loop(siv)
}
fn run_event_loop(mut siv: Cursive) -> Result<()> {
enum Outcome {
Quit,
OpenShell(ReviewKey, PathBuf),
}
loop {
let backend = cursive::backends::crossterm::Backend::init()?;
let outcome = {
let mut runner = siv.runner(backend);
runner.refresh();
loop {
runner.step();
if let Some((key, path)) = runner
.user_data::<Ctx>()
.and_then(|ctx| ctx.pending_shell.take())
{
break Outcome::OpenShell(key, path);
}
if !runner.is_running() {
break Outcome::Quit;
}
}
};
match outcome {
Outcome::Quit => return Ok(()),
Outcome::OpenShell(key, path) => {
let open_command = siv
.user_data::<Ctx>()
.and_then(|ctx| ctx.config.open_command.clone());
let (label, result) = run_open_command(open_command.as_deref(), &key, &path);
match result {
Ok(status) if status.success() => {
set_status(&mut siv, format!("back from {key} ({})", path.display()))
}
Ok(status) => set_status(
&mut siv,
format!("`{label}` exited with {status} in {}", path.display()),
),
Err(e) => set_status(&mut siv, format!("failed to launch `{label}`: {e}")),
}
}
}
}
}
fn build_open_command(
open_command: Option<&str>,
key: &ReviewKey,
path: &std::path::Path,
) -> (String, std::process::Command) {
let mut cmd = match open_command {
Some(script) => {
let mut cmd = std::process::Command::new("sh");
cmd.args(["-c", script]);
cmd
}
None => {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
std::process::Command::new(shell)
}
};
cmd.current_dir(path)
.env("RQ_REVIEW", key.slug())
.env("RQ_SOURCE", &key.source)
.env("RQ_ID", &key.id)
.env("RQ_WORKSPACE", path);
let label = open_command
.map(str::to_string)
.unwrap_or_else(|| cmd.get_program().to_string_lossy().into_owned());
(label, cmd)
}
fn run_open_command(
open_command: Option<&str>,
key: &ReviewKey,
path: &std::path::Path,
) -> (String, std::io::Result<std::process::ExitStatus>) {
let (label, mut cmd) = build_open_command(open_command, key, path);
let result = cmd.status();
(label, result)
}
fn open_workspace(s: &mut Cursive, key: ReviewKey, path: PathBuf) {
let Some(ctx) = s.user_data::<Ctx>() else {
return;
};
let detached = match &ctx.config.open_command {
Some(cmd) if !ctx.config.open_command_wait => cmd.clone(),
_ => {
ctx.pending_shell = Some((key, path));
return;
}
};
let (label, mut cmd) = build_open_command(Some(&detached), &key, &path);
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.process_group(0);
match cmd.spawn() {
Ok(mut child) => {
set_status(s, format!("opened {key} ({})", path.display()));
let sink = s.cb_sink().clone();
std::thread::spawn(move || {
if let Ok(status) = child.wait() {
if !status.success() {
let _ = sink.send(Box::new(move |s| {
set_status(s, format!("`{label}` exited with {status} for {key}"))
}));
}
}
});
}
Err(e) => set_status(s, format!("failed to launch `{label}`: {e}")),
}
}
fn load_rows(paths: &Paths, all: bool) -> Result<Vec<ReviewEntry>> {
let state = State::load(&paths.state_file())?;
Ok(order_by_stack(
state
.iter()
.filter(|e| all || e.in_queue)
.cloned()
.collect(),
))
}
fn order_by_stack(entries: Vec<ReviewEntry>) -> Vec<ReviewEntry> {
let stacks = stacks::group(entries.iter());
let mut by_key: BTreeMap<ReviewKey, ReviewEntry> =
entries.iter().map(|e| (e.key.clone(), e.clone())).collect();
let mut emitted: BTreeSet<ReviewKey> = BTreeSet::new();
let mut out = Vec::with_capacity(entries.len());
for e in &entries {
if emitted.contains(&e.key) {
continue;
}
let members = stacks::stack_containing(&stacks, &e.key)
.map(|s| s.members.clone())
.unwrap_or_else(|| vec![e.key.clone()]);
for key in members {
if emitted.insert(key.clone())
&& let Some(entry) = by_key.remove(&key)
{
out.push(entry);
}
}
}
out
}
fn column_widths(entries: &[ReviewEntry]) -> (usize, usize) {
let key_w = entries
.iter()
.map(|e| e.key.slug().len())
.max()
.unwrap_or(3)
.max(3);
let author_w = entries
.iter()
.map(|e| e.author.chars().count())
.max()
.unwrap_or(6)
.clamp(6, 20);
(key_w, author_w)
}
fn truncate(s: &str, width: usize) -> String {
if s.chars().count() <= width {
format!("{s:<width$}")
} else {
let head: String = s.chars().take(width.saturating_sub(1)).collect();
format!("{head}\u{2026}")
}
}
const LINK_W: usize = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Link {
Bottom,
Middle,
Top,
}
impl Link {
fn glyph(self) -> &'static str {
match self {
Link::Bottom => "\u{2510}",
Link::Middle => "\u{2524}",
Link::Top => "\u{2518}",
}
}
}
fn ansi(c: BaseColor) -> Color {
Color::Dark(c)
}
fn row_label(
e: &ReviewEntry,
key_w: usize,
author_w: usize,
expanded: bool,
link: Option<Link>,
spin: Option<Spin>,
) -> StyledString {
let marker = if expanded { '\u{25be}' } else { '\u{25b8}' };
let mut out = StyledString::new();
out.append_styled(format!("{marker} "), Effect::Dim);
if let Some(spin) = spin {
let color = if spin.cancelling {
BaseColor::Red
} else {
BaseColor::Yellow
};
out.append_styled(
SPINNER_FRAMES[spin.frame % SPINNER_FRAMES.len()],
ansi(color),
);
} else if e.stack_id.is_some() {
out.append_styled("\u{2913}", ansi(BaseColor::Cyan));
} else {
out.append_plain(" ");
}
out.append_plain(" ");
let slug = e.key.slug();
out.append_styled(&slug, ansi(BaseColor::Cyan));
let mut pad = key_w.saturating_sub(slug.chars().count()) + LINK_W;
if let Some(link) = link {
out.append_plain(" ");
out.append_styled(link.glyph(), ansi(BaseColor::Magenta));
pad -= LINK_W;
}
out.append_plain(" ".repeat(pad));
out.append_plain(" ");
out.append_plain(truncate(&e.author, author_w));
out.append_plain(" ");
out.append_plain(&e.title);
out
}
fn style_detail(line: &str) -> StyledString {
let mut out = StyledString::new();
if let Some((name, graph)) = line.split_once('|') {
out.append_plain(format!("{name}|"));
if graph.contains("Bin ") {
out.append_plain(graph);
return out;
}
for c in graph.chars() {
match c {
'+' => out.append_styled(c.to_string(), ansi(BaseColor::Green)),
'-' => out.append_styled(c.to_string(), ansi(BaseColor::Red)),
_ => out.append_plain(c.to_string()),
}
}
} else {
for (i, part) in line.split(',').enumerate() {
if i > 0 {
out.append_plain(",");
}
if part.ends_with("(+)") {
out.append_styled(part, ansi(BaseColor::Green));
} else if part.ends_with("(-)") {
out.append_styled(part, ansi(BaseColor::Red));
} else {
out.append_plain(part);
}
}
}
out
}
fn detail_lines(e: &ReviewEntry) -> Vec<String> {
match &e.diff_stat {
None => vec![" (no diffstat - run `rq sync`)".to_string()],
Some(stat) if stat.trim().is_empty() => vec![" (no changes)".to_string()],
Some(stat) => stat.lines().map(|l| format!(" {l}")).collect(),
}
}
const DETAIL_INDENT: &str = " ";
const DEFAULT_WIDTH: usize = 100;
fn terminal_width() -> usize {
cursive::backends::crossterm::crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(DEFAULT_WIDTH)
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
for raw in text.lines() {
let raw = raw.trim_end();
let indent = raw.len() - raw.trim_start().len();
let mut line = " ".repeat(indent);
let mut has_word = false;
for word in raw.split_whitespace() {
if has_word && line.chars().count() + 1 + word.chars().count() > width {
lines.push(std::mem::replace(&mut line, " ".repeat(indent)));
has_word = false;
}
if has_word {
line.push(' ');
}
line.push_str(word);
has_word = true;
}
lines.push(line);
}
lines
}
fn description_lines(e: &ReviewEntry, width: usize) -> Vec<String> {
let wrap = width.saturating_sub(DETAIL_INDENT.len() + 2).max(20);
let mut lines = wrap_text(&e.title, wrap);
lines.push(String::new());
if let Some(desc) = e
.description
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty())
{
lines.extend(wrap_text(desc, wrap));
lines.push(String::new());
}
lines
.into_iter()
.map(|l| format!("{DETAIL_INDENT}{l}"))
.collect()
}
fn build_rows(
entries: &[ReviewEntry],
key_w: usize,
author_w: usize,
expanded: &BTreeSet<ReviewKey>,
width: usize,
spinners: &Spinners,
) -> Vec<(StyledString, Row)> {
let stacks: Vec<_> = stacks::group(entries.iter())
.into_iter()
.filter(|s| s.members.len() > 1)
.collect();
let stack_of: BTreeMap<&ReviewKey, usize> = stacks
.iter()
.enumerate()
.flat_map(|(i, s)| s.members.iter().map(move |k| (k, i)))
.collect();
let mut rows = Vec::new();
for e in entries {
let stack = stack_of.get(&e.key).map(|&i| &stacks[i]);
let link = stack.map(|st| {
if st.members.first() == Some(&e.key) {
Link::Bottom
} else if st.members.last() == Some(&e.key) {
Link::Top
} else {
Link::Middle
}
});
let is_expanded = expanded.contains(&e.key);
let spin = spinners.get(stack.map_or(&e.key, |st| &st.tip)).copied();
rows.push((
row_label(e, key_w, author_w, is_expanded, link, spin),
Row::Entry(e.key.clone()),
));
if is_expanded {
for line in description_lines(e, width) {
rows.push((StyledString::plain(line), Row::Detail(e.key.clone())));
}
for line in detail_lines(e) {
rows.push((style_detail(&line), Row::Detail(e.key.clone())));
}
}
}
rows
}
fn set_status(s: &mut Cursive, msg: impl Into<String>) {
s.call_on_name("status", |v: &mut TextView| v.set_content(msg.into()));
}
fn move_selection(s: &mut Cursive, down: bool) {
s.call_on_name("reviews", |v: &mut SelectView<Row>| {
loop {
let before = v.selected_id();
let _ = if down {
v.select_down(1)
} else {
v.select_up(1)
};
if v.selected_id() == before {
break; }
if !matches!(v.selection().as_deref(), Some(Row::Detail(_))) {
break;
}
}
});
}
fn selected_row(s: &mut Cursive) -> Option<Row> {
s.call_on_name("reviews", |v: &mut SelectView<Row>| v.selection())
.flatten()
.map(|row| (*row).clone())
}
fn selected_key(s: &mut Cursive) -> Option<ReviewKey> {
selected_row(s).map(|row| row.key().clone())
}
fn set_expanded(s: &mut Cursive, expand: bool) {
let Some(row) = selected_row(s) else {
return;
};
if let Some(ctx) = s.user_data::<Ctx>() {
match row {
Row::Entry(key) | Row::Detail(key) => {
if expand {
ctx.expanded.insert(key);
} else {
ctx.expanded.remove(&key);
}
}
}
}
reload(s);
}
fn vim_keys(dialog: Dialog) -> OnEventView<Dialog> {
OnEventView::new(dialog)
.on_pre_event_inner(Event::Char('h'), |d, _| {
Some(d.on_event(Event::Key(Key::Left)))
})
.on_pre_event_inner(Event::Char('l'), |d, _| {
Some(d.on_event(Event::Key(Key::Right)))
})
.on_pre_event_inner(Event::Char('j'), |_, _| Some(EventResult::consumed()))
.on_pre_event_inner(Event::Char('k'), |_, _| Some(EventResult::consumed()))
}
fn show_help(s: &mut Cursive) {
let dialog = Dialog::text(HELP_DIALOG)
.title("Shortcuts")
.button("Close", |s| {
s.pop_layer();
});
s.add_layer(
vim_keys(dialog)
.on_event(Key::Esc, |s| {
s.pop_layer();
})
.on_event('?', |s| {
s.pop_layer();
}),
);
}
fn open_in_browser_selected(s: &mut Cursive) {
let Some(key) = selected_key(s) else {
return;
};
open_in_browser(s, &key);
}
fn open_in_browser(s: &mut Cursive, key: &ReviewKey) {
let url = s.user_data::<Ctx>().and_then(|ctx| {
State::load(&ctx.paths.state_file())
.ok()
.and_then(|st| st.get(key).map(|e| e.url.clone()))
});
match url {
Some(url) => match open::that(&url) {
Ok(()) => set_status(s, format!("opened {url}")),
Err(e) => set_status(s, format!("failed to open {url}: {e}")),
},
None => set_status(s, format!("`{key}` is no longer tracked")),
}
}
fn delete_workspace_selected(s: &mut Cursive) {
let Some(key) = selected_key(s) else {
return;
};
let guard = fetch_guard(s, &key);
if s.user_data::<Ctx>()
.is_some_and(|ctx| ctx.fetching.contains_key(&guard))
{
cancel_fetch(s, &guard);
return;
}
let sharing = s
.user_data::<Ctx>()
.and_then(|ctx| State::load(&ctx.paths.state_file()).ok())
.and_then(|st| {
let id = st.get(&key)?.stack_id.clone()?;
st.workspace(&id)?;
Some(st.members_of(&id).len())
});
if let Some(sharing) = sharing {
prompt_delete_workspace(s, key, sharing);
} else {
set_status(s, format!("`{key}` has no local workspace"));
}
}
fn prompt_delete_workspace(s: &mut Cursive, key: ReviewKey, sharing: usize) {
let text = if sharing > 1 {
format!(
"Delete the workspace for {key} and the {} other reviews in its stack?",
sharing - 1
)
} else {
format!("Delete workspace for {key}?")
};
let dialog = Dialog::text(text)
.title("Delete workspace?")
.button("No", |s| {
s.pop_layer();
})
.button("Yes", move |s| {
s.pop_layer();
begin_delete(s, key.clone());
});
s.add_layer(vim_keys(dialog));
}
fn begin_delete(s: &mut Cursive, key: ReviewKey) {
let guard = fetch_guard(s, &key);
let Some(ctx) = s.user_data::<Ctx>() else {
return;
};
ctx.fetching.insert(
guard.clone(),
Fetch {
cancelled: Arc::new(AtomicBool::new(false)),
frame: 0,
deleting: true,
},
);
let paths = ctx.paths.clone();
reload(s);
let done = Arc::new(AtomicBool::new(false));
spawn_spinner_ticker(s, guard.clone(), done.clone());
let cb_sink = s.cb_sink().clone();
std::thread::spawn(move || {
let result = sync::remove_workspace(&paths, &key, true);
done.store(true, Ordering::SeqCst);
let _ = cb_sink.send(Box::new(move |s| finish_delete(s, key, guard, result)));
});
}
fn finish_delete(s: &mut Cursive, key: ReviewKey, guard: ReviewKey, result: Result<()>) {
if let Some(ctx) = s.user_data::<Ctx>() {
ctx.fetching.remove(&guard);
}
match result {
Ok(()) => set_status(s, format!("deleted workspace for {key}")),
Err(e) => set_status(s, format!("failed to delete workspace for {key}: {e:#}")),
}
reload(s);
}
fn fetch_selected(s: &mut Cursive) {
let Some(key) = selected_key(s) else {
return;
};
let guard = fetch_guard(s, &key);
let Some(ctx) = s.user_data::<Ctx>() else {
return;
};
let on_missing = if ctx.config.auto_clone {
OnMissing::Clone
} else {
OnMissing::Ask
};
if let Some(f) = ctx.fetching.get(&guard) {
let msg = busy_message(&guard, f);
set_status(s, msg);
return;
}
begin_fetch(s, key, on_missing, false);
}
fn open_locally_selected_key(s: &mut Cursive, key: ReviewKey) {
let guard = fetch_guard(s, &key);
let Some(ctx) = s.user_data::<Ctx>() else {
return;
};
let on_missing = if ctx.config.auto_clone {
OnMissing::Clone
} else {
OnMissing::Ask
};
if let Some(f) = ctx.fetching.get(&guard) {
let msg = busy_message(&guard, f);
set_status(s, msg);
return;
}
let has_workspace = State::load(&ctx.paths.state_file())
.ok()
.is_some_and(|st| {
st.workspace_for(&key)
.is_some_and(|ws| ws.status != Status::ApplyFailed)
});
if has_workspace {
do_open_locally(s, key, on_missing);
} else {
prompt_confirm_fetch(s, key, on_missing);
}
}
fn prompt_confirm_fetch(s: &mut Cursive, key: ReviewKey, on_missing: OnMissing) {
let dialog = Dialog::text(format!("Create workspace for {key}?"))
.title("Fetch review?")
.button("Cancel", |s| {
s.pop_layer();
})
.button("Proceed", move |s| {
s.pop_layer();
begin_fetch(s, key.clone(), on_missing, true);
});
s.add_layer(vim_keys(dialog));
}
fn begin_fetch(s: &mut Cursive, key: ReviewKey, on_missing: OnMissing, open_shell: bool) {
let cancelled = Arc::new(AtomicBool::new(false));
let guard = fetch_guard(s, &key);
let Some(ctx) = s.user_data::<Ctx>() else {
return;
};
ctx.fetching.insert(
guard.clone(),
Fetch {
cancelled: cancelled.clone(),
frame: 0,
deleting: false,
},
);
let sources = ctx.sources.clone();
let paths = ctx.paths.clone();
let config = ctx.config.clone();
let handle = ctx.handle.clone();
let had_workspace = State::load(&ctx.paths.state_file())
.ok()
.is_some_and(|st| st.workspace_for(&key).is_some());
reload(s);
let done = Arc::new(AtomicBool::new(false));
spawn_spinner_ticker(s, guard.clone(), done.clone());
let cb_sink = s.cb_sink().clone();
let worker_key = key.clone();
std::thread::spawn(move || {
let result = handle.block_on(sync::fetch_local(
&sources,
&paths,
&config,
&worker_key,
on_missing,
));
done.store(true, Ordering::SeqCst);
let _ = cb_sink.send(Box::new(move |s| {
finish_fetch(
s,
worker_key,
guard,
result,
cancelled,
open_shell,
had_workspace,
)
}));
});
}
fn fetch_guard(s: &mut Cursive, key: &ReviewKey) -> ReviewKey {
s.user_data::<Ctx>()
.and_then(|ctx| State::load(&ctx.paths.state_file()).ok())
.and_then(|st| {
let stacks = stacks::group(st.iter());
stacks::stack_containing(&stacks, key).map(|stack| stack.tip.clone())
})
.unwrap_or_else(|| key.clone())
}
fn busy_message(key: &ReviewKey, f: &Fetch) -> String {
if f.deleting {
format!("{key} is being deleted")
} else {
format!("already fetching {key}")
}
}
fn cancel_fetch(s: &mut Cursive, key: &ReviewKey) {
if s.user_data::<Ctx>()
.and_then(|ctx| ctx.fetching.get(key))
.is_some_and(|f| f.deleting)
{
set_status(s, format!("{key} is being deleted - can't cancel"));
return;
}
let flagged = s
.user_data::<Ctx>()
.and_then(|ctx| ctx.fetching.get(key))
.map(|f| f.cancelled.swap(true, Ordering::SeqCst));
match flagged {
Some(false) => {
set_status(
s,
format!(
"cancelling {key} - it will be cleaned up once the running operation stops"
),
);
reload(s);
}
Some(true) => set_status(s, format!("already cancelling {key}")),
None => set_status(s, format!("`{key}` isn't being fetched")),
}
}
fn spawn_spinner_ticker(s: &Cursive, key: ReviewKey, done: Arc<AtomicBool>) {
let cb_sink = s.cb_sink().clone();
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_millis(120));
if done.load(Ordering::SeqCst) {
break;
}
let tick_key = key.clone();
if cb_sink
.send(Box::new(move |s| {
let ticked = s
.user_data::<Ctx>()
.and_then(|ctx| ctx.fetching.get_mut(&tick_key))
.map(|f| f.frame = f.frame.wrapping_add(1))
.is_some();
if ticked {
reload(s);
}
}))
.is_err()
{
break; }
}
});
}
fn finish_fetch(
s: &mut Cursive,
key: ReviewKey,
guard: ReviewKey,
result: Result<PathBuf>,
cancelled: Arc<AtomicBool>,
open_shell: bool,
had_workspace: bool,
) {
if let Some(ctx) = s.user_data::<Ctx>() {
ctx.fetching.remove(&guard);
}
if cancelled.load(Ordering::SeqCst) {
let status = match &result {
Ok(_) if had_workspace => format!("cancelled fetching {key}; existing workspace kept"),
Ok(_) => match s
.user_data::<Ctx>()
.map(|ctx| sync::remove_workspace(&ctx.paths, &key, true))
{
Some(Ok(())) => format!("cancelled fetching {key}; workspace cleaned up"),
Some(Err(e)) => format!("cancelled fetching {key}; cleanup failed: {e:#}"),
None => format!("cancelled fetching {key}"),
},
Err(_) => format!("cancelled fetching {key}"),
};
set_status(s, status);
reload(s);
return;
}
match result {
Ok(path) => {
if open_shell {
open_workspace(s, key, path);
} else {
set_status(s, format!("fetched {key} ({})", path.display()));
}
reload(s);
}
Err(e) => match e.downcast::<NeedsClone>() {
Ok(needs_clone) => prompt_clone(s, key, needs_clone.url, needs_clone.dest, open_shell),
Err(e) => {
set_status(s, format!("error fetching {key}: {e:#}"));
reload(s);
}
},
}
}
fn do_open_locally(s: &mut Cursive, key: ReviewKey, on_missing: OnMissing) {
let outcome = s.user_data::<Ctx>().map(|ctx| {
tokio::task::block_in_place(|| {
ctx.handle.clone().block_on(sync::fetch_local(
&ctx.sources,
&ctx.paths,
&ctx.config,
&key,
on_missing,
))
})
});
match outcome {
Some(Ok(path)) => {
open_workspace(s, key, path);
reload(s);
}
Some(Err(e)) => match e.downcast::<NeedsClone>() {
Ok(needs_clone) => prompt_clone(s, key, needs_clone.url, needs_clone.dest, true),
Err(e) => {
set_status(s, format!("error fetching {key}: {e:#}"));
reload(s);
}
},
None => {}
}
}
fn prompt_clone(s: &mut Cursive, key: ReviewKey, url: String, dest: PathBuf, open_shell: bool) {
let yes_key = key.clone();
let always_key = key.clone();
let dialog = Dialog::text(format!(
"No local checkout of `{url}` found.\nClone into {}?",
dest.display()
))
.title("Clone repo?")
.button("No", |s| {
s.pop_layer();
set_status(s, "skipped - not cloned");
})
.button("Yes", move |s| {
s.pop_layer();
begin_fetch(s, yes_key.clone(), OnMissing::Clone, open_shell);
})
.button("Always", move |s| {
s.pop_layer();
let saved = s.user_data::<Ctx>().map(|ctx| {
let result = config::set_auto_clone(&ctx.config_path);
if result.is_ok() {
ctx.config.auto_clone = true;
}
result
});
if let Some(Err(e)) = saved {
set_status(s, format!("failed to save auto_clone: {e:#}"));
return;
}
begin_fetch(s, always_key.clone(), OnMissing::Clone, open_shell);
});
s.add_layer(vim_keys(dialog));
}
fn reload(s: &mut Cursive) {
let loaded = s.user_data::<Ctx>().map(|ctx| {
(
load_rows(&ctx.paths, ctx.all),
ctx.key_w,
ctx.author_w,
ctx.expanded.clone(),
ctx.fetching
.iter()
.map(|(k, f)| {
let spin = Spin {
frame: f.frame,
cancelling: f.cancelled.load(Ordering::SeqCst),
};
(k.clone(), spin)
})
.collect::<Spinners>(),
)
});
let Some((loaded, key_w, author_w, expanded, spinners)) = loaded else {
return;
};
let entries = match loaded {
Ok(entries) => entries,
Err(e) => {
set_status(s, format!("reload failed: {e:#}"));
return;
}
};
s.call_on_name("reviews", |v: &mut SelectView<Row>| {
let selected = v.selection().map(|row| (*row).clone());
v.clear();
let rows = build_rows(
&entries,
key_w,
author_w,
&expanded,
terminal_width(),
&spinners,
);
for (label, row) in rows {
v.add_item(label, row);
}
if let Some(selected) = selected
&& let Some(idx) =
(0..v.len()).find(|&i| v.get_item(i).is_some_and(|(_, row)| *row == selected))
{
v.set_selection(idx);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::source::{RepoRef, ReviewKind};
fn entry(id: &str, diff_stat: Option<&str>) -> ReviewEntry {
ReviewEntry {
key: ReviewKey::new("moz", id),
title: "Fix the thing".into(),
author: "someone".into(),
url: format!("https://example.com/{id}"),
repo: RepoRef {
urls: vec!["https://example.com/o/r".into()],
display_name: "o/r".into(),
},
kind: ReviewKind::Direct,
version: "1".into(),
in_queue: true,
resolved: false,
last_synced: chrono::Utc::now(),
stack_id: None,
ancestors: Vec::new(),
diff_stat: diff_stat.map(String::from),
description: None,
}
}
fn stacked(id: &str, ancestors: &[&str]) -> ReviewEntry {
let mut e = entry(id, None);
e.ancestors = ancestors
.iter()
.map(|a| ReviewKey::new("moz", *a))
.collect();
e
}
#[test]
fn a_stack_is_grouped_bottom_first_where_its_first_member_sat() {
let entries = vec![
stacked("D1", &[]),
stacked("D2", &[]),
stacked("D3", &["D2"]),
stacked("D4", &[]),
];
let ordered = order_by_stack(entries);
let ids: Vec<_> = ordered.iter().map(|e| e.key.id.as_str()).collect();
assert_eq!(ids, ["D1", "D2", "D3", "D4"]);
let entries = vec![
stacked("D1", &["D9"]),
stacked("D2", &[]),
stacked("D9", &[]),
];
let ordered = order_by_stack(entries);
let ids: Vec<_> = ordered.iter().map(|e| e.key.id.as_str()).collect();
assert_eq!(
ids,
["D9", "D1", "D2"],
"D9 is D1's parent, so it comes first"
);
}
#[test]
fn stacked_members_are_flat_rows_linked_by_a_connector() {
let entries = order_by_stack(vec![
stacked("D1", &[]),
stacked("D2", &["D1"]),
stacked("D3", &["D1", "D2"]),
stacked("D9", &[]),
]);
let spinners = Spinners::from([(
ReviewKey::new("moz", "D3"),
Spin {
frame: 0,
cancelling: false,
},
)]);
let rows = build_rows(&entries, 6, 6, &BTreeSet::new(), 100, &spinners);
assert_eq!(rows.len(), 4);
assert!(
rows.iter().all(|(_, r)| matches!(r, Row::Entry(_))),
"no header rows"
);
let plain = |i: usize| rows[i].0.source().to_string();
let id_len = "moz/D1".len();
let slot = |i: usize| plain(i).chars().nth(4 + id_len + 1).unwrap().to_string();
assert_eq!(slot(0), Link::Bottom.glyph());
assert_eq!(slot(1), Link::Middle.glyph());
assert_eq!(slot(2), Link::Top.glyph());
assert!(plain(3).starts_with("▸ moz/D9 "), "{}", plain(3));
for i in 0..3 {
assert!(plain(i).contains(SPINNER_FRAMES[0]));
}
assert!(!plain(3).contains(SPINNER_FRAMES[0]));
}
#[test]
fn stacked_and_unstacked_rows_keep_author_and_title_aligned() {
let entries = order_by_stack(vec![
stacked("D1", &[]),
stacked("D2", &["D1"]),
stacked("D3", &[]),
]);
let (key_w, author_w) = column_widths(&entries);
let rows = build_rows(
&entries,
key_w,
author_w,
&BTreeSet::new(),
100,
&Spinners::new(),
);
let author_col: Vec<usize> = rows
.iter()
.filter(|(_, r)| matches!(r, Row::Entry(_)))
.map(|(l, _)| {
let src = l.source();
src[..src.find("someone").unwrap()].chars().count()
})
.collect();
assert_eq!(author_col.len(), 3);
assert!(
author_col.iter().all(|c| *c == author_col[0]),
"{author_col:?}"
);
}
#[test]
fn collapsed_entries_produce_one_row_each() {
let entries = vec![entry("D1", None), entry("D2", None)];
let rows = build_rows(&entries, 5, 6, &BTreeSet::new(), 100, &Spinners::new());
assert_eq!(rows.len(), 2);
assert!(matches!(&rows[0].1, Row::Entry(k) if k.id == "D1"));
assert!(matches!(&rows[1].1, Row::Entry(k) if k.id == "D2"));
}
#[test]
fn expanding_a_review_inserts_detail_rows_owned_by_its_key() {
let entries = vec![
entry("D1", Some("a.rs | 1 +\nb.rs | 2 ++")),
entry("D2", None),
];
let expanded = ReviewKey::new("moz", "D1");
let rows = build_rows(
&entries,
5,
6,
&BTreeSet::from([expanded.clone()]),
100,
&Spinners::new(),
);
assert_eq!(rows.len(), 6);
assert!(matches!(&rows[0].1, Row::Entry(k) if *k == expanded));
for (_, row) in &rows[1..5] {
assert_eq!(row, &Row::Detail(expanded.clone()));
}
assert!(matches!(&rows[5].1, Row::Entry(k) if k.id == "D2"));
}
#[test]
fn multiple_reviews_can_be_expanded_at_once() {
let entries = vec![
entry("D1", Some("a.rs | 1 +")),
entry("D2", Some("b.rs | 2 ++")),
];
let d1 = ReviewKey::new("moz", "D1");
let d2 = ReviewKey::new("moz", "D2");
let rows = build_rows(
&entries,
5,
6,
&BTreeSet::from([d1.clone(), d2.clone()]),
100,
&Spinners::new(),
);
assert_eq!(rows.len(), 8);
assert!(matches!(&rows[0].1, Row::Entry(k) if *k == d1));
for (_, row) in &rows[1..4] {
assert_eq!(row, &Row::Detail(d1.clone()));
}
assert!(matches!(&rows[4].1, Row::Entry(k) if *k == d2));
for (_, row) in &rows[5..8] {
assert_eq!(row, &Row::Detail(d2.clone()));
}
}
#[tokio::test]
async fn set_expanded_can_expand_multiple_reviews_independently() {
let tmp = tempfile::tempdir().unwrap();
let mut siv = cursive::dummy();
siv.set_user_data(ctx_with_state(
tmp.path(),
vec![entry("D1", None), entry("D2", None)],
));
let mut select = SelectView::<Row>::new();
select.add_item("D1", Row::Entry(ReviewKey::new("moz", "D1")));
select.add_item("D2", Row::Entry(ReviewKey::new("moz", "D2")));
siv.add_layer(select.with_name("reviews"));
set_expanded(&mut siv, true);
move_selection(&mut siv, true);
set_expanded(&mut siv, true);
let ctx = siv.user_data::<Ctx>().unwrap();
assert_eq!(
ctx.expanded,
BTreeSet::from([ReviewKey::new("moz", "D1"), ReviewKey::new("moz", "D2")]),
"expanding D2 must not collapse the already-expanded D1"
);
move_selection(&mut siv, false);
set_expanded(&mut siv, false);
let ctx = siv.user_data::<Ctx>().unwrap();
assert_eq!(
ctx.expanded,
BTreeSet::from([ReviewKey::new("moz", "D2")]),
"collapsing D1 must not touch D2"
);
}
#[test]
fn expanding_a_review_shows_title_and_wrapped_description_before_the_diffstat() {
let mut e = entry("D1", Some("a.rs | 1 +"));
e.description = Some(format!("first para\n\n{}", "word ".repeat(60)));
let lines = description_lines(&e, 100);
assert_eq!(lines[0], " Fix the thing");
assert_eq!(lines[1], " ");
assert_eq!(lines[2], " first para");
assert_eq!(lines[3], " ");
assert!(lines.len() > 6, "long paragraph should wrap: {lines:?}");
assert!(lines.iter().all(|l| l.chars().count() <= 100));
assert_eq!(lines.last().unwrap(), " ");
let expanded = BTreeSet::from([e.key.clone()]);
let rows = build_rows(&[e], 5, 6, &expanded, 100, &Spinners::new());
assert_eq!(rows.len(), 1 + lines.len() + 1);
}
#[test]
fn a_long_title_is_wrapped_in_full_to_the_given_width() {
let mut e = entry("D1", None);
e.title = "word ".repeat(30);
let lines = description_lines(&e, 40);
assert!(lines.len() > 4, "{lines:?}");
assert!(lines.iter().all(|l| l.chars().count() <= 40));
assert_eq!(
lines[..lines.len() - 1]
.join(" ")
.split_whitespace()
.count(),
30
);
}
#[test]
fn a_fetching_row_shows_a_spinner_instead_of_the_workspace_icon() {
let e = entry("D1", None);
let plain = row_label(&e, 5, 6, false, None, None).source().to_string();
let spin = Spin {
frame: 0,
cancelling: false,
};
let spinning = row_label(&e, 5, 6, false, None, Some(spin))
.source()
.to_string();
assert!(spinning.contains(SPINNER_FRAMES[0]));
assert!(!plain.contains(SPINNER_FRAMES[0]));
assert_eq!(plain.chars().count(), spinning.chars().count());
}
#[tokio::test]
async fn cancel_fetch_flags_only_a_review_that_is_being_fetched() {
let tmp = tempfile::tempdir().unwrap();
let mut siv = cursive::dummy();
siv.set_user_data(ctx_with_state(tmp.path(), vec![entry("D1", None)]));
let key = ReviewKey::new("moz", "D1");
let flag = Arc::new(AtomicBool::new(false));
siv.user_data::<Ctx>().unwrap().fetching.insert(
key.clone(),
Fetch {
cancelled: flag.clone(),
frame: 0,
deleting: false,
},
);
cancel_fetch(&mut siv, &ReviewKey::new("moz", "D2"));
assert!(!flag.load(Ordering::SeqCst));
cancel_fetch(&mut siv, &key);
assert!(flag.load(Ordering::SeqCst));
}
#[test]
fn h_and_l_move_between_dialog_buttons_without_touching_the_list() {
use cursive::views::DialogFocus;
let mut siv = cursive::dummy();
let mut select = SelectView::<Row>::new();
select.add_item("D1", Row::Entry(ReviewKey::new("moz", "D1")));
select.add_item("D2", Row::Entry(ReviewKey::new("moz", "D2")));
siv.add_layer(select.with_name("reviews"));
siv.add_global_callback('j', |s| move_selection(s, true));
siv.add_global_callback('l', |s| set_expanded(s, true));
siv.add_layer(
vim_keys(
Dialog::text("x")
.button("No", |_| {})
.button("Yes", |_| {})
.button("Always", |_| {}),
)
.with_name("dlg"),
);
let focus = |siv: &mut Cursive| {
siv.call_on_name("dlg", |v: &mut OnEventView<Dialog>| v.get_inner().focus())
.unwrap()
};
siv.runner().refresh(); assert_eq!(focus(&mut siv), DialogFocus::Button(0));
siv.on_event(Event::Char('l'));
assert_eq!(focus(&mut siv), DialogFocus::Button(1));
siv.on_event(Event::Char('l'));
assert_eq!(focus(&mut siv), DialogFocus::Button(2));
siv.on_event(Event::Char('h'));
assert_eq!(focus(&mut siv), DialogFocus::Button(1));
siv.on_event(Event::Char('j'));
assert!(matches!(selected_row(&mut siv), Row::Entry(k) if k.id == "D1"));
}
#[test]
fn a_review_without_a_description_still_shows_its_title() {
let mut e = entry("D1", None);
e.description = Some(" \n".into());
assert_eq!(
description_lines(&e, 100),
vec![" Fix the thing", " "]
);
}
#[test]
fn detail_lines_with_no_diff_stat_say_to_run_sync() {
let e = entry("D1", None);
let lines = detail_lines(&e);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("run `rq sync`"));
}
#[test]
fn detail_lines_with_an_empty_diff_stat_say_no_changes() {
let e = entry("D1", Some(""));
let lines = detail_lines(&e);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("no changes"));
}
#[test]
fn detail_lines_render_each_line_of_the_stored_diff_stat() {
let e = entry(
"D1",
Some("a.rs | 1 +\nb.rs | 2 ++\n2 files changed, 3 insertions(+)"),
);
let lines = detail_lines(&e);
assert_eq!(lines.len(), 3);
assert!(lines[0].contains("a.rs | 1 +"));
assert!(lines[1].contains("b.rs | 2 ++"));
assert!(lines[2].contains("2 files changed"));
}
fn selected_row(siv: &mut Cursive) -> Row {
(*siv
.call_on_name("reviews", |v: &mut SelectView<Row>| v.selection())
.flatten()
.unwrap())
.clone()
}
#[test]
fn move_selection_skips_detail_rows_in_both_directions() {
let mut siv = cursive::dummy();
let entries = vec![
entry("D1", Some("a.rs | 1 +\nb.rs | 2 ++")),
entry("D2", None),
];
let mut select = SelectView::<Row>::new();
let expanded = ReviewKey::new("moz", "D1");
for (label, row) in build_rows(
&entries,
5,
6,
&BTreeSet::from([expanded]),
100,
&Spinners::new(),
) {
select.add_item(label, row);
}
siv.add_layer(select.with_name("reviews"));
move_selection(&mut siv, true);
assert!(matches!(selected_row(&mut siv), Row::Entry(k) if k.id == "D2"));
move_selection(&mut siv, false);
assert!(matches!(selected_row(&mut siv), Row::Entry(k) if k.id == "D1"));
}
#[test]
fn move_selection_stops_at_the_last_row_even_if_it_is_a_detail_line() {
let mut siv = cursive::dummy();
let entries = vec![entry("D1", Some("a.rs | 1 +"))];
let mut select = SelectView::<Row>::new();
let expanded = ReviewKey::new("moz", "D1");
for (label, row) in build_rows(
&entries,
5,
6,
&BTreeSet::from([expanded]),
100,
&Spinners::new(),
) {
select.add_item(label, row);
}
siv.add_layer(select.with_name("reviews"));
move_selection(&mut siv, true); assert!(matches!(selected_row(&mut siv), Row::Detail(_)));
move_selection(&mut siv, true);
assert!(matches!(selected_row(&mut siv), Row::Detail(_)));
}
fn ctx_with_state(tmp: &std::path::Path, entries: Vec<ReviewEntry>) -> Ctx {
let paths = crate::paths::Paths::discover()
.unwrap()
.with_overrides(Some(tmp.join("data")));
let mut state = crate::state::State::default();
for e in entries {
state.insert(e);
}
state.save(&paths.state_file()).unwrap();
Ctx {
paths,
config: Config::default(),
config_path: PathBuf::new(),
sources: Arc::new(Vec::new()),
handle: tokio::runtime::Handle::current(),
all: true,
key_w: 5,
author_w: 6,
expanded: BTreeSet::new(),
pending_shell: None,
fetching: BTreeMap::new(),
}
}
#[tokio::test]
async fn set_expanded_is_directional_not_a_toggle() {
let tmp = tempfile::tempdir().unwrap();
let mut siv = cursive::dummy();
siv.set_user_data(ctx_with_state(tmp.path(), vec![entry("D1", None)]));
let mut select = SelectView::<Row>::new();
select.add_item("D1", Row::Entry(ReviewKey::new("moz", "D1")));
siv.add_layer(select.with_name("reviews"));
set_expanded(&mut siv, true);
set_expanded(&mut siv, true);
assert!(
siv.user_data::<Ctx>()
.unwrap()
.expanded
.contains(&ReviewKey::new("moz", "D1"))
);
set_expanded(&mut siv, false);
set_expanded(&mut siv, false);
assert!(
!siv.user_data::<Ctx>()
.unwrap()
.expanded
.contains(&ReviewKey::new("moz", "D1"))
);
}
#[tokio::test]
async fn set_expanded_targets_whichever_review_owns_the_selected_row() {
let tmp = tempfile::tempdir().unwrap();
let mut siv = cursive::dummy();
siv.set_user_data(ctx_with_state(
tmp.path(),
vec![entry("D1", Some("a.rs | 1 +")), entry("D2", None)],
));
let mut select = SelectView::<Row>::new();
select.add_item("D1", Row::Entry(ReviewKey::new("moz", "D1")));
select.add_item(" a.rs | 1 +", Row::Detail(ReviewKey::new("moz", "D1")));
select.add_item("D2", Row::Entry(ReviewKey::new("moz", "D2")));
select.set_selection(1);
siv.add_layer(select.with_name("reviews"));
set_expanded(&mut siv, true);
assert_eq!(
siv.user_data::<Ctx>().unwrap().expanded,
BTreeSet::from([ReviewKey::new("moz", "D1")]),
"the detail row's owning key (D1) should be expanded, not D2"
);
}
}