Phases 0-1 + Sprint A: module split, CLI layer, org-refresh fix, hygiene

- LICENSE (MIT) + README truth-pass; docs/cli.md CLI contract
- CLI subcommands (src/cli_cmds/) per "TUI for users, CLI for users+AI"
- Hub/detail refactor: keymap dispatch, modal split, action enum, BgMsg bus
- Fix: org repos were never refreshed (owner==username match); refresh now
  parallel — 49 repos in ~11s, surfaced 70 previously invisible PRs
- Fix: 3 date bugs in detail/git.rs (format-local, Local::now, 1y month math)
- Stash indicator + [m] actions, session persistence, gh CLI token fallback
- Legacy UI modules removed; plan/ docs (roadmap, audit, debate, handoff)
This commit is contained in:
dualfroz committed 2026-07-06 14:42:52 +02:00
1 parent 5787b14880
commit 0468c032c6
100 files changed
+11614 -9840

No files matched your search

+769
View File
@@ -0,0 +1,769 @@
//! 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)));
}
match state.tab {
Tab::Todos => {
entries.push(entry("Cycle todo status", "Space", kc(KeyCode::Char(' '))));
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("Toggle code todos", "X", k('X')));
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),
};
}
/// 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/basename slug for the API). Org-owned
// repos are reachable through the personal token, so no owner filtering.
let repos: Vec<(String, Option<String>, String)> = state.repos.iter()
.map(|r| {
let basename = 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)
})
.collect();
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)> = repos.iter()
.filter_map(|(name, owner, basename)| {
owner.as_ref().map(|o| (name.clone(), format!("{}/{}", o, basename)))
})
.collect();
let total = targets.len();
let done = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
// 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)| {
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 n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
let _ = tx.send(RefreshMsg::Progress(format!("Refreshed {}/{}: {}", n, total, name)));
(name, meta, p, i)
}
}))
.buffer_unordered(6)
.collect()
.await;
for (name, meta, p, i) in results {
if let Some(meta) = meta {
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_ok: None,
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));
}
let _ = tx.send(RefreshMsg::Done { metas, prs, issues, closed_counts });
});
});
}