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::(), Style::default().fg(col)), ]) }; let sep = || dsep(area.width); let mut lines: Vec = 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::()), 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::(), 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 = 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 = 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 = 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 = 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 = 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::().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 = 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> = 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> = vec![Span::raw(" ".to_string())]; for (i, d) in data.iter().enumerate() { let lbl = if i % label_every.max(1) == 0 { format!("{:(), 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 = 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::()), 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 = 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::(), 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 = 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 = 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::(), 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 = vec![ Line::from(Span::styled( format!(" {}", r.name.chars().take(w).collect::()), 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 = 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 = 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::()), 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 = 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::(), pr.base_branch.chars().take(8).collect::()); Row::new(vec![ Cell::from(format!("#{}", pr.number)).style(Style::default().fg(Color::Cyan)), Cell::from(pr.title.chars().take(45).collect::()), Cell::from(pr.author.chars().take(14).collect::()).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 = 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::(); Row::new(vec![ Cell::from(format!("#{}", issue.number)).style(Style::default().fg(Color::Cyan)), Cell::from(issue.title.chars().take(45).collect::()), Cell::from(issue.author.chars().take(14).collect::()).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 = 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::(); 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::()), 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), )) }