Sprint E, redesigned from the original per-repo-metadata plan: the deploy host already mirrors workspace_root 1:1 (documented convention), so instead of maintaining a repo->host/compose-path mapping by hand, vulp refresh runs one batched remote command (via a configurable ssh wrapper) listing every mirrored repo's HEAD sha and diffs it against local HEAD. - src/deploy.rs: DeployState (InSync/Behind(n)/Diverged), scan_deploy_heads (one remote round-trip, retried once for this VPS's known SSH flakiness), drift_for (path relative to workspace_root -> host lookup -> local git rev-list --count) - config: deploy_ssh_cmd + deploy_root, both empty by default (feature is fully inert, no SSH attempted, until configured) - cache: RepoMeta.deploy_status, folded into the existing refresh loop (TUI [R] and CLI) alongside CI/dependabot rather than a second pass - table: DEPLOY column (checkmark/behind-count/diverged/dash); CLI list --stale-deploy and deploy_status in --json - verified against the real VPS: 37/49 repos matched to a mirror (30 in sync, 7 behind 1-4 commits - a real signal), everything else correctly shows unset (no mirror there, e.g. vulp itself, a pure OSS dev tool) - set deploy_ssh_cmd/deploy_root in the live config so this is active now, not just theoretically configurable
297 lines
16 KiB
Rust
297 lines
16 KiB
Rust
//! Table renderers for the hub's list-style tabs (Repos, Projects, Todos,
|
|
//! PRs, Issues) — one row per visible `DisplayRow`, scrolled by
|
|
//! `HubState::offset`. The full-screen tabs (Overview, Stats, Activity,
|
|
//! HubSettings) have their own renderers in sibling `tabs_*` files.
|
|
|
|
use crate::status::format_age_pub;
|
|
use crate::todos::TodoSource;
|
|
use ratatui::{
|
|
layout::{Constraint, Rect},
|
|
style::{Color, Modifier, Style},
|
|
text::{Line, Span},
|
|
widgets::{Cell, Row, Table},
|
|
Frame,
|
|
};
|
|
use super::state::{DisplayRow, HubState};
|
|
use super::tabs::age_color;
|
|
|
|
pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
|
let avail = area.height.saturating_sub(1) as usize;
|
|
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
|
|
|
let header = Row::new(vec![
|
|
" ", "NAME", "BRANCH", "PROJECT", "VISIB", "AGE", "STATUS", "RELEASE", "LICENSE", "CI", "ALERT", "DEPLOY", "STARS",
|
|
]).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
|
|
|
let rows: Vec<Row> = state.display.iter().enumerate()
|
|
.skip(state.offset).take(avail)
|
|
.filter_map(|(di, row)| {
|
|
let DisplayRow::Repo { repo_idx: ri } = row else { return None; };
|
|
let r = &state.repos[*ri];
|
|
let is_cur = di == cur_di;
|
|
let is_sel = state.selected.contains(ri);
|
|
|
|
let check_sty = if is_sel { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) };
|
|
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
|
|
|
let proj_name = state.repo_project(*ri).map(|p| p.name.as_str()).unwrap_or("—");
|
|
let proj_color = state.repo_proj_color(*ri).unwrap_or(Color::DarkGray);
|
|
|
|
let vis = r.visibility.as_deref().unwrap_or("?");
|
|
let rel = r.last_tag.as_deref().unwrap_or("—");
|
|
let now_ts = chrono::Utc::now().timestamp();
|
|
let is_stale = (now_ts - r.last_commit_ts) / 86400 > 90;
|
|
let d_sym = if r.is_dirty { "⚠" } else if is_stale { "~" } else { "·" };
|
|
let a_str = if r.ahead > 0 { format!("+{}", r.ahead) } else { String::new() };
|
|
let b_str = if r.behind > 0 { format!("-{}", r.behind) } else { String::new() };
|
|
let status_str = format!("{} {}{}", d_sym, a_str, b_str);
|
|
let name_col = if is_cur || (!r.is_dirty && r.ahead == 0 && r.behind == 0) {
|
|
Color::White
|
|
} else {
|
|
Color::Yellow
|
|
};
|
|
let lic_str = if r.has_license { "✓" } else { "—" };
|
|
let (ci_str, ci_col) = match r.ci_status.as_deref() {
|
|
Some("success") => ("✓", Color::Green),
|
|
Some("failure") => ("✗", Color::Red),
|
|
Some("running") => ("●", Color::Yellow),
|
|
_ => ("—", Color::DarkGray),
|
|
};
|
|
let dep_str = match r.dependabot_open {
|
|
Some(0) => "0".to_string(),
|
|
Some(n) => format!("{}", n),
|
|
None => "—".into(),
|
|
};
|
|
let dep_col = match r.dependabot_open {
|
|
Some(0) | None => Color::DarkGray,
|
|
Some(_) => Color::Red,
|
|
};
|
|
let deploy_state = r.deploy_status.as_deref().and_then(crate::deploy::DeployState::parse);
|
|
let (deploy_str, deploy_col) = match deploy_state {
|
|
Some(crate::deploy::DeployState::InSync) => ("✓".to_string(), Color::Green),
|
|
Some(crate::deploy::DeployState::Behind(n)) => (format!("-{n}"), Color::Yellow),
|
|
Some(crate::deploy::DeployState::Diverged) => ("≠".to_string(), Color::Red),
|
|
None => ("—".to_string(), Color::DarkGray),
|
|
};
|
|
let stars_str = r.stars.map(|s| format!("{}", s)).unwrap_or_else(|| "—".into());
|
|
let stars_col = if r.stars.is_some() { Color::Yellow } else { Color::DarkGray };
|
|
let is_pinned = state.pins.contains(&r.path);
|
|
let prefix = match (is_pinned, r.bookmarked) {
|
|
(true, true) => "◆★",
|
|
(true, false) => "◆ ",
|
|
(false, true) => "★ ",
|
|
(false, false)=> " ",
|
|
};
|
|
|
|
Some(Row::new(vec![
|
|
Cell::from(format!("{} {}", if is_cur { "▶" } else { " " }, if is_sel { "[✓]" } else { "[ ]" })).style(check_sty),
|
|
Cell::from(format!("{}{}", prefix, r.name)).style(Style::default().fg(name_col)),
|
|
Cell::from(r.branch.chars().take(9).collect::<String>()),
|
|
Cell::from(proj_name.chars().take(21).collect::<String>()).style(Style::default().fg(proj_color)),
|
|
Cell::from(vis).style(match vis { "public" => Style::default().fg(Color::Green), "private" => Style::default().fg(Color::Yellow), _ => Style::default().fg(Color::DarkGray) }),
|
|
Cell::from(r.last_commit_age.clone()).style(Style::default().fg(age_color(r.last_commit_ts))),
|
|
Cell::from(status_str).style(if r.is_dirty || r.behind > 0 { Style::default().fg(Color::Yellow) } else if is_stale { Style::default().fg(Color::Magenta).add_modifier(Modifier::DIM) } else { Style::default().fg(Color::DarkGray) }),
|
|
Cell::from(rel.chars().take(7).collect::<String>()).style(Style::default().fg(Color::Cyan)),
|
|
Cell::from(lic_str).style(Style::default().fg(if r.has_license { Color::Green } else { Color::DarkGray })),
|
|
Cell::from(ci_str).style(Style::default().fg(ci_col)),
|
|
Cell::from(dep_str).style(Style::default().fg(dep_col)),
|
|
Cell::from(deploy_str).style(Style::default().fg(deploy_col)),
|
|
Cell::from(stars_str).style(Style::default().fg(stars_col)),
|
|
]).style(row_sty))
|
|
})
|
|
.collect();
|
|
|
|
let widths = [
|
|
Constraint::Length(6),
|
|
Constraint::Fill(1),
|
|
Constraint::Length(10),
|
|
Constraint::Length(22),
|
|
Constraint::Length(9),
|
|
Constraint::Length(9),
|
|
Constraint::Length(9),
|
|
Constraint::Length(10),
|
|
Constraint::Length(9),
|
|
Constraint::Length(4),
|
|
Constraint::Length(6),
|
|
Constraint::Length(7),
|
|
Constraint::Length(8),
|
|
];
|
|
f.render_widget(Table::new(rows, widths).header(header), area);
|
|
}
|
|
|
|
pub fn render_projects_table(f: &mut Frame, state: &HubState, area: Rect) {
|
|
let avail = area.height.saturating_sub(1) as usize;
|
|
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
|
|
|
let header = Row::new(vec![" ", "PROJECT", "COLOR", "REPOS", "DIRTY", "BEHIND"])
|
|
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
|
|
|
let rows: Vec<Row> = state.display.iter().enumerate()
|
|
.skip(state.offset).take(avail)
|
|
.filter_map(|(di, row)| {
|
|
let DisplayRow::Project { proj_idx: pi } = row else { return None; };
|
|
let p = &state.projects[*pi];
|
|
let all_ri = state.project_repos(*pi);
|
|
let dirty_n = all_ri.iter().filter(|&&ri| state.repos[ri].is_dirty).count();
|
|
let behind_n = all_ri.iter().filter(|&&ri| state.repos[ri].behind > 0).count();
|
|
let all_sel = !all_ri.is_empty() && all_ri.iter().all(|ri| state.selected.contains(ri));
|
|
let any_sel = all_ri.iter().any(|ri| state.selected.contains(ri));
|
|
let is_cur = di == cur_di;
|
|
let color = state.proj_color(*pi);
|
|
|
|
let check_sty = if all_sel || any_sel { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) };
|
|
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else if behind_n > 0 { Style::default().fg(Color::Red) } else if dirty_n > 0 { Style::default().fg(Color::Yellow) } else { Style::default() };
|
|
|
|
Some(Row::new(vec![
|
|
Cell::from(format!("{} {}", if is_cur { "▶" } else { " " }, if all_sel { "[✓]" } else if any_sel { "[◈]" } else { "[ ]" })).style(check_sty),
|
|
Cell::from(p.name.clone()).style(Style::default().fg(color).add_modifier(Modifier::BOLD)),
|
|
Cell::from(Line::from(vec![
|
|
Span::styled("██ ", Style::default().fg(color)),
|
|
Span::styled(p.color.clone(), Style::default().fg(color)),
|
|
])),
|
|
Cell::from(all_ri.len().to_string()),
|
|
Cell::from(if dirty_n > 0 { dirty_n.to_string() } else { "·".into() }).style(if dirty_n > 0 { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::DarkGray) }),
|
|
Cell::from(if behind_n > 0 { behind_n.to_string() } else { "·".into() }).style(if behind_n > 0 { Style::default().fg(Color::Red) } else { Style::default().fg(Color::DarkGray) }),
|
|
]).style(row_sty))
|
|
})
|
|
.collect();
|
|
|
|
f.render_widget(Table::new(rows, [Constraint::Length(6), Constraint::Fill(1), Constraint::Length(9), Constraint::Length(7), Constraint::Length(6), Constraint::Length(7)]).header(header), area);
|
|
}
|
|
|
|
pub fn render_todos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
|
let avail = area.height.saturating_sub(1) as usize;
|
|
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
|
|
|
let header = Row::new(vec![" SRC", " STATUS", "TITLE", "REPO", "PROJECT", "EST", "DIFF", "CREATED"])
|
|
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
|
|
|
let rows: Vec<Row> = state.display.iter().enumerate()
|
|
.skip(state.offset).take(avail)
|
|
.filter_map(|(di, row)| {
|
|
let DisplayRow::Todo { todo_idx: ti } = row else { return None; };
|
|
let t = &state.todos[*ti];
|
|
let is_cur = di == cur_di;
|
|
let status_color = t.status.color();
|
|
let proj_name = t.project.as_deref().unwrap_or("—");
|
|
let repo_name = t.repo.as_deref().unwrap_or("—");
|
|
let created = format_age_pub(t.created_at);
|
|
let est_str = t.estimate.as_deref().unwrap_or("—").chars().take(10).collect::<String>();
|
|
let diff_str = match t.difficulty {
|
|
Some(d) => format!("{}{}", "★".repeat(d.min(5) as usize), "☆".repeat(5usize.saturating_sub(d as usize))),
|
|
None => "—".into(),
|
|
};
|
|
let title_short: String = if t.source == TodoSource::CodeScan {
|
|
let raw = &t.title;
|
|
let trimmed = if let Some(end) = raw.find(']') { raw[end+1..].trim() } else { raw.as_str() };
|
|
trimmed.chars().take(55).collect()
|
|
} else {
|
|
t.title.chars().take(55).collect()
|
|
};
|
|
let src_icon = t.source.icon();
|
|
let src_col = match t.source { TodoSource::Manual => Color::Cyan, TodoSource::CodeScan => Color::DarkGray };
|
|
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
|
Some(Row::new(vec![
|
|
Cell::from(format!(" {}", src_icon)).style(Style::default().fg(src_col)),
|
|
Cell::from(format!(" {} {}", t.status.icon(), t.status.label()))
|
|
.style(Style::default().fg(status_color)),
|
|
Cell::from(title_short),
|
|
Cell::from(repo_name).style(Style::default().fg(Color::DarkGray)),
|
|
Cell::from(proj_name).style(Style::default().fg(Color::DarkGray)),
|
|
Cell::from(est_str).style(Style::default().fg(Color::DarkGray)),
|
|
Cell::from(diff_str).style(Style::default().fg(Color::Yellow)),
|
|
Cell::from(created).style(Style::default().fg(Color::DarkGray)),
|
|
]).style(row_sty))
|
|
})
|
|
.collect();
|
|
|
|
f.render_widget(
|
|
Table::new(rows, [
|
|
Constraint::Length(4),
|
|
Constraint::Length(14),
|
|
Constraint::Fill(1),
|
|
Constraint::Length(12),
|
|
Constraint::Length(12),
|
|
Constraint::Length(10),
|
|
Constraint::Length(7),
|
|
Constraint::Length(10),
|
|
]).header(header),
|
|
area,
|
|
);
|
|
}
|
|
|
|
pub fn render_prs_table(f: &mut Frame, state: &HubState, area: Rect) {
|
|
let avail = area.height.saturating_sub(1) as usize;
|
|
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
|
|
|
let header = Row::new(vec![" REPO", "#", "AGE", "AUTHOR", "TITLE", "DRAFT"])
|
|
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
|
|
|
let rows: Vec<Row> = state.display.iter().enumerate()
|
|
.skip(state.offset).take(avail)
|
|
.filter_map(|(di, row)| {
|
|
let DisplayRow::Pr { pr_idx } = row else { return None; };
|
|
let pr = &state.prs[*pr_idx];
|
|
let is_cur = di == cur_di;
|
|
let age = format_age_pub(pr.updated_ts);
|
|
let draft_s = if pr.draft { "draft" } else { "" };
|
|
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
|
Some(Row::new(vec![
|
|
Cell::from(format!(" {}", pr.repo.chars().take(14).collect::<String>()))
|
|
.style(Style::default().fg(Color::Cyan)),
|
|
Cell::from(format!("#{}", pr.number)).style(Style::default().fg(Color::DarkGray)),
|
|
Cell::from(age).style(Style::default().fg(Color::DarkGray)),
|
|
Cell::from(format!("@{}", pr.author.chars().take(12).collect::<String>()))
|
|
.style(Style::default().fg(Color::Green)),
|
|
Cell::from(pr.title.chars().take(60).collect::<String>()),
|
|
Cell::from(draft_s).style(Style::default().fg(Color::DarkGray)),
|
|
]).style(row_sty))
|
|
})
|
|
.collect();
|
|
|
|
f.render_widget(
|
|
Table::new(rows, [
|
|
Constraint::Length(16), Constraint::Length(7), Constraint::Length(10),
|
|
Constraint::Length(14), Constraint::Fill(1), Constraint::Length(6),
|
|
]).header(header),
|
|
area,
|
|
);
|
|
}
|
|
|
|
pub fn render_issues_table(f: &mut Frame, state: &HubState, area: Rect) {
|
|
let avail = area.height.saturating_sub(1) as usize;
|
|
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
|
|
|
let header = Row::new(vec![" REPO", "#", "AGE", "AUTHOR", "TITLE", "CMT"])
|
|
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
|
|
|
let rows: Vec<Row> = state.display.iter().enumerate()
|
|
.skip(state.offset).take(avail)
|
|
.filter_map(|(di, row)| {
|
|
let DisplayRow::Issue { issue_idx } = row else { return None; };
|
|
let issue = &state.issues[*issue_idx];
|
|
let is_cur = di == cur_di;
|
|
let age = format_age_pub(issue.updated_ts);
|
|
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
|
Some(Row::new(vec![
|
|
Cell::from(format!(" {}", issue.repo.chars().take(14).collect::<String>()))
|
|
.style(Style::default().fg(Color::Cyan)),
|
|
Cell::from(format!("#{}", issue.number)).style(Style::default().fg(Color::DarkGray)),
|
|
Cell::from(age).style(Style::default().fg(Color::DarkGray)),
|
|
Cell::from(format!("@{}", issue.author.chars().take(12).collect::<String>()))
|
|
.style(Style::default().fg(Color::Yellow)),
|
|
Cell::from(issue.title.chars().take(60).collect::<String>()),
|
|
Cell::from(issue.comments.to_string()).style(Style::default().fg(Color::DarkGray)),
|
|
]).style(row_sty))
|
|
})
|
|
.collect();
|
|
|
|
f.render_widget(
|
|
Table::new(rows, [
|
|
Constraint::Length(16), Constraint::Length(7), Constraint::Length(10),
|
|
Constraint::Length(14), Constraint::Fill(1), Constraint::Length(5),
|
|
]).header(header),
|
|
area,
|
|
);
|
|
}
|