feat: flag vendored clones and make the repo filter navigable

Two Repos-tab quality-of-life changes:

- External/vendored clones (repos with zero of your own commits — e.g. an
  upstream cloned for reference and never pushed to) are measured on
  refresh, cached in stats_cache (RepoStatsEntry.authored), shown with a
  dim ⧉ marker prefixed to the name, and hidden by the persistent
  hide_external_clones config toggle (key `x`, the `S` menu, or
  `vulp list --external|--no-external`). A fork you do commit to stays
  yours, so only true reference clones are flagged.

- Inside the `/` filter box, ↑↓ now move through the live-filtered
  results and Enter opens the highlighted repo/project directly, instead
  of the filter only "locking" on Enter with no way to scroll or select.
This commit is contained in:
dualfroz committed 2026-07-08 16:15:02 +02:00
1 parent 2ccf69f64c
commit 212c55d138
16 files changed
+120 -10

No files matched your search

+20 -1
View File
@@ -231,7 +231,26 @@ pub fn tui_phase(state: &mut HubState, existing_term: Option<crate::tui::Tty>) -
state.filter_text.clear();
state.rebuild_display();
}
KeyCode::Enter => { state.filtering = false; }
// Live navigation while the search box is open: arrows move
// the highlight through the filtered results so you can pick
// one without first "locking" the filter (j/k stay literal
// filter input). Enter opens whatever is highlighted.
KeyCode::Down => state.scroll_down(),
KeyCode::Up => state.scroll_up(),
KeyCode::Enter => match state.tab {
Tab::Repos | Tab::Projects
if state.cur_repo_idx().is_some() || state.cur_proj_idx().is_some() =>
{
// Open the highlighted repo/project directly, keeping
// the filter text applied for when we come back.
state.filtering = false;
state.open_detail = true;
return Ok(PhaseResult::OpenDetail);
}
// Nothing openable under the cursor: just lock the filter
// (exit typing, keep the text) so the full keymap works.
_ => state.filtering = false,
},
KeyCode::Backspace => {
state.filter_text.pop();
state.rebuild_display();
+13
View File
@@ -369,6 +369,18 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
KeyCode::Char('d') => { state.filter_dirty = !state.filter_dirty; state.rebuild_display(); }
KeyCode::Char('b') => { state.filter_behind = !state.filter_behind; state.rebuild_display(); }
KeyCode::Char('f') => { state.filter_ahead = !state.filter_ahead; state.rebuild_display(); }
KeyCode::Char('x') => {
state.cfg.hide_external_clones = !state.cfg.hide_external_clones;
let _ = crate::config::save_config(&state.cfg);
let n = state.external_repos.len();
state.status_msg = Some(if state.cfg.hide_external_clones {
if n == 0 { "Hiding external clones (none classified yet — run [R])".into() }
else { format!("Hiding {} external clone(s)", n) }
} else {
"Showing external clones".into()
});
state.rebuild_display();
}
KeyCode::Char('/') => {
state.filtering = true;
state.filter_text.clear();
@@ -397,6 +409,7 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
format!("[{}] Show behind only", chk(state.filter_behind)),
format!("[{}] Show ahead only", chk(state.filter_ahead)),
format!("[{}] Show archived", chk(state.filter_archived)),
format!("[{}] Hide external clones", chk(state.cfg.hide_external_clones)),
" Reset all".into(),
],
cursor: 0,
+4
View File
@@ -161,6 +161,10 @@ pub fn poll_refresh(state: &mut HubState) {
state.issues = cache.all_issues().into_iter().cloned().collect();
if closed_counts.is_some() { state.closed_counts = closed_counts; }
// The refresh thread just re-measured external-clone classification into
// stats_cache — pick up the fresh set so the marker/hide filter reflect it.
state.external_repos = crate::stats::cache::load().external_repo_names();
state.refresh_rx = None;
state.status_msg = Some(format!(
"Refreshed {} repos: {} PRs, {} issues", fetched_meta, fetched_prs, fetched_iss
+1
View File
@@ -109,6 +109,7 @@ pub async fn run_overview_tui(
selected: HashSet::new(),
filter_dirty: false, filter_behind: false, filter_ahead: false,
filter_archived: false,
external_repos: crate::stats::cache::load().external_repo_names(),
filter_text: String::new(), filtering: false,
sort_mode: SortMode::Default,
bookmarks,
+4
View File
@@ -26,6 +26,10 @@ pub fn try_confirm(state: &mut HubState, modal: Modal) -> Option<Modal> {
7 => state.filter_ahead = !state.filter_ahead,
8 => state.filter_archived = !state.filter_archived,
9 => {
state.cfg.hide_external_clones = !state.cfg.hide_external_clones;
let _ = crate::config::save_config(&state.cfg);
}
10 => {
state.sort_mode = SortMode::Default;
state.filter_dirty = false;
state.filter_behind = false;
+2
View File
@@ -859,10 +859,12 @@ pub fn spawn_api_refresh(state: &mut HubState) {
// network, cheap (~0.2s/50 repos measured), so a plain sequential
// pass is fine. Only run during `[R]`, never on tab entry.
let _ = tx.send(RefreshMsg::Progress("Measuring release cadence + repo size...".into()));
let matcher = crate::stats::OwnEmailMatcher::resolve(&cfg, &accounts);
let mut stats_cache = crate::stats::cache::load();
for (name, path) in &repo_paths {
let size = crate::stats::size::repo_size(path);
stats_cache.record_size(name, size.git_kb, size.tree_kb);
stats_cache.record_authored(name, crate::stats::has_own_commit(path, &matcher));
}
let _ = crate::stats::cache::save(&stats_cache);
+9 -2
View File
@@ -28,12 +28,13 @@ pub static HELP_LINES: &[&str] = &[
" q / Esc Quit (or clear active filter)",
"",
"── Filtering & Sorting ───────────────────────",
" / Fuzzy filter repos (name, branch, owner, lang)",
" / 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",
"",
@@ -150,6 +151,12 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) {
} 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)));
@@ -225,7 +232,7 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) {
]
} else if state.filtering {
vec![
Line::from(vec![Span::styled(" type to filter", dim), sep(), key("Enter"), Span::styled("lock ", dim), key("Esc"), Span::styled("clear", dim)]),
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(""),
]
+6
View File
@@ -55,6 +55,11 @@ pub struct HubState {
pub filter_text: String,
pub filtering: bool,
pub filter_archived:bool,
/// Repo names classified as vendored/reference clones (zero of your own
/// commits), loaded from `stats_cache` at startup and refreshed on `[R]`.
/// Hidden from the Repos list when `cfg.hide_external_clones` is set;
/// always shown with a dim `⧉` marker.
pub external_repos: HashSet<String>,
pub sort_mode: SortMode,
pub bookmarks: HashSet<PathBuf>,
pub pins: HashSet<PathBuf>,
@@ -137,6 +142,7 @@ pub struct HubState {
impl HubState {
pub fn passes_filter(&self, ri: usize) -> bool {
let r = &self.repos[ri];
if self.cfg.hide_external_clones && self.external_repos.contains(&r.name) { return false; }
if (self.filter_dirty && !r.is_dirty)
|| (self.filter_behind && r.behind == 0)
|| (self.filter_ahead && r.ahead == 0) { return false; }
+5 -2
View File
@@ -45,7 +45,10 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
let a_str = if r.ahead > 0 { format!("+{}", r.ahead) } else { String::new() };
let b_str = if r.behind > 0 { format!("-{}", r.behind) } else { String::new() };
let status_str = format!("{} {}{}", d_sym, a_str, b_str);
let name_col = if is_cur || (!r.is_dirty && r.ahead == 0 && r.behind == 0) {
let is_external = state.external_repos.contains(&r.name);
let name_col = if is_external {
Color::DarkGray
} else if is_cur || (!r.is_dirty && r.ahead == 0 && r.behind == 0) {
Color::White
} else {
Color::Yellow
@@ -85,7 +88,7 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
Some(Row::new(vec![
Cell::from(format!("{} {}", if is_cur { "▶" } else { " " }, if is_sel { "[✓]" } else { "[ ]" })).style(check_sty),
Cell::from(format!("{}{}", prefix, r.name)).style(Style::default().fg(name_col)),
Cell::from(format!("{}{}{}", prefix, if is_external { "⧉ " } else { "" }, r.name)).style(Style::default().fg(name_col)),
Cell::from(r.branch.chars().take(9).collect::<String>()),
Cell::from(proj_name.chars().take(21).collect::<String>()).style(Style::default().fg(proj_color)),
Cell::from(vis).style(match vis { "public" => Style::default().fg(Color::Green), "private" => Style::default().fg(Color::Yellow), _ => Style::default().fg(Color::DarkGray) }),