A dead PAT returns GitHub's actual 401 body ('Bad credentials'), which
matched none of the auth_fail substrings — it fell into the 'API
error, but authenticated' branch and never prompted the user to
re-run vulp auth import. Added 401/'bad credentials' to the check.
412 lines
18 KiB
Rust
412 lines
18 KiB
Rust
//! Background I/O for the repo-detail view: fetching activity-chart data,
|
|
//! loading/mutating Settings-tab data via the hosting provider's API, and
|
|
//! fetching PRs/issues. All the API calls run on a spawned thread with its
|
|
//! own short-lived Tokio runtime so the TUI's draw loop never blocks on
|
|
//! network I/O; results come back over an `mpsc` channel that the relevant
|
|
//! `poll_*` function drains each frame.
|
|
|
|
use super::git::git_run_ok;
|
|
use super::types::{DetailModal, RepoSettings, SettingOp, St, StatsPeriod};
|
|
use super::{activity_1d, activity_30d, activity_7d, activity_90d, activity_1y, activity_all};
|
|
use anyhow::Result;
|
|
|
|
/// Returns the Stats tab's activity series for `period`, recomputed from
|
|
/// scratch each time rather than cached, since switching periods is rare
|
|
/// enough that the recompute cost doesn't matter.
|
|
pub fn compute_activity(path: &std::path::Path, period: StatsPeriod) -> Vec<(String, usize)> {
|
|
match period {
|
|
StatsPeriod::Day1 => activity_1d(path),
|
|
StatsPeriod::Day7 => activity_7d(path),
|
|
StatsPeriod::Day30 => activity_30d(path),
|
|
StatsPeriod::Day90 => activity_90d(path),
|
|
StatsPeriod::Year1 => activity_1y(path),
|
|
StatsPeriod::AllTime => activity_all(path),
|
|
}
|
|
}
|
|
|
|
/// Settings fallback shown when there's no provider account configured at
|
|
/// all — `gh_auth_ok: false` tells the Settings tab to render an "add an
|
|
/// account" hint instead of treating this as a transient API failure.
|
|
pub fn settings_fallback(st: &St) -> RepoSettings {
|
|
RepoSettings {
|
|
visibility: st.summary.visibility.clone().unwrap_or_else(|| "unknown".into()),
|
|
description: String::new(),
|
|
topics: vec![],
|
|
gh_auth_ok: false,
|
|
}
|
|
}
|
|
|
|
/// Settings fallback shown when an account exists but a specific API call
|
|
/// failed — `gh_auth_ok: true` keeps the Settings tab's edit actions enabled
|
|
/// instead of hiding them behind an auth-missing message.
|
|
pub fn settings_fallback_auth(st: &St) -> RepoSettings {
|
|
RepoSettings {
|
|
visibility: st.summary.visibility.clone().unwrap_or_else(|| "unknown".into()),
|
|
description: String::new(),
|
|
topics: vec![],
|
|
gh_auth_ok: true,
|
|
}
|
|
}
|
|
|
|
/// Drains the in-flight Settings fetch, if any, and updates `st.settings`/
|
|
/// `st.settings_err`. Distinguishes an auth failure (no usable account) from
|
|
/// an API error with an existing account, since only the latter should keep
|
|
/// any already-loaded settings visible rather than falling back.
|
|
pub fn poll_settings_rx(st: &mut St) {
|
|
let done = match &st.settings_rx {
|
|
None => return,
|
|
Some(rx) => match rx.try_recv() {
|
|
Ok(r) => Some(r),
|
|
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Err("Channel disconnected".into())),
|
|
},
|
|
};
|
|
st.settings_rx = None;
|
|
match done {
|
|
Some(Ok(s)) => {
|
|
st.settings = Some(s);
|
|
st.settings_err = None;
|
|
}
|
|
Some(Err(e)) => {
|
|
// A revoked/expired token doesn't surface any of the "no
|
|
// account configured" phrasing below — it comes back from the
|
|
// provider as an HTTP 401 with a body like GitHub's literal
|
|
// "Bad credentials", which needs its own check so the user gets
|
|
// steered toward `vulp auth import` instead of a generic error.
|
|
let lower = e.to_lowercase();
|
|
let auth_fail = e.contains("No GitHub account") || e.contains("no account")
|
|
|| e.contains("no github") || e.contains("authentication")
|
|
|| lower.contains("bad credentials") || lower.contains(" 401 ");
|
|
if auth_fail {
|
|
st.settings = Some(settings_fallback(st));
|
|
st.settings_err = Some(e);
|
|
} else {
|
|
// API error, but we are authenticated — keep showing stale data, show error
|
|
if st.settings.is_none() { st.settings = Some(settings_fallback_auth(st)); }
|
|
st.settings_err = Some(format!("API error: {}", e));
|
|
}
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
/// Kicks off a background fetch of the repo's current settings from the
|
|
/// hosting provider, unless one is already in flight.
|
|
pub fn spawn_settings_fetch(st: &mut St) {
|
|
if st.settings_rx.is_some() { return; } // already in flight
|
|
let data_dir = st.cfg_data_dir.clone();
|
|
let repo_name = st.summary.name.clone();
|
|
let vis = st.summary.visibility.clone().unwrap_or_else(|| "unknown".into());
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
|
|
std::thread::spawn(move || {
|
|
let rt = tokio::runtime::Runtime::new().expect("tokio rt");
|
|
let result: Result<RepoSettings, String> = rt.block_on(async move {
|
|
let accounts = crate::auth::load_accounts_path(&data_dir)
|
|
.map_err(|e| e.to_string())?;
|
|
let account = crate::providers::default_account(&accounts, None)
|
|
.ok_or_else(|| "No GitHub account — run: vulp auth import".to_string())?;
|
|
let provider = crate::providers::make_provider(account)
|
|
.map_err(|e| e.to_string())?;
|
|
let meta = provider.repo_meta(&repo_name).await
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(RepoSettings {
|
|
visibility: vis,
|
|
description: meta.description.unwrap_or_default(),
|
|
topics: meta.topics,
|
|
gh_auth_ok: true,
|
|
})
|
|
});
|
|
let _ = tx.send(result);
|
|
});
|
|
|
|
st.settings_rx = Some(rx);
|
|
}
|
|
|
|
// ── settings modal opener ─────────────────────────────────────────────────────
|
|
|
|
/// Opens the appropriate modal for the Settings-tab row at `st.settings_cursor`.
|
|
/// Each row maps to a fixed `tag`, later read back by `handle_modal_key` to
|
|
/// route the submitted value to the right `SettingOp`.
|
|
pub fn open_settings_modal(st: &mut St) {
|
|
let cur_vis = st.settings.as_ref().map(|s| s.visibility.clone()).or_else(|| st.summary.visibility.clone()).unwrap_or_default();
|
|
let cur_desc = st.settings.as_ref().map(|s| s.description.clone()).unwrap_or_default();
|
|
let cur_top = st.settings.as_ref().map(|s| s.topics.join(", ")).unwrap_or_default();
|
|
let repo = st.summary.name.clone();
|
|
match st.settings_cursor {
|
|
0 => {
|
|
let cursor = if cur_vis == "private" { 1 } else { 0 };
|
|
st.modal = DetailModal::SelectOption {
|
|
title: "Visibility".into(),
|
|
options: vec!["public".into(), "private".into()],
|
|
cursor, tag: 60,
|
|
};
|
|
}
|
|
1 => {
|
|
st.modal = DetailModal::EditText {
|
|
title: "Description".into(),
|
|
prompt: "New description (blank to clear):".into(),
|
|
input: cur_desc, tag: 61,
|
|
};
|
|
}
|
|
2 => {
|
|
st.modal = DetailModal::EditText {
|
|
title: "Topics".into(),
|
|
prompt: "Comma-separated (e.g. rust, cli, tool). No spaces within words.".into(),
|
|
input: cur_top, tag: 62,
|
|
};
|
|
}
|
|
3 => {
|
|
st.modal = DetailModal::EditText {
|
|
title: "Add Collaborator".into(),
|
|
prompt: "GitHub username:".into(),
|
|
input: String::new(), tag: 63,
|
|
};
|
|
}
|
|
4 => {
|
|
st.modal = DetailModal::ConfirmAction {
|
|
message: format!("Archive '{}' on GitHub?", repo),
|
|
tag: 64,
|
|
};
|
|
}
|
|
5 => {
|
|
st.modal = DetailModal::EditText {
|
|
title: "Rename Repository".into(),
|
|
prompt: "New name:".into(),
|
|
input: repo, tag: 65,
|
|
};
|
|
}
|
|
6 => {
|
|
let name = st.summary.name.clone();
|
|
st.modal = DetailModal::ConfirmAction {
|
|
message: format!("Permanently delete '{}' on GitHub AND locally?\nThis cannot be undone.", name),
|
|
tag: 66,
|
|
};
|
|
}
|
|
7 => { st.run_action = Some(107); }
|
|
8 => { st.run_action = Some(108); }
|
|
9 => { st.run_action = Some(109); }
|
|
10 => {
|
|
let ok = git_run_ok(&st.summary.path, &["pull"]);
|
|
st.status_msg = Some(if ok { "Pulled successfully".into() } else { "Pull failed — check remote".into() });
|
|
if ok { st.summary.behind = 0; }
|
|
}
|
|
11 => {
|
|
st.modal = DetailModal::EditText {
|
|
title: "Commit".into(),
|
|
prompt: "Commit message (stages all changes):".into(),
|
|
input: String::new(),
|
|
tag: 70,
|
|
};
|
|
}
|
|
12 => {
|
|
let ok = git_run_ok(&st.summary.path, &["push"]);
|
|
st.status_msg = Some(if ok { "Pushed successfully".into() } else { "Push failed — check remote".into() });
|
|
if ok { st.summary.ahead = 0; }
|
|
}
|
|
13 => {
|
|
let ok = git_run_ok(&st.summary.path, &["stash"]);
|
|
st.status_msg = Some(if ok { "Stash saved".into() } else { "Stash failed".into() });
|
|
if ok { st.summary.is_dirty = false; st.stash_count += 1; }
|
|
}
|
|
14 => {
|
|
let ok = git_run_ok(&st.summary.path, &["stash", "pop"]);
|
|
st.status_msg = Some(if ok { "Stash popped".into() } else { "Stash pop failed — no stash?".into() });
|
|
if ok {
|
|
if st.stash_count > 0 { st.stash_count -= 1; }
|
|
let out = std::process::Command::new("git")
|
|
.arg("-C").arg(&st.summary.path).args(["status", "--porcelain"])
|
|
.output();
|
|
if let Ok(o) = out {
|
|
st.summary.is_dirty = !o.stdout.is_empty();
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// ── settings API background operations ───────────────────────────────────────
|
|
|
|
/// Kicks off a background mutation (`op`) against the hosting provider's
|
|
/// API. Refuses to start a second one while one is already in flight rather
|
|
/// than queuing, since these are all user-triggered one-shot actions.
|
|
pub fn spawn_settings_op(st: &mut St, op: SettingOp) {
|
|
if st.api_rx.is_some() {
|
|
st.status_msg = Some("Another operation in progress...".into());
|
|
return;
|
|
}
|
|
let data_dir = st.cfg_data_dir.clone();
|
|
let repo_name = st.summary.name.clone();
|
|
let repo_path = st.summary.path.clone();
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
st.quit_after_op = matches!(op, SettingOp::Delete);
|
|
|
|
std::thread::spawn(move || {
|
|
let rt = tokio::runtime::Runtime::new().expect("tokio rt");
|
|
let result: Result<String, String> = rt.block_on(async move {
|
|
let accounts = crate::auth::load_accounts_path(&data_dir)
|
|
.map_err(|e| e.to_string())?;
|
|
let account = crate::providers::default_account(&accounts, Some("github"))
|
|
.ok_or_else(|| "No GitHub account — run: vulp auth import".to_string())?;
|
|
let provider = crate::providers::make_provider(account)
|
|
.map_err(|e| e.to_string())?;
|
|
match op {
|
|
SettingOp::Visibility { private } => {
|
|
provider.update_visibility(&repo_name, private).await
|
|
.map(|_| format!("Visibility → {}", if private { "private" } else { "public" }))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
SettingOp::Description { text } => {
|
|
provider.update_description(&repo_name, &text).await
|
|
.map(|_| "Description updated".to_string())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
SettingOp::Topics { topics } => {
|
|
provider.set_topics(&repo_name, &topics).await
|
|
.map(|_| "Topics updated".to_string())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
SettingOp::AddCollaborator { name, role } => {
|
|
provider.add_collaborator(&repo_name, &name, &role).await
|
|
.map(|_| format!("Invited @{} as {}", name, role))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
SettingOp::Archive => {
|
|
provider.archive_repo(&repo_name).await
|
|
.map(|_| "Repository archived".to_string())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
SettingOp::Rename { new_name } => {
|
|
provider.rename_repo(&repo_name, &new_name).await
|
|
.map(|_| format!("Renamed to '{}'", new_name))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
SettingOp::Delete => {
|
|
provider.delete_repo(&repo_name).await.map_err(|e| e.to_string())?;
|
|
// If it's a symlink (linked from elsewhere into the workspace),
|
|
// only remove the link — never delete the real target folder.
|
|
let is_symlink = repo_path.symlink_metadata()
|
|
.map(|m| m.file_type().is_symlink())
|
|
.unwrap_or(false);
|
|
let local_result = if is_symlink {
|
|
std::fs::remove_file(&repo_path)
|
|
} else {
|
|
std::fs::remove_dir_all(&repo_path)
|
|
};
|
|
match local_result {
|
|
Ok(()) => Ok("Deleted on GitHub and locally".to_string()),
|
|
Err(e) => Err(format!("Deleted on GitHub, but local delete failed: {}", e)),
|
|
}
|
|
}
|
|
}
|
|
});
|
|
let _ = tx.send(result);
|
|
});
|
|
|
|
st.api_rx = Some(rx);
|
|
st.status_msg = Some("Updating GitHub...".into());
|
|
}
|
|
|
|
/// Drains the in-flight Settings-API mutation, if any, and surfaces its
|
|
/// result as a status message. A successful `Delete` leaves the repo gone,
|
|
/// so it also signals the view to quit back to the hub rather than keep
|
|
/// showing a now-nonexistent repo.
|
|
pub fn poll_api_rx(st: &mut St) {
|
|
let done = match &st.api_rx {
|
|
None => return,
|
|
Some(rx) => match rx.try_recv() {
|
|
Ok(r) => Some(r),
|
|
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Err("Connection lost".into())),
|
|
},
|
|
};
|
|
if let Some(result) = done {
|
|
let success = result.is_ok();
|
|
st.status_msg = Some(match result {
|
|
Ok(msg) => msg,
|
|
Err(msg) => format!("⚠ {}", msg),
|
|
});
|
|
st.api_rx = None;
|
|
// Delete leaves the repo (and possibly the folder) gone — staying in
|
|
// detail view would just keep showing stale data for something that
|
|
// no longer exists, so bounce back to the hub list once it succeeds.
|
|
if success && st.quit_after_op {
|
|
st.quit = true;
|
|
}
|
|
st.quit_after_op = false;
|
|
}
|
|
}
|
|
|
|
/// Drains the in-flight PRs/issues fetch, if any, and stores the result.
|
|
/// `prs_loaded` is set unconditionally (even on a disconnect) so a failed
|
|
/// fetch doesn't get silently retried forever on every tab switch.
|
|
pub fn poll_prs_issues_rx(st: &mut St) {
|
|
let done = match &st.prs_issues_rx {
|
|
None => return,
|
|
Some(rx) => match rx.try_recv() {
|
|
Ok(r) => Some(Ok(r)),
|
|
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Err(())),
|
|
},
|
|
};
|
|
st.prs_issues_rx = None;
|
|
st.prs_loaded = true;
|
|
if let Some(Ok((prs, issues))) = done {
|
|
st.repo_prs = prs;
|
|
st.repo_issues = issues;
|
|
}
|
|
}
|
|
|
|
/// Kicks off a background fetch of the repo's PRs and issues, unless one is
|
|
/// already in flight or already loaded this session.
|
|
pub fn spawn_prs_issues_fetch(st: &mut St) {
|
|
if st.prs_issues_rx.is_some() || st.prs_loaded { return; }
|
|
let data_dir = st.cfg_data_dir.clone();
|
|
let repo_name = st.summary.name.clone();
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
|
|
std::thread::spawn(move || {
|
|
let rt = tokio::runtime::Runtime::new().expect("tokio rt");
|
|
let result = rt.block_on(async move {
|
|
let accounts = crate::auth::load_accounts_path(&data_dir).ok()?;
|
|
let account = crate::providers::default_account(&accounts, None)?;
|
|
let provider = crate::providers::make_provider(account).ok()?;
|
|
let prs = provider.list_pull_requests(&repo_name).await.unwrap_or_default();
|
|
let issues = provider.list_issues(&repo_name).await.unwrap_or_default();
|
|
Some((prs, issues))
|
|
});
|
|
let _ = tx.send(result.unwrap_or_default());
|
|
});
|
|
|
|
st.prs_issues_rx = Some(rx);
|
|
st.status_msg = Some("Fetching PRs and issues...".into());
|
|
}
|
|
|
|
// ── provider helpers ──────────────────────────────────────────────────────────
|
|
|
|
/// Builds a provider client from the default GitHub account, failing with a
|
|
/// user-facing hint (`vulp auth import`) rather than a raw error when none
|
|
/// is configured.
|
|
pub async fn try_make_provider(st: &St) -> Result<Box<dyn crate::providers::Provider>> {
|
|
let accounts = crate::auth::load_accounts_path(&st.cfg_data_dir)?;
|
|
let account = crate::providers::default_account(&accounts, Some("github"))
|
|
.ok_or_else(|| anyhow::anyhow!("no github account — run: vulp auth import"))?;
|
|
crate::providers::make_provider(account)
|
|
}
|
|
|
|
/// Synchronous (non-channel) settings load used by the legacy CLI-prompt
|
|
/// action arms in `actions.rs` — unlike `spawn_settings_fetch`, this blocks
|
|
/// the caller until the API call returns.
|
|
pub async fn try_load_settings(st: &mut St) -> Result<()> {
|
|
let provider = try_make_provider(st).await?;
|
|
let meta = provider.repo_meta(&st.summary.name).await?;
|
|
st.settings = Some(RepoSettings {
|
|
visibility: st.summary.visibility.clone().unwrap_or_else(|| "unknown".into()),
|
|
description: meta.description.unwrap_or_default(),
|
|
topics: meta.topics,
|
|
gh_auth_ok: true,
|
|
});
|
|
Ok(())
|
|
}
|