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

+514
View File
@@ -0,0 +1,514 @@
//! Rendering for the project detail sub-view (`project_detail`): top/bottom
//! bars, the six tabs (Overview, Commits, Repos, Stats, Todos, Settings),
//! and the rename/color-picker modal overlays.
use super::project_detail::{AggCommit, ProjectTab, St, PROJ_PALETTE};
use crate::todos::TodoStatus;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Paragraph, Row, Table},
Frame,
};
pub fn draw(f: &mut Frame, st: &St) {
let area = f.area();
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(2)])
.split(area);
render_topbar(f, st, vert[0]);
let blk = Block::default().borders(Borders::ALL);
let inner = blk.inner(vert[1]);
f.render_widget(blk, vert[1]);
match st.tab {
ProjectTab::Overview => render_overview(f, st, inner),
ProjectTab::Commits => render_commits(f, st, inner),
ProjectTab::Repos => render_repos(f, st, inner),
ProjectTab::Stats => render_stats(f, st, inner),
ProjectTab::Todos => render_todos(f, st, inner),
ProjectTab::Settings => render_settings(f, st, inner),
}
render_bottombar(f, st, vert[2]);
// Modal overlays
if let Some(ref input) = st.rename_input {
draw_text_modal(f, area, "Rename Project", input, "Enter new name | Esc cancel");
}
if let Some(cursor) = st.color_cursor {
draw_color_pick_modal(f, area, cursor, &st.project.color);
}
}
fn render_topbar(f: &mut Frame, st: &St, area: Rect) {
let (pr, pg, pb) = crate::projects::hex_to_rgb(&st.project.color);
let proj_col = Color::Rgb(pr, pg, pb);
let dim = Style::default().fg(Color::DarkGray);
let tab_s = |label: &str, on: bool| -> Span<'static> {
let label = label.to_string();
if on { Span::styled(format!(" {} ", label), Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)) }
else { Span::styled(format!(" {} ", label), dim) }
};
let dirty_c = st.repos.iter().filter(|r| r.is_dirty).count();
let behind_c = st.repos.iter().filter(|r| r.behind > 0).count();
let open_t = st.todos.iter().filter(|t| matches!(t.status, TodoStatus::Open)).count();
let (health_icon, health_col) = if behind_c > 0 { ("↓", Color::Red) }
else if dirty_c > 0 { ("⚠", Color::Yellow) }
else { ("✓", Color::Green) };
let mut spans = vec![
Span::styled(" ◀ ", dim),
Span::styled("■ ", Style::default().fg(proj_col)),
Span::styled(st.project.name.clone(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
Span::styled(
format!(" · {} repo{} ", st.repos.len(), if st.repos.len() == 1 { "" } else { "s" }),
dim,
),
tab_s("1 Overview", st.tab == ProjectTab::Overview),
Span::raw(" "),
tab_s("2 Commits", st.tab == ProjectTab::Commits),
Span::raw(" "),
tab_s("3 Repos", st.tab == ProjectTab::Repos),
Span::raw(" "),
tab_s("4 Stats", st.tab == ProjectTab::Stats),
Span::raw(" "),
tab_s("5 Todos", st.tab == ProjectTab::Todos),
Span::raw(" "),
tab_s("6 Settings", st.tab == ProjectTab::Settings),
Span::styled(format!(" {} ", health_icon), Style::default().fg(health_col)),
];
if open_t > 0 {
spans.push(Span::styled(
format!("{} todo{}", open_t, if open_t == 1 { "" } else { "s" }),
Style::default().fg(Color::Cyan),
));
}
if let Some(ref msg) = st.status_msg {
spans.push(Span::styled(format!(" {}", msg), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)));
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn render_bottombar(f: &mut Frame, st: &St, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let key = |k: &str| Span::styled(format!("[{}] ", k), dim);
let hint = match st.tab {
ProjectTab::Repos => Line::from(vec![key("↑↓"), Span::styled("select ", dim), key("Enter"), Span::styled("open repo detail", dim)]),
ProjectTab::Todos => Line::from(vec![key("↑↓"), Span::styled("select ", dim), key("Enter"), Span::styled("cycle status", dim)]),
ProjectTab::Settings => Line::from(vec![key("Enter"), Span::styled("rename ", dim), key("c"), Span::styled("change color", dim)]),
_ => Line::from(vec![key("↑↓"), Span::styled("scroll", dim)]),
};
f.render_widget(Paragraph::new(vec![
Line::from(vec![key("←→/Tab"), Span::styled("tabs ", dim), key("1-6"), Span::styled("direct ", dim), key("q/Esc"), Span::styled("back", dim)]),
hint,
]), area);
}
fn render_overview(f: &mut Frame, st: &St, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let wht = Style::default().fg(Color::White);
let (pr, pg, pb) = crate::projects::hex_to_rgb(&st.project.color);
let proj_col = Color::Rgb(pr, pg, pb);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area);
// LEFT: project info + health bar + repo list
{
let dirty_c = st.repos.iter().filter(|r| r.is_dirty).count();
let behind_c = st.repos.iter().filter(|r| r.behind > 0).count();
let ahead_c = st.repos.iter().filter(|r| r.ahead > 0).count();
let total = st.repos.len();
let clean_n = if total > 0 { (20 * total.saturating_sub(dirty_c + behind_c)) / total } else { 20 };
let dirty_n = if total > 0 { (20 * dirty_c) / total } else { 0 };
let behind_n = 20usize.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 mut lines: Vec<Line> = vec![
Line::from(vec![
Span::styled(" ■ ", Style::default().fg(proj_col)),
Span::styled(st.project.name.clone(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
Span::styled(
format!(" {} repo{}", total, if total == 1 { "" } else { "s" }),
dim,
),
]),
Line::from(""),
Line::from(vec![
Span::styled(" Health ", dim),
Span::styled(health_bar, Style::default().fg(health_col)),
Span::styled(format!(" ↓{} ⚠{} ↑{}", behind_c, dirty_c, ahead_c), dim),
]),
Line::from(""),
Line::from(Span::styled(" Repos", yel)),
Line::from(Span::styled(" ─────────────────────────────────────────", dim)),
];
let avail = area.height.saturating_sub(6) as usize;
for r in st.repos.iter().take(avail) {
let status = if r.is_dirty { "⚠" } else if r.ahead > 0 { "↑" } else if r.behind > 0 { "↓" } else { "✓" };
let col = if r.is_dirty { Color::Yellow } else if r.behind > 0 { Color::Red } else { Color::Green };
lines.push(Line::from(vec![
Span::styled(format!(" {} ", status), Style::default().fg(col)),
Span::styled(format!("{:<18} ", r.name.chars().take(18).collect::<String>()), wht),
Span::styled(format!("{:<9} ", r.branch.chars().take(9).collect::<String>()), dim),
Span::styled(crate::status::format_age_pub(r.last_commit_ts), Style::default().fg(super::tabs::age_color(r.last_commit_ts))),
]));
}
f.render_widget(Paragraph::new(lines), cols[0]);
}
// RIGHT: open todos + recent commits
{
let open_t: Vec<&crate::todos::Todo> = st.todos.iter()
.filter(|t| matches!(t.status, TodoStatus::Open | TodoStatus::InProgress))
.collect();
let mut lines: Vec<Line> = vec![];
lines.push(Line::from(Span::styled(" Todos", yel)));
lines.push(Line::from(Span::styled(" ─────────────────────────────────", dim)));
if open_t.is_empty() {
lines.push(Line::from(Span::styled(" No open todos", dim)));
} else {
let half_h = (area.height as usize / 2).saturating_sub(2);
for t in open_t.iter().take(half_h) {
lines.push(Line::from(vec![
Span::styled(format!(" {} ", t.status.icon()), Style::default().fg(t.status.color())),
Span::styled(t.title.chars().take(28).collect::<String>(), wht),
]));
}
}
lines.push(Line::from(""));
if !st.commits.is_empty() {
lines.push(Line::from(Span::styled(" Recent Commits", yel)));
lines.push(Line::from(Span::styled(" ─────────────────────────────────", dim)));
let rem_h = (area.height as usize).saturating_sub(lines.len() + 1);
for c in st.commits.iter().take(rem_h) {
lines.push(Line::from(vec![
Span::styled(format!(" {:>5} ", c.age.chars().take(5).collect::<String>()), dim),
Span::styled(format!("{:<10} ", c.repo.chars().take(10).collect::<String>()), Style::default().fg(Color::Cyan)),
Span::styled(c.msg.chars().take(22).collect::<String>(), dim),
]));
}
} else if st.commits_rx.is_some() {
lines.push(Line::from(Span::styled(" Loading commits...", dim)));
}
f.render_widget(Paragraph::new(lines), cols[1]);
}
}
fn render_commits(f: &mut Frame, st: &St, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
if st.commits_rx.is_some() {
f.render_widget(Paragraph::new(" Loading commits from all repos...").style(dim), area);
return;
}
if st.commits.is_empty() {
f.render_widget(Paragraph::new(" No commits found").style(dim), area);
return;
}
let header = Row::new(vec!["REPO", "AGE", "AUTHOR", "MESSAGE"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let avail = area.height.saturating_sub(1) as usize;
let rows: Vec<Row> = st.commits.iter().skip(st.commit_off).take(avail)
.map(|c: &AggCommit| {
Row::new(vec![
Cell::from(c.repo.chars().take(14).collect::<String>()).style(yel),
Cell::from(c.age.chars().take(8).collect::<String>()).style(dim),
Cell::from(c.author.chars().take(12).collect::<String>()).style(dim),
Cell::from(c.msg.chars().take(60).collect::<String>()),
])
})
.collect();
f.render_widget(
Table::new(rows, [Constraint::Length(14), Constraint::Length(8), Constraint::Length(12), Constraint::Fill(1)]).header(header),
area,
);
}
fn render_repos(f: &mut Frame, st: &St, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let header = Row::new(vec!["NAME", "BRANCH", "STATUS", "AGE", "LAST COMMIT"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let avail = area.height.saturating_sub(1) as usize;
let rows: Vec<Row> = st.repos.iter().enumerate().skip(st.repo_cursor.saturating_sub(avail / 2)).take(avail)
.map(|(i, r)| {
let is_cur = i == st.repo_cursor;
let status = if r.is_dirty { format!("⚠ +{}-{}", r.ahead, r.behind) } else { format!("+{}-{}", r.ahead, r.behind) };
let col = if r.is_dirty || r.behind > 0 { Color::Yellow } else { Color::White };
Row::new(vec![
Cell::from(r.name.chars().take(20).collect::<String>()).style(Style::default().fg(col)),
Cell::from(r.branch.chars().take(12).collect::<String>()).style(dim),
Cell::from(status).style(dim),
Cell::from(crate::status::format_age_pub(r.last_commit_ts)).style(Style::default().fg(super::tabs::age_color(r.last_commit_ts))),
Cell::from(r.last_commit_msg.chars().take(35).collect::<String>()).style(dim),
]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() })
})
.collect();
f.render_widget(
Table::new(rows, [Constraint::Length(20), Constraint::Length(12), Constraint::Length(12), Constraint::Length(10), Constraint::Fill(1)]).header(header),
area,
);
}
fn render_stats(f: &mut Frame, st: &St, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let cyn = Style::default().fg(Color::Cyan);
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let mut lines: Vec<Line> = vec![];
lines.push(Line::from(Span::styled(" Activity (last 1y, all repos combined)", yel)));
lines.push(Line::from(Span::styled(" ──────────────────────────────────────────────────────", dim)));
if st.activity_rx.is_some() {
lines.push(Line::from(Span::styled(" Computing activity...", cyn)));
f.render_widget(Paragraph::new(lines), area);
return;
}
if st.activity.is_empty() {
lines.push(Line::from(Span::styled(" No activity data", dim)));
f.render_widget(Paragraph::new(lines), area);
return;
}
let max_count: usize = st.activity.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1);
let bar_h = (area.height as usize).saturating_sub(6).max(3);
let bar_w = (area.width as usize).saturating_sub(10).max(1);
let n = st.activity.len();
let step = if n > bar_w { (n / bar_w).max(1) } else { 1 };
let sampled: Vec<&(String, usize)> = st.activity.iter().step_by(step).collect();
let bar_count = sampled.len().min(bar_w);
let bar_width = if bar_count == 0 { 1 } else { (bar_w / bar_count).max(1) };
for row in (0..bar_h).rev() {
let threshold = (row * max_count) / bar_h;
let mut spans: Vec<Span<'static>> = vec![];
let y_label = if row == bar_h - 1 { format!(" {:>4} ", max_count) } else if row == 0 { " 0 ".to_string() } else { " ".to_string() };
spans.push(Span::styled(y_label, dim));
for j in 0..bar_count {
let count = sampled.get(j).map(|(_, c)| *c).unwrap_or(0);
let filled = count > threshold;
for _ in 0..bar_width {
if filled { spans.push(Span::styled("█", cyn)); }
else { spans.push(Span::styled("░", dim)); }
}
}
lines.push(Line::from(spans));
}
// X axis labels
let nth = (bar_count / 6).max(1);
let mut x_spans: Vec<Span> = vec![Span::raw(" ")];
for j in 0..bar_count {
let label = if j % nth == 0 {
let raw = sampled.get(j)
.map(|(d, _)| d.chars().skip(5).take(bar_width.min(5)).collect::<String>())
.unwrap_or_default();
format!("{:<width$}", raw, width = bar_width)
} else {
" ".repeat(bar_width)
};
x_spans.push(Span::styled(label, dim));
}
lines.push(Line::from(x_spans));
f.render_widget(Paragraph::new(lines), area);
}
fn render_todos(f: &mut Frame, st: &St, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let header = Row::new(vec!["STATUS", "TITLE", "EST", "CREATED"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
if st.todos.is_empty() {
f.render_widget(Paragraph::new(" No todos for this project").style(dim), area);
return;
}
let avail = area.height.saturating_sub(1) as usize;
let rows: Vec<Row> = st.todos.iter().enumerate()
.skip(st.todo_cursor.saturating_sub(avail / 2)).take(avail)
.map(|(i, t)| {
let is_cur = i == st.todo_cursor;
let status_str = format!("{} {}", t.status.icon(), t.status.label());
let est_str = t.estimate.as_deref().unwrap_or("—").chars().take(10).collect::<String>();
let created = crate::status::format_age_pub(t.created_at);
Row::new(vec![
Cell::from(status_str).style(Style::default().fg(t.status.color())),
Cell::from(t.title.chars().take(55).collect::<String>()),
Cell::from(est_str).style(dim),
Cell::from(created).style(dim),
]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() })
})
.collect();
f.render_widget(
Table::new(rows, [Constraint::Length(14), Constraint::Fill(1), Constraint::Length(10), Constraint::Length(10)]).header(header),
area,
);
}
fn render_settings(f: &mut Frame, st: &St, area: Rect) {
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 (pr, pg, pb) = crate::projects::hex_to_rgb(&st.project.color);
let proj_col = Color::Rgb(pr, pg, pb);
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(" Project Settings", yel)),
Line::from(Span::styled(" ────────────────────────────────────────────", dim)),
Line::from(""),
Line::from(vec![
Span::styled(" Name: ", dim),
Span::styled(st.project.name.clone(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(" Color: ", dim),
Span::styled(" ■ ", Style::default().fg(proj_col).add_modifier(Modifier::BOLD)),
Span::styled(st.project.color.clone(), Style::default().fg(proj_col)),
]),
Line::from(vec![
Span::styled(" Repos: ", dim),
Span::styled(st.repos.len().to_string(), Style::default().fg(Color::White)),
]),
Line::from(""),
Line::from(Span::styled(" Actions", yel)),
Line::from(Span::styled(" ────────────────────────────────────────────", dim)),
Line::from(vec![
Span::styled(" [Enter] ", cyn),
Span::styled("Rename project", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" [c] ", cyn),
Span::styled("Change color (palette picker)", Style::default().fg(Color::White)),
]),
Line::from(""),
Line::from(Span::styled(" Color Palette", yel)),
Line::from(Span::styled(" ────────────────────────────────────────────", dim)),
];
// Show palette swatches
let per_row = 4;
for chunk_start in (0..PROJ_PALETTE.len()).step_by(per_row) {
let mut spans: Vec<Span> = vec![Span::raw(" ")];
for i in 0..per_row {
if let Some((hex, name)) = PROJ_PALETTE.get(chunk_start + i) {
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
let c = Color::Rgb(cr, cg, cb);
let active = *hex == st.project.color.as_str();
spans.push(Span::styled(if active { "▶██◀ " } else { " ██ " }, Style::default().fg(c)));
spans.push(Span::styled(format!("{:<8}", name.chars().take(6).collect::<String>()), if active { Style::default().fg(c).add_modifier(Modifier::BOLD) } else { dim }));
}
}
lines.push(Line::from(spans));
}
if st.rename_input.is_some() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" → Rename modal open — type name, Enter confirm, Esc cancel", cyn)));
}
if st.color_cursor.is_some() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" → Color picker open — ←→↑↓ navigate, Enter confirm, Esc cancel", cyn)));
}
f.render_widget(Paragraph::new(lines), area);
}
fn draw_text_modal(f: &mut Frame, area: Rect, title: &str, input: &str, hint: &str) {
use ratatui::widgets::Clear;
let modal_area = crate::tui::centered_rect(50, 20, area);
f.render_widget(Clear, modal_area);
let blk = Block::default()
.borders(Borders::ALL)
.title(format!(" {} ", title))
.border_style(Style::default().fg(Color::Cyan));
let inner = blk.inner(modal_area);
f.render_widget(blk, modal_area);
f.render_widget(Paragraph::new(vec![
Line::from(Span::styled(format!(" {}_", input), Style::default().fg(Color::White).add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(Span::styled(format!(" {}", hint), Style::default().fg(Color::DarkGray))),
]), inner);
}
fn draw_color_pick_modal(f: &mut Frame, area: Rect, cursor: usize, current_hex: &str) {
use ratatui::widgets::Clear;
let modal_area = crate::tui::centered_rect(70, 50, area);
f.render_widget(Clear, modal_area);
let blk = Block::default()
.borders(Borders::ALL)
.title(" Choose Color ←→↑↓ navigate · Enter confirm · Esc cancel ")
.border_style(Style::default().fg(Color::Cyan));
let inner = blk.inner(modal_area);
f.render_widget(blk, modal_area);
let dim = Style::default().fg(Color::DarkGray);
let mut lines: Vec<Line> = vec![Line::from("")];
let per_row = 6usize;
for row_start in (0..PROJ_PALETTE.len()).step_by(per_row) {
// Swatch row
let mut swatch_spans: Vec<Span> = vec![Span::raw(" ")];
for i in 0..per_row {
if let Some((hex, _)) = PROJ_PALETTE.get(row_start + i) {
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
let c = Color::Rgb(cr, cg, cb);
let is_cur = row_start + i == cursor;
let is_active = *hex == current_hex;
let label = if is_cur { "▶████◀ " } else if is_active { " ████* " } else { " ████ " };
swatch_spans.push(Span::styled(label, Style::default().fg(c).add_modifier(if is_cur { Modifier::BOLD } else { Modifier::empty() })));
}
}
lines.push(Line::from(swatch_spans));
// Name row
let mut name_spans: Vec<Span> = vec![Span::raw(" ")];
for i in 0..per_row {
if let Some((hex, name)) = PROJ_PALETTE.get(row_start + i) {
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
let c = Color::Rgb(cr, cg, cb);
let is_cur = row_start + i == cursor;
name_spans.push(Span::styled(
format!("{:<9}", name.chars().take(7).collect::<String>()),
if is_cur { Style::default().fg(c).add_modifier(Modifier::BOLD) } else { dim },
));
}
}
lines.push(Line::from(name_spans));
lines.push(Line::from(""));
}
// Show selected hex
if let Some((hex, name)) = PROJ_PALETTE.get(cursor) {
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
lines.push(Line::from(vec![
Span::raw(" Selected: "),
Span::styled(format!("■ {} — {}", hex, name), Style::default().fg(Color::Rgb(cr, cg, cb)).add_modifier(Modifier::BOLD)),
]));
}
f.render_widget(Paragraph::new(lines), inner);
}