Files
vulp/src/detail/render.rs
T
2026-06-19 01:10:19 +02:00

1343 lines
59 KiB
Rust

use super::{DetailModal, HistEntry, St, StatsPeriod, Tab};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row, Table},
Frame,
};
// ── top-level draw ────────────────────────────────────────────────────────────
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(3)])
.split(area);
render_topbar(f, st, vert[0]);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(73), Constraint::Percentage(27)])
.split(vert[1]);
let left_blk = Block::default().borders(Borders::ALL);
let left_inner = left_blk.inner(cols[0]);
f.render_widget(left_blk, cols[0]);
match st.tab {
Tab::Overview => render_overview(f, st, left_inner),
Tab::Commits => render_commits(f, st, left_inner),
Tab::Branches => render_branches(f, st, left_inner),
Tab::Files => render_file_tree(f, st, left_inner),
Tab::Stats => render_stats(f, st, left_inner),
Tab::Settings => render_settings(f, st, left_inner),
Tab::PRs => render_prs(f, st, left_inner),
Tab::Issues => render_issues(f, st, left_inner),
Tab::Todos => render_repo_todos(f, st, left_inner),
}
render_right(f, st, cols[1]);
render_bottombar(f, st, vert[2]);
// Modal overlay — rendered last so it's on top
render_modal(f, st, area);
}
// ── top bar ───────────────────────────────────────────────────────────────────
fn render_topbar(f: &mut Frame, st: &St, area: Rect) {
let r = &st.summary;
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), Style::default().fg(Color::DarkGray))
}
};
let sync_span = match (r.ahead, r.behind) {
(0, 0) => Span::styled(" ✓", Style::default().fg(Color::DarkGray)),
(a, 0) => Span::styled(format!(" ↑{}", a), Style::default().fg(Color::Cyan)),
(0, b) => Span::styled(format!(" ↓{}", b), Style::default().fg(Color::Red)),
(a, b) => Span::styled(format!(" ↑{} ↓{}", a, b), Style::default().fg(Color::Yellow)),
};
let dirty_span = if r.is_dirty {
Span::styled(" ⚠ dirty", Style::default().fg(Color::Yellow))
} else {
Span::styled(" ✓ clean", Style::default().fg(Color::DarkGray))
};
let mut spans = vec![
Span::styled(" ← ", Style::default().fg(Color::DarkGray)),
Span::styled(r.name.clone(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
Span::styled(format!(" ⎇ {}", r.branch), Style::default().fg(Color::Cyan)),
sync_span,
dirty_span,
Span::raw(" "),
tab_s("1 Overview", st.tab == Tab::Overview),
Span::raw(" "),
tab_s("2 Commits", st.tab == Tab::Commits),
Span::raw(" "),
tab_s("3 Branches", st.tab == Tab::Branches),
Span::raw(" "),
tab_s("4 Files", st.tab == Tab::Files),
Span::raw(" "),
tab_s("5 Stats", st.tab == Tab::Stats),
Span::raw(" "),
tab_s("6 Settings", st.tab == Tab::Settings),
Span::raw(" "),
tab_s("7 PRs", st.tab == Tab::PRs),
Span::raw(" "),
tab_s("8 Issues", st.tab == Tab::Issues),
Span::raw(" "),
tab_s("9 Todos", st.tab == Tab::Todos),
];
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);
}
// ── 1. Overview tab ───────────────────────────────────────────────────────────
fn render_overview(f: &mut Frame, st: &St, area: Rect) {
let r = &st.summary;
let dim = Style::default().fg(Color::DarkGray);
let wht = Style::default().fg(Color::White);
let cyn = Style::default().fg(Color::Cyan);
let yel = Style::default().fg(Color::Yellow);
let w = area.width.saturating_sub(2) as usize;
let kv = |k: &str, v: &str, col: Color| -> Line<'static> {
Line::from(vec![
Span::styled(format!(" {:<16}", k), dim),
Span::styled(v.chars().take(w.saturating_sub(18)).collect::<String>(), Style::default().fg(col)),
])
};
let sep = || dsep(area.width);
let mut lines: Vec<Line> = vec![];
// ── location & links ──
lines.push(Line::from(Span::styled(" Location & Links", yel.add_modifier(Modifier::BOLD))));
lines.push(sep());
lines.push(kv("Path", &st.summary.path.to_string_lossy(), Color::DarkGray));
if let Some(ref url) = st.remote_url_str {
lines.push(kv("GitHub URL", url, Color::Cyan));
} else {
lines.push(kv("GitHub URL", "(no remote)", Color::DarkGray));
}
lines.push(Line::from(""));
// ── repository stats ──
lines.push(Line::from(Span::styled(" Repository", yel.add_modifier(Modifier::BOLD))));
lines.push(sep());
let dirty_txt = if r.is_dirty { format!("⚠ {} changed", st.files.len()) } else { "✓ clean".into() };
let sync_txt = match (r.ahead, r.behind) {
(0, 0) => "in sync".to_string(),
(a, 0) => format!("↑{} ahead", a),
(0, b) => format!("↓{} behind", b),
(a, b) => format!("↑{} ↓{}", a, b),
};
let age_txt = if st.repo_age_days > 365 {
format!("{} years old", st.repo_age_days / 365)
} else if st.repo_age_days > 30 {
format!("{} months old", st.repo_age_days / 30)
} else {
format!("{} days old", st.repo_age_days)
};
lines.push(kv("Total commits", &st.total_commits.to_string(), Color::White));
lines.push(kv("Total releases", &st.total_tags.to_string(), Color::Yellow));
lines.push(kv("Branches", &st.branch_count.to_string(), Color::White));
lines.push(kv("Tracked files", &st.file_count.to_string(), Color::DarkGray));
lines.push(kv("Working tree", &dirty_txt, if r.is_dirty { Color::Yellow } else { Color::DarkGray }));
lines.push(kv("Remote", &sync_txt, if r.behind > 0 { Color::Red } else if r.ahead > 0 { Color::Cyan } else { Color::DarkGray }));
lines.push(kv("Age", &age_txt, Color::DarkGray));
if let Some(lang) = &r.lang {
lines.push(kv("Languages", lang, Color::DarkGray));
}
lines.push(Line::from(""));
// ── latest version + recent releases ──
let tags: Vec<_> = st.history.iter().filter_map(|h| {
if let HistEntry::Tag { name, age, .. } = h { Some((name.clone(), age.clone())) } else { None }
}).collect();
if !tags.is_empty() {
lines.push(Line::from(Span::styled(" Releases", yel.add_modifier(Modifier::BOLD))));
lines.push(sep());
for (name, age) in tags.iter().take(3) {
lines.push(Line::from(vec![
Span::styled(format!(" {:<11} ", age), dim),
Span::styled(name.clone(), cyn),
]));
}
lines.push(Line::from(""));
}
// ── recent commits ──
if !st.ov_recent.is_empty() {
lines.push(Line::from(Span::styled(" Recent Commits", yel.add_modifier(Modifier::BOLD))));
lines.push(sep());
let skip = st.ov_scroll.min(st.ov_recent.len().saturating_sub(1));
for (age, msg) in st.ov_recent.iter().skip(skip).take(5) {
let msg_t: String = msg.chars().take(w.saturating_sub(14)).collect();
lines.push(Line::from(vec![
Span::styled(format!(" {:<11} ", age), dim),
Span::styled(msg_t, wht),
]));
}
lines.push(Line::from(""));
}
// ── top contributors ──
if !st.contributors.is_empty() {
lines.push(Line::from(Span::styled(" Top Contributors", yel.add_modifier(Modifier::BOLD))));
lines.push(sep());
for c in st.contributors.iter().take(3) {
lines.push(Line::from(vec![
Span::styled(format!(" {:<25}", c.name.chars().take(25).collect::<String>()), wht),
Span::styled(format!("{} commits", c.count), dim),
]));
}
lines.push(Line::from(""));
}
// ── active branches ──
if !st.branches.is_empty() {
lines.push(Line::from(Span::styled(" Active Branches", yel.add_modifier(Modifier::BOLD))));
lines.push(sep());
for b in st.branches.iter().take(4) {
let marker = if b.is_current { "▶ " } else { " " };
let sync = match (b.ahead, b.behind) {
(0, 0) => String::new(),
(a, 0) => format!(" ↑{}", a),
(0, b) => format!(" ↓{}", b),
(a, b) => format!(" ↑{}↓{}", a, b),
};
lines.push(Line::from(vec![
Span::styled(format!(" {}", marker), if b.is_current { cyn } else { dim }),
Span::styled(b.name.chars().take(25).collect::<String>(), if b.is_current { cyn } else { wht }),
Span::styled(sync, dim),
]));
}
}
f.render_widget(Paragraph::new(lines), area);
}
// ── 2. Commits tab ───────────────────────────────────────────────────────────
fn render_commits(f: &mut Frame, st: &St, area: Rect) {
let avail = area.height.saturating_sub(1) as usize;
let ahead = st.summary.ahead; // first N commits are local-only
let header = Row::new(vec!["", "SHA", "AGE", "AUTHOR", "MESSAGE"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let mut commit_idx = 0usize; // count Commit entries seen so far (for ahead marker)
let rows: Vec<Row> = st.history.iter()
.skip(st.hist_off)
.take(avail)
.map(|entry| {
match entry {
HistEntry::Tag { name, age, msg, .. } => Row::new(vec![
Cell::from("tag").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Cell::from(name.clone()).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Cell::from(age.clone()).style(Style::default().fg(Color::DarkGray)),
Cell::from(""),
Cell::from(if msg.is_empty() { "(lightweight tag)".into() } else { msg.clone() })
.style(Style::default().fg(Color::DarkGray)),
]),
HistEntry::Commit { sha, age, author, msg, .. } => {
let is_local = commit_idx < ahead;
commit_idx += 1;
let marker = if is_local { "↑" } else { "●" };
let marker_col = if is_local { Color::Cyan } else { Color::DarkGray };
let msg_col = if is_local { Color::White } else { Color::Reset };
Row::new(vec![
Cell::from(marker).style(Style::default().fg(marker_col)),
Cell::from(sha.clone()).style(Style::default().fg(Color::DarkGray)),
Cell::from(age.clone()).style(Style::default().fg(Color::DarkGray)),
Cell::from(format!("@{}", author)).style(Style::default().fg(Color::Cyan)),
Cell::from(msg.clone()).style(Style::default().fg(msg_col)),
])
}
}
})
.collect();
// Legend for local-only indicator
let legend = if ahead > 0 {
Rect::new(area.x, area.y + area.height.saturating_sub(1), area.width, 1)
} else {
Rect::new(0, 0, 0, 0)
};
let table_area = if ahead > 0 {
Rect::new(area.x, area.y, area.width, area.height.saturating_sub(1))
} else {
area
};
f.render_widget(
Table::new(rows, [
Constraint::Length(2),
Constraint::Length(9),
Constraint::Length(10),
Constraint::Length(14),
Constraint::Fill(1),
]).header(header),
table_area,
);
if ahead > 0 {
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ↑ ", Style::default().fg(Color::Cyan)),
Span::styled(format!("{} local-only commit(s) not yet pushed", ahead), Style::default().fg(Color::DarkGray)),
])),
legend,
);
}
}
// ── 3. Branches tab ──────────────────────────────────────────────────────────
fn render_branches(f: &mut Frame, st: &St, area: Rect) {
if st.branches.is_empty() {
f.render_widget(
Paragraph::new(" No branches found").style(Style::default().fg(Color::DarkGray)),
area,
);
return;
}
let avail = area.height.saturating_sub(1) as usize;
let header = Row::new(vec!["", "BRANCH", "UPSTREAM", "+", "-", "AGE", "LAST COMMIT"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let scroll = st.branch_cursor.saturating_sub(avail.saturating_sub(1));
let rows: Vec<Row> = st.branches.iter()
.enumerate()
.skip(scroll)
.take(avail)
.map(|(i, b)| {
let is_sel = i + scroll == st.branch_cursor;
let sel_bg = if is_sel { Color::DarkGray } else { Color::Reset };
let name_col= if b.is_current { Color::Cyan } else { Color::White };
let cur_mark= if b.is_current { "▶" } else { " " };
let ahead_col = if b.ahead > 0 { Color::Green } else { Color::DarkGray };
let behind_col= if b.behind > 0 { Color::Red } else { Color::DarkGray };
let msg: String = b.last_commit_msg.chars()
.take(area.width.saturating_sub(70).max(10) as usize).collect();
Row::new(vec![
Cell::from(cur_mark).style(Style::default().fg(Color::Cyan).bg(sel_bg)),
Cell::from(b.name.clone()).style(Style::default().fg(name_col).bg(sel_bg).add_modifier(if b.is_current { Modifier::BOLD } else { Modifier::empty() })),
Cell::from(b.upstream.as_deref().unwrap_or("—")).style(Style::default().fg(Color::DarkGray).bg(sel_bg)),
Cell::from(if b.ahead > 0 { b.ahead.to_string() } else { "—".into() }).style(Style::default().fg(ahead_col).bg(sel_bg)),
Cell::from(if b.behind > 0 { b.behind.to_string() } else { "—".into() }).style(Style::default().fg(behind_col).bg(sel_bg)),
Cell::from(b.last_commit_age.clone()).style(Style::default().fg(Color::DarkGray).bg(sel_bg)),
Cell::from(msg).style(Style::default().fg(Color::DarkGray).bg(sel_bg)),
])
})
.collect();
f.render_widget(
Table::new(rows, [
Constraint::Length(2),
Constraint::Length(24),
Constraint::Length(18),
Constraint::Length(5),
Constraint::Length(5),
Constraint::Length(10),
Constraint::Fill(1),
]).header(header),
area,
);
}
// ── 4. Files tab (tree) ───────────────────────────────────────────────────────
fn render_file_tree(f: &mut Frame, st: &St, area: Rect) {
if st.tree_visible.is_empty() {
f.render_widget(
Paragraph::new(" No tracked files").style(Style::default().fg(Color::DarkGray)),
area,
);
return;
}
let avail = area.height as usize;
let scroll = st.tree_cursor.saturating_sub(avail.saturating_sub(1));
let dirty: std::collections::HashSet<&str> = st.files.iter().map(|(_, p)| p.as_str()).collect();
let items: Vec<ListItem> = st.tree_visible.iter()
.skip(scroll)
.take(avail)
.enumerate()
.map(|(row_i, &ni)| {
let node = &st.tree_nodes[ni];
let is_sel = (scroll + row_i) == st.tree_cursor;
let indent: String = " ".repeat(node.depth);
let sel_bg = if is_sel { Color::DarkGray } else { Color::Reset };
let (prefix, name_col) = if node.is_dir {
let arrow = if node.expanded { "▼ " } else { "▶ " };
(format!("{}{}", indent, arrow), Color::Yellow)
} else {
(format!("{}· ", indent), Color::White)
};
let name_str = if node.is_dir { format!("{}/", node.name) } else { node.name.clone() };
let dirty_mark = if !node.is_dir && dirty.contains(node.rel_path.as_str()) {
let status = st.files.iter().find(|(_, p)| p == &node.rel_path).map(|(c, _)| *c);
match status {
Some('M') => Span::styled(" M", Style::default().fg(Color::Yellow)),
Some('D') => Span::styled(" D", Style::default().fg(Color::Red)),
_ => Span::styled(" ?", Style::default().fg(Color::Green)),
}
} else { Span::raw("") };
ListItem::new(Line::from(vec![
Span::styled(prefix, Style::default().fg(Color::DarkGray).bg(sel_bg)),
Span::styled(name_str, Style::default().fg(name_col).bg(sel_bg)),
dirty_mark,
]))
})
.collect();
f.render_widget(List::new(items), area);
}
// ── 5. Stats tab ─────────────────────────────────────────────────────────────
fn render_stats(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 all_periods = [
StatsPeriod::Day1, StatsPeriod::Day7, StatsPeriod::Day30,
StatsPeriod::Day90, StatsPeriod::Year1, StatsPeriod::AllTime,
];
// ── Period selector row ──
let mut period_spans: Vec<Span> = vec![Span::styled(" Period: ", dim)];
for p in all_periods {
if p == st.stats_period {
period_spans.push(Span::styled(
format!("[{}]", p.label()),
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD),
));
} else {
period_spans.push(Span::styled(format!(" {} ", p.label()), dim));
}
period_spans.push(Span::raw(" "));
}
period_spans.push(Span::styled("[p] cycle [</>] prev/next", dim));
let period_h = 2u16;
let period_area = Rect::new(area.x, area.y, area.width, period_h);
// ── Chart area ──
let chart_h = (area.height.saturating_sub(period_h + 2)).min(20).max(5); // 5..20 rows
let chart_area = Rect::new(area.x, area.y + period_h, area.width, chart_h);
// ── Bottom metrics ──
let metrics_y = area.y + period_h + chart_h + 1;
let metrics_h = area.height.saturating_sub(period_h + chart_h + 2);
let metrics_area = if metrics_h > 0 {
Rect::new(area.x, metrics_y, area.width, metrics_h)
} else {
Rect::new(0, 0, 0, 0)
};
// Render period selector
f.render_widget(Paragraph::new(vec![
Line::from(period_spans),
Line::from(dsep(area.width)),
]), period_area);
// Render vertical bar chart
render_bar_chart(f, &st.activity_data, chart_area);
// Render bottom metrics
if metrics_h > 0 {
let kv = |k: &str, v: &str, col: Color| -> Line<'static> {
Line::from(vec![
Span::styled(format!(" {:<18}", k), dim),
Span::styled(v.to_string(), Style::default().fg(col)),
])
};
let avg_per_month = if st.repo_age_days > 0 {
st.total_commits * 30 / (st.repo_age_days as usize).max(1)
} else { 0 };
let loc_str = if st.loc_total > 1000 {
format!("{}k", st.loc_total / 1000)
} else {
st.loc_total.to_string()
};
let mut mlines: Vec<Line> = vec![
Line::from(Span::styled(" Key Metrics", yel)),
dsep(area.width),
kv("Total commits", &st.total_commits.to_string(), Color::White),
kv("Total releases", &st.total_tags.to_string(), Color::Yellow),
kv("Contributors", &st.contributors.len().to_string(), Color::White),
kv("Lines of code", &loc_str, Color::Cyan),
kv("Avg/month", &format!("{} commits", avg_per_month), Color::DarkGray),
];
if !st.top_files.is_empty() {
mlines.push(Line::from(""));
mlines.push(Line::from(Span::styled(" Largest Files (lines)", yel)));
mlines.push(dsep(area.width));
for (name, lines) in st.top_files.iter().take(5) {
let short: String = name.chars().rev().take(30).collect::<String>().chars().rev().collect();
mlines.push(Line::from(vec![
Span::styled(format!(" {:<32}", short), dim),
Span::styled(format!("{:>6}", lines), Style::default().fg(Color::Cyan)),
]));
}
}
f.render_widget(Paragraph::new(mlines), metrics_area);
}
}
fn render_bar_chart(f: &mut Frame, data: &[(String, usize)], area: Rect) {
if data.is_empty() || area.height < 3 || area.width < 10 {
return;
}
let max_v = data.iter().map(|d| d.1).max().unwrap_or(1).max(1);
let axis_w = 6usize; // " 123 │"
let avail_w = area.width.saturating_sub(axis_w as u16) as usize;
let n_cols = data.len();
// Each column: bar_chars + 1 space. bar_chars = col_w chars.
let col_w = ((avail_w).saturating_sub(n_cols)) / n_cols.max(1);
let col_w = col_w.max(1).min(4);
let chart_h = area.height.saturating_sub(2) as usize; // -1 for axis, -1 for labels
let mut lines: Vec<Line> = vec![];
// Build chart rows from top to bottom
for row in 0..chart_h {
// row 0 = top (high values), row chart_h-1 = bottom (0)
let mut spans: Vec<Span<'static>> = vec![];
// Y-axis label
let y_val = max_v * (chart_h - row) / chart_h;
let y_lbl = if row == 0 {
format!("{:>4} │", max_v)
} else if row == chart_h / 2 {
format!("{:>4} │", y_val)
} else if row == chart_h - 1 {
" 0 │".to_string()
} else {
" │".to_string()
};
spans.push(Span::styled(y_lbl, Style::default().fg(Color::DarkGray)));
for d in data.iter() {
// bar_h = how many rows this column fills from bottom
let bar_h = if max_v == 0 { 0 } else { (d.1 * chart_h / max_v).min(chart_h) };
// Current row is filled if (row from bottom) < bar_h
// row from bottom = chart_h - 1 - row
let row_from_bottom = chart_h.saturating_sub(1).saturating_sub(row);
let filled = row_from_bottom < bar_h;
let bar_str = if filled {
"█".repeat(col_w)
} else {
" ".repeat(col_w)
};
let bar_col = if filled { Color::Cyan } else { Color::Reset };
spans.push(Span::styled(bar_str + " ", Style::default().fg(bar_col)));
}
lines.push(Line::from(spans));
}
// X-axis
let x_axis = format!(" └{}", "─".repeat((col_w + 1) * n_cols + 1));
lines.push(Line::from(Span::styled(x_axis, Style::default().fg(Color::DarkGray))));
// Labels (show every Nth to avoid crowding)
let label_every = ((n_cols as u16 * (col_w as u16 + 1)) / area.width.max(1) + 1) as usize;
let mut label_spans: Vec<Span<'static>> = vec![Span::raw(" ".to_string())];
for (i, d) in data.iter().enumerate() {
let lbl = if i % label_every.max(1) == 0 {
format!("{:<width$}", d.0.chars().take(col_w + 1).collect::<String>(), width = col_w + 1)
} else {
" ".repeat(col_w + 1)
};
label_spans.push(Span::styled(lbl, Style::default().fg(Color::DarkGray)));
}
lines.push(Line::from(label_spans));
f.render_widget(Paragraph::new(lines), area);
}
// ── 6. Settings tab ───────────────────────────────────────────────────────────
fn render_settings(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 red = Style::default().fg(Color::Red);
let (vis, desc, topics_str, gh_ok) = match &st.settings {
None => {
let msg = if st.settings_rx.is_some() {
" Fetching from GitHub API..."
} else {
" (loading...)"
};
f.render_widget(Paragraph::new(msg).style(dim), area);
return;
}
Some(s) => (s.visibility.clone(), s.description.clone(), s.topics.join(", "), s.gh_auth_ok),
};
let busy = st.api_rx.is_some();
struct Item { label: &'static str, value: String, hint: &'static str, danger: bool }
let items: &[Item] = &[
Item { label: "Visibility", value: vis.clone(), hint: "toggle public / private", danger: false },
Item { label: "Description", value: if desc.is_empty() { "(none)".into() } else { desc.chars().take(35).collect() }, hint: "edit text", danger: false },
Item { label: "Topics", value: if topics_str.is_empty() { "(none)".into() } else { topics_str.chars().take(35).collect() }, hint: "edit list", danger: false },
Item { label: "Collaborator", value: String::new(), hint: "invite user", danger: false },
Item { label: "Archive", value: String::new(), hint: "archive repo on GitHub", danger: true },
Item { label: "Rename", value: String::new(), hint: "rename on GitHub", danger: true },
Item { label: "Delete", value: String::new(), hint: "delete on GitHub ⚠", danger: true },
Item { label: ".gitignore", value: String::new(), hint: "open in $EDITOR", danger: false },
Item { label: "LICENSE", value: String::new(), hint: "open in $EDITOR", danger: false },
Item { label: "README", value: String::new(), hint: "open in $EDITOR", danger: false },
];
let mut lines: Vec<Line> = vec![];
if !gh_ok {
let msg = st.settings_err.as_deref()
.unwrap_or("No GitHub account — run: fxg auth import");
lines.push(Line::from(Span::styled(
format!(" ⚠ {}", msg),
Style::default().fg(Color::Yellow),
)));
lines.push(dsep(area.width));
lines.push(Line::from(""));
} else if let Some(err) = &st.settings_err {
lines.push(Line::from(Span::styled(
format!(" ⚠ {}", err),
Style::default().fg(Color::Yellow),
)));
lines.push(Line::from(""));
}
if busy {
lines.push(Line::from(Span::styled(
" Updating GitHub...",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
}
// Section: GitHub API
lines.push(Line::from(Span::styled(" GitHub API", yel)));
lines.push(dsep(area.width));
for (i, item) in items[..7].iter().enumerate() {
lines.push(settings_row(i, item.label, &item.value, item.hint, item.danger, i == st.settings_cur));
}
lines.push(Line::from(""));
// Section: Local Files
lines.push(Line::from(Span::styled(" Local Files", yel)));
lines.push(dsep(area.width));
for (i, item) in items[7..].iter().enumerate() {
lines.push(settings_row(7 + i, item.label, &item.value, item.hint, false, 7 + i == st.settings_cur));
}
lines.push(Line::from(""));
// Section: Git Operations
struct GitItem { label: &'static str, value: String, hint: &'static str }
let stash_hint = if st.stash_count > 0 { format!("{} stashed", st.stash_count) } else { String::new() };
let git_items: &[GitItem] = &[
GitItem { label: "Pull", value: String::new(), hint: "git pull (fetch + merge)" },
GitItem { label: "Commit", value: String::new(), hint: "stage all & commit" },
GitItem { label: "Push", value: if st.summary.ahead > 0 { format!("{} ahead", st.summary.ahead) } else { String::new() }, hint: "git push" },
GitItem { label: "Stash", value: stash_hint.clone(), hint: "git stash" },
GitItem { label: "Pop stash", value: stash_hint, hint: "git stash pop" },
];
lines.push(Line::from(Span::styled(" Git Operations", yel)));
lines.push(dsep(area.width));
for (i, item) in git_items.iter().enumerate() {
lines.push(settings_row(10 + i, item.label, &item.value, item.hint, false, 10 + i == st.settings_cur));
}
f.render_widget(Paragraph::new(lines), area);
// Suppress unused variable warnings via inline references
let _ = (cyn, red);
}
fn settings_row(
idx: usize,
label: &str,
value: &str,
hint: &str,
danger: bool,
sel: bool,
) -> Line<'static> {
let sel_bg = if sel { Color::DarkGray } else { Color::Reset };
let num_col = if sel { Color::Cyan } else { Color::DarkGray };
let lbl_col = if danger && !sel { Color::Red } else if sel { Color::White } else { Color::White };
let hint_col = if danger { Color::Red } else { Color::DarkGray };
Line::from(vec![
Span::styled(
format!(" {:>2}. ", idx + 1),
Style::default().fg(num_col).bg(sel_bg).add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }),
),
Span::styled(
format!("{:<16}", label),
Style::default().fg(lbl_col).bg(sel_bg).add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }),
),
Span::styled(
format!("{:<30}", value.chars().take(30).collect::<String>()),
Style::default().fg(Color::DarkGray).bg(sel_bg),
),
Span::styled(
hint.to_string(),
Style::default().fg(hint_col).bg(sel_bg),
),
])
}
// ── right panel router ────────────────────────────────────────────────────────
fn render_right(f: &mut Frame, st: &St, area: Rect) {
match st.tab {
Tab::Files => render_right_files(f, st, area),
Tab::Branches => render_right_branch(f, st, area),
Tab::Stats => render_right_stats(f, st, area),
_ => render_right_default(f, st, area),
}
}
fn render_right_default(f: &mut Frame, st: &St, area: Rect) {
let blk = Block::default().borders(Borders::ALL);
let inner = blk.inner(area);
f.render_widget(blk, area);
let actions_h = 16u16;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(actions_h)])
.split(inner);
render_info_block(f, st, layout[0]);
render_actions_block(f, layout[1]);
}
fn render_right_files(f: &mut Frame, st: &St, area: Rect) {
let blk = Block::default().borders(Borders::ALL);
let inner = blk.inner(area);
f.render_widget(blk, area);
let selected_node = st.tree_visible.get(st.tree_cursor).map(|&ni| &st.tree_nodes[ni]);
let title = if let Some(node) = selected_node {
if node.is_dir {
format!(" {} {} ", if node.expanded { "▼" } else { "▶" }, node.name)
} else {
format!(" {} ", node.name)
}
} else { " Preview ".to_string() };
let title_blk = Block::default().borders(Borders::BOTTOM).title(title.as_str())
.border_style(Style::default().fg(Color::DarkGray));
let title_h = 2u16;
let title_area = Rect::new(inner.x, inner.y, inner.width, title_h);
let preview_area = Rect::new(inner.x, inner.y + title_h, inner.width, inner.height.saturating_sub(title_h));
f.render_widget(title_blk, title_area);
if let Some(node) = selected_node {
if node.is_dir {
f.render_widget(
Paragraph::new(vec![
Line::from(Span::styled("(directory)", Style::default().fg(Color::DarkGray))),
Line::from(Span::styled("[Enter] expand/collapse", Style::default().fg(Color::DarkGray))),
Line::from(Span::styled("[e] open in editor", Style::default().fg(Color::DarkGray))),
]),
preview_area,
);
} else if st.file_preview.is_empty() {
f.render_widget(
Paragraph::new("(navigate to load)").style(Style::default().fg(Color::DarkGray)),
preview_area,
);
} else {
let avail = preview_area.height as usize;
let skip = st.preview_off.min(st.file_preview.len().saturating_sub(1));
let items: Vec<ListItem> = st.file_preview.iter()
.skip(skip).take(avail).enumerate()
.map(|(i, line)| {
let ln = skip + i + 1;
ListItem::new(Line::from(vec![
Span::styled(format!("{:>4} ", ln), Style::default().fg(Color::DarkGray)),
Span::styled(
line.chars().take(inner.width.saturating_sub(5) as usize).collect::<String>(),
Style::default().fg(Color::White),
),
]))
})
.collect();
f.render_widget(List::new(items), preview_area);
}
} else {
f.render_widget(
Paragraph::new("(no file selected)").style(Style::default().fg(Color::DarkGray)),
preview_area,
);
}
}
fn render_right_branch(f: &mut Frame, st: &St, area: Rect) {
let blk = Block::default().borders(Borders::ALL);
let inner = blk.inner(area);
f.render_widget(blk, area);
let dim = Style::default().fg(Color::DarkGray);
let kv = |k: &str, v: &str, col: Color| -> Line<'static> {
Line::from(vec![
Span::styled(format!(" {:<11}", k), dim),
Span::styled(v.to_string(), Style::default().fg(col)),
])
};
let mut lines: Vec<Line> = vec![];
if let Some(b) = st.branches.get(st.branch_cursor) {
lines.push(Line::from(Span::styled(
format!(" {}", b.name),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
)));
lines.push(dsep(inner.width));
lines.push(Line::from(""));
lines.push(kv("Status", if b.is_current { "✓ current" } else { "○ other" }, if b.is_current { Color::Cyan } else { Color::DarkGray }));
lines.push(kv("Upstream", b.upstream.as_deref().unwrap_or("(no tracking)"), Color::DarkGray));
if b.upstream.is_some() {
lines.push(kv("Ahead", &b.ahead.to_string(), if b.ahead > 0 { Color::Green } else { Color::DarkGray }));
lines.push(kv("Behind", &b.behind.to_string(), if b.behind > 0 { Color::Red } else { Color::DarkGray }));
}
lines.push(kv("Age", &b.last_commit_age, Color::DarkGray));
lines.push(Line::from(""));
let msg: String = b.last_commit_msg.chars().take(inner.width.saturating_sub(3) as usize).collect();
lines.push(kv("Last", &msg, Color::DarkGray));
} else {
lines.push(Line::from(Span::styled(" No branch selected", dim)));
}
lines.push(Line::from(""));
lines.push(dsep(inner.width));
lines.push(Line::from(Span::styled(" Actions", dim)));
lines.push(Line::from(""));
for (k, lbl) in [("Enter", "Checkout"), ("n", "New branch (modal)"), ("P", "Push"), ("D", "Delete")] {
lines.push(Line::from(vec![
Span::styled(format!(" [{}]", k), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled(format!(" {}", lbl), Style::default().fg(Color::White)),
]));
}
f.render_widget(Paragraph::new(lines), inner);
}
fn render_right_stats(f: &mut Frame, st: &St, area: Rect) {
let blk = Block::default().borders(Borders::ALL);
let inner = blk.inner(area);
f.render_widget(blk, area);
let dim = Style::default().fg(Color::DarkGray);
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let kv = |k: &str, v: &str, col: Color| -> Line<'static> {
Line::from(vec![
Span::styled(format!(" {:<13}", k), dim),
Span::styled(v.to_string(), Style::default().fg(col)),
])
};
let loc_str = if st.loc_total > 1000 {
format!("{}k lines", st.loc_total / 1000)
} else {
format!("{} lines", st.loc_total)
};
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(" Top Contributors", yel)),
dsep(inner.width),
Line::from(""),
];
for (i, c) in st.contributors.iter().take(8).enumerate() {
let name: String = c.name.chars().take((inner.width.saturating_sub(8)) as usize).collect();
lines.push(Line::from(vec![
Span::styled(format!(" {:>2}. ", i + 1), dim),
Span::styled(name, Style::default().fg(Color::White)),
]));
lines.push(Line::from(vec![
Span::styled(" ", dim),
Span::styled(format!("{} commits", c.count), dim),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" Code Stats", yel)));
lines.push(dsep(inner.width));
lines.push(kv("LOC", &loc_str, Color::Cyan));
lines.push(kv("Commits", &st.total_commits.to_string(), Color::White));
lines.push(kv("Tags", &st.total_tags.to_string(), Color::Yellow));
if let Some(lang) = &st.summary.lang {
lines.push(kv("Languages", lang, Color::DarkGray));
}
f.render_widget(Paragraph::new(lines), inner);
}
// ── info + actions (shared default right panel) ────────────────────────────
fn render_info_block(f: &mut Frame, st: &St, area: Rect) {
let r = &st.summary;
let w = area.width.saturating_sub(2) as usize;
let dim = Style::default().fg(Color::DarkGray);
let kv = |k: &str, v: &str, col: Color| -> Line<'static> {
Line::from(vec![
Span::styled(format!(" {:<10}", k), dim),
Span::styled(v.chars().take(w.saturating_sub(11)).collect::<String>(), Style::default().fg(col)),
])
};
let sync_txt = match (r.ahead, r.behind) {
(0, 0) => "in sync".to_string(),
(a, 0) => format!("↑{} ahead", a),
(0, b) => format!("↓{} behind", b),
(a, b) => format!("↑{} ↓{}", a, b),
};
let mut info: Vec<Line> = vec![
Line::from(Span::styled(
format!(" {}", r.name.chars().take(w).collect::<String>()),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
)),
dsep(area.width.saturating_add(2)),
Line::from(""),
kv("Remote", &sync_txt, if r.behind > 0 { Color::Red } else if r.ahead > 0 { Color::Cyan } else { Color::DarkGray }),
kv("Dirty", if r.is_dirty { "⚠ changes" } else { "✓ clean" }, if r.is_dirty { Color::Yellow } else { Color::DarkGray }),
Line::from(""),
kv("Owner", r.owner.as_deref().unwrap_or("—"), Color::White),
kv("Vis.", r.visibility.as_deref().unwrap_or("—"), match r.visibility.as_deref() { Some("public") => Color::Green, Some("private") => Color::Yellow, _ => Color::DarkGray }),
kv("License", if r.license { "✓" } else { "⚠ missing" }, if r.license { Color::DarkGray } else { Color::Yellow }),
];
if let Some(lang) = &r.lang { info.push(kv("Lang", lang, Color::DarkGray)); }
if let Some(s) = r.stars { info.push(kv("Stars", &format!("★ {}", s), Color::Yellow)); }
if let Some(i) = r.open_issues { info.push(kv("Issues", &i.to_string(), if i > 0 { Color::Red } else { Color::DarkGray })); }
if let Some(p) = r.open_prs { info.push(kv("PRs", &p.to_string(), if p > 0 { Color::Cyan } else { Color::DarkGray })); }
info.push(Line::from(""));
info.push(kv("Commits", &st.total_commits.to_string(), Color::White));
info.push(kv("Files", &st.file_count.to_string(), Color::DarkGray));
info.push(kv("Branches", &st.branch_count.to_string(), Color::DarkGray));
info.push(kv("Tags", &st.total_tags.to_string(), Color::Yellow));
info.push(kv("Stashes", &st.stash_count.to_string(), Color::DarkGray));
if let Some(tag) = &r.last_tag { info.push(kv("Last tag", tag, Color::Cyan)); }
f.render_widget(Paragraph::new(info), area);
}
fn render_actions_block(f: &mut Frame, area: Rect) {
let act_blk = Block::default().borders(Borders::TOP).title(" Actions ").border_style(Style::default().fg(Color::DarkGray));
let act_inner = act_blk.inner(area);
f.render_widget(act_blk, area);
let act = |k: &'static str, label: &'static str, col: Color| -> Line<'static> {
Line::from(vec![
Span::styled(format!(" [{}]", k), Style::default().fg(col).add_modifier(Modifier::BOLD)),
Span::styled(format!(" {}", label), Style::default().fg(Color::White)),
])
};
let div = || Line::from(Span::styled(" ─────────────────", Style::default().fg(Color::DarkGray)));
f.render_widget(Paragraph::new(vec![
act("s", "Sync", Color::Green),
act("r", "Release tag", Color::Green),
div(),
act("o", "Open in editor", Color::Cyan),
act("W", "Open browser", Color::Cyan),
act("y", "Copy path", Color::Cyan),
act("u", "Copy URL", Color::Cyan),
act("g", ".gitignore", Color::Cyan),
div(),
act("S", "Stash", Color::White),
act("A", "Pop stash", Color::White),
div(),
act("q", "← Back", Color::DarkGray),
]), act_inner);
}
// ── bottom bar ────────────────────────────────────────────────────────────────
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 line2: Line = match st.tab {
Tab::Overview => Line::from(vec![key("↑↓"), Span::styled("scroll", dim)]),
Tab::Commits => Line::from(vec![
key("↑↓/jk"), Span::styled("scroll ", dim),
key("PgUp/Dn"), Span::styled("fast", dim),
]),
Tab::Branches => Line::from(vec![
key("↑↓"), Span::styled("select ", dim),
key("Enter"), Span::styled("checkout ", dim),
key("n"), Span::styled("new (modal) ", dim),
key("P"), Span::styled("push ", dim),
key("D"), Span::styled("delete", dim),
]),
Tab::Files => Line::from(vec![
key("↑↓"), Span::styled("navigate ", dim),
key("Enter"), Span::styled("expand/collapse ", dim),
key("e"), Span::styled("edit in $EDITOR ", dim),
key("[/]"), Span::styled("scroll preview", dim),
]),
Tab::Stats => Line::from(vec![
key("p"), Span::styled("cycle period ", dim),
key("</>"), Span::styled("prev/next period ", dim),
key("↑↓"), Span::styled("scroll contributors", dim),
]),
Tab::Settings => Line::from(vec![
key("↑↓"), Span::styled("select ", dim),
key("Enter"), Span::styled("edit/action", dim),
]),
Tab::PRs | Tab::Issues => Line::from(vec![
key("↑↓"), Span::styled("select ", dim),
key("Enter"), Span::styled("open in browser", dim),
]),
Tab::Todos => Line::from(vec![
key("↑↓"), Span::styled("select ", dim),
key("Enter"), Span::styled("cycle status", dim),
]),
};
f.render_widget(Paragraph::new(vec![
Line::from(vec![
key("←→/Tab"), Span::styled("switch tab ", dim),
key("1-9"), Span::styled("direct ", dim),
key("q/Esc"), Span::styled("back ", dim),
key("s"), Span::styled("sync ", dim),
key("r"), Span::styled("release ", dim),
key("W"), Span::styled("browser", dim),
]),
line2,
Line::from(""),
]), area);
}
// ── modal overlay ─────────────────────────────────────────────────────────────
fn render_modal(f: &mut Frame, st: &St, area: Rect) {
match &st.modal {
DetailModal::None => {}
DetailModal::NewBranch { input, error } => {
draw_text_modal_with_error(f, area, " New Branch ", input, "branch name", error.as_deref(), Color::Cyan);
}
DetailModal::EditText { title, prompt, input, .. } => {
let prompt_owned = prompt.clone();
draw_text_modal(f, area, &format!(" {} ", title), input, &prompt_owned, Color::Cyan);
}
DetailModal::SelectOption { title, options, cursor, .. } => {
let h = (options.len() as u16 + 4).min(area.height.saturating_sub(4));
let w = 44u16.min(area.width.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let modal_area = Rect::new(x, y, w, h);
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);
let mut lines: Vec<Line> = vec![Line::from("")];
for (i, opt) in options.iter().enumerate() {
let sel = i == *cursor;
lines.push(Line::from(vec![
Span::styled(
format!(" {} {}", if sel { "▶" } else { " " }, opt),
Style::default()
.fg(if sel { Color::Cyan } else { Color::White })
.add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }),
),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" [↑↓] select [Enter] confirm [Esc] cancel", Style::default().fg(Color::DarkGray))));
f.render_widget(Paragraph::new(lines), inner);
}
DetailModal::SelectRole { collab_name, cursor } => {
const ROLES: &[&str] = &["pull", "push", "maintain", "admin"];
let h = (ROLES.len() as u16 + 5).min(area.height.saturating_sub(4));
let w = 44u16.min(area.width.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let modal_area = Rect::new(x, y, w, h);
f.render_widget(Clear, modal_area);
let blk = Block::default().borders(Borders::ALL)
.title(format!(" Role for @{} ", collab_name))
.border_style(Style::default().fg(Color::Cyan));
let inner = blk.inner(modal_area);
f.render_widget(blk, modal_area);
let mut lines: Vec<Line> = vec![Line::from("")];
for (i, role) in ROLES.iter().enumerate() {
let sel = i == *cursor;
lines.push(Line::from(Span::styled(
format!(" {} {}", if sel { "▶" } else { " " }, role),
Style::default().fg(if sel { Color::Cyan } else { Color::White })
.add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" [↑↓] select [Enter] invite [Esc] cancel", Style::default().fg(Color::DarkGray))));
f.render_widget(Paragraph::new(lines), inner);
}
DetailModal::ConfirmAction { message, .. } => {
let w = 52u16.min(area.width.saturating_sub(4));
let h = 7u16;
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let modal_area = Rect::new(x, y, w, h);
f.render_widget(Clear, modal_area);
let blk = Block::default().borders(Borders::ALL)
.title(" Confirm ")
.border_style(Style::default().fg(Color::Red));
let inner = blk.inner(modal_area);
f.render_widget(blk, modal_area);
let msg_clone = message.clone();
f.render_widget(
Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(format!(" {}", msg_clone), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(Span::styled(" [y / Enter] confirm", Style::default().fg(Color::Red))),
Line::from(Span::styled(" [n / Esc] cancel", Style::default().fg(Color::DarkGray))),
]),
inner,
);
}
}
}
fn draw_text_modal(f: &mut Frame, area: Rect, title: &str, input: &str, hint: &str, border_col: Color) {
draw_text_modal_with_error(f, area, title, input, hint, None, border_col);
}
fn draw_text_modal_with_error(f: &mut Frame, area: Rect, title: &str, input: &str, hint: &str, error: Option<&str>, border_col: Color) {
let w = 60u16.min(area.width.saturating_sub(4));
let h = if error.is_some() { 7u16 } else { 6u16 };
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let modal_area = Rect::new(x, y, w, h);
f.render_widget(Clear, modal_area);
let border_col = if error.is_some() { Color::Red } else { border_col };
let blk = Block::default().borders(Borders::ALL)
.title(title.to_string())
.border_style(Style::default().fg(border_col));
let inner = blk.inner(modal_area);
f.render_widget(blk, modal_area);
let mut lines = vec![
Line::from(Span::styled(format!(" {}", hint), Style::default().fg(Color::DarkGray))),
Line::from(Span::styled(
format!(" > {}█", input.chars().take((w as usize).saturating_sub(6)).collect::<String>()),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(" [Enter] confirm [Esc] cancel", Style::default().fg(Color::DarkGray))),
];
if let Some(err) = error {
lines.insert(2, Line::from(Span::styled(format!(" ⚠ {}", err), Style::default().fg(Color::Red))));
}
f.render_widget(Paragraph::new(lines), inner);
}
// ── PRs tab ──────────────────────────────────────────────────────────────────
fn render_prs(f: &mut Frame, st: &St, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let header = Row::new(vec!["#", "TITLE", "AUTHOR", "BRANCH", "AGE", "DRAFT"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
if st.prs_issues_rx.is_some() {
f.render_widget(Paragraph::new(" Fetching PRs...").style(dim), area);
return;
}
if st.repo_prs.is_empty() {
let msg = if st.prs_loaded { " No open PRs" } else { " PRs not loaded — switch to this tab to load" };
f.render_widget(Paragraph::new(msg).style(dim), area);
return;
}
let avail = area.height.saturating_sub(1) as usize;
let pr_off = st.pr_cursor.saturating_sub(avail / 2);
let rows: Vec<Row> = st.repo_prs.iter().enumerate()
.skip(pr_off).take(avail)
.map(|(i, pr)| {
let is_cur = i + pr_off == st.pr_cursor;
let age = crate::status::format_age_pub(pr.created_ts);
let draft_str = if pr.draft { "draft" } else { "" };
let branch_str = format!("{} → {}", pr.head_branch.chars().take(10).collect::<String>(), pr.base_branch.chars().take(8).collect::<String>());
Row::new(vec![
Cell::from(format!("#{}", pr.number)).style(Style::default().fg(Color::Cyan)),
Cell::from(pr.title.chars().take(45).collect::<String>()),
Cell::from(pr.author.chars().take(14).collect::<String>()).style(dim),
Cell::from(branch_str).style(dim),
Cell::from(age).style(dim),
Cell::from(draft_str).style(Style::default().fg(Color::Yellow)),
]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() })
})
.collect();
f.render_widget(
Table::new(rows, [
Constraint::Length(7),
Constraint::Fill(1),
Constraint::Length(14),
Constraint::Length(22),
Constraint::Length(10),
Constraint::Length(6),
]).header(header),
area,
);
}
// ── Issues tab ────────────────────────────────────────────────────────────────
fn render_issues(f: &mut Frame, st: &St, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let header = Row::new(vec!["#", "TITLE", "AUTHOR", "LABELS", "AGE", "CMT"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
if st.prs_issues_rx.is_some() {
f.render_widget(Paragraph::new(" Fetching issues...").style(dim), area);
return;
}
if st.repo_issues.is_empty() {
let msg = if st.prs_loaded { " No open issues" } else { " Issues not loaded — switch to this tab to load" };
f.render_widget(Paragraph::new(msg).style(dim), area);
return;
}
let avail = area.height.saturating_sub(1) as usize;
let issue_off = st.issue_cursor.saturating_sub(avail / 2);
let rows: Vec<Row> = st.repo_issues.iter().enumerate()
.skip(issue_off).take(avail)
.map(|(i, issue)| {
let is_cur = i + issue_off == st.issue_cursor;
let age = crate::status::format_age_pub(issue.created_ts);
let labels = issue.labels.join(", ").chars().take(18).collect::<String>();
Row::new(vec![
Cell::from(format!("#{}", issue.number)).style(Style::default().fg(Color::Cyan)),
Cell::from(issue.title.chars().take(45).collect::<String>()),
Cell::from(issue.author.chars().take(14).collect::<String>()).style(dim),
Cell::from(labels).style(Style::default().fg(Color::Yellow)),
Cell::from(age).style(dim),
Cell::from(issue.comments.to_string()).style(dim),
]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() })
})
.collect();
f.render_widget(
Table::new(rows, [
Constraint::Length(7),
Constraint::Fill(1),
Constraint::Length(14),
Constraint::Length(18),
Constraint::Length(10),
Constraint::Length(5),
]).header(header),
area,
);
}
// ── Todos tab (per-repo) ──────────────────────────────────────────────────────
fn render_repo_todos(f: &mut Frame, st: &St, area: Rect) {
use crate::todos::TodoStatus;
let dim = Style::default().fg(Color::DarkGray);
let header = Row::new(vec!["STATUS", "TITLE", "EST", "DIFF", "CREATED"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
if st.repo_todos.is_empty() {
f.render_widget(Paragraph::new(" No todos for this repo").style(dim), area);
return;
}
let avail = area.height.saturating_sub(1) as usize;
let todo_off = st.todo_cursor.saturating_sub(avail / 2);
let rows: Vec<Row> = st.repo_todos.iter().enumerate()
.skip(todo_off).take(avail)
.map(|(i, t)| {
let is_cur = i + todo_off == st.todo_cursor;
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 status_col = t.status.color();
let status_str = format!("{} {}", t.status.icon(), t.status.label());
let created = crate::status::format_age_pub(t.created_at);
Row::new(vec![
Cell::from(status_str).style(Style::default().fg(status_col)),
Cell::from(t.title.chars().take(50).collect::<String>()),
Cell::from(est_str).style(dim),
Cell::from(diff_str).style(Style::default().fg(Color::Yellow)),
Cell::from(created).style(dim),
]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() })
})
.collect();
let _ = TodoStatus::Open; // suppress unused import warning
f.render_widget(
Table::new(rows, [
Constraint::Length(14),
Constraint::Fill(1),
Constraint::Length(10),
Constraint::Length(7),
Constraint::Length(10),
]).header(header),
area,
);
}
// ── helpers ───────────────────────────────────────────────────────────────────
fn dsep(width: u16) -> Line<'static> {
Line::from(Span::styled(
" ".to_string() + &"─".repeat(width.saturating_sub(2) as usize),
Style::default().fg(Color::DarkGray),
))
}