use std::sync::mpsc::{Receiver, TryRecvError};
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Cell, Paragraph, Row, Table, TableState};
use super::filter::{Filter, Handled};
use super::{Action, Context, Result, View};
use crate::domain::Repo;
use crate::repos::{self, Divergence, RepoStatus, Update};
struct Running {
job: repos::Job,
updates: Receiver<Update>,
}
pub struct ReposView {
repos: Vec<Repo>,
rows: Vec<Option<RepoStatus>>,
table: TableState,
visible: Vec<usize>,
filter: Filter,
running: Option<Running>,
loaded: bool,
}
impl Default for ReposView {
fn default() -> Self {
Self::new()
}
}
impl ReposView {
pub fn new() -> Self {
Self {
repos: Vec::new(),
rows: Vec::new(),
table: TableState::default().with_selected(Some(0)),
visible: Vec::new(),
filter: Filter::default(),
running: None,
loaded: false,
}
}
fn load(&mut self, ctx: &mut Context) -> Result<()> {
self.repos = ctx.store.list_repos(false)?;
self.rows = vec![None; self.repos.len()];
self.narrow();
self.loaded = true;
let last = self.repos.len().saturating_sub(1);
if self.table.selected().unwrap_or(0) > last {
self.table.select(Some(last));
}
self.start(repos::Job::Refresh(self.repos.clone()), ctx);
Ok(())
}
fn narrow(&mut self) {
self.visible = (0..self.repos.len())
.filter(|&i| {
let branch = self.rows[i]
.as_ref()
.and_then(|row| row.branch.clone())
.unwrap_or_default();
self.filter
.matches_any([self.repos[i].name.as_str(), branch.as_str()])
})
.collect();
let last = self.visible.len().saturating_sub(1);
if self.table.selected().unwrap_or(0) > last {
self.table.select(Some(last));
}
if self.table.selected().is_none() && !self.visible.is_empty() {
self.table.select(Some(0));
}
}
fn start(&mut self, job: repos::Job, ctx: &mut Context) {
if let Some(running) = &self.running {
ctx.say(format!("still {}", running.job.doing()));
return;
}
self.running = Some(Running {
updates: repos::spawn(job.clone()),
job,
});
}
fn drain(&mut self, ctx: &mut Context) {
let Some(running) = &self.running else {
return;
};
loop {
match running.updates.try_recv() {
Ok(Update::Row(row)) => {
if let Some(slot) = self
.repos
.iter()
.position(|repo| repo.id == row.repo_id)
.and_then(|index| self.rows.get_mut(index))
{
*slot = Some(*row);
}
}
Ok(Update::Says(message)) => ctx.say(message),
Ok(Update::Done) => break,
Err(TryRecvError::Empty) => return,
Err(TryRecvError::Disconnected) => break,
}
}
if let Some(running) = self.running.take()
&& let Some(message) = running.job.did()
{
ctx.say(message);
}
}
fn selected(&self) -> Option<&Repo> {
self.table
.selected()
.and_then(|i| self.visible.get(i))
.and_then(|&i| self.repos.get(i))
}
fn move_by(&mut self, delta: isize) {
if self.visible.is_empty() {
return;
}
let current = self.table.selected().unwrap_or(0) as isize;
let last = self.visible.len() as isize - 1;
self.table
.select(Some(current.saturating_add(delta).clamp(0, last) as usize));
}
fn row(&self, index: usize, repo: &Repo) -> Row<'static> {
let Some(status) = self.rows.get(index).and_then(Option::as_ref) else {
return Row::new(vec![
Cell::from(repo.name.clone()),
Cell::from(Span::styled(
"reading…",
Style::default().add_modifier(Modifier::DIM),
)),
]);
};
Row::new(vec![
Cell::from(status.name.clone()),
Cell::from(branch_cell(status)),
Cell::from(gap_cell(
status.upstream.as_ref(),
"untracked",
status.branch.as_deref(),
)),
Cell::from(gap_cell(status.default.as_ref(), "", None)),
Cell::from(changes_cell(status)),
])
}
}
impl View for ReposView {
fn title(&self) -> String {
let count = Filter::count_label(self.visible.len(), self.repos.len());
let filter = self.filter.label();
match &self.running {
Some(running) => format!("Repos ({count}) · {}{filter}", running.job.doing()),
None => format!("Repos ({count}){filter}"),
}
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &mut Context) {
if !self.loaded {
let _ = self.load(ctx);
}
self.drain(ctx);
if self.filter.is_active() {
self.narrow();
}
if self.repos.is_empty() {
frame.render_widget(
Paragraph::new(vec![
Line::from(""),
Line::from(" No repos found."),
Line::from(""),
Line::from(Span::styled(
" marver scans below --scan-root; check that it points at your repos.",
Style::default().add_modifier(Modifier::DIM),
)),
]),
area,
);
return;
}
let rows: Vec<Row> = self
.visible
.iter()
.map(|&index| self.row(index, &self.repos[index]))
.collect();
let table = Table::new(
rows,
[
Constraint::Percentage(22),
Constraint::Percentage(26),
Constraint::Length(12),
Constraint::Min(18),
Constraint::Length(10),
],
)
.header(
Row::new(vec!["repo", "branch", "upstream", "default", "changes"])
.style(Style::default().add_modifier(Modifier::BOLD | Modifier::DIM)),
)
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol("");
let [area, field] =
Layout::vertical([Constraint::Min(1), Constraint::Length(self.filter.height())])
.areas(area);
self.filter.render(frame, field);
frame.render_stateful_widget(table, area, &mut self.table);
}
fn handle_key(&mut self, key: KeyEvent, ctx: &mut Context) -> Result<Action> {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('q') {
return Ok(Action::Pop);
}
match self.filter.handle_key(key) {
Handled::Changed => {
self.narrow();
return Ok(Action::None);
}
Handled::Yes => return Ok(Action::None),
Handled::Leave => return Ok(Action::Pop),
Handled::No => {}
}
if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return Ok(match key.code {
KeyCode::Char('c') => Action::Pop,
_ => Action::None,
});
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(Action::Pop),
KeyCode::Char('j') | KeyCode::Down => self.move_by(1),
KeyCode::Char('k') | KeyCode::Up => self.move_by(-1),
KeyCode::Char('g') | KeyCode::Home => self.table.select(Some(0)),
KeyCode::Char('G') | KeyCode::End => {
self.table
.select(Some(self.visible.len().saturating_sub(1)));
}
KeyCode::Char('r') => match &self.running {
Some(running) => ctx.say(format!("still {}", running.job.doing())),
None => self.load(ctx)?,
},
KeyCode::Char('f') => {
if let Some(repo) = self.selected().cloned() {
self.start(repos::Job::Fetch(repo), ctx);
}
}
KeyCode::Char('p') => {
if let Some(repo) = self.selected().cloned() {
self.start(repos::Job::Pull(repo), ctx);
}
}
_ => {}
}
Ok(Action::None)
}
fn tick(&mut self, ctx: &mut Context) -> Result<()> {
self.drain(ctx);
Ok(())
}
fn keys(&self) -> Vec<(&'static str, &'static str)> {
vec![
("j/k", "move"),
("f", "fetch"),
("p", "pull"),
("/", "filter"),
("r", "refresh"),
("esc", "back"),
]
}
}
fn branch_cell(status: &RepoStatus) -> Line<'static> {
if let Some(trouble) = &status.trouble {
return Line::from(Span::styled(
trouble.clone(),
Style::default().fg(Color::Red),
));
}
match &status.branch {
Some(branch) => Line::from(branch.clone()),
None => Line::from(Span::styled(
"detached",
Style::default().add_modifier(Modifier::DIM),
)),
}
}
fn gap_cell(gap: Option<&Divergence>, missing: &str, namesake: Option<&str>) -> Line<'static> {
let dim = Style::default().add_modifier(Modifier::DIM);
let Some(gap) = gap else {
return Line::from(Span::styled(missing.to_string(), dim));
};
let reference = match namesake {
Some(branch) if mirrors(&gap.reference, branch) => String::new(),
_ => format!(" {}", short(&gap.reference)),
};
if gap.is_level() {
return Line::from(Span::styled(
if reference.is_empty() {
"up to date".to_string()
} else {
format!("={reference}")
},
dim,
));
}
let mut spans = Vec::new();
if gap.ahead > 0 {
spans.push(Span::styled(
format!("↑{}", gap.ahead),
Style::default().fg(Color::Cyan),
));
}
if gap.behind > 0 {
if !spans.is_empty() {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
format!("↓{}", gap.behind),
Style::default().fg(Color::Yellow),
));
}
if !reference.is_empty() {
spans.push(Span::styled(reference, dim));
}
Line::from(spans)
}
fn mirrors(reference: &str, branch: &str) -> bool {
let reference = short(reference);
reference == branch
|| reference
.rsplit_once('/')
.is_some_and(|(_, name)| name == branch)
}
fn short(reference: &str) -> String {
reference
.strip_prefix("refs/remotes/")
.or_else(|| reference.strip_prefix("refs/heads/"))
.unwrap_or(reference)
.to_string()
}
fn changes_cell(status: &RepoStatus) -> Line<'static> {
if status.trouble.is_some() {
return Line::from("");
}
match status.dirty {
0 => Line::from(Span::styled(
"clean",
Style::default().add_modifier(Modifier::DIM),
)),
1 => Line::from(Span::styled("1 file", Style::default().fg(Color::Yellow))),
n => Line::from(Span::styled(
format!("{n} files"),
Style::default().fg(Color::Yellow),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::testing::init_repo;
use crate::store::Store;
use crate::tui::testing::{press, render_view, tick_view, with_context};
use chrono::{TimeZone, Utc};
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn store_with(repos: &[&Path]) -> Store {
let store = Store::open_in_memory().unwrap();
let now = Utc.timestamp_opt(0, 0).unwrap();
for path in repos {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
store.upsert_repo(path, &name, now).unwrap();
}
store
}
fn settle(view: &mut ReposView, store: &mut Store) {
for _ in 0..200 {
tick_view(view, store);
if view.running.is_none() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
panic!("the worker never finished");
}
#[test]
fn every_scanned_repo_gets_a_row_with_its_branch() {
let tmp = TempDir::new().unwrap();
let one = tmp.path().join("api");
let two = tmp.path().join("web");
init_repo(&one, "main");
init_repo(&two, "trunk");
let mut store = store_with(&[&one, &two]);
let mut view = ReposView::new();
render_view(&mut view, &mut store, 90, 10);
settle(&mut view, &mut store);
let screen = render_view(&mut view, &mut store, 90, 10);
assert!(screen.iter().any(|l| l.contains("api")), "{screen:?}");
assert!(screen.iter().any(|l| l.contains("main")), "{screen:?}");
assert!(screen.iter().any(|l| l.contains("web")), "{screen:?}");
assert!(screen.iter().any(|l| l.contains("trunk")), "{screen:?}");
}
#[test]
fn rows_are_shown_before_they_have_been_read() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("api");
init_repo(&repo, "main");
let mut store = store_with(&[&repo]);
let mut view = ReposView::new();
let screen = render_view(&mut view, &mut store, 90, 10);
assert!(screen.iter().any(|l| l.contains("api")), "{screen:?}");
}
#[test]
fn the_count_of_uncommitted_files_reaches_the_screen() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("api");
init_repo(&repo, "main");
std::fs::write(repo.join("a.txt"), "one").unwrap();
std::fs::write(repo.join("b.txt"), "two").unwrap();
let mut store = store_with(&[&repo]);
let mut view = ReposView::new();
render_view(&mut view, &mut store, 90, 10);
settle(&mut view, &mut store);
let screen = render_view(&mut view, &mut store, 90, 10);
assert!(screen.iter().any(|l| l.contains("2 files")), "{screen:?}");
}
#[test]
fn being_behind_is_shown_once_a_fetch_has_found_out() {
let tmp = TempDir::new().unwrap();
let origin = tmp.path().join("origin");
init_repo(&origin, "main");
let clone = tmp.path().join("api");
let out = Command::new("git")
.args(["clone", "-q"])
.arg(&origin)
.arg(&clone)
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
std::fs::write(origin.join("theirs.txt"), "new").unwrap();
for args in [
vec!["add", "."],
vec![
"-c",
"user.email=t@t.invalid",
"-c",
"user.name=t",
"commit",
"-q",
"-m",
"theirs",
],
] {
let out = Command::new("git")
.arg("-C")
.arg(&origin)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
}
let mut store = store_with(&[&clone]);
let mut view = ReposView::new();
render_view(&mut view, &mut store, 90, 10);
settle(&mut view, &mut store);
let before = render_view(&mut view, &mut store, 90, 10);
assert!(
!before.iter().any(|l| l.contains("↓1")),
"nothing has been fetched yet: {before:?}"
);
press(&mut view, &mut store, key(KeyCode::Char('f')));
settle(&mut view, &mut store);
let after = render_view(&mut view, &mut store, 90, 10);
assert!(after.iter().any(|l| l.contains("↓1")), "{after:?}");
}
#[test]
fn a_second_job_is_refused_rather_than_queued() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("api");
init_repo(&repo, "main");
let mut store = store_with(&[&repo]);
let mut view = ReposView::new();
with_context(&mut store, |ctx| view.load(ctx).unwrap());
let running = view.running.as_ref().map(|r| r.job.doing());
let repo = store.list_repos(false).unwrap()[0].clone();
with_context(&mut store, |ctx| {
view.start(repos::Job::Fetch(repo), ctx);
});
assert_eq!(
view.running.as_ref().map(|r| r.job.doing()),
running,
"the first job must still be the one running"
);
settle(&mut view, &mut store);
}
fn text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
#[test]
fn a_branch_tracking_its_namesake_does_not_repeat_the_name() {
let level = Divergence {
reference: "origin/main".into(),
ahead: 0,
behind: 0,
};
assert_eq!(
text(&gap_cell(Some(&level), "", Some("main"))),
"up to date"
);
let drifted = Divergence {
reference: "origin/main".into(),
ahead: 2,
behind: 5,
};
assert_eq!(text(&gap_cell(Some(&drifted), "", Some("main"))), "↑2 ↓5");
}
#[test]
fn a_branch_tracking_something_else_says_what() {
let gap = Divergence {
reference: "origin/main".into(),
ahead: 0,
behind: 5,
};
assert_eq!(
text(&gap_cell(Some(&gap), "", Some("release-prep"))),
"↓5 origin/main"
);
assert_eq!(text(&gap_cell(Some(&gap), "", None)), "↓5 origin/main");
}
#[test]
fn slash_narrows_the_repos() {
let tmp = TempDir::new().unwrap();
let api = tmp.path().join("api");
let web = tmp.path().join("web");
init_repo(&api, "main");
init_repo(&web, "main");
let mut store = store_with(&[&api, &web]);
let mut view = ReposView::new();
render_view(&mut view, &mut store, 90, 10);
settle(&mut view, &mut store);
assert_eq!(view.visible.len(), 2);
press(&mut view, &mut store, key(KeyCode::Char('/')));
press(&mut view, &mut store, key(KeyCode::Char('w')));
assert_eq!(view.visible.len(), 1);
assert_eq!(view.selected().map(|r| r.name.clone()), Some("web".into()));
let screen = render_view(&mut view, &mut store, 90, 10);
assert!(!screen.join("\n").contains("api"), "{screen:?}");
}
#[test]
fn escape_goes_back() {
let mut store = store_with(&[]);
let mut view = ReposView::new();
assert!(matches!(
press(&mut view, &mut store, key(KeyCode::Esc)),
Action::Pop
));
}
#[test]
fn the_two_chords_that_leave_every_screen_leave_this_one() {
let mut store = store_with(&[]);
for chord in ['c', 'q'] {
let mut view = ReposView::new();
let key = KeyEvent::new(KeyCode::Char(chord), KeyModifiers::CONTROL);
assert!(
matches!(press(&mut view, &mut store, key), Action::Pop),
"ctrl-{chord} must leave"
);
}
}
#[test]
fn an_empty_scan_says_where_to_look_rather_than_showing_a_bare_table() {
let mut store = store_with(&[]);
let mut view = ReposView::new();
let screen = render_view(&mut view, &mut store, 90, 10);
assert!(
screen.iter().any(|l| l.contains("No repos found")),
"{screen:?}"
);
assert!(
screen.iter().any(|l| l.contains("scan-root")),
"and what to do about it: {screen:?}"
);
}
#[test]
fn a_repo_that_has_gone_is_flagged_in_place() {
let tmp = TempDir::new().unwrap();
let mut store = store_with(&[&tmp.path().join("was-here")]);
let mut view = ReposView::new();
render_view(&mut view, &mut store, 90, 10);
settle(&mut view, &mut store);
let screen = render_view(&mut view, &mut store, 90, 10);
assert!(
screen.iter().any(|l| l.contains("gone from disk")),
"{screen:?}"
);
}
}