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:
1 parent
5787b14880
commit
0468c032c6
100 files changed
+11614
-9840
No files matched your search
@@ -0,0 +1,268 @@
|
||||
//! 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", "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 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(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(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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user