Files
vulp/src/hub/modals_confirm_sync.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

444 lines
22 KiB
Rust

//! `process_modal_confirm` arms for sort/filter, repo creation entry points,
//! select-by-condition, the in-TUI sync flow, the contextual [m] action menu
//! (incl. PR/Issue creation), and the code-intelligence action chain.
use super::modals_spawn::{
spawn_convention_preview, spawn_create_issue, spawn_create_pr, spawn_find_usages,
spawn_list_functions, spawn_repo_stats, spawn_rename_preview,
};
use super::state::{ConfirmTag, HubState, MenuAction, Modal, PhaseResult, SelectTag, SortMode, TextTag};
/// Handles one step of a sort/filter, repo-creation, sync, Quick Actions, or
/// code-intel modal chain. Returns `None` once `modal` has been consumed by
/// one of this file's arms, or `Some(modal)` unchanged so the next file in
/// the chain (see `modals::process_modal_confirm`) can try it.
pub fn try_confirm(state: &mut HubState, modal: Modal) -> Option<Modal> {
match modal {
Modal::Select { cursor, tag: SelectTag::SortFilter, .. } => {
match cursor {
0 => state.sort_mode = SortMode::Default,
1 => state.sort_mode = SortMode::Name,
2 => state.sort_mode = SortMode::Age,
3 => state.sort_mode = SortMode::DirtyFirst,
4 => state.sort_mode = SortMode::BehindFirst,
5 => state.filter_dirty = !state.filter_dirty,
6 => state.filter_behind = !state.filter_behind,
7 => state.filter_ahead = !state.filter_ahead,
8 => state.filter_archived = !state.filter_archived,
9 => {
state.cfg.hide_external_clones = !state.cfg.hide_external_clones;
let _ = crate::config::save_config(&state.cfg);
}
10 => {
state.sort_mode = SortMode::Default;
state.filter_dirty = false;
state.filter_behind = false;
state.filter_ahead = false;
state.filter_archived = false;
}
_ => {}
}
state.rebuild_display();
None
}
Modal::Select { cursor, tag: SelectTag::NewRepo, .. } => {
match cursor {
0 => {
let prompt = format!("Git URL (clones into {}):", state.workspace_root.display());
state.modal = Modal::Text { title: "Clone from URL".into(), prompt, value: String::new(), tag: TextTag::CloneUrl, max_len: None };
}
1 => {
state.modal = Modal::Text {
title: "Link local folder".into(),
prompt: "Absolute path to folder (~ supported):".into(),
value: String::new(),
tag: TextTag::LinkPath,
max_len: None,
};
}
2 => {
let prompt = format!("Folder name (in {}):", state.workspace_root.display());
state.modal = Modal::Text { title: "Create new repo".into(), prompt, value: String::new(), tag: TextTag::InitName, max_len: Some(60) };
}
3 => {
state.modal = Modal::Text { title: "Link existing folder".into(), prompt: "Absolute path:".into(), value: String::new(), tag: TextTag::LinkPath, max_len: None };
}
_ => {}
}
None
}
Modal::Select { cursor, tag: SelectTag::SelectByCondition, .. } => {
let repos = &state.repos;
let candidates: Vec<usize> = (0..repos.len()).filter(|&ri| state.passes_filter(ri)).collect();
state.selected.clear();
match cursor {
0 => { for ri in candidates { state.selected.insert(ri); } }
1 => { for ri in candidates { if repos[ri].is_dirty { state.selected.insert(ri); } } }
2 => { for ri in candidates { if repos[ri].behind > 0 { state.selected.insert(ri); } } }
3 => { for ri in candidates { if repos[ri].ahead > 0 { state.selected.insert(ri); } } }
4 => { for ri in candidates { if !repos[ri].has_license { state.selected.insert(ri); } } }
5 => { for ri in candidates { if repos[ri].lang.as_deref() == Some("Rust") { state.selected.insert(ri); } } }
6 => { for ri in candidates { if repos[ri].lang.as_deref() == Some("TypeScript") { state.selected.insert(ri); } } }
7 => { for ri in candidates { if repos[ri].lang.as_deref() == Some("Python") { state.selected.insert(ri); } } }
8 => { for ri in candidates { if repos[ri].lang.as_deref() == Some("Go") { state.selected.insert(ri); } } }
_ => {}
}
state.status_msg = Some(format!("Selected {} repos", state.selected.len()));
None
}
// ── Hub sync via in-TUI modals ────────────────────────────────────────
Modal::Select { cursor, tag: SelectTag::SyncAction, .. } => {
match cursor {
0 => { // Pull
state.pending_sync_action = Some("pull".into());
state.pending_phase = Some(PhaseResult::ExecSync);
}
1 => { // Commit+Push
state.pending_sync_action = Some("commit_push".into());
state.modal = Modal::Text {
title: "Sync — Commit Message".into(),
prompt: "Message (stages all changes):".into(),
value: "chore: update".into(),
tag: TextTag::SyncMsg,
max_len: None,
};
}
_ => { // Push only
state.pending_sync_action = Some("push_only".into());
state.pending_phase = Some(PhaseResult::ExecSync);
}
}
None
}
Modal::Text { value, tag: TextTag::SyncMsg, .. } => {
state.pending_sync_msg = Some(value);
state.pending_phase = Some(PhaseResult::ExecSync);
None
}
// ── Contextual [m] action menu ─────────────────────────────────────────
Modal::Select { cursor, tag: SelectTag::ActionMenu(actions), .. } => {
match actions.get(cursor) {
Some(MenuAction::Key(k)) => {
// Re-dispatch the entry's key through the global keymap.
// `state.modal` is already None here (mem::replace in
// process_modal_confirm), so a follow-up modal opened by
// the handler lands cleanly.
if let Some(phase) = super::keys_dispatch::handle_global_key(state, *k) {
state.pending_phase = Some(phase);
}
}
Some(MenuAction::NewPr) => {
state.modal = Modal::Text {
title: "New PR — Repository".into(),
prompt: "Repo name:".into(),
value: String::new(),
tag: TextTag::PrRepo,
max_len: Some(80),
};
}
Some(MenuAction::NewIssue) => {
state.modal = Modal::Text {
title: "New Issue — Repository".into(),
prompt: "Repo name:".into(),
value: String::new(),
tag: TextTag::IssueRepo,
max_len: Some(80),
};
}
Some(MenuAction::OpenAccountProfile) => {
if let Some(acc) = state.accounts.first() {
let host = acc.base_url.clone().unwrap_or_else(|| match acc.provider.as_str() {
"gitlab" => "https://gitlab.com".into(),
_ => "https://github.com".into(),
});
let url = format!("{}/{}", host.trim_end_matches('/'), acc.username);
crate::util::open_path(&url);
state.status_msg = Some("Opening profile in browser...".into());
}
}
Some(MenuAction::CodeIntelPicker) => super::modals_spawn::open_codeintel_picker(state),
Some(MenuAction::AddLicense) => {
let targets: Vec<usize> = state.action_targets().into_iter()
.filter(|&ri| !state.repos[ri].has_license)
.collect();
if targets.is_empty() {
state.status_msg = Some("All targeted repos already have a LICENSE".into());
} else {
let cursor = match crate::license::default_kind(&state.cfg) {
crate::license::LicenseKind::Apache2 => 1,
crate::license::LicenseKind::Mit => 0,
};
state.modal = Modal::Select {
title: format!("Add LICENSE — {} repo{}", targets.len(), if targets.len() == 1 { "" } else { "s" }),
options: vec!["MIT".into(), "Apache-2.0".into()],
cursor,
tag: SelectTag::LicenseKind { targets },
};
}
}
Some(MenuAction::StashSave(ri)) | Some(MenuAction::StashPop(ri)) => {
let ri = *ri;
let repo = state.repos.get(ri)?;
let (args, verb): (&[&str], _) = match actions.get(cursor) {
Some(MenuAction::StashPop(_)) => (&["stash", "pop"], "popped"),
_ => (&["stash"], "stashed"),
};
let path = repo.path.clone();
let name = repo.name.clone();
if crate::util::git::git_run_ok(&path, args) {
// Point-refresh just this repo — no full rescan needed.
if let Ok(fresh) = crate::status::repo_summary(&path) {
let bookmarked = state.repos[ri].bookmarked;
let (stars, oi, op) = (state.repos[ri].stars, state.repos[ri].open_issues, state.repos[ri].open_prs);
state.repos[ri] = fresh;
state.repos[ri].bookmarked = bookmarked;
state.repos[ri].stars = stars;
state.repos[ri].open_issues = oi;
state.repos[ri].open_prs = op;
}
state.status_msg = Some(format!("{}: {}", name, verb));
} else {
state.status_msg = Some(format!("git stash failed on {} (conflict?)", name));
}
}
None => {}
}
None
}
// ── Add LICENSE ([m] action, bulk over action_targets) ────────────────
Modal::Select { cursor, tag: SelectTag::LicenseKind { targets }, .. } => {
let kind = if cursor == 1 { crate::license::LicenseKind::Apache2 } else { crate::license::LicenseKind::Mit };
let names: Vec<&str> = targets.iter().take(6)
.filter_map(|&ri| state.repos.get(ri).map(|r| r.name.as_str()))
.collect();
let more = targets.len().saturating_sub(names.len());
let list = if more > 0 {
format!("{} +{} more", names.join(", "), more)
} else {
names.join(", ")
};
state.modal = Modal::Confirm {
message: format!(
"Write {} LICENSE to {} repo{}? ({}) — not committed",
kind.label(), targets.len(), if targets.len() == 1 { "" } else { "s" }, list,
),
tag: ConfirmTag::WriteLicense { kind, targets },
};
None
}
Modal::Confirm { tag: ConfirmTag::WriteLicense { kind, targets }, .. } => {
let holder = crate::license::holder_for(&state.cfg);
let year = chrono::Datelike::year(&chrono::Utc::now());
let (mut written, mut skipped, mut failed) = (0usize, 0usize, 0usize);
for ri in targets {
let Some(repo) = state.repos.get(ri) else { continue };
let (path, name) = (repo.path.clone(), repo.name.clone());
match crate::license::write_license(&path, kind, &holder, year) {
Ok(crate::license::WriteOutcome::Written) => {
written += 1;
state.repos[ri].has_license = true;
// New untracked LICENSE file — the repo is dirty now.
state.repos[ri].is_dirty = true;
crate::activity::log(&crate::activity::ActivityEntry {
ts: chrono::Utc::now().timestamp(),
kind: crate::activity::ActivityKind::License,
repo: name,
detail: format!("{} ({})", kind.label(), holder),
});
}
Ok(crate::license::WriteOutcome::AlreadyLicensed) => {
skipped += 1;
state.repos[ri].has_license = true;
}
Err(_) => failed += 1,
}
}
let mut msg = format!("LICENSE ({}): {} written", kind.label(), written);
if skipped > 0 { msg.push_str(&format!(", {} already licensed", skipped)); }
if failed > 0 { msg.push_str(&format!(", {} FAILED", failed)); }
state.status_msg = Some(msg);
None
}
Modal::Text { value, tag: TextTag::PrRepo, .. } => {
let repo = value.trim().to_string();
if repo.is_empty() { return None; }
state.modal = Modal::Text {
title: format!("New PR — '{}'", repo),
prompt: "Title:".into(),
value: String::new(),
tag: TextTag::PrTitle(repo),
max_len: None,
};
None
}
Modal::Text { value, tag: TextTag::PrTitle(repo), .. } => {
let title = value.trim().to_string();
if title.is_empty() { return None; }
state.modal = Modal::Text {
title: format!("New PR — '{}'", repo),
prompt: "Head branch:".into(),
value: String::new(),
tag: TextTag::PrHead(repo, title),
max_len: None,
};
None
}
Modal::Text { value, tag: TextTag::PrHead(repo, title), .. } => {
let head = value.trim().to_string();
if head.is_empty() { return None; }
state.modal = Modal::Text {
title: format!("New PR — '{}'", repo),
prompt: "Base branch:".into(),
value: "main".into(),
tag: TextTag::PrBase(repo, title, head),
max_len: None,
};
None
}
Modal::Text { value, tag: TextTag::PrBase(repo, title, head), .. } => {
let base = if value.trim().is_empty() { "main".to_string() } else { value.trim().to_string() };
spawn_create_pr(state, repo, title, head, base);
None
}
Modal::Text { value, tag: TextTag::IssueRepo, .. } => {
let repo = value.trim().to_string();
if repo.is_empty() { return None; }
state.modal = Modal::Text {
title: format!("New Issue — '{}'", repo),
prompt: "Title:".into(),
value: String::new(),
tag: TextTag::IssueTitle(repo),
max_len: None,
};
None
}
Modal::Text { value, tag: TextTag::IssueTitle(repo), .. } => {
let title = value.trim().to_string();
if title.is_empty() { return None; }
spawn_create_issue(state, repo, title);
None
}
// ── Code intelligence (Stats tab, [m]) ─────────────────────────────────
Modal::Select { cursor, tag: SelectTag::CodeIntelRepoPick, .. } => {
if let Some(r) = state.repos.get(cursor) {
let repo = r.path.clone();
let name = r.name.clone();
state.modal = Modal::Select {
title: format!("Code Intel — '{}'", name),
options: vec![
"List functions — names only".into(),
"List functions — names + args".into(),
"List functions — names + args + meta (length, refs)".into(),
"Find usages of an identifier...".into(),
"Rename an identifier...".into(),
"Convert naming convention...".into(),
"Repo code stats".into(),
],
cursor: 0,
tag: SelectTag::CodeIntelAction { repo, name },
};
}
None
}
Modal::Select { cursor, tag: SelectTag::CodeIntelAction { repo, name }, .. } => {
match cursor {
0 => spawn_list_functions(state, repo, name, crate::codeintel::FuncFormat::NamesOnly),
1 => spawn_list_functions(state, repo, name, crate::codeintel::FuncFormat::WithArgs),
2 => spawn_list_functions(state, repo, name, crate::codeintel::FuncFormat::WithMeta),
3 => {
state.modal = Modal::Text {
title: format!("Find usages — '{}'", name),
prompt: "Identifier:".into(),
value: String::new(),
tag: TextTag::CodeIntelFindUsages { repo, name },
max_len: Some(80),
};
}
4 => {
state.modal = Modal::Text {
title: format!("Rename — '{}'", name),
prompt: "Current identifier name:".into(),
value: String::new(),
tag: TextTag::CodeIntelRenameOld { repo, name },
max_len: Some(80),
};
}
5 => {
state.modal = Modal::Select {
title: "Convert naming convention — target".into(),
options: vec!["snake_case".into(), "camelCase".into(), "PascalCase".into()],
cursor: 0,
tag: SelectTag::CodeIntelConvention { repo, name },
};
}
_ => spawn_repo_stats(state, repo, name),
}
None
}
Modal::Text { value, tag: TextTag::CodeIntelFindUsages { repo, name }, .. } => {
let ident = value.trim().to_string();
if ident.is_empty() { return None; }
spawn_find_usages(state, repo, name, ident);
None
}
Modal::Text { value, tag: TextTag::CodeIntelRenameOld { repo, name }, .. } => {
let old = value.trim().to_string();
if old.is_empty() { return None; }
state.modal = Modal::Text {
title: format!("Rename '{}' to...", old),
prompt: "New name:".into(),
value: String::new(),
tag: TextTag::CodeIntelRenameNew { repo, name, old },
max_len: Some(80),
};
None
}
Modal::Text { value, tag: TextTag::CodeIntelRenameNew { repo, name, old }, .. } => {
let new = value.trim().to_string();
if new.is_empty() || new == old { return None; }
spawn_rename_preview(state, repo, name, old, new);
None
}
Modal::Select { cursor, tag: SelectTag::CodeIntelConvention { repo, name }, .. } => {
let target = match cursor {
0 => crate::codeintel::NamingConvention::Snake,
1 => crate::codeintel::NamingConvention::Camel,
_ => crate::codeintel::NamingConvention::Pascal,
};
spawn_convention_preview(state, repo, name, target);
None
}
Modal::RenamePreview { plan, .. } => {
let n_files = plan.file_count();
let n_edits = plan.edits.len();
match crate::codeintel::apply_rename_plan(&plan) {
Ok(applied) => {
state.modal = Modal::Info {
title: "✓ Rename applied".into(),
lines: vec![format!("{} edit{} across {} file{}", applied, if applied == 1 {""} else {"s"}, n_files, if n_files == 1 {""} else {"s"})],
scroll: 0,
};
}
Err(e) => {
state.modal = Modal::Info {
title: "✗ Rename failed".into(),
lines: vec![format!("{} of {} edits may be applied — error: {e}", n_edits, n_edits)],
scroll: 0,
};
}
}
None
}
other => Some(other),
}
}