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
+10
-1
@@ -50,7 +50,7 @@ vulp <command> [--json] # non-interactive (scripts/AI)
|
|||||||
```
|
```
|
||||||
vulp scan # repo count + dirty/behind/ahead summary
|
vulp scan # repo count + dirty/behind/ahead summary
|
||||||
vulp brief [repo] # one-shot picture: state + commits + todos + PR/issues
|
vulp brief [repo] # one-shot picture: state + commits + todos + PR/issues
|
||||||
vulp list [--dirty] [--behind] [--ahead] [--lang ts] [--project NAME] [--alerts] [--stale-deploy]
|
vulp list [--dirty] [--behind] [--ahead] [--lang ts] [--project NAME] [--alerts] [--stale-deploy] [--no-external | --external]
|
||||||
vulp status <repo> # branch, dirty, ahead/behind, last commit...
|
vulp status <repo> # branch, dirty, ahead/behind, last commit...
|
||||||
vulp stats [--period 1d|7d|30d|90d|1y|all] # commits per repo in period
|
vulp stats [--period 1d|7d|30d|90d|1y|all] # commits per repo in period
|
||||||
vulp activity [--limit N] [--kind push|pull|release|"new repo"|todo|pr|issue|commit|license]
|
vulp activity [--limit N] [--kind push|pull|release|"new repo"|todo|pr|issue|commit|license]
|
||||||
@@ -78,6 +78,15 @@ open". `--alerts` filters to repos with `dependabot_open > 0`.
|
|||||||
**Deploy drift** below. `--stale-deploy` filters to repos that are `behind`
|
**Deploy drift** below. `--stale-deploy` filters to repos that are `behind`
|
||||||
or `diverged` (excludes `in_sync` and `null`/not-applicable).
|
or `diverged` (excludes `in_sync` and `null`/not-applicable).
|
||||||
|
|
||||||
|
`--no-external` hides vendored/reference clones (repos you have **zero of your
|
||||||
|
own commits** in — e.g. an upstream `pterodactyl` you cloned but never pushed
|
||||||
|
to); `--external` shows only those. Classification is measured on `vulp refresh`
|
||||||
|
(a git-side `--author` scan of your identities, cached in `stats_cache.toml`);
|
||||||
|
before the first refresh nothing is classified and both flags match nothing. A
|
||||||
|
fork you *do* commit to (even a handful of commits) counts as yours and is not
|
||||||
|
external. In the TUI this is the `x` key / the `⧉` marker, and the persistent
|
||||||
|
`hide_external_clones` config toggle.
|
||||||
|
|
||||||
## Deploy drift
|
## Deploy drift
|
||||||
|
|
||||||
Auto-discovered, not manually configured: if `deploy_root` (config) mirrors
|
Auto-discovered, not manually configured: if `deploy_root` (config) mirrors
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ pub enum Commands {
|
|||||||
#[arg(long)] alerts: bool,
|
#[arg(long)] alerts: bool,
|
||||||
/// Only repos whose deploy host is behind or diverged (needs deploy_ssh_cmd/deploy_root configured)
|
/// Only repos whose deploy host is behind or diverged (needs deploy_ssh_cmd/deploy_root configured)
|
||||||
#[arg(long)] stale_deploy: bool,
|
#[arg(long)] stale_deploy: bool,
|
||||||
|
/// Hide vendored/reference clones — repos with zero of your own commits (classified on refresh)
|
||||||
|
#[arg(long)] no_external: bool,
|
||||||
|
/// Only vendored/reference clones — repos with zero of your own commits
|
||||||
|
#[arg(long)] external: bool,
|
||||||
},
|
},
|
||||||
/// Detailed status of one repo
|
/// Detailed status of one repo
|
||||||
Status { repo: String },
|
Status { repo: String },
|
||||||
|
|||||||
+2
-2
@@ -27,8 +27,8 @@ pub(crate) struct Guard {
|
|||||||
/// `Config`/`Auth`/the bare TUI (those keep their existing paths in `app.rs`).
|
/// `Config`/`Auth`/the bare TUI (those keep their existing paths in `app.rs`).
|
||||||
pub async fn run(cmd: Commands, json: bool, guard: Guard, cfg: &AppConfig) -> Result<()> {
|
pub async fn run(cmd: Commands, json: bool, guard: Guard, cfg: &AppConfig) -> Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Commands::List { dirty, behind, ahead, lang, project, alerts, stale_deploy } =>
|
Commands::List { dirty, behind, ahead, lang, project, alerts, stale_deploy, no_external, external } =>
|
||||||
workspace::list(cfg, json, dirty, behind, ahead, lang, project, alerts, stale_deploy),
|
workspace::list(cfg, json, dirty, behind, ahead, lang, project, alerts, stale_deploy, no_external, external),
|
||||||
Commands::Status { repo } => workspace::status(cfg, json, &repo),
|
Commands::Status { repo } => workspace::status(cfg, json, &repo),
|
||||||
Commands::Scan => workspace::scan_cmd(cfg, json),
|
Commands::Scan => workspace::scan_cmd(cfg, json),
|
||||||
Commands::Sync { repos, all, project, action, message, allow_secrets } =>
|
Commands::Sync { repos, all, project, action, message, allow_secrets } =>
|
||||||
|
|||||||
@@ -398,12 +398,16 @@ pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> {
|
|||||||
}
|
}
|
||||||
let _ = crate::cache::save(&cache);
|
let _ = crate::cache::save(&cache);
|
||||||
|
|
||||||
// Tier-B local measurement (repo size trend) — no network, cheap, only
|
// Tier-B local measurements (repo size trend, external-clone classification)
|
||||||
// ever recorded during a refresh (never on a plain read like `vulp status`).
|
// — no network, cheap, only ever recorded during a refresh (never on a
|
||||||
|
// plain read like `vulp status`).
|
||||||
|
let accounts = crate::auth::load_accounts(cfg).unwrap_or_default();
|
||||||
|
let matcher = crate::stats::OwnEmailMatcher::resolve(cfg, &accounts);
|
||||||
let mut stats_cache = crate::stats::cache::load();
|
let mut stats_cache = crate::stats::cache::load();
|
||||||
for r in &repos {
|
for r in &repos {
|
||||||
let size = crate::stats::size::repo_size(&r.path);
|
let size = crate::stats::size::repo_size(&r.path);
|
||||||
stats_cache.record_size(&r.name, size.git_kb, size.tree_kb);
|
stats_cache.record_size(&r.name, size.git_kb, size.tree_kb);
|
||||||
|
stats_cache.record_authored(&r.name, crate::stats::has_own_commit(&r.path, &matcher));
|
||||||
}
|
}
|
||||||
let _ = crate::stats::cache::save(&stats_cache);
|
let _ = crate::stats::cache::save(&stats_cache);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub fn list(
|
|||||||
cfg: &AppConfig, json: bool,
|
cfg: &AppConfig, json: bool,
|
||||||
dirty: bool, behind: bool, ahead: bool,
|
dirty: bool, behind: bool, ahead: bool,
|
||||||
lang: Option<String>, project: Option<String>, alerts: bool, stale_deploy: bool,
|
lang: Option<String>, project: Option<String>, alerts: bool, stale_deploy: bool,
|
||||||
|
no_external: bool, external: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut repos = scan_repos(cfg);
|
let mut repos = scan_repos(cfg);
|
||||||
|
|
||||||
@@ -17,6 +18,11 @@ pub fn list(
|
|||||||
if behind { repos.retain(|r| r.behind > 0); }
|
if behind { repos.retain(|r| r.behind > 0); }
|
||||||
if ahead { repos.retain(|r| r.ahead > 0); }
|
if ahead { repos.retain(|r| r.ahead > 0); }
|
||||||
if alerts { repos.retain(|r| r.dependabot_open.unwrap_or(0) > 0); }
|
if alerts { repos.retain(|r| r.dependabot_open.unwrap_or(0) > 0); }
|
||||||
|
if no_external || external {
|
||||||
|
let ext = crate::stats::cache::load().external_repo_names();
|
||||||
|
if external { repos.retain(|r| ext.contains(&r.name)); }
|
||||||
|
if no_external { repos.retain(|r| !ext.contains(&r.name)); }
|
||||||
|
}
|
||||||
if stale_deploy {
|
if stale_deploy {
|
||||||
repos.retain(|r| {
|
repos.retain(|r| {
|
||||||
!matches!(
|
!matches!(
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ pub struct AppConfig {
|
|||||||
/// paths past these two roots are identical). Empty = feature disabled.
|
/// paths past these two roots are identical). Empty = feature disabled.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub deploy_root: String,
|
pub deploy_root: String,
|
||||||
|
/// Hide vendored/reference clones (repos with zero of your own commits,
|
||||||
|
/// classified on `[R]`) from the Repos list. A durable preference rather
|
||||||
|
/// than a transient filter, so it lives here and not in the session file.
|
||||||
|
#[serde(default)]
|
||||||
|
pub hide_external_clones: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_license_kind() -> String { "mit".into() }
|
fn default_license_kind() -> String { "mit".into() }
|
||||||
@@ -104,6 +109,7 @@ impl Default for AppConfig {
|
|||||||
stats_author_emails: vec![],
|
stats_author_emails: vec![],
|
||||||
deploy_ssh_cmd: String::new(),
|
deploy_ssh_cmd: String::new(),
|
||||||
deploy_root: String::new(),
|
deploy_root: String::new(),
|
||||||
|
hide_external_clones: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +139,7 @@ impl AppConfig {
|
|||||||
("stats_author_emails", self.stats_author_emails.join(", ")),
|
("stats_author_emails", self.stats_author_emails.join(", ")),
|
||||||
("deploy_ssh_cmd", self.deploy_ssh_cmd.clone()),
|
("deploy_ssh_cmd", self.deploy_ssh_cmd.clone()),
|
||||||
("deploy_root", self.deploy_root.clone()),
|
("deploy_root", self.deploy_root.clone()),
|
||||||
|
("hide_external_clones", self.hide_external_clones.to_string()),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +179,7 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
"deploy_ssh_cmd" => self.deploy_ssh_cmd = value,
|
"deploy_ssh_cmd" => self.deploy_ssh_cmd = value,
|
||||||
"deploy_root" => self.deploy_root = value,
|
"deploy_root" => self.deploy_root = value,
|
||||||
|
"hide_external_clones" => self.hide_external_clones = value == "true",
|
||||||
"workspace_groups" => {
|
"workspace_groups" => {
|
||||||
self.workspace_groups = value
|
self.workspace_groups = value
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
+20
-1
@@ -231,7 +231,26 @@ pub fn tui_phase(state: &mut HubState, existing_term: Option<crate::tui::Tty>) -
|
|||||||
state.filter_text.clear();
|
state.filter_text.clear();
|
||||||
state.rebuild_display();
|
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 => {
|
KeyCode::Backspace => {
|
||||||
state.filter_text.pop();
|
state.filter_text.pop();
|
||||||
state.rebuild_display();
|
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('d') => { state.filter_dirty = !state.filter_dirty; state.rebuild_display(); }
|
||||||
KeyCode::Char('b') => { state.filter_behind = !state.filter_behind; 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('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('/') => {
|
KeyCode::Char('/') => {
|
||||||
state.filtering = true;
|
state.filtering = true;
|
||||||
state.filter_text.clear();
|
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 behind only", chk(state.filter_behind)),
|
||||||
format!("[{}] Show ahead only", chk(state.filter_ahead)),
|
format!("[{}] Show ahead only", chk(state.filter_ahead)),
|
||||||
format!("[{}] Show archived", chk(state.filter_archived)),
|
format!("[{}] Show archived", chk(state.filter_archived)),
|
||||||
|
format!("[{}] Hide external clones", chk(state.cfg.hide_external_clones)),
|
||||||
" Reset all".into(),
|
" Reset all".into(),
|
||||||
],
|
],
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ pub fn poll_refresh(state: &mut HubState) {
|
|||||||
state.issues = cache.all_issues().into_iter().cloned().collect();
|
state.issues = cache.all_issues().into_iter().cloned().collect();
|
||||||
if closed_counts.is_some() { state.closed_counts = closed_counts; }
|
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.refresh_rx = None;
|
||||||
state.status_msg = Some(format!(
|
state.status_msg = Some(format!(
|
||||||
"Refreshed {} repos: {} PRs, {} issues", fetched_meta, fetched_prs, fetched_iss
|
"Refreshed {} repos: {} PRs, {} issues", fetched_meta, fetched_prs, fetched_iss
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ pub async fn run_overview_tui(
|
|||||||
selected: HashSet::new(),
|
selected: HashSet::new(),
|
||||||
filter_dirty: false, filter_behind: false, filter_ahead: false,
|
filter_dirty: false, filter_behind: false, filter_ahead: false,
|
||||||
filter_archived: false,
|
filter_archived: false,
|
||||||
|
external_repos: crate::stats::cache::load().external_repo_names(),
|
||||||
filter_text: String::new(), filtering: false,
|
filter_text: String::new(), filtering: false,
|
||||||
sort_mode: SortMode::Default,
|
sort_mode: SortMode::Default,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ pub fn try_confirm(state: &mut HubState, modal: Modal) -> Option<Modal> {
|
|||||||
7 => state.filter_ahead = !state.filter_ahead,
|
7 => state.filter_ahead = !state.filter_ahead,
|
||||||
8 => state.filter_archived = !state.filter_archived,
|
8 => state.filter_archived = !state.filter_archived,
|
||||||
9 => {
|
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.sort_mode = SortMode::Default;
|
||||||
state.filter_dirty = false;
|
state.filter_dirty = false;
|
||||||
state.filter_behind = 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
|
// network, cheap (~0.2s/50 repos measured), so a plain sequential
|
||||||
// pass is fine. Only run during `[R]`, never on tab entry.
|
// pass is fine. Only run during `[R]`, never on tab entry.
|
||||||
let _ = tx.send(RefreshMsg::Progress("Measuring release cadence + repo size...".into()));
|
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();
|
let mut stats_cache = crate::stats::cache::load();
|
||||||
for (name, path) in &repo_paths {
|
for (name, path) in &repo_paths {
|
||||||
let size = crate::stats::size::repo_size(path);
|
let size = crate::stats::size::repo_size(path);
|
||||||
stats_cache.record_size(name, size.git_kb, size.tree_kb);
|
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);
|
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)",
|
" q / Esc Quit (or clear active filter)",
|
||||||
"",
|
"",
|
||||||
"── Filtering & Sorting ───────────────────────",
|
"── Filtering & Sorting ───────────────────────",
|
||||||
" / Fuzzy filter repos (name, branch, owner, lang)",
|
" / Fuzzy filter repos (↑↓ move · Enter open · Esc clear)",
|
||||||
" S Sort & filter menu",
|
" S Sort & filter menu",
|
||||||
" d Toggle: show only dirty repos",
|
" d Toggle: show only dirty repos",
|
||||||
" b Toggle: show only behind repos",
|
" b Toggle: show only behind repos",
|
||||||
" f Toggle: show only ahead repos",
|
" f Toggle: show only ahead repos",
|
||||||
" a Toggle: show archived repos (no commits >90d)",
|
" a Toggle: show archived repos (no commits >90d)",
|
||||||
|
" x Toggle: hide external clones (⧉ — zero of your commits)",
|
||||||
" * Select repos by condition",
|
" * Select repos by condition",
|
||||||
" Ctrl+A Select / deselect all visible repos",
|
" 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 {
|
} else if state.filtering {
|
||||||
top_spans.push(Span::styled("[/: _] ", active_filter_style));
|
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(health_str, Style::default().fg(health_col)));
|
||||||
top_spans.push(Span::styled(pos_str, Style::default().fg(Color::DarkGray)));
|
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 {
|
} else if state.filtering {
|
||||||
vec![
|
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(""),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ pub struct HubState {
|
|||||||
pub filter_text: String,
|
pub filter_text: String,
|
||||||
pub filtering: bool,
|
pub filtering: bool,
|
||||||
pub filter_archived: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 sort_mode: SortMode,
|
||||||
pub bookmarks: HashSet<PathBuf>,
|
pub bookmarks: HashSet<PathBuf>,
|
||||||
pub pins: HashSet<PathBuf>,
|
pub pins: HashSet<PathBuf>,
|
||||||
@@ -137,6 +142,7 @@ pub struct HubState {
|
|||||||
impl HubState {
|
impl HubState {
|
||||||
pub fn passes_filter(&self, ri: usize) -> bool {
|
pub fn passes_filter(&self, ri: usize) -> bool {
|
||||||
let r = &self.repos[ri];
|
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)
|
if (self.filter_dirty && !r.is_dirty)
|
||||||
|| (self.filter_behind && r.behind == 0)
|
|| (self.filter_behind && r.behind == 0)
|
||||||
|| (self.filter_ahead && r.ahead == 0) { return false; }
|
|| (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 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 b_str = if r.behind > 0 { format!("-{}", r.behind) } else { String::new() };
|
||||||
let status_str = format!("{} {}{}", d_sym, a_str, b_str);
|
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
|
Color::White
|
||||||
} else {
|
} else {
|
||||||
Color::Yellow
|
Color::Yellow
|
||||||
@@ -85,7 +88,7 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
|||||||
|
|
||||||
Some(Row::new(vec![
|
Some(Row::new(vec![
|
||||||
Cell::from(format!("{} {}", if is_cur { "▶" } else { " " }, if is_sel { "[✓]" } else { "[ ]" })).style(check_sty),
|
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(r.branch.chars().take(9).collect::<String>()),
|
||||||
Cell::from(proj_name.chars().take(21).collect::<String>()).style(Style::default().fg(proj_color)),
|
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) }),
|
Cell::from(vis).style(match vis { "public" => Style::default().fg(Color::Green), "private" => Style::default().fg(Color::Yellow), _ => Style::default().fg(Color::DarkGray) }),
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ pub struct SizeSample {
|
|||||||
pub struct RepoStatsEntry {
|
pub struct RepoStatsEntry {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub size_samples: Vec<SizeSample>,
|
pub size_samples: Vec<SizeSample>,
|
||||||
|
/// Whether you've ever committed here (`stats::has_own_commit`), measured
|
||||||
|
/// on `[R]`. `Some(false)` = a vendored/reference clone you've never
|
||||||
|
/// touched; `Some(true)` = yours; `None` = not yet measured or no identity
|
||||||
|
/// configured. Drives the external-clone marker and the hide-clones filter.
|
||||||
|
#[serde(default)]
|
||||||
|
pub authored: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
@@ -61,4 +67,18 @@ impl StatsCache {
|
|||||||
pub fn latest_size(&self, repo: &str) -> Option<&SizeSample> {
|
pub fn latest_size(&self, repo: &str) -> Option<&SizeSample> {
|
||||||
self.repos.get(repo).and_then(|e| e.size_samples.last())
|
self.repos.get(repo).and_then(|e| e.size_samples.last())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records whether `repo` has any of your own commits.
|
||||||
|
pub fn record_authored(&mut self, repo: &str, authored: Option<bool>) {
|
||||||
|
self.repos.entry(repo.to_string()).or_default().authored = authored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repo names classified as external/vendored clones — ones measured on a
|
||||||
|
/// past `[R]` to have zero of your commits.
|
||||||
|
pub fn external_repo_names(&self) -> std::collections::HashSet<String> {
|
||||||
|
self.repos.iter()
|
||||||
|
.filter(|(_, e)| e.authored == Some(false))
|
||||||
|
.map(|(n, _)| n.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user