Files
vulp/src/hub/render.rs
T
dualfroz 4a3fa58745 feat: show active-todo count in the tab bar and declutter the Todos title
The Todos tab now carries a count badge (4 Todos(N), N = open + in
progress) like PRs/Issues do. The panel title drops the [s]/[g]/[c]
keybind soup that read as clutter; the current view/group/code-scan
state and the keys that change it move to the bottom hint bar, where
every other keybinding already lives.
2026-07-11 22:19:59 +02:00

277 lines
13 KiB
Rust

//! Top-level draw routine for the hub: the tab bar, the 73/27 split between
//! the tab body (`tabs::render_list`) and the right info panel
//! (`right_panel::render_right`), the bottom hint/debug bar, and the modal
//! overlay.
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use super::modals::render_modal;
use super::right_panel::render_right;
use super::state::{HubState, SortMode, Tab};
use super::tabs::render_list;
/// Full key-binding reference shown by the `?` help modal.
/// KEEP IN SYNC with `keys_dispatch.rs` (and the `[m]` menu in
/// `modals_spawn::open_action_menu`) — stale entries here are a bug.
pub static HELP_LINES: &[&str] = &[
"── Navigation ───────────────────────────────",
" m Action menu for current tab/selection",
" ↑↓ / jk Move cursor up/down",
" Tab / ←→ Switch tab",
" 1-9 Jump straight to a tab",
" Enter Open repo/project detail (browser for PRs/issues)",
" q / Esc Quit (or clear active filter)",
"",
"── Filtering & Sorting ───────────────────────",
" / Fuzzy filter repos (↑↓ move · Enter open · Esc clear)",
" S Sort & filter menu",
" d Toggle: show only dirty repos",
" b Toggle: show only behind repos",
" f Toggle: show only ahead repos",
" a Toggle: show archived repos (no commits >90d)",
" x Toggle: hide external clones (⧉ — zero of your commits)",
" * Select repos by condition",
" Ctrl+A Select / deselect all visible repos",
"",
"── Open / Navigate ───────────────────────────",
" o Open repo in $EDITOR",
" O Open repo in file manager (xdg-open)",
" W Open repo in browser (GitHub/GitLab)",
" y Copy repo path to clipboard",
" u Copy remote URL to clipboard",
" Ctrl+U Copy URL picker (SSH vs HTTPS)",
" g Open .gitignore in $EDITOR",
"",
"── Repo Actions ──────────────────────────────",
" s Sync (pull / commit+push) selected repos",
" r Create release tag",
" B Toggle bookmark (★) — visible in table",
" P Toggle pin (◆) — pinned repos shown at top",
" n Rename/label repo display name",
" N New repository (clone / create / link)",
" R Refresh metadata from provider API",
" Space Select repo for batch action",
"",
"── Projects & Todos ──────────────────────────",
" p Project menu (create / assign / rename / delete)",
" + New todo",
" e Edit todo (Todos tab)",
" Space (Todos tab) Cycle todo status, or expand/collapse a group",
" Enter (Todos tab) Expand/collapse a group header",
" s (Todos tab) Cycle status view: active → open → done → all",
" g (Todos tab) Cycle grouping: status → project → repo → none",
" c / X (Todos tab) Toggle code-scan todos",
" D / Del Delete todo or project",
" T Scan repos for TODO/FIXME/HACK → import to Todos",
"",
"── Help ──────────────────────────────────────",
" ? Show this help",
" v (Overview) Open release notes in browser",
" Esc / q Close modal or quit",
];
/// Draws the entire hub frame: top tab bar, the current tab's body plus
/// right panel (or the debug log, in debug mode), and either a contextual
/// hint line or the active modal on top of everything.
pub fn render_hub(f: &mut Frame, state: &mut HubState) {
let area = f.area();
let bottom_h: u16 = if state.debug_mode { 11 } else { 3 };
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(bottom_h)])
.split(area);
let tab_span = |label: &str, on: bool| {
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 n = state.display.len();
let pos_str = format!(" {}/{}", if n == 0 { 0 } else { state.cursor + 1 }, n);
let behind_c = state.repos.iter().filter(|r| r.behind > 0).count();
let dirty_c = state.repos.iter().filter(|r| r.is_dirty).count();
let health_str = if behind_c > 0 || dirty_c > 0 {
format!(" ↓{} ⚠{} ", behind_c, dirty_c)
} else {
" ✓ ".into()
};
let health_col = if behind_c > 0 { Color::Red } else if dirty_c > 0 { Color::Yellow } else { Color::Green };
let todo_active = state.todos.iter()
.filter(|t| matches!(t.status, crate::todos::TodoStatus::Open | crate::todos::TodoStatus::InProgress))
.count();
let todo_badge = if todo_active > 0 { format!("4 Todos({})", todo_active) } else { "4 Todos".into() };
let pr_badge = if !state.prs.is_empty() { format!("5 PRs({})", state.prs.len()) } else { "5 PRs".into() };
let issue_badge = if !state.issues.is_empty() { format!("6 Issues({})", state.issues.len()) } else { "6 Issues".into() };
let mut top_spans = vec![
Span::styled(" vulp ", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
tab_span("1 Overview", state.tab == Tab::Overview),
Span::raw(" "),
tab_span("2 Repos", state.tab == Tab::Repos),
Span::raw(" "),
tab_span("3 Projects", state.tab == Tab::Projects),
Span::raw(" "),
tab_span(&todo_badge, state.tab == Tab::Todos),
Span::raw(" "),
tab_span(&pr_badge, state.tab == Tab::PRs),
Span::raw(" "),
tab_span(&issue_badge, state.tab == Tab::Issues),
Span::raw(" "),
tab_span("7 Stats", state.tab == Tab::Stats),
Span::raw(" "),
tab_span("8 Activity", state.tab == Tab::Activity),
Span::raw(" "),
tab_span("9 Settings", state.tab == Tab::HubSettings),
Span::raw(" "),
];
// Active filter indicators — only shown when on, so they don't crowd the bar.
let active_filter_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
if state.filter_dirty { top_spans.push(Span::styled("[d:dirty] ", active_filter_style)); }
if state.filter_behind { top_spans.push(Span::styled("[b:behind] ", active_filter_style)); }
if state.filter_ahead { top_spans.push(Span::styled("[f:ahead] ", active_filter_style)); }
if state.filter_archived { top_spans.push(Span::styled("[a:archived] ", active_filter_style)); }
if state.sort_mode != SortMode::Default {
top_spans.push(Span::styled(
format!("[S:{}] ", state.sort_mode.label()),
Style::default().fg(Color::Cyan),
));
}
if !state.filter_text.is_empty() {
top_spans.push(Span::styled(
format!("[/:{}_] ", state.filter_text),
active_filter_style,
));
} else if state.filtering {
top_spans.push(Span::styled("[/: _] ", active_filter_style));
}
if state.tab == Tab::Repos && state.cfg.hide_external_clones && !state.external_repos.is_empty() {
top_spans.push(Span::styled(
format!("[⧉ {} hidden] ", state.external_repos.len()),
Style::default().fg(Color::DarkGray),
));
}
top_spans.push(Span::styled(health_str, Style::default().fg(health_col)));
top_spans.push(Span::styled(pos_str, Style::default().fg(Color::DarkGray)));
if let Some(ref msg) = state.status_msg {
top_spans.push(Span::styled(format!(" {}", msg), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)));
}
f.render_widget(Paragraph::new(Line::from(top_spans)), outer[0]);
if state.tab == Tab::Overview {
super::tabs_overview::render_overview_global(f, state, outer[1]);
} else {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(73), Constraint::Percentage(27)])
.split(outer[1]);
let mut left_blk = Block::default().borders(Borders::ALL);
if state.tab == Tab::Todos {
let in_progress = state.todos.iter().filter(|t| t.status == crate::todos::TodoStatus::InProgress).count();
let open = state.todos.iter().filter(|t| t.status == crate::todos::TodoStatus::Open).count();
left_blk = left_blk.title(format!(" todos · {} in progress · {} open ", in_progress, open));
}
let left_inner = left_blk.inner(cols[0]);
f.render_widget(left_blk, cols[0]);
state.visible_rows = left_inner.height.saturating_sub(1) as usize;
render_list(f, state, left_inner);
render_right(f, state, cols[1]);
}
if state.debug_mode {
let blk = Block::default().borders(Borders::TOP).title(" debug ")
.border_style(Style::default().fg(Color::DarkGray));
let inner = blk.inner(outer[2]);
f.render_widget(blk, outer[2]);
let h = inner.height as usize;
let n = state.debug_log.len();
let start = n.saturating_sub(h);
let lines: Vec<Line> = state.debug_log[start..]
.iter()
.map(|s| Line::from(Span::styled(s.clone(), Style::default().fg(Color::DarkGray))))
.collect();
f.render_widget(Paragraph::new(lines), inner);
} else {
let dim = Style::default().fg(Color::DarkGray);
let key = |k: &str| Span::styled(format!("[{}] ", k), dim);
let sep = || Span::styled(" ", dim);
// Contextual line 3: scan progress, selection hint, or (on the Todos
// tab) the current view/group state plus the keys that change it —
// moved here out of the panel title, which read as clutter.
let line3 = if state.scan_rx.is_some() {
Line::from(Span::styled(" scanning repositories…", dim))
} else if !state.selected.is_empty() {
Line::from(vec![
Span::styled(format!(" {} selected", state.selected.len()), Style::default().fg(Color::Cyan)),
Span::styled(" — ", dim),
key("s"), Span::styled("sync ", dim),
key("r"), Span::styled("release", dim),
])
} else if state.tab == Tab::Todos {
let cyn = Style::default().fg(Color::Cyan);
Line::from(vec![
Span::styled(" view ", dim), Span::styled(state.todo_status_view.label(), cyn),
Span::styled(" · group ", dim), Span::styled(state.todo_group_by.label(), cyn),
Span::styled(" · code-scan ", dim), Span::styled(if state.show_code_todos { "on" } else { "off" }, cyn),
Span::styled(" — ", dim),
key("s"), Span::styled("view ", dim), key("g"), Span::styled("group ", dim), key("c"), Span::styled("code-scan", dim),
])
} else {
Line::from("")
};
let hint_lines: Vec<Line> = if state.modal.is_some() {
vec![
Line::from(vec![key("↑↓/jk"), Span::styled("navigate ", dim), key("Enter"), Span::styled("confirm ", dim), key("Esc"), Span::styled("cancel", dim)]),
Line::from(""),
Line::from(""),
]
} else if state.filtering {
vec![
Line::from(vec![Span::styled(" type to filter", dim), sep(), key("↑↓"), Span::styled("move ", dim), key("Enter"), Span::styled("open ", dim), key("Esc"), Span::styled("clear", dim)]),
Line::from(""),
Line::from(""),
]
} else if state.confirming.is_some() {
vec![
Line::from(vec![key("y"), Span::styled("confirm ", dim), key("Esc"), Span::styled("cancel", dim)]),
Line::from(""),
Line::from(""),
]
} else {
vec![
Line::from(vec![
key("m"), Span::styled("actions ", dim),
key("↑↓/jk"), Span::styled("navigate ", dim),
key("Tab/←→"), Span::styled("tabs ", dim),
key("/"), Span::styled("filter ", dim),
key("?"), Span::styled("help ", dim),
key("q"), Span::styled("quit", dim),
]),
Line::from(""),
line3,
]
};
f.render_widget(Paragraph::new(hint_lines), outer[2]);
}
if state.modal.is_some() {
render_modal(f, &state.modal, area);
}
}