Files
vulp/src/hub/modals_spawn.rs
T
dualfroz 212c55d138 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.
2026-07-08 16:15:02 +02:00

875 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Background-thread launchers for every hub action that talks to a
//! provider API, runs git/shell commands, or otherwise does work too slow
//! to run on the draw loop's thread. Each `spawn_*` function starts a
//! `std::thread` (with its own short-lived Tokio runtime where async work
//! is needed) and returns immediately; progress/results flow back over the
//! channel already wired into `HubState` (`create_repo_rx`, `codeintel_rx`,
//! `pull_all_rx`, etc.), polled every frame by `keys_poll`/`keys`.
use super::state::{CodeIntelMsg, CreateRepoMsg, HubState, Modal, SelectTag};
/// Initializes a new local repo onto a remote provider entirely in the background,
/// without ever leaving the TUI: runs `commands` (if any), then stages, commits,
/// creates the remote repo via the API, and pushes. Progress streams into a live
/// `Modal::Info` via `state.create_repo_rx`.
pub fn spawn_create_repo(
state: &mut HubState,
path: std::path::PathBuf,
display_name: String,
initial_lines: Vec<String>,
commands: Vec<String>,
owner: Option<String>,
) {
let (tx, rx) = std::sync::mpsc::channel::<CreateRepoMsg>();
state.create_repo_rx = Some(rx);
state.modal = Modal::Info {
title: format!("Creating '{}'", display_name),
lines: initial_lines,
scroll: 0,
};
let cfg = state.cfg.clone();
let fallback_name = display_name;
let private = cfg.default_visibility != "public";
std::thread::spawn(move || {
for cmd in &commands {
let _ = tx.send(CreateRepoMsg::Progress(format!("$ {}", cmd)));
// .output() captures stdout/stderr instead of inheriting the TUI's
// terminal, so the command can't corrupt the still-active raw-mode screen.
let _ = std::process::Command::new("sh")
.args(["-c", cmd.as_str()])
.current_dir(&path)
.output();
}
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(CreateRepoMsg::Done {
success: false, name: fallback_name, detail: format!("runtime error: {e}"),
});
return;
}
};
let tx_progress = tx.clone();
let result = rt.block_on(crate::legacy::adopt::adopt_folder(
&path, &cfg, private, "Initial commit", None, owner.as_deref(),
move |s: &str| { let _ = tx_progress.send(CreateRepoMsg::Progress(s.to_string())); },
));
let detail = if result.success {
format!("Pushed to {}", result.url.unwrap_or_default())
} else {
result.detail
};
if result.success {
crate::activity::log(&crate::activity::ActivityEntry {
ts: chrono::Utc::now().timestamp(), kind: crate::activity::ActivityKind::NewRepo,
repo: result.name.clone(), detail: detail.clone(),
});
}
let _ = tx.send(CreateRepoMsg::Done { success: result.success, name: result.name, detail });
});
}
/// Like `spawn_create_repo`, but for a folder that already has git commits —
/// only creates the remote (if missing) and pushes; never stages or commits.
pub fn spawn_publish_existing(state: &mut HubState, path: std::path::PathBuf, display_name: String, owner: Option<String>) {
let (tx, rx) = std::sync::mpsc::channel::<CreateRepoMsg>();
state.create_repo_rx = Some(rx);
state.modal = Modal::Info {
title: format!("Publishing '{}'", display_name),
lines: vec!["Linked into workspace ✓".into()],
scroll: 0,
};
let cfg = state.cfg.clone();
let fallback_name = display_name;
let private = cfg.default_visibility != "public";
std::thread::spawn(move || {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(CreateRepoMsg::Done {
success: false, name: fallback_name, detail: format!("runtime error: {e}"),
});
return;
}
};
let tx_progress = tx.clone();
let result = rt.block_on(crate::legacy::adopt::publish_existing(
&path, &cfg, private, None, owner.as_deref(),
move |s: &str| { let _ = tx_progress.send(CreateRepoMsg::Progress(s.to_string())); },
));
let detail = if result.success {
format!("Pushed to {}", result.url.unwrap_or_default())
} else {
result.detail
};
if result.success {
crate::activity::log(&crate::activity::ActivityEntry {
ts: chrono::Utc::now().timestamp(), kind: crate::activity::ActivityKind::NewRepo,
repo: result.name.clone(), detail: detail.clone(),
});
}
let _ = tx.send(CreateRepoMsg::Done { success: result.success, name: result.name, detail });
});
}
/// Opens a pull request via the Quick Actions panel. Reuses `CreateRepoMsg`/
/// `create_repo_rx` the same way `spawn_profile_push` does — this isn't a repo
/// creation, but the generic Progress/Done shape and the `Modal::Info` polling
/// already wired up in `keys.rs` apply just as well here.
pub fn spawn_create_pr(state: &mut HubState, repo: String, title: String, head: String, base: String) {
let (tx, rx) = std::sync::mpsc::channel::<CreateRepoMsg>();
state.create_repo_rx = Some(rx);
state.modal = Modal::Info {
title: format!("Opening PR on '{}'", repo),
lines: vec![format!("{} → {}", head, base)],
scroll: 0,
};
let Some(account) = crate::providers::default_account(&state.accounts, None).cloned() else {
state.modal = Modal::Info { title: "No account configured".into(), lines: vec!["Run: vulp auth import".into()], scroll: 0 };
state.create_repo_rx = None;
return;
};
std::thread::spawn(move || {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => { let _ = tx.send(CreateRepoMsg::Done { success: false, name: repo, detail: format!("runtime error: {e}") }); return; }
};
let result = rt.block_on(async {
let provider = crate::providers::make_provider(&account)?;
provider.create_pull_request(&repo, &title, "", &head, &base).await
});
match result {
Ok(pr) => {
crate::activity::log(&crate::activity::ActivityEntry {
ts: chrono::Utc::now().timestamp(), kind: crate::activity::ActivityKind::Pr,
repo: repo.clone(), detail: format!("#{} {} (opened via vulp)", pr.number, title),
});
let _ = tx.send(CreateRepoMsg::Done { success: true, name: format!("PR #{}", pr.number), detail: pr.url });
}
Err(e) => { let _ = tx.send(CreateRepoMsg::Done { success: false, name: repo, detail: format!("{e:#}") }); }
}
});
}
/// Opens an issue via the Quick Actions panel. Same shared-channel pattern as
/// `spawn_create_pr` above.
pub fn spawn_create_issue(state: &mut HubState, repo: String, title: String) {
let (tx, rx) = std::sync::mpsc::channel::<CreateRepoMsg>();
state.create_repo_rx = Some(rx);
state.modal = Modal::Info {
title: format!("Opening issue on '{}'", repo),
lines: vec![title.clone()],
scroll: 0,
};
let Some(account) = crate::providers::default_account(&state.accounts, None).cloned() else {
state.modal = Modal::Info { title: "No account configured".into(), lines: vec!["Run: vulp auth import".into()], scroll: 0 };
state.create_repo_rx = None;
return;
};
std::thread::spawn(move || {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => { let _ = tx.send(CreateRepoMsg::Done { success: false, name: repo, detail: format!("runtime error: {e}") }); return; }
};
let result = rt.block_on(async {
let provider = crate::providers::make_provider(&account)?;
provider.create_issue(&repo, &title, "").await
});
match result {
Ok(iss) => {
crate::activity::log(&crate::activity::ActivityEntry {
ts: chrono::Utc::now().timestamp(), kind: crate::activity::ActivityKind::Issue,
repo: repo.clone(), detail: format!("#{} {} (opened via vulp)", iss.number, title),
});
let _ = tx.send(CreateRepoMsg::Done { success: true, name: format!("Issue #{}", iss.number), detail: iss.url });
}
Err(e) => { let _ = tx.send(CreateRepoMsg::Done { success: false, name: repo, detail: format!("{e:#}") }); }
}
});
}
/// Shared plumbing for the code-intelligence actions below: opens the
/// channel, shows a busy status message, and runs `work` on a plain OS
/// thread (no Tokio needed — `codeintel` is synchronous).
fn spawn_codeintel<F>(state: &mut HubState, busy_msg: &str, work: F)
where
F: FnOnce() -> CodeIntelMsg + Send + 'static,
{
let (tx, rx) = std::sync::mpsc::channel::<CodeIntelMsg>();
state.codeintel_rx = Some(rx);
state.modal = Modal::None;
state.status_msg = Some(busy_msg.to_string());
std::thread::spawn(move || {
let _ = tx.send(work());
});
}
/// Formats parsed function signatures per the requested verbosity level, for
/// the "List functions" code-intel action.
fn format_function_lines(funcs: &[crate::codeintel::FunctionInfo], format: crate::codeintel::FuncFormat) -> Vec<String> {
use crate::codeintel::FuncFormat;
funcs.iter().map(|f| match format {
FuncFormat::NamesOnly => f.name.clone(),
FuncFormat::WithArgs => format!("{}({})", f.name, f.args.join(", ")),
FuncFormat::WithMeta => format!(
"{}({}) [{}:{}, {} lines, {} ref{}]",
f.name, f.args.join(", "), f.file, f.line, f.length_lines, f.ref_count,
if f.ref_count == 1 { "" } else { "s" },
),
}).collect()
}
/// Code-intel action: lists every function found in the repo at `format`'s
/// verbosity level.
pub fn spawn_list_functions(state: &mut HubState, repo: std::path::PathBuf, name: String, format: crate::codeintel::FuncFormat) {
spawn_codeintel(state, "Parsing functions...", move || {
let funcs = crate::codeintel::list_functions(&repo, format);
let mut lines = format_function_lines(&funcs, format);
if lines.is_empty() { lines.push("No functions found (unsupported language(s)?)".into()); }
CodeIntelMsg::Info { title: format!("Functions — '{}' ({})", name, funcs.len()), lines }
});
}
/// Code-intel action: finds every reference to `ident` in the repo.
pub fn spawn_find_usages(state: &mut HubState, repo: std::path::PathBuf, name: String, ident: String) {
spawn_codeintel(state, "Finding usages...", move || {
let usages = crate::codeintel::find_usages(&repo, &ident);
let mut lines: Vec<String> = usages.iter()
.map(|u| format!("{}:{}:{} {}", u.file, u.line, u.col, u.context))
.collect();
if lines.is_empty() { lines.push(format!("No usages of '{}' found", ident)); }
CodeIntelMsg::Info { title: format!("Usages of '{}' in '{}' ({})", ident, name, usages.len()), lines }
});
}
/// Code-intel action: computes per-language function/file counts, longest
/// functions, and largest files for the repo.
pub fn spawn_repo_stats(state: &mut HubState, repo: std::path::PathBuf, name: String) {
spawn_codeintel(state, "Computing repo stats...", move || {
let s = crate::codeintel::repo_stats(&repo);
let mut lines = vec![
format!("Functions: {} Files: {} Avg fn length: {:.1} lines", s.total_functions, s.total_files, s.avg_fn_len),
String::new(),
"Languages:".to_string(),
];
for (lang, count) in &s.lang_counts {
lines.push(format!(" {} — {} file{}", lang, count, if *count == 1 { "" } else { "s" }));
}
lines.push(String::new());
lines.push("Longest functions:".to_string());
for (file, fname, len) in &s.longest_functions {
lines.push(format!(" {} ({}:{} lines)", fname, file, len));
}
lines.push(String::new());
lines.push("Largest files:".to_string());
for (file, loc) in &s.largest_files {
lines.push(format!(" {} ({} lines)", file, loc));
}
CodeIntelMsg::Info { title: format!("Code stats — '{}'", name), lines }
});
}
/// Formats a rename/convention `RenamePlan` as a unified-diff-style preview
/// (capped at 200 edit lines so a huge plan doesn't flood the modal).
fn rename_plan_preview_lines(plan: &crate::codeintel::RenamePlan) -> Vec<String> {
let mut lines = vec![format!("{} edit(s) across {} file(s):", plan.edits.len(), plan.file_count()), String::new()];
for e in plan.edits.iter().take(200) {
lines.push(format!(
"{}:{}:{} {} → {}",
e.file.display(), e.line, e.col, e.old_text, e.new_text,
));
}
if plan.edits.len() > 200 {
lines.push(format!("... and {} more", plan.edits.len() - 200));
}
lines
}
/// Code-intel action: builds a dry-run plan for renaming `old` to `new` and
/// shows it as a `Modal::RenamePreview` awaiting confirmation.
pub fn spawn_rename_preview(state: &mut HubState, repo: std::path::PathBuf, name: String, old: String, new: String) {
spawn_codeintel(state, "Building rename plan...", move || {
let plan = crate::codeintel::build_rename_plan(&repo, &old, &new);
if plan.edits.is_empty() {
return CodeIntelMsg::Info {
title: format!("Rename — '{}'", name),
lines: vec![format!("No occurrences of '{}' found in '{}'", old, name)],
};
}
let lines = rename_plan_preview_lines(&plan);
CodeIntelMsg::Preview { title: format!("Rename '{}' → '{}' in '{}' — dry run", old, new, name), lines, plan }
});
}
/// Code-intel action: builds a dry-run plan for converting every function
/// name in the repo to `target`'s naming convention.
pub fn spawn_convention_preview(state: &mut HubState, repo: std::path::PathBuf, name: String, target: crate::codeintel::NamingConvention) {
spawn_codeintel(state, "Building conversion plan...", move || {
let plan = crate::codeintel::build_convention_plan(&repo, target);
if plan.edits.is_empty() {
return CodeIntelMsg::Info {
title: format!("Convert naming — '{}'", name),
lines: vec![format!("All function names already match {}", target.label())],
};
}
let lines = rename_plan_preview_lines(&plan);
CodeIntelMsg::Preview { title: format!("Convert to {} in '{}' — dry run", target.label(), name), lines, plan }
});
}
/// Pushes a settings snapshot to the (private) settings-sync repo, creating
/// it on first use. Mirrors `spawn_create_repo`'s background-thread + live
/// `Modal::Info` pattern; reuses `CreateRepoMsg` since a push needs no
/// payload back, just success/failure.
pub fn spawn_profile_push(state: &mut HubState, snap: crate::profile::SettingsSnapshot) {
let (tx, rx) = std::sync::mpsc::channel::<CreateRepoMsg>();
state.create_repo_rx = Some(rx);
let repo_name = crate::profile::SETTINGS_REPO_NAME.to_string();
state.modal = Modal::Info {
title: format!("Pushing settings to '{}'", repo_name),
lines: vec!["Preparing snapshot...".into()],
scroll: 0,
};
let cfg = state.cfg.clone();
std::thread::spawn(move || {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(CreateRepoMsg::Done {
success: false, name: repo_name, detail: format!("runtime error: {e}"),
});
return;
}
};
let _ = tx.send(CreateRepoMsg::Progress("Pushing...".into()));
match rt.block_on(crate::profile::push_to_repo(&snap, &cfg, &repo_name)) {
Ok(url) => { let _ = tx.send(CreateRepoMsg::Done { success: true, name: repo_name, detail: format!("Pushed to {url}") }); }
Err(e) => { let _ = tx.send(CreateRepoMsg::Done { success: false, name: repo_name, detail: format!("{e:#}") }); }
}
});
}
/// Pulls a settings snapshot from the settings-sync repo. Unlike push, the
/// result has to carry the fetched `SettingsSnapshot` back to the main
/// thread to be applied, so it uses the dedicated `ProfilePullMsg` channel
/// instead of `CreateRepoMsg`.
pub fn spawn_profile_pull(state: &mut HubState) {
use super::state::ProfilePullMsg;
let (tx, rx) = std::sync::mpsc::channel::<ProfilePullMsg>();
state.profile_pull_rx = Some(rx);
let repo_name = crate::profile::SETTINGS_REPO_NAME.to_string();
state.modal = Modal::Info {
title: format!("Pulling settings from '{}'", repo_name),
lines: vec!["Fetching...".into()],
scroll: 0,
};
let cfg = state.cfg.clone();
std::thread::spawn(move || {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(ProfilePullMsg::Done(Err(format!("runtime error: {e}"))));
return;
}
};
let result = rt.block_on(crate::profile::pull_from_repo(&cfg, &repo_name));
let _ = tx.send(ProfilePullMsg::Done(result.map_err(|e| format!("{e:#}"))));
});
}
/// Opens the affiliation picker for bulk-importing every repo on an account.
/// "Repos I own" is pre-checked and effectively can't be turned off (enforced
/// when the picker is confirmed, not in the checkbox itself — see the
/// `MultiTag::BulkImportAffiliation` handler).
pub fn open_bulk_import_picker(state: &mut HubState, acc_idx: usize) {
state.modal = Modal::Multi {
title: "Bulk Import — repo categories".into(),
options: vec![
"Repos I own".into(),
"Repos I collaborate on".into(),
"Repos from orgs I'm a member of".into(),
],
checks: vec![true, false, false],
cursor: 0,
tag: super::state::MultiTag::BulkImportAffiliation(acc_idx),
};
}
/// Fetches every repo matching `affiliation` via the account's own provider
/// API (no `gh` CLI dependency) and clones/pulls each into the workspace.
/// If a repo named `vulp-settings` turns up among them, offers nothing —
/// it auto-imports it immediately, since landing on this flow already means
/// "I trust this account's data" and a second confirmation would be noise.
pub fn spawn_bulk_import(state: &mut HubState, acc_idx: usize, affiliation: crate::providers::RepoAffiliation) {
let Some(acc) = state.accounts.get(acc_idx).cloned() else { return; };
let (tx, rx) = std::sync::mpsc::channel::<String>();
state.pull_all_rx = Some(rx);
state.status_msg = Some(format!("Fetching repos for @{}...", acc.username));
let workspace = state.workspace_root.clone();
let cfg = state.cfg.clone();
std::thread::spawn(move || {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
let _ = tx.send(format!("ERROR: runtime error: {e}"));
let _ = tx.send("DONE".into());
return;
}
};
rt.block_on(async {
let provider = match crate::providers::make_provider(&acc) {
Ok(p) => p,
Err(e) => { let _ = tx.send(format!("ERROR: {e:#}")); let _ = tx.send("DONE".into()); return; }
};
let repos = match provider.list_repos(&affiliation).await {
Ok(r) => r,
Err(e) => { let _ = tx.send(format!("ERROR: {e:#}")); let _ = tx.send("DONE".into()); return; }
};
let total = repos.len();
let _ = tx.send(format!("Found {} repos for @{} ({})", total, acc.username, acc.provider));
if repos.iter().any(|r| r.name == crate::profile::SETTINGS_REPO_NAME) {
let _ = tx.send(format!("Found your '{}' on this account — importing settings...", crate::profile::SETTINGS_REPO_NAME));
match crate::profile::pull_from_repo(&cfg, crate::profile::SETTINGS_REPO_NAME).await {
Ok(snap) => {
let mut cfg_mut = cfg.clone();
match crate::profile::apply(&snap, &mut cfg_mut) {
Ok(()) => { let _ = tx.send("SETTINGS_IMPORTED".into()); }
Err(e) => { let _ = tx.send(format!("Settings fetched but failed to apply: {e:#}")); }
}
}
Err(e) => { let _ = tx.send(format!("Settings import skipped: {e:#}")); }
}
}
// Disable interactive prompts: a bare `git clone`/`pull` on a private
// repo with no working SSH agent (or a credential helper that's
// itself stuck) will otherwise sit forever with no terminal to
// satisfy it. Env vars cover git's own prompts; the wall-clock
// timeout below covers everything else (slow/hung credential
// helpers, DNS, etc.) so one bad repo can't freeze the whole batch.
let no_prompt = |cmd: &mut std::process::Command| {
cmd.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
};
let run_with_timeout = |cmd: &mut std::process::Command| -> bool {
let Ok(mut child) = cmd.spawn() else { return false };
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
match child.try_wait() {
Ok(Some(status)) => return status.success(),
Ok(None) => {
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return false;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
Err(_) => return false,
}
}
};
for (i, repo) in repos.iter().enumerate() {
let target = workspace.join(&repo.name);
// `repo.url` is the human-facing html_url, not a git remote —
// append `.git` for the HTTPS fallback when there's no ssh_url.
let https_url = format!("{}.git", repo.url);
let url = if !repo.ssh_url.is_empty() { &repo.ssh_url } else { &https_url };
let action = if target.exists() {
let mut cmd = std::process::Command::new("git");
cmd.args(["pull"]).current_dir(&target);
no_prompt(&mut cmd);
if run_with_timeout(&mut cmd) { "pulled" } else { "pull failed" }
} else {
let mut cmd = std::process::Command::new("git");
cmd.args(["clone", url.as_str()]).current_dir(&workspace);
no_prompt(&mut cmd);
if run_with_timeout(&mut cmd) { "cloned" } else { "clone failed" }
};
let _ = tx.send(format!("[{}/{}] {} {}", i + 1, total, action, repo.name));
}
let _ = tx.send("DONE".into());
});
});
}
/// Orgs the default account can create repos under, if any. A single quick
/// blocking API call — fine for the repo-creation flow, which already pauses
/// for a name prompt anyway.
pub fn fetch_orgs_blocking(state: &HubState) -> Vec<String> {
let accounts = crate::auth::load_accounts(&state.cfg).unwrap_or_default();
let Some(acc) = crate::providers::default_account(&accounts, Some(&state.cfg.default_provider)).cloned() else {
return vec![];
};
// We're called from inside the already-running tokio runtime (main is
// #[tokio::main]), so block_on-ing here directly would panic with
// "Cannot start a runtime from within a runtime". Do the call on a plain
// OS thread instead, same trick spawn_create_repo uses above.
let handle = std::thread::spawn(move || {
let Ok(provider) = crate::providers::make_provider(&acc) else { return vec![]; };
let Ok(rt) = tokio::runtime::Runtime::new() else { return vec![]; };
rt.block_on(provider.list_orgs()).unwrap_or_default()
});
handle.join().unwrap_or_default()
}
/// Opens the template picker shown after naming a new repo, listing "Blank"
/// plus every saved `RepoTemplate`.
pub fn open_template_picker(state: &mut HubState, name: String, owner: Option<String>) {
let mut opts = vec!["Blank (no template)".to_string()];
for t in &state.templates {
let n = t.commands.len();
opts.push(format!("{:<20} ({} cmd{})", t.name, n, if n == 1 { "" } else { "s" }));
}
let title = match &owner {
Some(o) => format!("Create '{}' in @{} — Template", name, o),
None => format!("Create '{}' — Template", name),
};
state.modal = Modal::Select {
title,
options: opts,
cursor: 0,
tag: SelectTag::InitTemplate { name, owner },
};
}
/// Opens the Code Intelligence repo picker (previously bound to `m` on the
/// Stats tab, now reached through the action menu).
pub fn open_codeintel_picker(state: &mut HubState) {
if state.repos.is_empty() {
state.status_msg = Some("No repos in workspace".into());
return;
}
let options = state.repos.iter().map(|r| r.name.clone()).collect();
state.modal = Modal::Select {
title: "Code Intelligence — pick repo".into(),
options,
cursor: 0,
tag: SelectTag::CodeIntelRepoPick,
};
}
/// Opens the contextual `[m]` action menu: every action available on the
/// current tab (plus the cursor's repo/project/todo, if any), each entry
/// showing its direct key so the menu doubles as a cheat sheet. Selected
/// entries mostly re-dispatch their key through `handle_global_key`.
pub fn open_action_menu(state: &mut HubState) {
use super::state::{MenuAction, Tab};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let k = |c: char| MenuAction::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
let kc = |code: KeyCode| MenuAction::Key(KeyEvent::new(code, KeyModifiers::NONE));
let entry = |label: &str, hint: &str, action: MenuAction| {
(format!("{:<34}[{}]", label, hint), action)
};
let mut entries: Vec<(String, MenuAction)> = Vec::new();
if state.cur_repo_idx().is_some() {
entries.push(entry("Open repo detail", "↵", kc(KeyCode::Enter)));
entries.push(entry("Sync (pull / push)", "s", k('s')));
entries.push(entry("Create release", "r", k('r')));
entries.push(entry("Open in editor", "o", k('o')));
entries.push(entry("Open in file manager", "O", k('O')));
entries.push(entry("Open in browser", "W", k('W')));
entries.push(entry("Copy path", "y", k('y')));
entries.push(entry("Copy remote URL", "u", k('u')));
entries.push(entry("Copy URL (SSH/HTTPS)", "^u",
MenuAction::Key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL))));
entries.push(entry("Toggle bookmark", "B", k('B')));
entries.push(entry("Toggle pin", "P", k('P')));
entries.push(entry("Set display label", "n", k('n')));
entries.push(entry("Edit .gitignore", "g", k('g')));
entries.push(entry("Select for batch", "Space", kc(KeyCode::Char(' '))));
if let Some(ri) = state.cur_repo_idx() {
if state.repos[ri].is_dirty {
entries.push(entry("Stash changes", "", MenuAction::StashSave(ri)));
}
if state.repos[ri].stash_count > 0 {
entries.push(entry("Pop stash", "", MenuAction::StashPop(ri)));
}
}
} else if state.tab == Tab::Projects && state.cur_proj_idx().is_some() {
entries.push(entry("Open project detail", "↵", kc(KeyCode::Enter)));
entries.push(entry("Delete project", "Del", kc(KeyCode::Delete)));
}
// Bulk-aware (multi-select > cursor repo > project): only shown when at
// least one targeted repo is missing a license file.
if state.action_targets().iter().any(|&ri| !state.repos[ri].has_license) {
entries.push(entry("Add LICENSE (MIT/Apache)", "", MenuAction::AddLicense));
}
match state.tab {
Tab::Todos => {
entries.push(entry("Cycle todo status", "Space", kc(KeyCode::Char(' '))));
entries.push(entry("Expand/collapse group","↵", kc(KeyCode::Enter)));
entries.push(entry("Edit todo", "e", k('e')));
entries.push(entry("Delete todo", "Del", kc(KeyCode::Delete)));
entries.push(entry("New todo", "+", k('+')));
entries.push(entry("Cycle status view", "s", k('s')));
entries.push(entry("Cycle grouping", "g", k('g')));
entries.push(entry("Toggle code todos", "c", k('c')));
entries.push(entry("Scan repos for TODOs","T", k('T')));
}
Tab::PRs => {
entries.push(entry("Open PR in browser", "↵", kc(KeyCode::Enter)));
entries.push(entry("New PR", "", MenuAction::NewPr));
}
Tab::Issues => {
entries.push(entry("Open issue in browser","↵", kc(KeyCode::Enter)));
entries.push(entry("New issue", "", MenuAction::NewIssue));
}
Tab::Overview => {
entries.push(entry("New PR", "", MenuAction::NewPr));
entries.push(entry("New issue", "", MenuAction::NewIssue));
entries.push(entry("Open account profile","", MenuAction::OpenAccountProfile));
entries.push(entry("View release notes", "v", k('v')));
}
Tab::Stats => {
entries.push(entry("Code intelligence", "", MenuAction::CodeIntelPicker));
entries.push(entry("Cycle stats period", "p", k('p')));
}
_ => {}
}
entries.push(entry("New repository", "N", k('N')));
// 'p' on Stats is the period cycle (guarded arm runs first), so the
// project menu would be unreachable through re-dispatch there.
if state.tab != Tab::Stats {
entries.push(entry("Project menu", "p", k('p')));
}
entries.push(entry("Sort & filter", "S", k('S')));
entries.push(entry("Select by condition", "*", k('*')));
entries.push(entry("Refresh from API", "R", k('R')));
entries.push(entry("Help / keybindings", "?", k('?')));
let (options, actions): (Vec<String>, Vec<MenuAction>) = entries.into_iter().unzip();
state.modal = Modal::Select {
title: "Actions".into(),
options,
cursor: 0,
tag: SelectTag::ActionMenu(actions),
};
}
/// Builds the weekly digest in the background ([d] on Stats) and shows it
/// as a `Modal::Info` when done — a content preview before anything is ever
/// posted to Discord (see `crate::digest`).
pub fn spawn_digest_preview(state: &mut HubState) {
use super::state::DigestMsg;
if state.digest_rx.is_some() {
state.status_msg = Some("Digest already loading...".into());
return;
}
let repos = state.repos.clone();
let cfg = state.cfg.clone();
let accounts = crate::auth::load_accounts(&cfg).unwrap_or_default();
let (tx, rx) = std::sync::mpsc::channel::<DigestMsg>();
state.digest_rx = Some(rx);
state.status_msg = Some("Building weekly digest...".into());
std::thread::spawn(move || {
let rt = match tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(_) => return };
rt.block_on(async move {
let matcher = crate::stats::OwnEmailMatcher::resolve(&cfg, &accounts);
let default_account = accounts.iter().find(|a| a.is_default).or(accounts.first());
let mut owners: std::collections::HashSet<String> =
repos.iter().filter_map(|r| r.owner.clone()).collect();
if let Some(acc) = default_account { owners.insert(acc.username.clone()); }
let scope = owners.iter().map(|o| format!("user:{o}")).collect::<Vec<_>>().join(" ");
let provider = default_account.and_then(|acc| crate::providers::make_provider(acc).ok());
let d = crate::digest::build(&repos, &matcher, provider.as_deref(), &scope).await;
let lines: Vec<String> = crate::digest::render_plaintext(&d)
.lines().map(|s| s.to_string()).collect();
let _ = tx.send(DigestMsg { lines });
});
});
}
/// Runs the full API refresh ([R]) in the background: per-repo metadata,
/// open PR/issue lists, and workspace-wide merged/closed counters. Progress
/// streams through `state.refresh_rx` (polled by `keys_poll::poll_refresh`),
/// which also applies the results — the UI never blocks.
pub fn spawn_api_refresh(state: &mut HubState) {
use super::state::RefreshMsg;
if state.refresh_rx.is_some() {
state.status_msg = Some("Refresh already running...".into());
return;
}
let accounts = match crate::auth::load_accounts(&state.cfg) {
Ok(a) if !a.is_empty() => a,
_ => { state.status_msg = Some("No accounts configured — vulp auth add".into()); return; }
};
// (display name = cache key, owner/repo slug for the API, branch for
// the CI-run lookup). Org-owned repos are reachable through the personal
// token, so no owner filtering. The repo name comes from the remote URL,
// not the local folder name — they can differ (vulp's own folder is
// "vulp", its GitHub repo is "fxg").
let repos: Vec<(String, Option<String>, String, String)> = state.repos.iter()
.map(|r| {
let basename = r.remote_repo_name.clone().unwrap_or_else(|| {
r.path.file_name().map(|b| b.to_string_lossy().to_string()).unwrap_or_else(|| r.name.clone())
});
(r.name.clone(), r.owner.clone(), basename, r.branch.clone())
})
.collect();
// For the Tier-B local measurements (release cadence, repo size, deploy
// drift) below — those need the on-disk path, not the provider slug.
let repo_paths: Vec<(String, std::path::PathBuf)> = state.repos.iter()
.map(|r| (r.name.clone(), r.path.clone()))
.collect();
let cfg = state.cfg.clone();
let (tx, rx) = std::sync::mpsc::channel::<RefreshMsg>();
state.refresh_rx = Some(rx);
state.status_msg = Some("Refreshing from API...".into());
std::thread::spawn(move || {
use futures::stream::{self, StreamExt};
let rt = match tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(_) => return };
rt.block_on(async move {
let mut metas = Vec::new();
let mut prs = Vec::new();
let mut issues = Vec::new();
let mut closed_counts = None;
let account = accounts.iter().find(|a| a.is_default).or(accounts.first()).cloned();
let Some(account) = account else { return };
let Ok(provider) = crate::providers::make_provider(&account) else {
let _ = tx.send(RefreshMsg::Done { metas, prs, issues, closed_counts });
return;
};
let provider: std::sync::Arc<dyn crate::providers::Provider> = std::sync::Arc::from(provider);
let targets: Vec<(String, String, String)> = repos.iter()
.filter_map(|(name, owner, basename, branch)| {
owner.as_ref().map(|o| (name.clone(), format!("{}/{}", o, basename), branch.clone()))
})
.collect();
let total = targets.len();
let done = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
// Dependabot alert counts: one org-level request per distinct
// owner (personal owners 404 → None → unknown, shown as "—").
let mut dependabot: std::collections::HashMap<String, Option<std::collections::HashMap<String, u32>>> =
std::collections::HashMap::new();
{
let owners: std::collections::HashSet<String> =
repos.iter().filter_map(|(_, o, _, _)| o.clone()).collect();
for owner in owners {
let _ = tx.send(RefreshMsg::Progress(format!("Dependabot alerts: {}", owner)));
let counts = provider.org_dependabot_counts(&owner).await.ok().flatten();
dependabot.insert(owner.to_lowercase(), counts);
}
}
// Deploy drift: one batched remote command (if configured) lists
// every mirrored repo's HEAD sha; comparing against local HEAD is
// then a cheap per-repo git call, folded into the metas loop below.
let deploy_heads = if crate::deploy::is_configured(&cfg) {
let _ = tx.send(RefreshMsg::Progress("Checking deploy drift...".into()));
crate::deploy::scan_deploy_heads(&cfg)
} else {
None
};
// Up to 6 repos in flight — sequential fetch of ~50 repos × 4
// requests took 40-80 s; this brings it to ~10 s without tripping
// GitHub's secondary rate limits.
let results: Vec<_> = stream::iter(targets.into_iter().map(|(name, slug, branch)| {
let provider = provider.clone();
let tx = tx.clone();
let done = done.clone();
async move {
let meta = provider.repo_meta(&slug).await.ok();
let p = provider.list_pull_requests(&slug).await.ok();
let i = provider.list_issues(&slug).await.ok();
let ci = provider.latest_ci_status(&slug, &branch).await.ok().flatten();
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
let _ = tx.send(RefreshMsg::Progress(format!("Refreshed {}/{}: {}", n, total, name)));
(name, slug, meta, p, i, ci)
}
}))
.buffer_unordered(6)
.collect()
.await;
for (name, slug, meta, p, i, ci) in results {
if let Some(meta) = meta {
let slug_lc = slug.to_lowercase();
let dep = dependabot
.get(slug_lc.split('/').next().unwrap_or(""))
.and_then(|per_org| per_org.as_ref()
.map(|m| m.get(&slug_lc).copied().unwrap_or(0)));
let deploy_status = deploy_heads.as_ref().and_then(|heads| {
let path = &repo_paths.iter().find(|(n, _)| n == &name)?.1;
crate::deploy::drift_for(path, &cfg.workspace_root, heads)
}).map(|s| s.as_str());
metas.push((name.clone(), crate::cache::RepoMeta {
stars: Some(meta.stars),
open_issues: Some(meta.open_issues),
open_prs: Some(meta.open_prs),
visibility: None,
ci_status: ci.map(|c| c.as_str().to_string()),
dependabot_open: dep,
deploy_status,
fetched_at: chrono::Utc::now().timestamp(),
}));
}
if let Some(p) = p { prs.push((name.clone(), p)); }
if let Some(i) = i { issues.push((name, i)); }
}
// Workspace-wide merged/closed counters — one search query per
// metric, scoped to every owner we know.
{
let mut owners: std::collections::HashSet<String> =
repos.iter().filter_map(|(_, o, _, _)| o.clone()).collect();
owners.insert(account.username.clone());
let scope = owners.iter().map(|o| format!("user:{}", o)).collect::<Vec<_>>().join(" ");
let _ = tx.send(RefreshMsg::Progress("Counting merged/closed...".into()));
let merged = provider.count_search(&format!("is:pr is:merged {}", scope)).await.unwrap_or(0);
let closed = provider.count_search(&format!("is:pr is:closed is:unmerged {}", scope)).await.unwrap_or(0);
let ciss = provider.count_search(&format!("is:issue is:closed {}", scope)).await.unwrap_or(0);
closed_counts = Some((merged, closed, ciss));
}
// Tier-B local measurements (release cadence, repo size) — no
// 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);
let _ = tx.send(RefreshMsg::Done { metas, prs, issues, closed_counts });
});
});
}