Phases 0-1 + Sprint A: module split, CLI layer, org-refresh fix, hygiene

- LICENSE (MIT) + README truth-pass; docs/cli.md CLI contract
- CLI subcommands (src/cli_cmds/) per "TUI for users, CLI for users+AI"
- Hub/detail refactor: keymap dispatch, modal split, action enum, BgMsg bus
- Fix: org repos were never refreshed (owner==username match); refresh now
  parallel — 49 repos in ~11s, surfaced 70 previously invisible PRs
- Fix: 3 date bugs in detail/git.rs (format-local, Local::now, 1y month math)
- Stash indicator + [m] actions, session persistence, gh CLI token fallback
- Legacy UI modules removed; plan/ docs (roadmap, audit, debate, handoff)
This commit is contained in:
dualfroz committed 2026-07-06 14:42:52 +02:00
1 parent 5787b14880
commit 0468c032c6
100 files changed
+11614 -9840

No files matched your search

+512
View File
@@ -0,0 +1,512 @@
//! The hub's Overview tab: a bento-box dashboard (workspace info in columns,
//! general settings, a merged activity feed, then todos/PRs/issues)
//! summarizing the whole workspace at a glance, rather than listing
//! individual rows like the other tabs.
use crate::status::format_age_pub;
use crate::todos::TodoSource;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
use super::state::HubState;
use super::tabs::age_color;
/// Color used for an activity-kind label in the merged feed.
fn kind_color(kind: crate::activity::ActivityKind) -> Color {
use crate::activity::ActivityKind as K;
match kind {
K::Commit => Color::Green,
K::Push => Color::Cyan,
K::Pull => Color::Blue,
K::Release => Color::Magenta,
K::NewRepo => Color::Green,
K::Todo => Color::Yellow,
K::Pr => Color::Cyan,
K::Issue => Color::Yellow,
}
}
pub fn render_overview_global(f: &mut Frame, state: &HubState, area: Rect) {
use ratatui::widgets::{Block, Borders};
let dim = Style::default().fg(Color::DarkGray);
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let cyn = Style::default().fg(Color::Cyan);
let grn = Style::default().fg(Color::Green);
let red = Style::default().fg(Color::Red);
let wht = Style::default().fg(Color::White);
// ── Shared stats ─────────────────────────────────────────────────────────
let total_repos = state.repos.len();
let dirty_c = state.repos.iter().filter(|r| r.is_dirty).count();
let behind_c = state.repos.iter().filter(|r| r.behind > 0).count();
let ahead_c = state.repos.iter().filter(|r| r.ahead > 0).count();
let no_lic_c = state.repos.iter().filter(|r| !r.has_license).count();
let open_todos = state.todos.iter().filter(|t| matches!(t.status, crate::todos::TodoStatus::Open)).count();
let inprog_todos = state.todos.iter().filter(|t| matches!(t.status, crate::todos::TodoStatus::InProgress)).count();
let done_todos = state.todos.iter().filter(|t| matches!(t.status, crate::todos::TodoStatus::Done)).count();
let canc_todos = state.todos.iter().filter(|t| matches!(t.status, crate::todos::TodoStatus::Cancelled)).count();
// ── Bento rows: 36% / 38% / 26% ─────────────────────────────────────────
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(36), Constraint::Percentage(38), Constraint::Percentage(26)])
.split(area);
// ── Row 1: Workspace (75%) + General Settings (25%) ──────────────────────
let row1 = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(75), Constraint::Percentage(25)])
.split(rows[0]);
// Cell 1 — Workspace: header line, then three inner columns
let app_blk = Block::default().borders(Borders::ALL)
.title(Span::styled(" workspace ", yel))
.title_alignment(Alignment::Center)
.border_style(dim);
let app_inner = app_blk.inner(row1[0]);
f.render_widget(app_blk, row1[0]);
{
let version = env!("CARGO_PKG_VERSION");
let debug_flag = if state.debug_mode { " [debug]" } else { "" };
let ws = state.workspace_root.display().to_string();
let ws_short = if ws.starts_with("/home/") {
ws.splitn(4, '/').nth(3).map(|s| format!("~/{}", s)).unwrap_or(ws.clone())
} else { ws };
// Header line spans the whole tile, columns start below it.
let header_area = Rect { height: 1, ..app_inner };
f.render_widget(Paragraph::new(Line::from(vec![
Span::styled(format!(" vulp v{}{}", version, debug_flag), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled(" workspace ", dim),
Span::styled(ws_short, wht),
])), header_area);
let body = Rect {
y: app_inner.y + 2,
height: app_inner.height.saturating_sub(2),
..app_inner
};
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30), Constraint::Percentage(35), Constraint::Percentage(35),
])
.split(body);
// Col A — general counts, one stat per line, then health
{
let acct_names: std::collections::HashSet<&str> =
state.accounts.iter().map(|a| a.username.as_str()).collect();
let orgs: std::collections::HashSet<&str> = state.repos.iter()
.filter_map(|r| r.owner.as_deref())
.filter(|o| !acct_names.contains(o))
.collect();
let vulp_pushes = state.vulp_log.iter()
.filter(|e| e.kind == crate::activity::ActivityKind::Push)
.count();
let clean_c = total_repos.saturating_sub(dirty_c + behind_c);
let bar_total = 12usize;
let clean_n = if total_repos > 0 { bar_total * clean_c / total_repos } else { bar_total };
let dirty_n = if total_repos > 0 { bar_total * dirty_c / total_repos } else { 0 };
let behind_n = bar_total.saturating_sub(clean_n + dirty_n);
let health_bar = format!("{}{}{}",
"█".repeat(clean_n), "▓".repeat(dirty_n), "░".repeat(behind_n));
let health_col = if behind_c > 0 { Color::Red } else if dirty_c > 0 { Color::Yellow } else { Color::Green };
let stat = |label: &str, value: String, style: Style| Line::from(vec![
Span::styled(format!(" {:<14}", label), dim),
Span::styled(value, style),
]);
let commits_val = match &state.global_stats {
Some(gs) => format!("{} ({})", gs.total_commits, state.global_stats_period.label()),
None => "computing...".into(),
};
let lines: Vec<Line> = vec![
stat("Repositories", format!("{}", total_repos), wht),
stat("Projects", format!("{}", state.projects.len()), wht),
stat("Accounts", format!("{}", state.accounts.len()), wht),
stat("Organizations",format!("{}", orgs.len()), wht),
stat("Commits", commits_val, wht),
stat("Pushes (vulp)",format!("{}", vulp_pushes), wht),
stat("PRs", format!("{}", state.prs.len()), if state.prs.is_empty() { dim } else { cyn }),
stat("Issues", format!("{}", state.issues.len()), if state.issues.is_empty() { dim } else { Style::default().fg(Color::Yellow) }),
stat("Todos", format!("{}", open_todos), if open_todos == 0 { dim } else { cyn }),
Line::from(vec![
Span::styled(" Health ", dim),
Span::styled(health_bar, Style::default().fg(health_col)),
Span::styled(format!(" {}/{}", clean_c, total_repos), Style::default().fg(health_col)),
]),
Line::from(vec![
Span::styled(format!(" ↓{}", behind_c), if behind_c > 0 { red } else { dim }),
Span::styled(format!(" ⚠{}", dirty_c), if dirty_c > 0 { Style::default().fg(Color::Yellow) } else { dim }),
Span::styled(format!(" ↑{}", ahead_c), if ahead_c > 0 { grn } else { dim }),
Span::styled(format!(" no-lic {}", no_lic_c), if no_lic_c > 0 { Style::default().fg(Color::Yellow) } else { dim }),
]),
];
f.render_widget(Paragraph::new(lines), cols[0]);
}
// Col B — project list
{
let max_rows = (cols[1].height as usize).saturating_sub(1);
let name_w = (cols[1].width as usize).saturating_sub(8).max(5);
let mut lines: Vec<Line> = vec![Line::from(Span::styled(" projects", dim))];
if state.projects.is_empty() {
lines.push(Line::from(Span::styled(" no projects yet", dim)));
lines.push(Line::from(Span::styled(" [p] create one", dim)));
} else {
for (pi, p) in state.projects.iter().take(max_rows).enumerate() {
let repo_cnt = state.project_repos(pi).len();
let name = p.name.chars().take(name_w).collect::<String>();
lines.push(Line::from(vec![
Span::styled(" ● ", Style::default().fg(state.proj_color(pi))),
Span::styled(format!("{:<width$} ", name, width = name_w), wht),
Span::styled(format!("{} repo{}", repo_cnt, if repo_cnt == 1 { "" } else { "s" }), dim),
]));
}
}
f.render_widget(Paragraph::new(lines), cols[1]);
}
// Col C — top repos (and projects) by commits in the current period,
// as many as fit
{
let period = state.global_stats_period.label();
let name_w = (cols[2].width as usize).saturating_sub(10).max(5);
let mut lines: Vec<Line> = vec![Line::from(Span::styled(format!(" top repos ({}) by commits", period), dim))];
match &state.global_stats {
None => lines.push(Line::from(Span::styled(" computing...", dim))),
Some(gs) => {
if gs.repo_commits.is_empty() {
lines.push(Line::from(Span::styled(" no commits in period", dim)));
}
let height = cols[2].height as usize;
// Leave room for a top-projects section when projects exist.
let proj_section = if state.projects.is_empty() { 0 } else { 4 };
let repo_rows = height.saturating_sub(1 + proj_section).max(3);
for (name, cnt) in gs.repo_commits.iter().take(repo_rows) {
let n = name.chars().take(name_w).collect::<String>();
lines.push(Line::from(vec![
Span::styled(format!(" {:<width$} ", n, width = name_w), wht),
Span::styled(format!("{}", cnt), cyn),
]));
}
if !state.projects.is_empty() {
let commit_map: std::collections::HashMap<&str, usize> =
gs.repo_commits.iter().map(|(n, c)| (n.as_str(), *c)).collect();
let mut proj_commits: Vec<(&str, usize)> = state.projects.iter().enumerate()
.map(|(pi, p)| {
let sum = state.project_repos(pi).iter()
.filter_map(|&ri| state.repos.get(ri))
.filter_map(|r| commit_map.get(r.name.as_str()))
.sum();
(p.name.as_str(), sum)
})
.filter(|(_, c)| *c > 0)
.collect();
proj_commits.sort_by(|a, b| b.1.cmp(&a.1));
if !proj_commits.is_empty() {
lines.push(Line::from(Span::styled(format!(" top projects ({})", period), dim)));
for (name, cnt) in proj_commits.iter().take(3) {
let n = name.chars().take(name_w).collect::<String>();
lines.push(Line::from(vec![
Span::styled(format!(" {:<width$} ", n, width = name_w), wht),
Span::styled(format!("{}", cnt), cyn),
]));
}
}
}
}
}
f.render_widget(Paragraph::new(lines), cols[2]);
}
}
// Cell 2 — General Settings
let acc_blk = Block::default().borders(Borders::ALL)
.title(Span::styled(" general settings ", yel))
.title_alignment(Alignment::Center)
.border_style(dim);
let acc_inner = acc_blk.inner(row1[1]);
f.render_widget(acc_blk, row1[1]);
{
let mut lines: Vec<Line> = vec![];
let version = env!("CARGO_PKG_VERSION");
// Version + provenance. Prefer real release metadata; fall back to
// the compile-time build date when no GitHub release exists.
lines.push(Line::from(Span::styled(format!(" vulp v{}", version), cyn)));
if let Some(Ok(cur)) = &state.current_release {
let date = cur.published_at.split('T').next().unwrap_or_default();
lines.push(Line::from(vec![
Span::styled(" released ", dim),
Span::styled(date.to_string(), wht),
Span::styled(" by ", dim),
Span::styled(format!("@{}", cur.author), wht),
]));
} else {
lines.push(Line::from(vec![
Span::styled(" built ", dim),
Span::styled(env!("VULP_BUILD_DATE"), wht),
Span::styled(" by ", dim),
Span::styled(env!("CARGO_PKG_AUTHORS"), wht),
]));
}
match &state.latest_release {
Some(Ok(latest)) => {
if crate::release_info::is_outdated(version, &latest.tag) {
lines.push(Line::from(vec![
Span::styled(format!(" ↑ {} available", latest.tag), Style::default().fg(Color::Yellow)),
Span::styled(" — [v] notes", cyn),
]));
} else {
lines.push(Line::from(Span::styled(" ✓ no new updates available", grn)));
}
}
Some(Err(e)) => {
let msg = if e.contains("404") { "no releases published yet" } else { "update check failed (offline?)" };
lines.push(Line::from(Span::styled(format!(" {}", msg), dim)));
}
None => {
lines.push(Line::from(Span::styled(" checking for updates...", dim)));
}
}
lines.push(Line::from(""));
if state.accounts.is_empty() {
lines.push(Line::from(Span::styled(" no accounts", dim)));
lines.push(Line::from(Span::styled(" run: vulp auth import", Style::default().fg(Color::Cyan))));
} else {
for acc in &state.accounts {
let token_ok = crate::auth::get_token(&acc.id()).is_ok();
let def_mark = if acc.is_default { " ★" } else { "" };
lines.push(Line::from(vec![
Span::styled(" account ", dim),
Span::styled(format!("@{}{}", acc.username, def_mark), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
Span::styled(format!(" · {}", acc.provider), dim),
if token_ok { Span::styled(" ✓", grn) } else { Span::styled(" ✗ token", red) },
]));
let mut details: Vec<String> = Vec::new();
if let Some(since) = state.account_since.get(&acc.username) {
details.push(format!("since {}", since));
}
if let Some(added) = &acc.added_at {
details.push(format!("added to vulp {}", added));
}
if !details.is_empty() {
lines.push(Line::from(Span::styled(format!(" {}", details.join(" · ")), dim)));
}
}
}
f.render_widget(Paragraph::new(lines), acc_inner);
}
// ── Row 2: Recent Activity — merged feed: latest git commit per repo +
// vulp-performed actions (push/pull/release/new repo/todo/PR/issue) ─────
let act_blk = Block::default().borders(Borders::ALL)
.title(Span::styled(" recent activity ", yel))
.title_alignment(Alignment::Center)
.border_style(dim);
let act_inner = act_blk.inner(rows[1]);
f.render_widget(act_blk, rows[1]);
{
use crate::activity::ActivityKind;
struct FeedRow<'a> { ts: i64, kind: ActivityKind, repo: &'a str, detail: &'a str }
let mut feed: Vec<FeedRow> = state.repos.iter()
.filter(|r| r.last_commit_ts > 0)
.map(|r| FeedRow { ts: r.last_commit_ts, kind: ActivityKind::Commit, repo: &r.name, detail: &r.last_commit_msg })
.collect();
// Commits come from git itself (authoritative, sees work done outside
// vulp too) — take everything else from vulp's own action log.
feed.extend(state.vulp_log.iter()
.filter(|e| e.kind != ActivityKind::Commit)
.map(|e| FeedRow { ts: e.ts, kind: e.kind, repo: &e.repo, detail: &e.detail }));
feed.sort_by(|a, b| b.ts.cmp(&a.ts));
let max_rows = act_inner.height as usize;
let age_w = 12usize;
let kind_w = 10usize;
let name_w = feed.iter().take(max_rows)
.map(|r| r.repo.chars().count())
.max()
.unwrap_or(8)
.min((act_inner.width as usize) * 2 / 5)
.max(8);
let msg_w = (act_inner.width as usize).saturating_sub(age_w + kind_w + name_w + 4).max(5);
let mut lines: Vec<Line> = feed.iter().take(max_rows).map(|r| {
let name = r.repo.chars().take(name_w).collect::<String>();
let detail = r.detail.chars().take(msg_w).collect::<String>();
Line::from(vec![
Span::styled(format!(" {:<width$} ", format_age_pub(r.ts), width = age_w), Style::default().fg(age_color(r.ts))),
Span::styled(format!("{:<width$} ", r.kind.label(), width = kind_w), Style::default().fg(kind_color(r.kind))),
Span::styled(format!("{:<width$} ", name, width = name_w), wht),
Span::styled(detail, dim),
])
}).collect();
if lines.is_empty() {
lines.push(Line::from(Span::styled(" no activity yet", dim)));
}
f.render_widget(Paragraph::new(lines), act_inner);
}
// ── Row 3: Todos (33%) + PRs (33%) + Issues (34%) ────────────────────────
let row3 = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33), Constraint::Percentage(33), Constraint::Percentage(34),
])
.split(rows[2]);
// Todos — stacked status counts, then most recent todos
let todo_blk = Block::default().borders(Borders::ALL)
.title(Span::styled(" todos ", yel))
.title_alignment(Alignment::Center)
.border_style(dim);
let todo_inner = todo_blk.inner(row3[0]);
f.render_widget(todo_blk, row3[0]);
{
let name_w = (todo_inner.width as usize).saturating_sub(4).max(5);
let mut lines = vec![
Line::from(vec![
Span::styled(format!(" {:>3} ", open_todos), cyn),
Span::styled("open", dim),
]),
Line::from(vec![
Span::styled(format!(" {:>3} ", inprog_todos), Style::default().fg(Color::Yellow)),
Span::styled("in progress", dim),
]),
Line::from(vec![
Span::styled(format!(" {:>3} ", done_todos), grn),
Span::styled("done", dim),
]),
Line::from(vec![
Span::styled(format!(" {:>3} ", canc_todos), dim),
Span::styled("cancelled", dim),
]),
];
let mut recent: Vec<&crate::todos::Todo> = state.todos.iter()
.filter(|t| t.source == TodoSource::Manual)
.collect();
recent.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
let slots = (todo_inner.height as usize).saturating_sub(lines.len() + 1);
if slots > 0 && !recent.is_empty() {
lines.push(Line::from(""));
for t in recent.iter().take(slots) {
let icon = t.status.icon();
let title = t.title.chars().take(name_w).collect::<String>();
lines.push(Line::from(vec![
Span::styled(format!(" {} ", icon), Style::default().fg(t.status.color())),
Span::styled(title, wht),
]));
}
}
f.render_widget(Paragraph::new(lines), todo_inner);
}
// PRs — status breakdown (incl. merged/closed from the search API when
// refreshed), then recent titles
let pr_blk = Block::default().borders(Borders::ALL)
.title(Span::styled(" prs ", cyn))
.title_alignment(Alignment::Center)
.border_style(dim);
let pr_inner = pr_blk.inner(row3[1]);
f.render_widget(pr_blk, row3[1]);
{
let title_w = (pr_inner.width as usize).saturating_sub(9).max(6);
let ready_c = state.prs.iter().filter(|p| !p.draft).count();
let draft_c = state.prs.iter().filter(|p| p.draft).count();
let mut lines = vec![
Line::from(vec![
Span::styled(format!(" {:>3} ", ready_c), if ready_c == 0 { dim } else { cyn }),
Span::styled("open", dim),
]),
Line::from(vec![
Span::styled(format!(" {:>3} ", draft_c), if draft_c == 0 { dim } else { Style::default().fg(Color::Yellow) }),
Span::styled("draft", dim),
]),
];
match state.closed_counts {
Some((merged, closed, _)) => {
lines.push(Line::from(vec![
Span::styled(format!(" {:>3} ", merged), if merged == 0 { dim } else { Style::default().fg(Color::Magenta) }),
Span::styled("merged", dim),
]));
lines.push(Line::from(vec![
Span::styled(format!(" {:>3} ", closed), dim),
Span::styled("closed", dim),
]));
}
None => lines.push(Line::from(Span::styled(" – merged/closed — [R] refresh", dim))),
}
let mut sorted: Vec<&crate::providers::PrDetail> = state.prs.iter().collect();
sorted.sort_by(|a, b| b.created_ts.cmp(&a.created_ts));
let slots = (pr_inner.height as usize).saturating_sub(lines.len() + 1);
if slots > 0 && !sorted.is_empty() {
lines.push(Line::from(""));
for pr in sorted.iter().take(slots) {
let title = pr.title.chars().take(title_w).collect::<String>();
lines.push(Line::from(vec![
Span::styled(format!(" #{:<4} ", pr.number), cyn),
Span::styled(title, wht),
]));
}
}
f.render_widget(Paragraph::new(lines), pr_inner);
}
// Issues — status breakdown, then recent titles
let iss_yel = Style::default().fg(Color::Yellow);
let iss_blk = Block::default().borders(Borders::ALL)
.title(Span::styled(" issues ", iss_yel))
.title_alignment(Alignment::Center)
.border_style(dim);
let iss_inner = iss_blk.inner(row3[2]);
f.render_widget(iss_blk, row3[2]);
{
let title_w = (iss_inner.width as usize).saturating_sub(9).max(6);
let unanswered_c = state.issues.iter().filter(|i| i.comments == 0).count();
let mut lines = vec![
Line::from(vec![
Span::styled(format!(" {:>3} ", state.issues.len()), if state.issues.is_empty() { dim } else { iss_yel }),
Span::styled("open", dim),
]),
Line::from(vec![
Span::styled(format!(" {:>3} ", unanswered_c), if unanswered_c == 0 { dim } else { red }),
Span::styled("unanswered", dim),
]),
];
match state.closed_counts {
Some((_, _, closed_iss)) => {
lines.push(Line::from(vec![
Span::styled(format!(" {:>3} ", closed_iss), if closed_iss == 0 { dim } else { grn }),
Span::styled("closed", dim),
]));
}
None => lines.push(Line::from(Span::styled(" – closed — [R] refresh", dim))),
}
let mut sorted: Vec<&crate::providers::IssueDetail> = state.issues.iter().collect();
sorted.sort_by(|a, b| b.created_ts.cmp(&a.created_ts));
let slots = (iss_inner.height as usize).saturating_sub(lines.len() + 1);
if slots > 0 && !sorted.is_empty() {
lines.push(Line::from(""));
for iss in sorted.iter().take(slots) {
let title = iss.title.chars().take(title_w).collect::<String>();
lines.push(Line::from(vec![
Span::styled(format!(" #{:<4} ", iss.number), iss_yel),
Span::styled(title, wht),
]));
}
}
f.render_widget(Paragraph::new(lines), iss_inner);
}
}