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:
1 parent
2ccf69f64c
commit
212c55d138
16 files changed
+120
-10
No files matched your search
+20
-1
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(""),
|
||||
]
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
Reference in new issue
Block a user