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:
1 parent
5787b14880
commit
0468c032c6
100 files changed
+11614
-9840
No files matched your search
+68
-82
@@ -1,5 +1,10 @@
|
||||
//! Hub actions that fall outside the TUI's draw/key-handling split: the
|
||||
//! blocking sync flow (which drops out of the TUI to plain stdout while it
|
||||
//! runs), API refresh against configured provider accounts, the TODO/FIXME
|
||||
//! scanner, clipboard/URL re-exports, and the `~/.vulp/*.toml` persistence
|
||||
//! for bookmarks, pins, and repo-creation templates.
|
||||
|
||||
use anyhow::Result;
|
||||
use crate::config::AppConfig;
|
||||
use crate::status::RepoSummary;
|
||||
use crate::todos::{self, TodoSource};
|
||||
use std::collections::HashSet;
|
||||
@@ -8,6 +13,10 @@ use super::state::HubState;
|
||||
|
||||
// ── sync flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Runs `action` ("pull", "commit_push", or anything else meaning "push")
|
||||
/// against `targets` (indices into `repos`), printing progress straight to
|
||||
/// stdout — the caller suspends the TUI for the duration so this output is
|
||||
/// visible, then waits for Enter before restoring the screen.
|
||||
pub async fn run_sync_flow(repos: &[RepoSummary], targets: &[usize], action: &str, commit_msg: &str) -> Result<()> {
|
||||
let sel: Vec<&RepoSummary> = targets.iter().filter_map(|&i| repos.get(i)).collect();
|
||||
if sel.is_empty() { return Ok(()); }
|
||||
@@ -16,16 +25,27 @@ pub async fn run_sync_flow(repos: &[RepoSummary], targets: &[usize], action: &st
|
||||
for r in &sel { println!(" · {}", r.name); }
|
||||
println!();
|
||||
|
||||
let run_logged = |path: &std::path::Path, args: &[&str], kind: crate::activity::ActivityKind, repo: &str| {
|
||||
let ok = crate::util::git::git_run_ok(path, args);
|
||||
if !ok {
|
||||
eprintln!(" git {} → failed", args.join(" "));
|
||||
} else {
|
||||
crate::activity::log(&crate::activity::ActivityEntry {
|
||||
ts: chrono::Utc::now().timestamp(), kind, repo: repo.to_string(),
|
||||
detail: format!("vulp sync ({})", args.join(" ")),
|
||||
});
|
||||
}
|
||||
};
|
||||
for r in &sel {
|
||||
println!("\n ── {} ──", r.name);
|
||||
match action {
|
||||
"pull" => { git_run(&r.path, &["pull"]); }
|
||||
"pull" => run_logged(&r.path, &["pull"], crate::activity::ActivityKind::Pull, &r.name),
|
||||
"commit_push" => {
|
||||
git_run(&r.path, &["add", "-A"]);
|
||||
git_run(&r.path, &["commit", "-m", commit_msg]);
|
||||
git_run(&r.path, &["push"]);
|
||||
run_logged(&r.path, &["push"], crate::activity::ActivityKind::Push, &r.name);
|
||||
}
|
||||
_ => { git_run(&r.path, &["push"]); }
|
||||
_ => run_logged(&r.path, &["push"], crate::activity::ActivityKind::Push, &r.name),
|
||||
}
|
||||
}
|
||||
println!("\n ✓ Done. Press Enter…");
|
||||
@@ -35,78 +55,9 @@ pub async fn run_sync_flow(repos: &[RepoSummary], targets: &[usize], action: &st
|
||||
|
||||
pub use crate::util::git::git_run;
|
||||
|
||||
// ── API refresh ───────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn exec_api_refresh(state: &mut HubState, cfg: &AppConfig) {
|
||||
use crate::auth;
|
||||
use crate::providers;
|
||||
|
||||
let accounts = match auth::load_accounts(cfg) {
|
||||
Ok(a) => a,
|
||||
Err(_) => { state.status_msg = Some("No accounts configured — fxg auth add".into()); return; }
|
||||
};
|
||||
if accounts.is_empty() {
|
||||
state.status_msg = Some("No accounts configured — fxg auth add".into());
|
||||
return;
|
||||
}
|
||||
|
||||
let mut cache = crate::cache::load();
|
||||
let mut fetched_meta = 0usize;
|
||||
let mut fetched_prs = 0usize;
|
||||
let mut fetched_iss = 0usize;
|
||||
|
||||
for account in &accounts {
|
||||
let provider = match providers::make_provider(account) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
state.status_msg = Some(format!("Auth error ({}): {e}", account.provider));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for r in &mut state.repos {
|
||||
let is_mine = r.owner.as_deref() == Some(account.username.as_str());
|
||||
if !is_mine { continue; }
|
||||
|
||||
if let Ok(meta) = provider.repo_meta(&r.name).await {
|
||||
let cache_meta = crate::cache::RepoMeta {
|
||||
stars: Some(meta.stars),
|
||||
open_issues: Some(meta.open_issues),
|
||||
open_prs: Some(meta.open_prs),
|
||||
visibility: r.visibility.clone(),
|
||||
ci_ok: None,
|
||||
fetched_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
r.stars = Some(meta.stars);
|
||||
r.open_issues = Some(meta.open_issues);
|
||||
r.open_prs = Some(meta.open_prs);
|
||||
cache.set_meta(&r.name, cache_meta);
|
||||
fetched_meta += 1;
|
||||
}
|
||||
|
||||
if let Ok(prs) = provider.list_pull_requests(&r.name).await {
|
||||
fetched_prs += prs.len();
|
||||
cache.set_prs(&r.name, prs);
|
||||
}
|
||||
|
||||
if let Ok(issues) = provider.list_issues(&r.name).await {
|
||||
fetched_iss += issues.len();
|
||||
cache.set_issues(&r.name, issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = crate::cache::save(&cache);
|
||||
state.prs = cache.all_prs().into_iter().cloned().collect();
|
||||
state.issues = cache.all_issues().into_iter().cloned().collect();
|
||||
|
||||
state.status_msg = Some(format!(
|
||||
"Refreshed {} repos: {} PRs, {} issues",
|
||||
fetched_meta, fetched_prs, fetched_iss
|
||||
));
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
/// Re-scans the workspace from disk, reapplies bookmark state, and rebuilds
|
||||
/// the display rows — used after operations that change repo state on disk
|
||||
/// outside of `HubState` (e.g. after a sync or a new repo creation).
|
||||
pub fn refresh_repos(state: &mut HubState) {
|
||||
if let Ok(fresh) = crate::status::scan_workspace(&state.workspace_root, &[]) {
|
||||
state.repos = fresh;
|
||||
@@ -114,12 +65,28 @@ pub fn refresh_repos(state: &mut HubState) {
|
||||
r.bookmarked = state.bookmarks.contains(&r.path);
|
||||
}
|
||||
state.focused_cache.clear();
|
||||
// `selected` holds indices into the old repo order — after a rescan
|
||||
// they'd silently point at different repos (batch sync/release would
|
||||
// hit wrong targets), so drop the selection entirely.
|
||||
state.selected.clear();
|
||||
state.rebuild_display();
|
||||
}
|
||||
// Actions that trigger a rescan (sync, release, repo creation) also write
|
||||
// to the activity log — reload it so the Overview vulp-log tile stays live.
|
||||
state.vulp_log = {
|
||||
let mut log = crate::activity::load_logged();
|
||||
log.sort_by(|a, b| b.ts.cmp(&a.ts));
|
||||
log.truncate(50);
|
||||
log
|
||||
};
|
||||
}
|
||||
|
||||
// ── TODO scanner ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Scans every repo for `TODO`/`FIXME`/`HACK` markers (via
|
||||
/// [`crate::legacy::refactor`]) and imports each unseen one as a new todo,
|
||||
/// skipping any whose generated title already matches an existing todo so
|
||||
/// re-running the scan doesn't create duplicates. Returns the count added.
|
||||
pub fn scan_todos_from_repos(state: &mut HubState) -> usize {
|
||||
let mut added = 0usize;
|
||||
let existing_titles: HashSet<String> =
|
||||
@@ -127,7 +94,8 @@ pub fn scan_todos_from_repos(state: &mut HubState) -> usize {
|
||||
|
||||
for r in &state.repos {
|
||||
for pattern in &["TODO", "FIXME", "HACK"] {
|
||||
let results = crate::legacy::refactor::find_in_doc_files(pattern, &r.path, &r.name);
|
||||
let mut results = crate::legacy::refactor::find_in_doc_files(pattern, &r.path, &r.name);
|
||||
results.extend(crate::legacy::refactor::find_in_source_files(pattern, &r.path, &r.name));
|
||||
for fr in results.iter().take(50) {
|
||||
let short_file = std::path::Path::new(&fr.file)
|
||||
.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| fr.file.clone());
|
||||
@@ -135,10 +103,14 @@ pub fn scan_todos_from_repos(state: &mut HubState) -> usize {
|
||||
fr.text.trim().chars().take(60).collect::<String>());
|
||||
if existing_titles.contains(&title) { continue; }
|
||||
let id = todos::next_id(&state.todos);
|
||||
let mut t = todos::Todo::new_scan(id, title, r.name.clone());
|
||||
let mut t = todos::Todo::new_scan(id, title.clone(), r.name.clone());
|
||||
t.source = TodoSource::CodeScan;
|
||||
state.todos.push(t);
|
||||
added += 1;
|
||||
crate::activity::log(&crate::activity::ActivityEntry {
|
||||
ts: chrono::Utc::now().timestamp(), kind: crate::activity::ActivityKind::Todo,
|
||||
repo: r.name.clone(), detail: title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,6 +122,7 @@ pub fn scan_todos_from_repos(state: &mut HubState) -> usize {
|
||||
|
||||
pub use crate::util::clipboard::copy_to_clipboard;
|
||||
|
||||
/// Reads the `origin` remote URL straight from the repo's git config.
|
||||
pub fn get_remote_url(path: &PathBuf) -> Option<String> {
|
||||
let repo = git2::Repository::open(path).ok()?;
|
||||
let remote = repo.find_remote("origin").ok()?;
|
||||
@@ -161,9 +134,11 @@ pub use crate::util::url::to_web_url;
|
||||
// ── bookmarks persistence ─────────────────────────────────────────────────────
|
||||
|
||||
fn bookmarks_path() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_default().join(".fxg").join("bookmarks.toml")
|
||||
dirs::home_dir().unwrap_or_default().join(".vulp").join("bookmarks.toml")
|
||||
}
|
||||
|
||||
/// Loads bookmarked repo paths from `~/.vulp/bookmarks.toml`, or an empty
|
||||
/// set if the file doesn't exist or fails to parse.
|
||||
pub fn load_bookmarks() -> HashSet<PathBuf> {
|
||||
let p = bookmarks_path();
|
||||
if !p.exists() { return HashSet::new(); }
|
||||
@@ -174,6 +149,7 @@ pub fn load_bookmarks() -> HashSet<PathBuf> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Writes `bk` to `~/.vulp/bookmarks.toml`, creating the directory if needed.
|
||||
pub fn save_bookmarks(bk: &HashSet<PathBuf>) {
|
||||
#[derive(serde::Serialize)]
|
||||
struct Bk { paths: Vec<String> }
|
||||
@@ -186,17 +162,20 @@ pub fn save_bookmarks(bk: &HashSet<PathBuf>) {
|
||||
// ── templates persistence ─────────────────────────────────────────────────────
|
||||
|
||||
fn templates_path() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_default().join(".fxg").join("templates.toml")
|
||||
dirs::home_dir().unwrap_or_default().join(".vulp").join("templates.toml")
|
||||
}
|
||||
|
||||
/// On-disk shape of `~/.vulp/templates.toml`.
|
||||
#[derive(serde::Serialize, serde::Deserialize, Default)]
|
||||
struct TemplatesFile {
|
||||
#[serde(default)]
|
||||
templates: Vec<super::state::RepoTemplate>,
|
||||
}
|
||||
|
||||
/// Loads repo-creation templates from `~/.vulp/templates.toml`. On first run
|
||||
/// (file missing or empty), seeds and saves the built-in stack templates
|
||||
/// from [`default_templates`] so the picker is never empty.
|
||||
pub fn load_templates() -> Vec<super::state::RepoTemplate> {
|
||||
use super::state::RepoTemplate;
|
||||
let p = templates_path();
|
||||
if p.exists() {
|
||||
let loaded = toml::from_str::<TemplatesFile>(&std::fs::read_to_string(&p).unwrap_or_default())
|
||||
@@ -210,6 +189,8 @@ pub fn load_templates() -> Vec<super::state::RepoTemplate> {
|
||||
defaults
|
||||
}
|
||||
|
||||
/// Built-in repo-creation templates (Rust, Node.js, React, Python, Go,
|
||||
/// Laravel) used to seed `templates.toml` on first run.
|
||||
fn default_templates() -> Vec<super::state::RepoTemplate> {
|
||||
use super::state::RepoTemplate;
|
||||
vec![
|
||||
@@ -279,6 +260,8 @@ fn default_templates() -> Vec<super::state::RepoTemplate> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Writes `templates` to `~/.vulp/templates.toml`, creating the directory if
|
||||
/// needed.
|
||||
pub fn save_templates(templates: &[super::state::RepoTemplate]) {
|
||||
let p = templates_path();
|
||||
if let Some(d) = p.parent() { let _ = std::fs::create_dir_all(d); }
|
||||
@@ -289,9 +272,11 @@ pub fn save_templates(templates: &[super::state::RepoTemplate]) {
|
||||
// ── pins persistence ──────────────────────────────────────────────────────────
|
||||
|
||||
fn pins_path() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_default().join(".fxg").join("pins.toml")
|
||||
dirs::home_dir().unwrap_or_default().join(".vulp").join("pins.toml")
|
||||
}
|
||||
|
||||
/// Loads pinned repo paths from `~/.vulp/pins.toml`, or an empty set if the
|
||||
/// file doesn't exist or fails to parse.
|
||||
pub fn load_pins() -> HashSet<PathBuf> {
|
||||
let p = pins_path();
|
||||
if !p.exists() { return HashSet::new(); }
|
||||
@@ -302,6 +287,7 @@ pub fn load_pins() -> HashSet<PathBuf> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Writes `pins` to `~/.vulp/pins.toml`, creating the directory if needed.
|
||||
pub fn save_pins(pins: &HashSet<PathBuf>) {
|
||||
#[derive(serde::Serialize)]
|
||||
struct Pins { paths: Vec<String> }
|
||||
|
||||
+132
-559
@@ -1,16 +1,21 @@
|
||||
//! The hub's main event loop: one terminal frame, one input event, dispatched
|
||||
//! to whichever input mode is active (modal open, confirm prompt, filter
|
||||
//! typing, or the plain global keymap in `keys_dispatch`), plus the two
|
||||
//! lazy-loaders that kick off a background fetch the first time their tab
|
||||
//! is opened.
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event, KeyCode, KeyModifiers, MouseEventKind};
|
||||
use super::actions::{copy_to_clipboard, get_remote_url, refresh_repos, scan_todos_from_repos, save_bookmarks, save_pins, to_web_url};
|
||||
use crossterm::event::{Event, KeyCode, MouseEventKind};
|
||||
use super::modals::{handle_modal_key, process_modal_confirm};
|
||||
use super::render::{render_hub, HELP_LINES};
|
||||
use super::state::{
|
||||
ConfirmTag, DisplayRow, HubState, Modal, ModalAction, PendingAction,
|
||||
PhaseResult, SelectTag, Tab, TextTag,
|
||||
};
|
||||
use crate::todos;
|
||||
use super::render::render_hub;
|
||||
use super::state::{HubState, Modal, ModalAction, PendingAction, PhaseResult, Tab};
|
||||
use super::{keys_dispatch, keys_poll};
|
||||
|
||||
pub fn maybe_load_global_stats(state: &mut HubState) {
|
||||
if state.tab != Tab::Stats { return; }
|
||||
if !matches!(state.tab, Tab::Stats | Tab::Overview) { return; }
|
||||
// Wait for the workspace scan to finish — computing stats from a
|
||||
// partially-streamed repo list bakes in an empty/incomplete snapshot.
|
||||
if state.scan_rx.is_some() { return; }
|
||||
if state.global_stats.is_some() || state.global_stats_rx.is_some() { return; }
|
||||
|
||||
let repos_snapshot: Vec<(String, std::path::PathBuf, Option<String>, bool, usize)> = state.repos.iter()
|
||||
@@ -47,6 +52,7 @@ pub fn maybe_load_global_stats(state: &mut HubState) {
|
||||
let mut day_pattern = [0usize; 7];
|
||||
for (date_str, count) in &all_dates {
|
||||
if let Ok(d) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
||||
use chrono::Datelike;
|
||||
let wd = d.weekday().num_days_from_monday() as usize;
|
||||
day_pattern[wd] += count;
|
||||
}
|
||||
@@ -55,18 +61,24 @@ pub fn maybe_load_global_stats(state: &mut HubState) {
|
||||
let mut activity: Vec<(String, usize)> = all_dates.into_iter().collect();
|
||||
activity.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
top_repos.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
let repo_commits = top_repos.clone();
|
||||
top_repos.truncate(5);
|
||||
|
||||
let mut lang_map: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for (_, _, lang, _, _) in &repos_snapshot {
|
||||
if let Some(l) = lang { *lang_map.entry(l.clone()).or_default() += 1; }
|
||||
// Repo lang strings can carry a percentage suffix ("TypeScript 92%") —
|
||||
// aggregate on the bare language name so they count as one language.
|
||||
if let Some(l) = lang {
|
||||
let name = l.split_whitespace().next().unwrap_or(l).to_string();
|
||||
*lang_map.entry(name).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut lang_dist: Vec<(String, usize)> = lang_map.into_iter().collect();
|
||||
lang_dist.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
lang_dist.truncate(8);
|
||||
|
||||
let _ = tx.send(GlobalStats {
|
||||
activity, lang_dist, top_repos, total_commits,
|
||||
activity, lang_dist, top_repos, repo_commits, total_commits,
|
||||
dirty_count, behind_count,
|
||||
day_pattern,
|
||||
});
|
||||
@@ -76,29 +88,84 @@ pub fn maybe_load_global_stats(state: &mut HubState) {
|
||||
state.status_msg = Some("Computing global stats...".into());
|
||||
}
|
||||
|
||||
/// Builds the Activity tab's feed: git commits + tags (ground truth, sourced
|
||||
/// straight from each repo's history, regardless of how the activity
|
||||
/// happened) merged with PR/issue timestamps already in memory, merged with
|
||||
/// the locally-logged push/pull/release/new-repo/todo events that only vulp
|
||||
/// itself can see (see `crate::activity`).
|
||||
pub fn maybe_load_activity(state: &mut HubState) {
|
||||
if state.tab != Tab::Activity { return; }
|
||||
if state.activity_feed.is_some() || state.activity_rx.is_some() { return; }
|
||||
|
||||
let repos_snapshot: Vec<(String, std::path::PathBuf)> = state.repos.iter()
|
||||
.map(|r| (r.name.clone(), r.path.clone())).collect();
|
||||
let mut entries: Vec<crate::activity::ActivityEntry> = state.prs.iter().map(|pr| {
|
||||
crate::activity::ActivityEntry {
|
||||
ts: pr.created_ts, kind: crate::activity::ActivityKind::Pr,
|
||||
repo: pr.repo.clone(), detail: format!("#{} {} (@{})", pr.number, pr.title, pr.author),
|
||||
}
|
||||
}).collect();
|
||||
entries.extend(state.issues.iter().map(|iss| {
|
||||
crate::activity::ActivityEntry {
|
||||
ts: iss.created_ts, kind: crate::activity::ActivityKind::Issue,
|
||||
repo: iss.repo.clone(), detail: format!("#{} {} (@{})", iss.number, iss.title, iss.author),
|
||||
}
|
||||
}));
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut entries = entries;
|
||||
entries.extend(crate::activity::load_logged());
|
||||
for (name, path) in &repos_snapshot {
|
||||
for h in crate::detail::build_history_pub(path, 200) {
|
||||
match h {
|
||||
crate::detail::HistEntry::Commit { ts, author, msg, .. } => {
|
||||
entries.push(crate::activity::ActivityEntry {
|
||||
ts, kind: crate::activity::ActivityKind::Commit,
|
||||
repo: name.clone(), detail: format!("{author}: {msg}"),
|
||||
});
|
||||
}
|
||||
crate::detail::HistEntry::Tag { ts, name: tag, msg, .. } => {
|
||||
entries.push(crate::activity::ActivityEntry {
|
||||
ts, kind: crate::activity::ActivityKind::Release,
|
||||
repo: name.clone(), detail: if msg.is_empty() { tag } else { format!("{tag}: {msg}") },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.sort_by(|a, b| b.ts.cmp(&a.ts));
|
||||
entries.truncate(2000);
|
||||
let _ = tx.send(entries);
|
||||
});
|
||||
|
||||
state.activity_rx = Some(rx);
|
||||
state.status_msg = Some("Loading activity...".into());
|
||||
}
|
||||
|
||||
/// Runs the hub's TUI for one or more frames until something needs to leave
|
||||
/// the loop: draws a frame, polls every background channel, reads one input
|
||||
/// event, and routes it to whichever input mode is currently active. Modal/
|
||||
/// confirm/filter input short-circuit here (they're loop control flow, not
|
||||
/// part of the global keymap); everything else goes to `keys_dispatch`.
|
||||
pub fn tui_phase(state: &mut HubState) -> Result<(PhaseResult, Option<crate::tui::Tty>)> {
|
||||
let mut terminal = crate::tui::setup()?;
|
||||
let result = (|| -> Result<PhaseResult> {
|
||||
loop {
|
||||
state.poll_incoming();
|
||||
// No-op until the scan completes and only on Overview/Stats —
|
||||
// kicks off the first stats computation once repos are final.
|
||||
maybe_load_global_stats(state);
|
||||
state.poll_diff();
|
||||
state.poll_global_stats();
|
||||
// poll pull-all background worker
|
||||
{
|
||||
let done = if let Some(rx) = &state.pull_all_rx {
|
||||
let mut finished = false;
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
if msg == "DONE" { finished = true; }
|
||||
else { state.status_msg = Some(msg); }
|
||||
}
|
||||
finished
|
||||
} else { false };
|
||||
if done {
|
||||
state.pull_all_rx = None;
|
||||
state.status_msg = Some("Pull-all complete! Refreshing...".into());
|
||||
refresh_repos(state);
|
||||
}
|
||||
}
|
||||
state.poll_activity();
|
||||
state.poll_release_info();
|
||||
state.poll_codeintel();
|
||||
keys_poll::poll_pull_all(state);
|
||||
keys_poll::poll_create_repo(state);
|
||||
keys_poll::poll_profile_pull(state);
|
||||
keys_poll::poll_refresh(state);
|
||||
|
||||
terminal.draw(|f| render_hub(f, state))?;
|
||||
state.update_focused_extra();
|
||||
|
||||
@@ -119,553 +186,59 @@ pub fn tui_phase(state: &mut HubState) -> Result<(PhaseResult, Option<crate::tui
|
||||
continue;
|
||||
}
|
||||
let key = match event { Some(Event::Key(k)) => k, _ => continue };
|
||||
{
|
||||
|
||||
if state.modal.is_some() {
|
||||
match handle_modal_key(&mut state.modal, key.code) {
|
||||
ModalAction::Confirm => process_modal_confirm(state),
|
||||
ModalAction::Cancel => state.modal = Modal::None,
|
||||
ModalAction::Pending => {}
|
||||
}
|
||||
if let Some(url) = state.pending_clone.take() {
|
||||
return Ok(PhaseResult::ExecClone(url));
|
||||
}
|
||||
if let Some(phase) = state.pending_phase.take() {
|
||||
return Ok(phase);
|
||||
}
|
||||
continue;
|
||||
match handle_modal_key(&mut state.modal, key.code) {
|
||||
ModalAction::Confirm => process_modal_confirm(state),
|
||||
ModalAction::Cancel => state.modal = Modal::None,
|
||||
ModalAction::Pending => {}
|
||||
}
|
||||
|
||||
if state.confirming.is_some() {
|
||||
match key.code {
|
||||
KeyCode::Char('y') => { state.exec_action = true; }
|
||||
KeyCode::Esc => { state.confirming = None; }
|
||||
_ => {}
|
||||
}
|
||||
if state.exec_action {
|
||||
let r = match state.confirming.as_ref().unwrap() {
|
||||
PendingAction::Sync => PhaseResult::ExecSync,
|
||||
PendingAction::Release => PhaseResult::ExecRelease,
|
||||
};
|
||||
return Ok(r);
|
||||
}
|
||||
continue;
|
||||
if let Some(url) = state.pending_clone.take() {
|
||||
return Ok(PhaseResult::ExecClone(url));
|
||||
}
|
||||
|
||||
if state.filtering {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('/') => {
|
||||
state.filtering = false;
|
||||
state.filter_text.clear();
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Enter => { state.filtering = false; }
|
||||
KeyCode::Backspace => {
|
||||
state.filter_text.pop();
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
state.filter_text.push(c);
|
||||
state.rebuild_display();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
if let Some(phase) = state.pending_phase.take() {
|
||||
return Ok(phase);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
state.status_msg = None;
|
||||
if state.confirming.is_some() {
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => {
|
||||
if !state.filter_text.is_empty() {
|
||||
state.filter_text.clear(); state.rebuild_display();
|
||||
} else {
|
||||
state.quit = true; return Ok(PhaseResult::None);
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if state.tab == Tab::HubSettings {
|
||||
state.hub_settings_cur = state.hub_settings_cur.saturating_sub(1);
|
||||
} else {
|
||||
state.scroll_up();
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if state.tab == Tab::HubSettings {
|
||||
let max = 10 + 1 + state.templates.len();
|
||||
if state.hub_settings_cur + 1 < max { state.hub_settings_cur += 1; }
|
||||
} else {
|
||||
state.scroll_down();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
let visible: Vec<usize> = state.display.iter().filter_map(|row| {
|
||||
if let DisplayRow::Repo { repo_idx } = row { Some(*repo_idx) } else { None }
|
||||
}).collect();
|
||||
let all_sel = visible.iter().all(|ri| state.selected.contains(ri));
|
||||
if all_sel {
|
||||
state.selected.clear();
|
||||
state.status_msg = Some("All deselected".into());
|
||||
} else {
|
||||
for ri in visible { state.selected.insert(ri); }
|
||||
state.status_msg = Some(format!("Selected {} repos", state.selected.len()));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('*') => {
|
||||
state.modal = Modal::Select {
|
||||
title: "Select by condition".into(),
|
||||
options: vec![
|
||||
"All visible".into(),
|
||||
"Dirty (uncommitted changes)".into(),
|
||||
"Behind remote".into(),
|
||||
"Ahead of remote".into(),
|
||||
"No license".into(),
|
||||
"Language: Rust".into(),
|
||||
"Language: TypeScript".into(),
|
||||
"Language: Python".into(),
|
||||
"Language: Go".into(),
|
||||
],
|
||||
cursor: 0,
|
||||
tag: SelectTag::SelectByCondition,
|
||||
};
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
match state.tab {
|
||||
Tab::Todos => {
|
||||
if let Some(ti) = state.cur_todo_idx() {
|
||||
state.todos[ti].status = state.todos[ti].status.cycle();
|
||||
state.todos[ti].touch();
|
||||
let _ = todos::save(&state.todos);
|
||||
}
|
||||
}
|
||||
_ => state.toggle_select(),
|
||||
}
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right => {
|
||||
state.tab = match state.tab {
|
||||
Tab::Overview => Tab::Repos,
|
||||
Tab::Repos => Tab::Projects,
|
||||
Tab::Projects => Tab::Todos,
|
||||
Tab::Todos => Tab::PRs,
|
||||
Tab::PRs => Tab::Issues,
|
||||
Tab::Issues => Tab::Stats,
|
||||
Tab::Stats => Tab::HubSettings,
|
||||
Tab::HubSettings => Tab::Overview,
|
||||
};
|
||||
state.cursor = 0; state.offset = 0; state.selected.clear();
|
||||
state.rebuild_display();
|
||||
maybe_load_global_stats(state);
|
||||
}
|
||||
KeyCode::Left => {
|
||||
state.tab = match state.tab {
|
||||
Tab::Overview => Tab::HubSettings,
|
||||
Tab::Repos => Tab::Overview,
|
||||
Tab::Projects => Tab::Repos,
|
||||
Tab::Todos => Tab::Projects,
|
||||
Tab::PRs => Tab::Todos,
|
||||
Tab::Issues => Tab::PRs,
|
||||
Tab::Stats => Tab::Issues,
|
||||
Tab::HubSettings => Tab::Stats,
|
||||
};
|
||||
state.cursor = 0; state.offset = 0; state.selected.clear();
|
||||
state.rebuild_display();
|
||||
maybe_load_global_stats(state);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
match state.tab {
|
||||
Tab::Repos | Tab::Projects => {
|
||||
if state.cur_repo_idx().is_some() || state.cur_proj_idx().is_some() {
|
||||
state.open_detail = true;
|
||||
return Ok(PhaseResult::OpenDetail);
|
||||
}
|
||||
}
|
||||
Tab::PRs => {
|
||||
let di = state.cur_di();
|
||||
if let Some(di) = di {
|
||||
if let DisplayRow::Pr { idx } = &state.display[di] {
|
||||
let url = state.prs[*idx].url.clone();
|
||||
if !url.is_empty() {
|
||||
crate::util::open_path(&url);
|
||||
state.status_msg = Some("Opening PR in browser...".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Tab::Issues => {
|
||||
let di = state.cur_di();
|
||||
if let Some(di) = di {
|
||||
if let DisplayRow::Issue { idx } = &state.display[di] {
|
||||
let url = state.issues[*idx].url.clone();
|
||||
if !url.is_empty() {
|
||||
crate::util::open_path(&url);
|
||||
state.status_msg = Some("Opening issue in browser...".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Tab::Todos | Tab::Overview | Tab::Stats => {}
|
||||
Tab::HubSettings => {
|
||||
match state.hub_settings_cur {
|
||||
0 => {
|
||||
state.modal = Modal::Text {
|
||||
title: "Workspace Root".into(),
|
||||
prompt: "Absolute path (~ supported):".into(),
|
||||
value: state.workspace_root.to_string_lossy().to_string(),
|
||||
tag: TextTag::SetWorkspace,
|
||||
max_len: None,
|
||||
};
|
||||
}
|
||||
1 => {
|
||||
state.modal = Modal::Text {
|
||||
title: "Editor".into(),
|
||||
prompt: "Command (e.g. nvim, code):".into(),
|
||||
value: state.editor.clone(),
|
||||
tag: TextTag::SetEditor,
|
||||
max_len: Some(60),
|
||||
};
|
||||
}
|
||||
2 => {
|
||||
state.modal = Modal::Text {
|
||||
title: "Clipboard Command".into(),
|
||||
prompt: "Command (e.g. xclip -sel clip):".into(),
|
||||
value: state.clipboard_cmd.clone(),
|
||||
tag: TextTag::SetClipboard,
|
||||
max_len: Some(60),
|
||||
};
|
||||
}
|
||||
7 => {
|
||||
if state.accounts.is_empty() {
|
||||
state.status_msg = Some("No accounts — run: fxg auth import".into());
|
||||
} else if state.accounts.len() == 1 {
|
||||
let name = format!("@{} ({})", state.accounts[0].username, state.accounts[0].provider);
|
||||
state.modal = Modal::Confirm {
|
||||
message: format!("Pull all repos for {}?", name),
|
||||
tag: ConfirmTag::PullAll(0),
|
||||
};
|
||||
} else {
|
||||
let opts: Vec<String> = state.accounts.iter()
|
||||
.map(|a| format!("@{} ({})", a.username, a.provider))
|
||||
.collect();
|
||||
state.modal = Modal::Select {
|
||||
title: "Pull all repos — Account".into(),
|
||||
options: opts,
|
||||
cursor: 0,
|
||||
tag: SelectTag::PullAllAccount,
|
||||
};
|
||||
}
|
||||
}
|
||||
8 => {
|
||||
state.modal = Modal::Text {
|
||||
title: "Import Directory as Repos".into(),
|
||||
prompt: "Directory to scan (git init + link each subfolder):".into(),
|
||||
value: String::new(),
|
||||
tag: TextTag::ImportDir,
|
||||
max_len: None,
|
||||
};
|
||||
}
|
||||
9 => {
|
||||
let n = state.repos.len();
|
||||
state.modal = Modal::Confirm {
|
||||
message: format!("DANGER: Delete .git from all {} repos in workspace? Cannot be undone!", n),
|
||||
tag: ConfirmTag::RemoveGitAll,
|
||||
};
|
||||
}
|
||||
10 => {
|
||||
state.modal = Modal::Text {
|
||||
title: "New Template — Name".into(),
|
||||
prompt: "Template name:".into(),
|
||||
value: String::new(),
|
||||
tag: TextTag::NewTemplateName,
|
||||
max_len: Some(50),
|
||||
};
|
||||
}
|
||||
n if n >= 11 => {
|
||||
let ti = n - 11;
|
||||
if ti < state.templates.len() {
|
||||
let t = &state.templates[ti];
|
||||
let cmd_preview = t.commands.iter().take(2).cloned().collect::<Vec<_>>().join("; ");
|
||||
let cmd_str = if cmd_preview.is_empty() { "(no commands)".into() } else { cmd_preview };
|
||||
state.modal = Modal::Select {
|
||||
title: format!("Template: {}", t.name),
|
||||
options: vec![
|
||||
format!("Commands: {}", cmd_str.chars().take(40).collect::<String>()),
|
||||
"Delete template".into(),
|
||||
],
|
||||
cursor: 0,
|
||||
tag: SelectTag::TemplateMenu(ti),
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
if !state.action_targets().is_empty() {
|
||||
state.modal = Modal::Select {
|
||||
title: "Sync — Action".into(),
|
||||
options: vec!["Pull".into(), "Commit+Push".into(), "Push only".into()],
|
||||
cursor: 0,
|
||||
tag: SelectTag::SyncAction,
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
if !state.action_targets().is_empty() { state.confirming = Some(PendingAction::Release); }
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let cur = state.repos[ri].name.clone();
|
||||
state.modal = Modal::Text { title: "Set label".into(), prompt: "Display name (blank = reset):".into(), value: cur, tag: TextTag::Label(ri), max_len: Some(60) };
|
||||
}
|
||||
}
|
||||
KeyCode::Char('p') if state.tab == Tab::Stats => {
|
||||
state.global_stats = None;
|
||||
state.global_stats_rx = None;
|
||||
state.global_stats_period = state.global_stats_period.next();
|
||||
maybe_load_global_stats(state);
|
||||
}
|
||||
KeyCode::Char('<') if state.tab == Tab::Stats => {
|
||||
state.global_stats = None;
|
||||
state.global_stats_rx = None;
|
||||
state.global_stats_period = state.global_stats_period.prev();
|
||||
maybe_load_global_stats(state);
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
let mut opts = vec!["Create project".to_string()];
|
||||
if !state.projects.is_empty() {
|
||||
opts.push("Assign repos".into());
|
||||
opts.push("Rename / recolor".into());
|
||||
opts.push("Delete project".into());
|
||||
}
|
||||
state.modal = Modal::Select { title: "Projects".into(), options: opts, cursor: 0, tag: SelectTag::ProjMenu };
|
||||
}
|
||||
KeyCode::Char('+') => {
|
||||
state.modal = Modal::Text { title: "New todo — Title".into(), prompt: "Title:".into(), value: String::new(), tag: TextTag::TodoTitle, max_len: Some(100) };
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
if let Some(ti) = state.cur_todo_idx() {
|
||||
let cur = state.todos[ti].title.clone();
|
||||
state.modal = Modal::Text { title: "Edit todo — Title".into(), prompt: "Title:".into(), value: cur, tag: TextTag::TodoEditTitle(ti), max_len: Some(100) };
|
||||
}
|
||||
}
|
||||
KeyCode::Delete | KeyCode::Char('D') if key.modifiers.contains(KeyModifiers::SHIFT) => {
|
||||
if state.tab == Tab::Todos {
|
||||
if let Some(ti) = state.cur_todo_idx() {
|
||||
let title = state.todos[ti].title.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete '{}'?", title), tag: ConfirmTag::DeleteTodo(ti) };
|
||||
}
|
||||
} else if state.tab == Tab::Projects {
|
||||
if let Some(pi) = state.cur_proj_idx() {
|
||||
let name = state.projects[pi].name.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete project '{}'?", name), tag: ConfirmTag::DeleteProject(pi) };
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if state.tab == Tab::Todos {
|
||||
if let Some(ti) = state.cur_todo_idx() {
|
||||
let title = state.todos[ti].title.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete '{}'?", title), tag: ConfirmTag::DeleteTodo(ti) };
|
||||
}
|
||||
} else if state.tab == Tab::Projects {
|
||||
if let Some(pi) = state.cur_proj_idx() {
|
||||
let name = state.projects[pi].name.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete project '{}'?", name), tag: ConfirmTag::DeleteProject(pi) };
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => { state.filter_dirty = !state.filter_dirty; state.rebuild_display(); }
|
||||
KeyCode::Char('b') => { state.filter_behind = !state.filter_behind; state.rebuild_display(); }
|
||||
KeyCode::Char('f') => { state.filter_ahead = !state.filter_ahead; state.rebuild_display(); }
|
||||
KeyCode::Char('/') => {
|
||||
state.filtering = true;
|
||||
KeyCode::Char('y') => { state.exec_action = true; }
|
||||
KeyCode::Esc => { state.confirming = None; }
|
||||
_ => {}
|
||||
}
|
||||
if state.exec_action {
|
||||
let PendingAction::Release = state.confirming.as_ref().unwrap();
|
||||
return Ok(PhaseResult::ExecRelease);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if state.filtering {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('/') => {
|
||||
state.filtering = false;
|
||||
state.filter_text.clear();
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Char('?') => {
|
||||
state.modal = Modal::Info {
|
||||
title: "Keybindings".into(),
|
||||
lines: HELP_LINES.iter().map(|s| s.to_string()).collect(),
|
||||
scroll: 0,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('S') => {
|
||||
let sm = state.sort_mode;
|
||||
let dot = |m: super::state::SortMode| if sm == m { "●" } else { "○" };
|
||||
let chk = |b: bool| if b { "✓" } else { " " };
|
||||
state.modal = Modal::Select {
|
||||
title: "Sort & Filter".into(),
|
||||
options: vec![
|
||||
format!("{} Default order", dot(super::state::SortMode::Default)),
|
||||
format!("{} By name", dot(super::state::SortMode::Name)),
|
||||
format!("{} By recent commit", dot(super::state::SortMode::Age)),
|
||||
format!("{} Dirty first", dot(super::state::SortMode::DirtyFirst)),
|
||||
format!("{} Behind first", dot(super::state::SortMode::BehindFirst)),
|
||||
format!("[{}] Show dirty only", chk(state.filter_dirty)),
|
||||
format!("[{}] Show behind only", chk(state.filter_behind)),
|
||||
format!("[{}] Show ahead only", chk(state.filter_ahead)),
|
||||
format!("[{}] Show archived", chk(state.filter_archived)),
|
||||
" Reset all".into(),
|
||||
],
|
||||
cursor: 0,
|
||||
tag: SelectTag::SortFilter,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('o') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
state.pending_phase = Some(PhaseResult::ExecOpenEditor(path));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('y') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.to_string_lossy().to_string();
|
||||
let cmd = state.clipboard_cmd.clone();
|
||||
if copy_to_clipboard(&path, &cmd) {
|
||||
state.status_msg = Some("Path copied!".into());
|
||||
} else {
|
||||
state.status_msg = Some("No clipboard cmd".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
state.modal = Modal::Select {
|
||||
title: "Copy URL".into(),
|
||||
options: vec!["SSH URL (git@…)".into(), "HTTPS URL (https://…)".into()],
|
||||
cursor: 0,
|
||||
tag: SelectTag::CopyUrl(path),
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Char('u') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
let cmd = state.clipboard_cmd.clone();
|
||||
if let Some(url) = get_remote_url(&path) {
|
||||
if copy_to_clipboard(&url, &cmd) {
|
||||
state.status_msg = Some("SSH URL copied!".into());
|
||||
} else {
|
||||
state.status_msg = Some(format!(" {}", url));
|
||||
}
|
||||
} else {
|
||||
state.status_msg = Some("No remote origin".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('B') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
if state.bookmarks.contains(&path) {
|
||||
state.bookmarks.remove(&path);
|
||||
state.repos[ri].bookmarked = false;
|
||||
state.status_msg = Some("Bookmark removed".into());
|
||||
} else {
|
||||
state.bookmarks.insert(path.clone());
|
||||
state.repos[ri].bookmarked = true;
|
||||
state.status_msg = Some("Bookmarked!".into());
|
||||
}
|
||||
save_bookmarks(&state.bookmarks);
|
||||
state.rebuild_display();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('T') => {
|
||||
let count = scan_todos_from_repos(state);
|
||||
state.status_msg = Some(format!("Scanned: {} new code todos", count));
|
||||
state.tab = Tab::Todos;
|
||||
state.show_code_todos = true;
|
||||
KeyCode::Enter => { state.filtering = false; }
|
||||
KeyCode::Backspace => {
|
||||
state.filter_text.pop();
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Char('X') => {
|
||||
if state.tab == Tab::Todos {
|
||||
state.show_code_todos = !state.show_code_todos;
|
||||
state.rebuild_display();
|
||||
state.status_msg = Some(if state.show_code_todos {
|
||||
"Showing code scan todos".into()
|
||||
} else {
|
||||
"Showing manual todos only".into()
|
||||
});
|
||||
}
|
||||
}
|
||||
KeyCode::Char('g') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let gitignore = state.repos[ri].path.join(".gitignore");
|
||||
state.pending_phase = Some(PhaseResult::ExecOpenEditor(gitignore));
|
||||
state.status_msg = Some("Opening .gitignore...".into());
|
||||
}
|
||||
}
|
||||
KeyCode::Char('O') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.to_string_lossy().to_string();
|
||||
crate::util::open_path(&path);
|
||||
state.status_msg = Some("Opening in file manager...".into());
|
||||
}
|
||||
}
|
||||
KeyCode::Char('W') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
if let Some(url) = get_remote_url(&path) {
|
||||
let web = to_web_url(&url);
|
||||
crate::util::open_path(&web);
|
||||
state.status_msg = Some("Opening in browser...".into());
|
||||
} else {
|
||||
state.status_msg = Some("No remote origin".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('P') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
if state.pins.contains(&path) {
|
||||
state.pins.remove(&path);
|
||||
state.status_msg = Some("Unpinned".into());
|
||||
} else {
|
||||
state.pins.insert(path.clone());
|
||||
state.status_msg = Some("Pinned to top! ◆".into());
|
||||
}
|
||||
save_pins(&state.pins);
|
||||
state.rebuild_display();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
state.filter_archived = !state.filter_archived;
|
||||
KeyCode::Char(c) => {
|
||||
state.filter_text.push(c);
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Char('N') => {
|
||||
state.modal = Modal::Select {
|
||||
title: "New repository".into(),
|
||||
options: vec![
|
||||
"Clone from URL".into(),
|
||||
"Clone local folder (path)".into(),
|
||||
"Create new empty repo".into(),
|
||||
"Link existing folder".into(),
|
||||
],
|
||||
cursor: 0,
|
||||
tag: SelectTag::NewRepo,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('R') => {
|
||||
state.status_msg = Some("Fetching from GitHub API...".into());
|
||||
return Ok(PhaseResult::ExecRefresh);
|
||||
}
|
||||
|
||||
// Direct tab switching (number keys)
|
||||
KeyCode::Char('1') => { state.tab = Tab::Overview; state.rebuild_display(); }
|
||||
KeyCode::Char('2') => { state.tab = Tab::Repos; state.rebuild_display(); }
|
||||
KeyCode::Char('3') => { state.tab = Tab::Projects; state.rebuild_display(); }
|
||||
KeyCode::Char('4') => { state.tab = Tab::Todos; state.rebuild_display(); }
|
||||
KeyCode::Char('5') => { state.tab = Tab::PRs; state.rebuild_display(); }
|
||||
KeyCode::Char('6') => { state.tab = Tab::Issues; state.rebuild_display(); }
|
||||
KeyCode::Char('7') => {
|
||||
state.tab = Tab::Stats;
|
||||
state.rebuild_display();
|
||||
maybe_load_global_stats(state);
|
||||
}
|
||||
KeyCode::Char('8') => { state.tab = Tab::HubSettings; state.rebuild_display(); }
|
||||
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
state.status_msg = None;
|
||||
if let Some(phase) = keys_dispatch::handle_global_key(state, key) {
|
||||
return Ok(phase);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
//! The hub's global keymap: one key event in, an optional `PhaseResult` out.
|
||||
//! This only runs once `tui_phase` has ruled out a modal/confirm/filter input
|
||||
//! being open — every arm here is a key that means something on the main
|
||||
//! repo/project/todo/PR/issue list itself.
|
||||
|
||||
use super::actions::{copy_to_clipboard, get_remote_url, scan_todos_from_repos, save_bookmarks, save_pins, to_web_url};
|
||||
use super::modals::open_bulk_import_picker;
|
||||
use super::render::HELP_LINES;
|
||||
use super::state::{
|
||||
ConfirmTag, DisplayRow, HubState, Modal, PendingAction, PhaseResult, SelectTag, Tab, TextTag,
|
||||
};
|
||||
use crate::todos;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
/// Handles one key event against the hub's global keymap. Returns `Some` only
|
||||
/// for the handful of keys that need `tui_phase` to leave its loop (quit, open
|
||||
/// a sub-view, or run something that needs the TUI torn down first) — every
|
||||
/// other key is fully handled here and the caller should just keep looping.
|
||||
pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseResult> {
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => {
|
||||
if !state.filter_text.is_empty() {
|
||||
state.filter_text.clear(); state.rebuild_display();
|
||||
} else {
|
||||
state.quit = true; return Some(PhaseResult::None);
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if state.tab == Tab::HubSettings {
|
||||
state.hub_settings_cur = state.hub_settings_cur.saturating_sub(1);
|
||||
} else {
|
||||
state.scroll_up();
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if state.tab == Tab::HubSettings {
|
||||
let max = 10 + 1 + state.templates.len();
|
||||
if state.hub_settings_cur + 1 < max { state.hub_settings_cur += 1; }
|
||||
} else {
|
||||
state.scroll_down();
|
||||
}
|
||||
}
|
||||
// Terminals deliver Ctrl+A as lowercase 'a' + CONTROL (byte 0x01);
|
||||
// match both cases so the binding works everywhere.
|
||||
KeyCode::Char('a') | KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
let visible: Vec<usize> = state.display.iter().filter_map(|row| {
|
||||
if let DisplayRow::Repo { repo_idx } = row { Some(*repo_idx) } else { None }
|
||||
}).collect();
|
||||
let all_sel = visible.iter().all(|ri| state.selected.contains(ri));
|
||||
if all_sel {
|
||||
state.selected.clear();
|
||||
state.status_msg = Some("All deselected".into());
|
||||
} else {
|
||||
for ri in visible { state.selected.insert(ri); }
|
||||
state.status_msg = Some(format!("Selected {} repos", state.selected.len()));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('*') => {
|
||||
state.modal = Modal::Select {
|
||||
title: "Select by condition".into(),
|
||||
options: vec![
|
||||
"All visible".into(),
|
||||
"Dirty (uncommitted changes)".into(),
|
||||
"Behind remote".into(),
|
||||
"Ahead of remote".into(),
|
||||
"No license".into(),
|
||||
"Language: Rust".into(),
|
||||
"Language: TypeScript".into(),
|
||||
"Language: Python".into(),
|
||||
"Language: Go".into(),
|
||||
],
|
||||
cursor: 0,
|
||||
tag: SelectTag::SelectByCondition,
|
||||
};
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
match state.tab {
|
||||
Tab::Todos => {
|
||||
if let Some(ti) = state.cur_todo_idx() {
|
||||
state.todos[ti].status = state.todos[ti].status.cycle();
|
||||
state.todos[ti].touch();
|
||||
let _ = todos::save(&state.todos);
|
||||
}
|
||||
}
|
||||
_ => state.toggle_select(),
|
||||
}
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right => {
|
||||
state.tab = match state.tab {
|
||||
Tab::Overview => Tab::Repos,
|
||||
Tab::Repos => Tab::Projects,
|
||||
Tab::Projects => Tab::Todos,
|
||||
Tab::Todos => Tab::PRs,
|
||||
Tab::PRs => Tab::Issues,
|
||||
Tab::Issues => Tab::Stats,
|
||||
Tab::Stats => Tab::Activity,
|
||||
Tab::Activity => Tab::HubSettings,
|
||||
Tab::HubSettings => Tab::Overview,
|
||||
};
|
||||
state.cursor = 0; state.offset = 0; state.selected.clear();
|
||||
state.rebuild_display();
|
||||
super::keys::maybe_load_global_stats(state);
|
||||
super::keys::maybe_load_activity(state);
|
||||
}
|
||||
KeyCode::Left => {
|
||||
state.tab = match state.tab {
|
||||
Tab::Overview => Tab::HubSettings,
|
||||
Tab::Repos => Tab::Overview,
|
||||
Tab::Projects => Tab::Repos,
|
||||
Tab::Todos => Tab::Projects,
|
||||
Tab::PRs => Tab::Todos,
|
||||
Tab::Issues => Tab::PRs,
|
||||
Tab::Stats => Tab::Issues,
|
||||
Tab::Activity => Tab::Stats,
|
||||
Tab::HubSettings => Tab::Activity,
|
||||
};
|
||||
state.cursor = 0; state.offset = 0; state.selected.clear();
|
||||
state.rebuild_display();
|
||||
super::keys::maybe_load_global_stats(state);
|
||||
super::keys::maybe_load_activity(state);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
match state.tab {
|
||||
Tab::Repos | Tab::Projects => {
|
||||
if state.cur_repo_idx().is_some() || state.cur_proj_idx().is_some() {
|
||||
state.open_detail = true;
|
||||
return Some(PhaseResult::OpenDetail);
|
||||
}
|
||||
}
|
||||
Tab::PRs => {
|
||||
let di = state.cur_di();
|
||||
if let Some(di) = di {
|
||||
if let DisplayRow::Pr { pr_idx } = &state.display[di] {
|
||||
let url = state.prs[*pr_idx].url.clone();
|
||||
if !url.is_empty() {
|
||||
crate::util::open_path(&url);
|
||||
state.status_msg = Some("Opening PR in browser...".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Tab::Issues => {
|
||||
let di = state.cur_di();
|
||||
if let Some(di) = di {
|
||||
if let DisplayRow::Issue { issue_idx } = &state.display[di] {
|
||||
let url = state.issues[*issue_idx].url.clone();
|
||||
if !url.is_empty() {
|
||||
crate::util::open_path(&url);
|
||||
state.status_msg = Some("Opening issue in browser...".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Tab::Todos | Tab::Overview | Tab::Stats | Tab::Activity => {}
|
||||
Tab::HubSettings => {
|
||||
match state.hub_settings_cur {
|
||||
0 => {
|
||||
state.modal = Modal::Text {
|
||||
title: "Workspace Root".into(),
|
||||
prompt: "Absolute path (~ supported):".into(),
|
||||
value: state.workspace_root.to_string_lossy().to_string(),
|
||||
tag: TextTag::SetWorkspace,
|
||||
max_len: None,
|
||||
};
|
||||
}
|
||||
1 => {
|
||||
let mut opts = crate::util::detect_editors();
|
||||
opts.push("Custom...".into());
|
||||
let cursor = opts.iter().position(|o| o == &state.editor).unwrap_or(0);
|
||||
state.modal = Modal::Select {
|
||||
title: "Editor".into(),
|
||||
options: opts,
|
||||
cursor,
|
||||
tag: SelectTag::SetEditor,
|
||||
};
|
||||
}
|
||||
2 => {
|
||||
let mut opts = crate::util::detect_clipboard_tools();
|
||||
opts.push("Custom...".into());
|
||||
let cursor = opts.iter().position(|o| o == &state.clipboard_cmd).unwrap_or(0);
|
||||
state.modal = Modal::Select {
|
||||
title: "Clipboard Command".into(),
|
||||
options: opts,
|
||||
cursor,
|
||||
tag: SelectTag::SetClipboard,
|
||||
};
|
||||
}
|
||||
7 => {
|
||||
if state.accounts.is_empty() {
|
||||
state.status_msg = Some("No accounts — run: vulp auth import".into());
|
||||
} else if state.accounts.len() == 1 {
|
||||
open_bulk_import_picker(state, 0);
|
||||
} else {
|
||||
let opts: Vec<String> = state.accounts.iter()
|
||||
.map(|a| format!("@{} ({})", a.username, a.provider))
|
||||
.collect();
|
||||
state.modal = Modal::Select {
|
||||
title: "Bulk Import — Account".into(),
|
||||
options: opts,
|
||||
cursor: 0,
|
||||
tag: SelectTag::PullAllAccount,
|
||||
};
|
||||
}
|
||||
}
|
||||
8 => {
|
||||
state.modal = Modal::Text {
|
||||
title: "Import Directory as Repos".into(),
|
||||
prompt: "Directory to scan (git init + link each subfolder):".into(),
|
||||
value: String::new(),
|
||||
tag: TextTag::ImportDir,
|
||||
max_len: None,
|
||||
};
|
||||
}
|
||||
9 => {
|
||||
let n = state.repos.len();
|
||||
state.modal = Modal::Confirm {
|
||||
message: format!("DANGER: Delete .git from all {} repos in workspace? Cannot be undone!", n),
|
||||
tag: ConfirmTag::RemoveGitAll,
|
||||
};
|
||||
}
|
||||
10 => {
|
||||
state.modal = Modal::Text {
|
||||
title: "New Template — Name".into(),
|
||||
prompt: "Template name:".into(),
|
||||
value: String::new(),
|
||||
tag: TextTag::NewTemplateName,
|
||||
max_len: Some(50),
|
||||
};
|
||||
}
|
||||
n if n >= 11 => {
|
||||
let ti = n - 11;
|
||||
if ti < state.templates.len() {
|
||||
let t = &state.templates[ti];
|
||||
let cmd_preview = t.commands.iter().take(2).cloned().collect::<Vec<_>>().join("; ");
|
||||
let cmd_str = if cmd_preview.is_empty() { "(no commands)".into() } else { cmd_preview };
|
||||
state.modal = Modal::Select {
|
||||
title: format!("Template: {}", t.name),
|
||||
options: vec![
|
||||
format!("Commands: {}", cmd_str.chars().take(40).collect::<String>()),
|
||||
"Delete template".into(),
|
||||
],
|
||||
cursor: 0,
|
||||
tag: SelectTag::TemplateMenu(ti),
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
if !state.action_targets().is_empty() {
|
||||
state.modal = Modal::Select {
|
||||
title: "Sync — Action".into(),
|
||||
options: vec!["Pull".into(), "Commit+Push".into(), "Push only".into()],
|
||||
cursor: 0,
|
||||
tag: SelectTag::SyncAction,
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
if !state.action_targets().is_empty() { state.confirming = Some(PendingAction::Release); }
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let cur = state.repos[ri].name.clone();
|
||||
state.modal = Modal::Text { title: "Set label".into(), prompt: "Display name (blank = reset):".into(), value: cur, tag: TextTag::Label(ri), max_len: Some(60) };
|
||||
}
|
||||
}
|
||||
KeyCode::Char('p') if state.tab == Tab::Stats => {
|
||||
state.global_stats = None;
|
||||
state.global_stats_rx = None;
|
||||
state.global_stats_period = state.global_stats_period.next();
|
||||
super::keys::maybe_load_global_stats(state);
|
||||
}
|
||||
KeyCode::Char('<') if state.tab == Tab::Stats => {
|
||||
state.global_stats = None;
|
||||
state.global_stats_rx = None;
|
||||
state.global_stats_period = state.global_stats_period.prev();
|
||||
super::keys::maybe_load_global_stats(state);
|
||||
}
|
||||
KeyCode::Char('v') if state.tab == Tab::Overview => {
|
||||
if let Some(Ok(latest)) = &state.latest_release {
|
||||
if !latest.html_url.is_empty() {
|
||||
crate::util::open_path(&latest.html_url);
|
||||
state.status_msg = Some("Opening release notes in browser...".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('c') if state.tab == Tab::Activity => {
|
||||
use crate::activity::ActivityKind;
|
||||
let cycle = ActivityKind::cycle();
|
||||
state.activity_filter = match state.activity_filter {
|
||||
None => Some(cycle[0]),
|
||||
Some(k) => {
|
||||
let idx = cycle.iter().position(|&c| c == k).unwrap_or(0);
|
||||
if idx + 1 < cycle.len() { Some(cycle[idx + 1]) } else { None }
|
||||
}
|
||||
};
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
let mut opts = vec!["Create project".to_string()];
|
||||
if !state.projects.is_empty() {
|
||||
opts.push("Assign repos".into());
|
||||
opts.push("Rename / recolor".into());
|
||||
opts.push("Delete project".into());
|
||||
}
|
||||
state.modal = Modal::Select { title: "Projects".into(), options: opts, cursor: 0, tag: SelectTag::ProjMenu };
|
||||
}
|
||||
KeyCode::Char('+') => {
|
||||
state.modal = Modal::Text { title: "New todo — Title".into(), prompt: "Title:".into(), value: String::new(), tag: TextTag::TodoTitle, max_len: Some(100) };
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
if let Some(ti) = state.cur_todo_idx() {
|
||||
let cur = state.todos[ti].title.clone();
|
||||
state.modal = Modal::Text { title: "Edit todo — Title".into(), prompt: "Title:".into(), value: cur, tag: TextTag::TodoEditTitle(ti), max_len: Some(100) };
|
||||
}
|
||||
}
|
||||
KeyCode::Delete | KeyCode::Char('D') if key.modifiers.contains(KeyModifiers::SHIFT) => {
|
||||
if state.tab == Tab::Todos {
|
||||
if let Some(ti) = state.cur_todo_idx() {
|
||||
let title = state.todos[ti].title.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete '{}'?", title), tag: ConfirmTag::DeleteTodo(ti) };
|
||||
}
|
||||
} else if state.tab == Tab::Projects {
|
||||
if let Some(pi) = state.cur_proj_idx() {
|
||||
let name = state.projects[pi].name.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete project '{}'?", name), tag: ConfirmTag::DeleteProject(pi) };
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if state.tab == Tab::Todos {
|
||||
if let Some(ti) = state.cur_todo_idx() {
|
||||
let title = state.todos[ti].title.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete '{}'?", title), tag: ConfirmTag::DeleteTodo(ti) };
|
||||
}
|
||||
} else if state.tab == Tab::Projects {
|
||||
if let Some(pi) = state.cur_proj_idx() {
|
||||
let name = state.projects[pi].name.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete project '{}'?", name), tag: ConfirmTag::DeleteProject(pi) };
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => { state.filter_dirty = !state.filter_dirty; state.rebuild_display(); }
|
||||
KeyCode::Char('b') => { state.filter_behind = !state.filter_behind; state.rebuild_display(); }
|
||||
KeyCode::Char('f') => { state.filter_ahead = !state.filter_ahead; state.rebuild_display(); }
|
||||
KeyCode::Char('/') => {
|
||||
state.filtering = true;
|
||||
state.filter_text.clear();
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Char('?') => {
|
||||
state.modal = Modal::Info {
|
||||
title: "Keybindings".into(),
|
||||
lines: HELP_LINES.iter().map(|s| s.to_string()).collect(),
|
||||
scroll: 0,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('S') => {
|
||||
let sm = state.sort_mode;
|
||||
let dot = |m: super::state::SortMode| if sm == m { "●" } else { "○" };
|
||||
let chk = |b: bool| if b { "✓" } else { " " };
|
||||
state.modal = Modal::Select {
|
||||
title: "Sort & Filter".into(),
|
||||
options: vec![
|
||||
format!("{} Default order", dot(super::state::SortMode::Default)),
|
||||
format!("{} By name", dot(super::state::SortMode::Name)),
|
||||
format!("{} By recent commit", dot(super::state::SortMode::Age)),
|
||||
format!("{} Dirty first", dot(super::state::SortMode::DirtyFirst)),
|
||||
format!("{} Behind first", dot(super::state::SortMode::BehindFirst)),
|
||||
format!("[{}] Show dirty only", chk(state.filter_dirty)),
|
||||
format!("[{}] Show behind only", chk(state.filter_behind)),
|
||||
format!("[{}] Show ahead only", chk(state.filter_ahead)),
|
||||
format!("[{}] Show archived", chk(state.filter_archived)),
|
||||
" Reset all".into(),
|
||||
],
|
||||
cursor: 0,
|
||||
tag: SelectTag::SortFilter,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('o') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
state.pending_phase = Some(PhaseResult::ExecOpenEditor(path));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('y') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.to_string_lossy().to_string();
|
||||
let cmd = state.clipboard_cmd.clone();
|
||||
if copy_to_clipboard(&path, &cmd) {
|
||||
state.status_msg = Some("Path copied!".into());
|
||||
} else {
|
||||
state.status_msg = Some("No clipboard cmd".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
state.modal = Modal::Select {
|
||||
title: "Copy URL".into(),
|
||||
options: vec!["SSH URL (git@…)".into(), "HTTPS URL (https://…)".into()],
|
||||
cursor: 0,
|
||||
tag: SelectTag::CopyUrl(path),
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Char('u') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
let cmd = state.clipboard_cmd.clone();
|
||||
if let Some(url) = get_remote_url(&path) {
|
||||
if copy_to_clipboard(&url, &cmd) {
|
||||
state.status_msg = Some("SSH URL copied!".into());
|
||||
} else {
|
||||
state.status_msg = Some(format!(" {}", url));
|
||||
}
|
||||
} else {
|
||||
state.status_msg = Some("No remote origin".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('B') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
if state.bookmarks.contains(&path) {
|
||||
state.bookmarks.remove(&path);
|
||||
state.repos[ri].bookmarked = false;
|
||||
state.status_msg = Some("Bookmark removed".into());
|
||||
} else {
|
||||
state.bookmarks.insert(path.clone());
|
||||
state.repos[ri].bookmarked = true;
|
||||
state.status_msg = Some("Bookmarked!".into());
|
||||
}
|
||||
save_bookmarks(&state.bookmarks);
|
||||
state.rebuild_display();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('T') => {
|
||||
let count = scan_todos_from_repos(state);
|
||||
state.status_msg = Some(format!("Scanned: {} new code todos", count));
|
||||
state.tab = Tab::Todos;
|
||||
state.show_code_todos = true;
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Char('X') => {
|
||||
if state.tab == Tab::Todos {
|
||||
state.show_code_todos = !state.show_code_todos;
|
||||
state.rebuild_display();
|
||||
state.status_msg = Some(if state.show_code_todos {
|
||||
"Showing code scan todos".into()
|
||||
} else {
|
||||
"Showing manual todos only".into()
|
||||
});
|
||||
}
|
||||
}
|
||||
KeyCode::Char('g') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let gitignore = state.repos[ri].path.join(".gitignore");
|
||||
state.pending_phase = Some(PhaseResult::ExecOpenEditor(gitignore));
|
||||
state.status_msg = Some("Opening .gitignore...".into());
|
||||
}
|
||||
}
|
||||
KeyCode::Char('O') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.to_string_lossy().to_string();
|
||||
crate::util::open_path(&path);
|
||||
state.status_msg = Some("Opening in file manager...".into());
|
||||
}
|
||||
}
|
||||
KeyCode::Char('W') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
if let Some(url) = get_remote_url(&path) {
|
||||
let web = to_web_url(&url);
|
||||
crate::util::open_path(&web);
|
||||
state.status_msg = Some("Opening in browser...".into());
|
||||
} else {
|
||||
state.status_msg = Some("No remote origin".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('M') if state.tab == Tab::HubSettings => {
|
||||
let profiles = crate::profile::list_profiles(&state.cfg);
|
||||
let profile_count = profiles.len();
|
||||
let mut opts: Vec<String> = profiles.into_iter().map(|n| format!("Switch to '{}'", n)).collect();
|
||||
opts.push("+ Save current settings as profile".into());
|
||||
opts.push("Copy current settings as JSON".into());
|
||||
opts.push("Import settings from JSON (paste)".into());
|
||||
opts.push(format!("Push settings to '{}' (private repo)", crate::profile::SETTINGS_REPO_NAME));
|
||||
opts.push(format!("Pull settings from '{}' (private repo)", crate::profile::SETTINGS_REPO_NAME));
|
||||
state.modal = Modal::Select {
|
||||
title: "Profiles & Sync".into(),
|
||||
options: opts,
|
||||
cursor: 0,
|
||||
tag: SelectTag::ProfileMenu { profile_count },
|
||||
};
|
||||
}
|
||||
KeyCode::Char('P') => {
|
||||
if let Some(ri) = state.cur_repo_idx() {
|
||||
let path = state.repos[ri].path.clone();
|
||||
if state.pins.contains(&path) {
|
||||
state.pins.remove(&path);
|
||||
state.status_msg = Some("Unpinned".into());
|
||||
} else {
|
||||
state.pins.insert(path.clone());
|
||||
state.status_msg = Some("Pinned to top! ◆".into());
|
||||
}
|
||||
save_pins(&state.pins);
|
||||
state.rebuild_display();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
state.filter_archived = !state.filter_archived;
|
||||
state.rebuild_display();
|
||||
}
|
||||
KeyCode::Char('N') => {
|
||||
state.modal = Modal::Select {
|
||||
title: "New repository".into(),
|
||||
options: vec![
|
||||
"Clone from URL".into(),
|
||||
"Clone local folder (path)".into(),
|
||||
"Create new empty repo".into(),
|
||||
"Link existing folder".into(),
|
||||
],
|
||||
cursor: 0,
|
||||
tag: SelectTag::NewRepo,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('R') => {
|
||||
super::modals_spawn::spawn_api_refresh(state);
|
||||
}
|
||||
KeyCode::Char('m') => super::modals_spawn::open_action_menu(state),
|
||||
|
||||
// Direct tab switching (number keys)
|
||||
KeyCode::Char('1') => {
|
||||
state.tab = Tab::Overview;
|
||||
state.rebuild_display();
|
||||
super::keys::maybe_load_global_stats(state);
|
||||
}
|
||||
KeyCode::Char('2') => { state.tab = Tab::Repos; state.rebuild_display(); }
|
||||
KeyCode::Char('3') => { state.tab = Tab::Projects; state.rebuild_display(); }
|
||||
KeyCode::Char('4') => { state.tab = Tab::Todos; state.rebuild_display(); }
|
||||
KeyCode::Char('5') => { state.tab = Tab::PRs; state.rebuild_display(); }
|
||||
KeyCode::Char('6') => { state.tab = Tab::Issues; state.rebuild_display(); }
|
||||
KeyCode::Char('7') => {
|
||||
state.tab = Tab::Stats;
|
||||
state.rebuild_display();
|
||||
super::keys::maybe_load_global_stats(state);
|
||||
}
|
||||
KeyCode::Char('8') => {
|
||||
state.tab = Tab::Activity;
|
||||
state.rebuild_display();
|
||||
super::keys::maybe_load_activity(state);
|
||||
}
|
||||
KeyCode::Char('9') => { state.tab = Tab::HubSettings; state.rebuild_display(); }
|
||||
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Per-frame polling of hub background-thread channels that don't have a
|
||||
//! dedicated `HubState::poll_*` method: pull-all, repo creation, and
|
||||
//! settings-profile pull. Each function is a no-op if its channel isn't
|
||||
//! open, so all three are safe to call unconditionally every loop iteration.
|
||||
|
||||
use super::actions::refresh_repos;
|
||||
use super::state::{CreateRepoMsg, HubState, Modal, ProfilePullMsg};
|
||||
|
||||
/// Drains progress messages from a "pull all repos for this account" job
|
||||
/// and, once it finishes, reloads settings (if the job imported any) and
|
||||
/// rescans the workspace so the newly-cloned repos show up.
|
||||
pub fn poll_pull_all(state: &mut HubState) {
|
||||
let done = if let Some(rx) = &state.pull_all_rx {
|
||||
let mut finished = false;
|
||||
let mut settings_imported = false;
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
if msg == "DONE" { finished = true; }
|
||||
else if msg == "SETTINGS_IMPORTED" { settings_imported = true; }
|
||||
else { state.status_msg = Some(msg); }
|
||||
}
|
||||
if settings_imported {
|
||||
if let Ok(cfg) = crate::config::load_config() {
|
||||
state.editor = cfg.editor.clone();
|
||||
state.clipboard_cmd = cfg.clipboard_cmd.clone();
|
||||
state.cfg = cfg;
|
||||
}
|
||||
}
|
||||
finished
|
||||
} else { false };
|
||||
if done {
|
||||
state.pull_all_rx = None;
|
||||
state.status_msg = Some("Pull-all complete! Refreshing...".into());
|
||||
refresh_repos(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains progress from a background "create repo" job, appending lines to
|
||||
/// the open `Modal::Info` (if any) so the user sees live progress, then
|
||||
/// replaces it with the final result once the job reports `Done`.
|
||||
pub fn poll_create_repo(state: &mut HubState) {
|
||||
let outcome = if let Some(rx) = &state.create_repo_rx {
|
||||
let mut done = None;
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
match msg {
|
||||
CreateRepoMsg::Progress(s) => {
|
||||
match &mut state.modal {
|
||||
Modal::Info { lines, .. } => lines.push(s),
|
||||
_ => state.status_msg = Some(s),
|
||||
}
|
||||
}
|
||||
CreateRepoMsg::Done { success, name, detail } => done = Some((success, name, detail)),
|
||||
}
|
||||
}
|
||||
done
|
||||
} else { None };
|
||||
if let Some((success, name, detail)) = outcome {
|
||||
state.create_repo_rx = None;
|
||||
let title = if success { format!("✓ '{}' created", name) } else { format!("✗ '{}' failed", name) };
|
||||
let mut lines = match std::mem::replace(&mut state.modal, Modal::None) {
|
||||
Modal::Info { lines, .. } => lines,
|
||||
_ => vec![],
|
||||
};
|
||||
lines.push(detail);
|
||||
state.modal = Modal::Info { title, lines, scroll: 0 };
|
||||
refresh_repos(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains progress from a background "pull settings profile from the sync
|
||||
/// repo" job and, once it finishes, applies the fetched snapshot to `cfg`
|
||||
/// (rescanning the workspace too, if the snapshot included repo structure).
|
||||
pub fn poll_profile_pull(state: &mut HubState) {
|
||||
let outcome = if let Some(rx) = &state.profile_pull_rx {
|
||||
let mut done = None;
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
let ProfilePullMsg::Done(result) = msg;
|
||||
done = Some(result);
|
||||
}
|
||||
done
|
||||
} else { None };
|
||||
if let Some(result) = outcome {
|
||||
state.profile_pull_rx = None;
|
||||
match result {
|
||||
Ok(snap) => {
|
||||
let has_structure = snap.structure.is_some();
|
||||
match crate::profile::apply(&snap, &mut state.cfg) {
|
||||
Ok(()) => {
|
||||
state.editor = state.cfg.editor.clone();
|
||||
state.clipboard_cmd = state.cfg.clipboard_cmd.clone();
|
||||
if has_structure { refresh_repos(state); }
|
||||
state.modal = Modal::Info {
|
||||
title: "✓ Settings pulled".into(),
|
||||
lines: vec!["Applied successfully".into()],
|
||||
scroll: 0,
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
state.modal = Modal::Info {
|
||||
title: "✗ Pull failed".into(),
|
||||
lines: vec![format!("Fetched but failed to apply: {e:#}")],
|
||||
scroll: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
state.modal = Modal::Info {
|
||||
title: "✗ Pull failed".into(),
|
||||
lines: vec![e],
|
||||
scroll: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains the background API-refresh channel: progress goes to the status
|
||||
/// line, `Done` applies fetched metadata/PRs/issues to state + cache and
|
||||
/// rebuilds the display.
|
||||
pub fn poll_refresh(state: &mut HubState) {
|
||||
use super::state::RefreshMsg;
|
||||
let mut done = None;
|
||||
if let Some(rx) = &state.refresh_rx {
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
match msg {
|
||||
RefreshMsg::Progress(s) => state.status_msg = Some(s),
|
||||
RefreshMsg::Done { .. } => { done = Some(msg); }
|
||||
}
|
||||
}
|
||||
// Thread gone without Done — drop the receiver so [R] works again.
|
||||
if done.is_none() {
|
||||
if let Err(std::sync::mpsc::TryRecvError::Disconnected) = rx.try_recv() {
|
||||
state.refresh_rx = None;
|
||||
state.status_msg = Some("Refresh failed (thread died)".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(RefreshMsg::Done { metas, prs, issues, closed_counts }) = done else { return };
|
||||
|
||||
let mut cache = crate::cache::load();
|
||||
let mut fetched_meta = 0usize;
|
||||
for (name, meta) in metas {
|
||||
if let Some(r) = state.repos.iter_mut().find(|r| r.name == name) {
|
||||
r.stars = meta.stars;
|
||||
r.open_issues = meta.open_issues;
|
||||
r.open_prs = meta.open_prs;
|
||||
}
|
||||
cache.set_meta(&name, meta);
|
||||
fetched_meta += 1;
|
||||
}
|
||||
let mut fetched_prs = 0usize;
|
||||
for (name, p) in prs { fetched_prs += p.len(); cache.set_prs(&name, p); }
|
||||
let mut fetched_iss = 0usize;
|
||||
for (name, i) in issues { fetched_iss += i.len(); cache.set_issues(&name, i); }
|
||||
let _ = crate::cache::save(&cache);
|
||||
|
||||
state.prs = cache.all_prs().into_iter().cloned().collect();
|
||||
state.issues = cache.all_issues().into_iter().cloned().collect();
|
||||
if closed_counts.is_some() { state.closed_counts = closed_counts; }
|
||||
|
||||
state.refresh_rx = None;
|
||||
state.status_msg = Some(format!(
|
||||
"Refreshed {} repos: {} PRs, {} issues", fetched_meta, fetched_prs, fetched_iss
|
||||
));
|
||||
state.rebuild_display();
|
||||
}
|
||||
+90
-48
@@ -1,13 +1,32 @@
|
||||
mod actions;
|
||||
// pub: the CLI (`cli_cmds`) reuses the bookmark/pin persistence and
|
||||
// remote-url helpers instead of duplicating them.
|
||||
pub mod actions;
|
||||
mod keys;
|
||||
mod keys_dispatch;
|
||||
mod keys_poll;
|
||||
mod modals;
|
||||
mod modals_confirm_project;
|
||||
mod modals_confirm_repo;
|
||||
mod modals_confirm_settings;
|
||||
mod modals_confirm_sync;
|
||||
mod modals_spawn;
|
||||
mod project_detail;
|
||||
mod project_detail_render;
|
||||
mod render;
|
||||
mod right_panel;
|
||||
mod session;
|
||||
mod state;
|
||||
mod state_bg;
|
||||
mod state_misc;
|
||||
mod state_modal;
|
||||
mod state_rows;
|
||||
mod tabs;
|
||||
mod tabs_overview;
|
||||
mod tabs_settings;
|
||||
mod tabs_stats;
|
||||
mod tabs_tables;
|
||||
|
||||
use actions::{exec_api_refresh, load_bookmarks, load_pins, load_templates, refresh_repos, run_sync_flow};
|
||||
use actions::{load_bookmarks, load_pins, load_templates, refresh_repos, run_sync_flow};
|
||||
use keys::tui_phase;
|
||||
use state::{HubState, Modal, PhaseResult, SortMode, Tab};
|
||||
|
||||
@@ -16,10 +35,37 @@ use crate::config::AppConfig;
|
||||
use crate::projects;
|
||||
use crate::providers::{IssueDetail, PrDetail};
|
||||
use crate::status::RepoSummary;
|
||||
use crossterm::event::KeyCode;
|
||||
use ratatui::text::Line;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Kicks off a one-time, best-effort fetch of release info for the General
|
||||
/// Settings tile: the very latest GitHub release (for the update check) and
|
||||
/// the release matching the version actually running (for "shipped by X on
|
||||
/// Y"). Unauthenticated, so it works with zero accounts configured.
|
||||
fn spawn_release_info_fetch(state: &mut HubState) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let version = env!("CARGO_PKG_VERSION").to_string();
|
||||
let gh_users: Vec<String> = state.accounts.iter()
|
||||
.filter(|a| a.provider == "github" && a.base_url.is_none())
|
||||
.map(|a| a.username.clone())
|
||||
.collect();
|
||||
std::thread::spawn(move || {
|
||||
let rt = match tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(_) => return };
|
||||
let (latest, current, since) = rt.block_on(async {
|
||||
let latest = crate::release_info::fetch_latest().await.map_err(|e| format!("{e:#}"));
|
||||
let current = crate::release_info::fetch_current(&version).await.map_err(|e| format!("{e:#}"));
|
||||
let mut since = Vec::new();
|
||||
for user in &gh_users {
|
||||
if let Ok(date) = crate::release_info::fetch_user_since(user).await {
|
||||
if !date.is_empty() { since.push((user.clone(), date)); }
|
||||
}
|
||||
}
|
||||
(latest, current, since)
|
||||
});
|
||||
let _ = tx.send((latest, current, since));
|
||||
});
|
||||
state.release_rx = Some(rx);
|
||||
}
|
||||
|
||||
pub async fn run_overview_tui(
|
||||
mut repos: Vec<RepoSummary>,
|
||||
scan_rx: Option<std::sync::mpsc::Receiver<RepoSummary>>,
|
||||
@@ -55,7 +101,7 @@ pub async fn run_overview_tui(
|
||||
prs: cached_prs,
|
||||
issues: cached_issues,
|
||||
tab: Tab::Overview,
|
||||
display: Vec::new(), repo_pos: Vec::new(),
|
||||
display: Vec::new(),
|
||||
cursor: 0, offset: 0, visible_rows: 0,
|
||||
selected: HashSet::new(),
|
||||
filter_dirty: false, filter_behind: false, filter_ahead: false,
|
||||
@@ -81,6 +127,11 @@ pub async fn run_overview_tui(
|
||||
global_stats: None,
|
||||
global_stats_rx: None,
|
||||
global_stats_period: crate::detail::StatsPeriod::Day30,
|
||||
activity_feed: None,
|
||||
activity_rx: None,
|
||||
activity_filter: None,
|
||||
vulp_log: Vec::new(),
|
||||
pending_cursor_restore: None,
|
||||
hub_settings_cur: 0,
|
||||
accounts,
|
||||
templates,
|
||||
@@ -88,16 +139,45 @@ pub async fn run_overview_tui(
|
||||
pending_sync_msg: None,
|
||||
pending_phase: None,
|
||||
pull_all_rx: None,
|
||||
refresh_rx: None,
|
||||
create_repo_rx: None,
|
||||
profile_pull_rx: None,
|
||||
codeintel_rx: None,
|
||||
latest_release: None,
|
||||
current_release: None,
|
||||
account_since: HashMap::new(),
|
||||
closed_counts: None,
|
||||
release_rx: None,
|
||||
cfg: cfg.clone(),
|
||||
};
|
||||
for r in &mut state.repos {
|
||||
r.bookmarked = state.bookmarks.contains(&r.path);
|
||||
}
|
||||
// Restore last session's tab + remember which repo to re-focus once the
|
||||
// scan finishes (cursor can't be set yet — repos stream in unsorted).
|
||||
let session = session::load();
|
||||
state.tab = session::u8_to_tab(session.tab);
|
||||
state.pending_cursor_restore = session.cursor_repo_path;
|
||||
state.rebuild_display();
|
||||
spawn_release_info_fetch(&mut state);
|
||||
// Tab starts on Overview, which now needs global stats (top repos/projects)
|
||||
// and the local vulp action log for its tiles.
|
||||
keys::maybe_load_global_stats(&mut state);
|
||||
crate::activity::compact(5000);
|
||||
state.vulp_log = {
|
||||
let mut log = crate::activity::load_logged();
|
||||
log.sort_by(|a, b| b.ts.cmp(&a.ts));
|
||||
log.truncate(50);
|
||||
log
|
||||
};
|
||||
|
||||
loop {
|
||||
let (phase_result, handed_term) = tui_phase(&mut state)?;
|
||||
|
||||
if state.quit { break; }
|
||||
if state.quit {
|
||||
session::save(state.tab, state.cur_repo_idx().map(|ri| state.repos[ri].path.clone()));
|
||||
break;
|
||||
}
|
||||
|
||||
match phase_result {
|
||||
PhaseResult::OpenDetail => {
|
||||
@@ -110,6 +190,10 @@ pub async fn run_overview_tui(
|
||||
repo_indices.iter().filter_map(|&ri| state.repos.get(ri)).cloned().collect();
|
||||
project_detail::run(state.projects[pi].clone(), proj_repos, cfg, handed_term).await?;
|
||||
}
|
||||
// Detail view can rename/delete/archive repos or change branches —
|
||||
// rescan so the hub's list reflects whatever just happened, instead
|
||||
// of silently showing stale data until some other action refreshes it.
|
||||
refresh_repos(&mut state);
|
||||
}
|
||||
PhaseResult::ExecSync => {
|
||||
state.exec_action = false;
|
||||
@@ -139,22 +223,6 @@ pub async fn run_overview_tui(
|
||||
let _ = std::io::stdin().lines().next();
|
||||
refresh_repos(&mut state);
|
||||
}
|
||||
PhaseResult::ExecRefresh => {
|
||||
exec_api_refresh(&mut state, cfg).await;
|
||||
}
|
||||
PhaseResult::ExecTemplateInit { path, commands } => {
|
||||
println!("\n── Running template commands ──\n");
|
||||
for cmd in &commands {
|
||||
println!(" $ {}", cmd);
|
||||
let _ = std::process::Command::new("sh")
|
||||
.args(["-c", cmd.as_str()])
|
||||
.current_dir(&path)
|
||||
.status();
|
||||
}
|
||||
println!("\n ✓ Done. Press Enter...");
|
||||
let _ = std::io::stdin().lines().next();
|
||||
refresh_repos(&mut state);
|
||||
}
|
||||
PhaseResult::ExecOpenEditor(path) => {
|
||||
let editor = state.editor.clone();
|
||||
let _ = std::process::Command::new(&editor).arg(&path).status();
|
||||
@@ -164,29 +232,3 @@ pub async fn run_overview_tui(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
pub fn run_detail_tui(detail: crate::status::RepoDetail) -> Result<()> {
|
||||
use crossterm::event::Event;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
let mut quit = false;
|
||||
crate::tui::run_loop(|terminal| {
|
||||
terminal.draw(|f| {
|
||||
let area = f.area();
|
||||
let blk = Block::default().title(format!(" {} · {} ", detail.summary.name, detail.summary.branch)).borders(Borders::ALL);
|
||||
let inner = blk.inner(area);
|
||||
f.render_widget(blk, area);
|
||||
let lines = vec![
|
||||
Line::from(format!(" Branch: {} +{} ahead -{} behind dirty: {}", detail.summary.branch, detail.summary.ahead, detail.summary.behind, detail.summary.is_dirty)),
|
||||
Line::from(format!(" Path: {}", detail.summary.path.display())),
|
||||
Line::from(""),
|
||||
Line::from(" Modified files:"),
|
||||
];
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
})?;
|
||||
if let Some(Event::Key(key)) = crate::tui::next_event(250)? {
|
||||
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) { quit = true; }
|
||||
}
|
||||
Ok(quit)
|
||||
})
|
||||
}
|
||||
+75
-573
@@ -1,8 +1,25 @@
|
||||
use crate::projects;
|
||||
use crate::todos::{self, Todo, TodoStatus};
|
||||
use crossterm::event::KeyCode;
|
||||
//! Hub modal system: rendering (`render_modal`), per-keystroke editing
|
||||
//! (`handle_modal_key`), and committing a modal once the user confirms
|
||||
//! (`process_modal_confirm`). The actual confirm logic for each feature area
|
||||
//! lives in the sibling `modals_confirm_*` files, chained together below;
|
||||
//! background-thread launchers live in `modals_spawn`.
|
||||
|
||||
fn expand_tilde(s: &str) -> std::path::PathBuf {
|
||||
use crossterm::event::KeyCode;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use super::state::{HubState, Modal, ModalAction};
|
||||
|
||||
pub use super::modals_spawn::open_bulk_import_picker;
|
||||
|
||||
/// Expands a leading `~` or `~/...` to the user's home directory; returns
|
||||
/// `s` unchanged (as a `PathBuf`) otherwise. Used by every modal that takes
|
||||
/// a free-form filesystem path, since users naturally type `~/...` paths.
|
||||
pub fn expand_tilde(s: &str) -> std::path::PathBuf {
|
||||
let s = s.trim();
|
||||
if s == "~" {
|
||||
dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(s))
|
||||
@@ -12,19 +29,9 @@ fn expand_tilde(s: &str) -> std::path::PathBuf {
|
||||
std::path::PathBuf::from(s)
|
||||
}
|
||||
}
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use super::state::{
|
||||
ConfirmTag, HubState, Modal, ModalAction, MultiTag,
|
||||
ProjAction, SelectTag, TextTag,
|
||||
};
|
||||
use super::actions::save_templates;
|
||||
|
||||
/// Draws the currently active modal, dispatching on its variant. Each
|
||||
/// variant gets a centered box sized to its content (`centered_rect`).
|
||||
pub fn render_modal(f: &mut Frame, modal: &Modal, area: Rect) {
|
||||
match modal {
|
||||
Modal::Text { title, prompt, value, max_len, .. } => {
|
||||
@@ -122,6 +129,27 @@ pub fn render_modal(f: &mut Frame, modal: &Modal, area: Rect) {
|
||||
all.push(hint);
|
||||
f.render_widget(List::new(all).style(Style::default().bg(Color::Black)), inner);
|
||||
}
|
||||
Modal::RenamePreview { title, lines, scroll, .. } => {
|
||||
let h = (lines.len() + 4).min(30) as u16;
|
||||
let rect = centered_rect(76, h, area);
|
||||
f.render_widget(Clear, rect);
|
||||
let blk = Block::default().title(format!(" {} ", title)).borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Yellow));
|
||||
let inner = blk.inner(rect);
|
||||
f.render_widget(blk, rect);
|
||||
let avail = inner.height.saturating_sub(1) as usize;
|
||||
let items: Vec<ListItem> = lines.iter().skip(*scroll).take(avail)
|
||||
.map(|l| ListItem::new(Line::from(Span::styled(format!(" {}", l), Style::default()))))
|
||||
.collect();
|
||||
let hint = ListItem::new(Line::from(vec![
|
||||
Span::styled(" [↑↓] scroll ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("[y/Enter] apply ", Style::default().fg(Color::Green)),
|
||||
Span::styled("[n/Esc] discard", Style::default().fg(Color::Red)),
|
||||
]));
|
||||
let mut all = items;
|
||||
all.push(hint);
|
||||
f.render_widget(List::new(all).style(Style::default().bg(Color::Black)), inner);
|
||||
}
|
||||
Modal::None => {}
|
||||
}
|
||||
}
|
||||
@@ -142,18 +170,26 @@ const PALETTE: &[(&str, &str)] = &[
|
||||
("#f43f5e", "Rose"),
|
||||
];
|
||||
|
||||
/// Lists every palette entry as a "`hex Name`" display string, for the
|
||||
/// project-color `Modal::Select` picker.
|
||||
pub fn color_palette() -> Vec<String> {
|
||||
PALETTE.iter().map(|(hex, name)| format!("{} {}", hex, name)).collect()
|
||||
}
|
||||
|
||||
/// Looks up the hex string for a palette index, falling back to the default
|
||||
/// gray if `cursor` is somehow out of range.
|
||||
pub fn palette_hex(cursor: usize) -> String {
|
||||
PALETTE.get(cursor).map(|(h, _)| h.to_string()).unwrap_or_else(|| "#808080".into())
|
||||
}
|
||||
|
||||
/// Finds the palette index for a stored hex value, so re-opening the color
|
||||
/// picker on an existing project starts with the right entry highlighted.
|
||||
pub fn palette_cursor_for(hex: &str) -> usize {
|
||||
PALETTE.iter().position(|(h, _)| *h == hex).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Centers a `pct_x`%-wide, `height`-tall box inside `area`, clamping the
|
||||
/// height so it never exceeds the available space.
|
||||
pub fn centered_rect(pct_x: u16, height: u16, area: Rect) -> Rect {
|
||||
let w = area.width * pct_x / 100;
|
||||
let x = area.x + (area.width.saturating_sub(w)) / 2;
|
||||
@@ -161,11 +197,14 @@ pub fn centered_rect(pct_x: u16, height: u16, area: Rect) -> Rect {
|
||||
Rect::new(x, y, w, height.min(area.height))
|
||||
}
|
||||
|
||||
/// Routes one key event to the active modal's per-character editing logic
|
||||
/// (typing into a text field, moving a list cursor, toggling a checkbox) and
|
||||
/// reports whether the modal should be confirmed, cancelled, or left open.
|
||||
pub fn handle_modal_key(modal: &mut Modal, code: KeyCode) -> ModalAction {
|
||||
match modal {
|
||||
Modal::Text { value, max_len, .. } => match code {
|
||||
KeyCode::Char(c) => {
|
||||
if max_len.map_or(true, |m| value.len() < m) { value.push(c); }
|
||||
if max_len.is_none_or(|m| value.len() < m) { value.push(c); }
|
||||
ModalAction::Pending
|
||||
}
|
||||
KeyCode::Backspace => { value.pop(); ModalAction::Pending }
|
||||
@@ -202,566 +241,29 @@ pub fn handle_modal_key(modal: &mut Modal, code: KeyCode) -> ModalAction {
|
||||
}
|
||||
_ => ModalAction::Pending,
|
||||
},
|
||||
Modal::RenamePreview { lines, scroll, .. } => match code {
|
||||
KeyCode::Char('y') | KeyCode::Enter => ModalAction::Confirm,
|
||||
KeyCode::Char('n') | KeyCode::Esc | KeyCode::Char('q') => ModalAction::Cancel,
|
||||
KeyCode::Up | KeyCode::Char('k') => { *scroll = scroll.saturating_sub(1); ModalAction::Pending }
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if *scroll + 1 < lines.len() { *scroll += 1; }
|
||||
ModalAction::Pending
|
||||
}
|
||||
_ => ModalAction::Pending,
|
||||
},
|
||||
Modal::None => ModalAction::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits the currently active modal: takes it out of `state.modal`
|
||||
/// (replacing with `Modal::None`) and routes it through each
|
||||
/// `modals_confirm_*::try_confirm` in turn until one of them recognizes its
|
||||
/// tag and handles it. A modal not recognized by any of them is silently
|
||||
/// dropped, same as an unmatched arm in a single big `match` would be.
|
||||
pub fn process_modal_confirm(state: &mut HubState) {
|
||||
let modal = std::mem::replace(&mut state.modal, Modal::None);
|
||||
match modal {
|
||||
Modal::Text { value, tag: TextTag::Label(ri), .. } => {
|
||||
let display = if value.trim().is_empty() {
|
||||
state.repos[ri].path.file_name().unwrap_or_default().to_string_lossy().to_string()
|
||||
} else { value.clone() };
|
||||
state.repos[ri].name = display.clone();
|
||||
let _ = crate::labels::save_label(&state.repos[ri].path.clone(), &value, &state.workspace_root);
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::ProjName, .. } => {
|
||||
if !value.trim().is_empty() {
|
||||
state.modal = Modal::Select {
|
||||
title: "Create project — Color".into(),
|
||||
options: color_palette(),
|
||||
cursor: 0,
|
||||
tag: SelectTag::ProjColorCreate(value),
|
||||
};
|
||||
}
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::ProjColorCreate(name), .. } => {
|
||||
let color = palette_hex(cursor);
|
||||
state.projects.push(crate::projects::Project { name, color, repos: vec![] });
|
||||
let _ = projects::save(&state.projects);
|
||||
state.rebuild_project_map();
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::ProjRenameName(pi), .. } => {
|
||||
let cur_cursor = palette_cursor_for(&state.projects[pi].color);
|
||||
state.modal = Modal::Select {
|
||||
title: format!("Rename '{}' — Color", state.projects[pi].name),
|
||||
options: color_palette(),
|
||||
cursor: cur_cursor,
|
||||
tag: SelectTag::ProjColorRename(pi, value),
|
||||
};
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::ProjColorRename(pi, name), .. } => {
|
||||
let color = palette_hex(cursor);
|
||||
state.projects[pi].name = name;
|
||||
state.projects[pi].color = color;
|
||||
let _ = projects::save(&state.projects);
|
||||
state.rebuild_project_map();
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
Modal::Select { cursor, tag: SelectTag::ProjMenu, .. } => {
|
||||
if state.projects.is_empty() && cursor == 0 {
|
||||
state.modal = Modal::Text { title: "Create project — Name".into(), prompt: "Project name:".into(), value: String::new(), tag: TextTag::ProjName, max_len: Some(50) };
|
||||
return;
|
||||
}
|
||||
match cursor {
|
||||
0 => {
|
||||
state.modal = Modal::Text { title: "Create project — Name".into(), prompt: "Project name:".into(), value: String::new(), tag: TextTag::ProjName, max_len: Some(50) };
|
||||
}
|
||||
1 => {
|
||||
let names: Vec<String> = state.projects.iter().map(|p| p.name.clone()).collect();
|
||||
state.modal = Modal::Select { title: "Assign repos to…".into(), options: names, cursor: 0, tag: SelectTag::ProjForAction(ProjAction::Assign) };
|
||||
}
|
||||
2 => {
|
||||
let names: Vec<String> = state.projects.iter().map(|p| p.name.clone()).collect();
|
||||
state.modal = Modal::Select { title: "Rename…".into(), options: names, cursor: 0, tag: SelectTag::ProjForAction(ProjAction::Rename) };
|
||||
}
|
||||
3 => {
|
||||
let names: Vec<String> = state.projects.iter().map(|p| p.name.clone()).collect();
|
||||
state.modal = Modal::Select { title: "Delete…".into(), options: names, cursor: 0, tag: SelectTag::ProjForAction(ProjAction::Delete) };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::ProjForAction(action), .. } => {
|
||||
let pi = cursor;
|
||||
match action {
|
||||
ProjAction::Assign => {
|
||||
let rel_paths: Vec<String> = state.repos.iter()
|
||||
.map(|r| projects::rel_key(&r.path, &state.workspace_root))
|
||||
.collect();
|
||||
let options: Vec<String> = state.repos.iter().zip(&rel_paths)
|
||||
.map(|(r, rel)| format!("{:<18} {}", r.name, rel))
|
||||
.collect();
|
||||
let checks: Vec<bool> = rel_paths.iter()
|
||||
.map(|rel| state.projects[pi].repos.contains(rel))
|
||||
.collect();
|
||||
state.modal = Modal::Multi { title: format!("Assign repos to '{}'", state.projects[pi].name), options, checks, cursor: 0, tag: MultiTag::ProjAssign(pi) };
|
||||
}
|
||||
ProjAction::Rename => {
|
||||
let cur_name = state.projects[pi].name.clone();
|
||||
state.modal = Modal::Text { title: "Rename project".into(), prompt: "New name:".into(), value: cur_name, tag: TextTag::ProjRenameName(pi), max_len: Some(50) };
|
||||
}
|
||||
ProjAction::Delete => {
|
||||
let name = state.projects[pi].name.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete project '{}'?", name), tag: ConfirmTag::DeleteProject(pi) };
|
||||
}
|
||||
}
|
||||
}
|
||||
Modal::Multi { checks, tag: MultiTag::ProjAssign(pi), .. } => {
|
||||
let rel_paths: Vec<String> = state.repos.iter()
|
||||
.map(|r| projects::rel_key(&r.path, &state.workspace_root))
|
||||
.collect();
|
||||
state.projects[pi].repos = rel_paths.into_iter().zip(checks.iter()).filter(|(_, &c)| c).map(|(p, _)| p).collect();
|
||||
let _ = projects::save(&state.projects);
|
||||
state.rebuild_project_map();
|
||||
state.rebuild_display();
|
||||
}
|
||||
Modal::Confirm { tag: ConfirmTag::DeleteProject(pi), .. } => {
|
||||
state.projects.remove(pi);
|
||||
let _ = projects::save(&state.projects);
|
||||
state.rebuild_project_map();
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::TodoTitle, .. } => {
|
||||
if !value.trim().is_empty() {
|
||||
state.modal = Modal::Text { title: "New todo — Description".into(), prompt: "Description (optional):".into(), value: String::new(), tag: TextTag::TodoDesc(value), max_len: None };
|
||||
}
|
||||
}
|
||||
Modal::Text { value: desc, tag: TextTag::TodoDesc(title), .. } => {
|
||||
let options = TodoStatus::all_labels().iter().map(|s| s.to_string()).collect();
|
||||
state.modal = Modal::Select { title: "New todo — Status".into(), options, cursor: 0, tag: SelectTag::TodoStatus { title, desc } };
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::TodoStatus { title, desc }, .. } => {
|
||||
let mut opts: Vec<String> = state.projects.iter().map(|p| p.name.clone()).collect();
|
||||
opts.insert(0, "— no project —".into());
|
||||
state.modal = Modal::Select { title: "New todo — Project".into(), options: opts, cursor: 0, tag: SelectTag::TodoProject { title, desc, status_idx: cursor } };
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::TodoProject { title, desc, status_idx }, .. } => {
|
||||
let project = if cursor == 0 { None } else { Some(state.projects[cursor - 1].name.clone()) };
|
||||
let id = todos::next_id(&state.todos);
|
||||
let todo = Todo::new(id, title, desc, TodoStatus::from_idx(status_idx), project);
|
||||
state.todos.push(todo);
|
||||
let _ = todos::save(&state.todos);
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::TodoEditTitle(ti), .. } => {
|
||||
state.todos[ti].title = value.clone();
|
||||
let cur_desc = state.todos[ti].description.clone();
|
||||
state.todos[ti].touch();
|
||||
state.modal = Modal::Text { title: "Edit todo — Description".into(), prompt: "Description:".into(), value: cur_desc, tag: TextTag::TodoEditDesc(ti, value), max_len: None };
|
||||
}
|
||||
Modal::Text { value: desc, tag: TextTag::TodoEditDesc(ti, _), .. } => {
|
||||
state.todos[ti].description = desc;
|
||||
state.todos[ti].touch();
|
||||
let _ = todos::save(&state.todos);
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
Modal::Select { cursor, tag: SelectTag::TodoChangeStatus(ti), .. } => {
|
||||
state.todos[ti].status = TodoStatus::from_idx(cursor);
|
||||
state.todos[ti].touch();
|
||||
let _ = todos::save(&state.todos);
|
||||
}
|
||||
|
||||
Modal::Select { cursor, tag: SelectTag::SortFilter, .. } => {
|
||||
use super::state::SortMode;
|
||||
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.sort_mode = SortMode::Default;
|
||||
state.filter_dirty = false;
|
||||
state.filter_behind = false;
|
||||
state.filter_ahead = false;
|
||||
state.filter_archived = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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].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()));
|
||||
}
|
||||
|
||||
Modal::Confirm { tag: ConfirmTag::DeleteTodo(ti), .. } => {
|
||||
state.todos.remove(ti);
|
||||
let _ = todos::save(&state.todos);
|
||||
state.rebuild_display();
|
||||
}
|
||||
|
||||
// ── Hub sync via in-TUI modals ────────────────────────────────────────
|
||||
Modal::Select { cursor, tag: SelectTag::SyncAction, .. } => {
|
||||
use super::state::PhaseResult;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Modal::Text { value, tag: TextTag::SyncMsg, .. } => {
|
||||
use super::state::PhaseResult;
|
||||
state.pending_sync_msg = Some(value);
|
||||
state.pending_phase = Some(PhaseResult::ExecSync);
|
||||
}
|
||||
|
||||
// ── Hub Settings editing ──────────────────────────────────────────────
|
||||
Modal::Text { value, tag: TextTag::SetWorkspace, .. } => {
|
||||
let path = expand_tilde(&value);
|
||||
if !path.exists() {
|
||||
state.status_msg = Some(format!("Directory does not exist: {}", path.display()));
|
||||
return;
|
||||
}
|
||||
if !path.is_dir() {
|
||||
state.status_msg = Some("Not a directory".into());
|
||||
return;
|
||||
}
|
||||
state.workspace_root = path;
|
||||
super::actions::refresh_repos(state);
|
||||
state.status_msg = Some(format!(
|
||||
"Workspace changed to {} (to persist: edit ~/.fxg/config.toml)",
|
||||
state.workspace_root.display(),
|
||||
));
|
||||
}
|
||||
Modal::Text { value, tag: TextTag::SetEditor, .. } => {
|
||||
if !value.trim().is_empty() {
|
||||
state.editor = value.trim().to_string();
|
||||
state.status_msg = Some(format!("Editor set to: {}", state.editor));
|
||||
}
|
||||
}
|
||||
Modal::Text { value, tag: TextTag::SetClipboard, .. } => {
|
||||
state.clipboard_cmd = value.trim().to_string();
|
||||
state.status_msg = Some("Clipboard command updated".into());
|
||||
}
|
||||
|
||||
// ── Pull-all repos ────────────────────────────────────────────────────
|
||||
Modal::Select { cursor, tag: SelectTag::PullAllAccount, .. } => {
|
||||
let acc_name = state.accounts.get(cursor).map(|a| format!("@{} ({})", a.username, a.provider)).unwrap_or_default();
|
||||
state.modal = Modal::Confirm {
|
||||
message: format!("Pull all repos for {}?", acc_name),
|
||||
tag: ConfirmTag::PullAll(cursor),
|
||||
};
|
||||
}
|
||||
Modal::Confirm { tag: ConfirmTag::PullAll(acc_idx), .. } => {
|
||||
if let Some(acc) = state.accounts.get(acc_idx) {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
let workspace = state.workspace_root.clone();
|
||||
let provider = acc.provider.clone();
|
||||
let username = acc.username.clone();
|
||||
state.pull_all_rx = Some(rx);
|
||||
state.status_msg = Some(format!("Fetching repos for @{}...", username));
|
||||
std::thread::spawn(move || {
|
||||
let out = std::process::Command::new("gh")
|
||||
.args(["api", "user/repos", "--paginate", "--jq", ".[].clone_url"])
|
||||
.output();
|
||||
let Ok(o) = out else {
|
||||
let _ = tx.send("ERROR: gh CLI failed — is it installed and authenticated?".into());
|
||||
return;
|
||||
};
|
||||
let urls: Vec<String> = String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let total = urls.len();
|
||||
let _ = tx.send(format!("Found {} repos for @{} ({})", total, username, provider));
|
||||
for (i, url) in urls.iter().enumerate() {
|
||||
let repo_name = url.rsplitn(2, '/').next().unwrap_or("").trim_end_matches(".git");
|
||||
let target = workspace.join(repo_name);
|
||||
let action = if target.exists() {
|
||||
let _ = std::process::Command::new("git")
|
||||
.args(["pull"]).current_dir(&target).status();
|
||||
"pulled"
|
||||
} else {
|
||||
let _ = std::process::Command::new("git")
|
||||
.args(["clone", url.as_str()]).current_dir(&workspace).status();
|
||||
"cloned"
|
||||
};
|
||||
let _ = tx.send(format!("[{}/{}] {} {}", i + 1, total, action, repo_name));
|
||||
}
|
||||
let _ = tx.send("DONE".into());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::CloneUrl, .. } => {
|
||||
if !value.trim().is_empty() {
|
||||
state.pending_clone = Some(value.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::InitName, .. } => {
|
||||
let name = value.trim().to_string();
|
||||
if name.is_empty() { return; }
|
||||
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" }));
|
||||
}
|
||||
state.modal = Modal::Select {
|
||||
title: format!("Create '{}' — Template", name),
|
||||
options: opts,
|
||||
cursor: 0,
|
||||
tag: SelectTag::InitTemplate(name),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Repo creation with template ───────────────────────────────────────
|
||||
Modal::Select { cursor, tag: SelectTag::InitTemplate(repo_name), .. } => {
|
||||
use super::state::PhaseResult;
|
||||
let template = if cursor == 0 { None } else { state.templates.get(cursor - 1).cloned() };
|
||||
let target = state.workspace_root.join(&repo_name);
|
||||
if let Err(e) = std::fs::create_dir_all(&target) {
|
||||
state.status_msg = Some(format!("mkdir failed: {e}"));
|
||||
return;
|
||||
}
|
||||
let git_ok = std::process::Command::new("git")
|
||||
.arg("init").current_dir(&target).output()
|
||||
.map(|o| o.status.success()).unwrap_or(false);
|
||||
if !git_ok {
|
||||
state.status_msg = Some("git init failed".into());
|
||||
return;
|
||||
}
|
||||
// create .fxg/meta.toml
|
||||
let fxg_dir = target.join(".fxg");
|
||||
let _ = std::fs::create_dir_all(&fxg_dir);
|
||||
let tmpl_name = template.as_ref().map(|t| t.name.as_str()).unwrap_or("blank");
|
||||
let meta = format!("created_at = \"{}\"\ntemplate = \"{}\"\n",
|
||||
chrono::Utc::now().format("%Y-%m-%d"), tmpl_name);
|
||||
let _ = std::fs::write(fxg_dir.join("meta.toml"), meta);
|
||||
|
||||
if let Some(t) = &template {
|
||||
if !t.commands.is_empty() {
|
||||
state.pending_phase = Some(PhaseResult::ExecTemplateInit {
|
||||
path: target.clone(),
|
||||
commands: t.commands.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
super::actions::refresh_repos(state);
|
||||
state.status_msg = Some(format!("Created '{}'{}",
|
||||
repo_name,
|
||||
template.as_ref().map(|t| format!(" using '{}'", t.name)).unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
|
||||
// ── Import directory as repos ─────────────────────────────────────────
|
||||
Modal::Text { value, tag: TextTag::ImportDir, .. } => {
|
||||
let src = expand_tilde(&value);
|
||||
if !src.exists() {
|
||||
state.status_msg = Some(format!("Path does not exist: {}", src.display()));
|
||||
return;
|
||||
}
|
||||
let workspace = state.workspace_root.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
state.pull_all_rx = Some(rx);
|
||||
state.status_msg = Some(format!("Importing from {}...", src.display()));
|
||||
std::thread::spawn(move || {
|
||||
let Ok(entries) = std::fs::read_dir(&src) else {
|
||||
let _ = tx.send("ERROR: Cannot read directory".into());
|
||||
let _ = tx.send("DONE".into());
|
||||
return;
|
||||
};
|
||||
let mut linked = 0usize;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() { continue; }
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
if name.starts_with('.') { continue; }
|
||||
if !path.join(".git").exists() {
|
||||
let _ = std::process::Command::new("git")
|
||||
.arg("init").current_dir(&path).status();
|
||||
let _ = tx.send(format!("git init: {}", name));
|
||||
}
|
||||
let link = workspace.join(&name);
|
||||
if !link.exists() {
|
||||
if std::os::unix::fs::symlink(&path, &link).is_ok() {
|
||||
linked += 1;
|
||||
let _ = tx.send(format!("linked: {}", name));
|
||||
}
|
||||
} else {
|
||||
let _ = tx.send(format!("skip (exists): {}", name));
|
||||
}
|
||||
}
|
||||
let _ = tx.send(format!("Done: {} new repos linked", linked));
|
||||
let _ = tx.send("DONE".into());
|
||||
});
|
||||
}
|
||||
|
||||
// ── Template management ───────────────────────────────────────────────
|
||||
Modal::Text { value, tag: TextTag::NewTemplateName, .. } => {
|
||||
let name = value.trim().to_string();
|
||||
if !name.is_empty() {
|
||||
state.modal = Modal::Text {
|
||||
title: format!("Template '{}' — Commands", name),
|
||||
prompt: "Commands (semicolon-separated, e.g. cargo init; echo ok):".into(),
|
||||
value: String::new(),
|
||||
tag: TextTag::NewTemplateCmds(name),
|
||||
max_len: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
Modal::Text { value, tag: TextTag::NewTemplateCmds(name), .. } => {
|
||||
let commands: Vec<String> = value.split(';')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let t = super::state::RepoTemplate { name: name.clone(), description: String::new(), commands };
|
||||
state.templates.push(t);
|
||||
save_templates(&state.templates);
|
||||
state.status_msg = Some(format!("Template '{}' saved", name));
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::TemplateMenu(ti), .. } => {
|
||||
if cursor == 1 { // Delete
|
||||
let name = state.templates[ti].name.clone();
|
||||
state.modal = Modal::Confirm {
|
||||
message: format!("Delete template '{}'?", name),
|
||||
tag: ConfirmTag::DeleteTemplate(ti),
|
||||
};
|
||||
}
|
||||
// cursor 0 is "view commands" — just dismiss
|
||||
}
|
||||
Modal::Confirm { tag: ConfirmTag::DeleteTemplate(ti), .. } => {
|
||||
let name = state.templates[ti].name.clone();
|
||||
state.templates.remove(ti);
|
||||
save_templates(&state.templates);
|
||||
let max = 10 + 1 + state.templates.len();
|
||||
if state.hub_settings_cur >= max { state.hub_settings_cur = max.saturating_sub(1); }
|
||||
state.status_msg = Some(format!("Template '{}' deleted", name));
|
||||
}
|
||||
|
||||
// ── Remove .git from all workspace repos ──────────────────────────────
|
||||
Modal::Confirm { tag: ConfirmTag::RemoveGitAll, .. } => {
|
||||
let workspace = state.workspace_root.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
state.pull_all_rx = Some(rx);
|
||||
state.status_msg = Some("Removing .git directories...".into());
|
||||
std::thread::spawn(move || {
|
||||
let Ok(entries) = std::fs::read_dir(&workspace) else {
|
||||
let _ = tx.send("DONE".into());
|
||||
return;
|
||||
};
|
||||
let mut count = 0usize;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() { continue; }
|
||||
let git_dir = path.join(".git");
|
||||
if git_dir.exists() {
|
||||
if std::fs::remove_dir_all(&git_dir).is_ok() {
|
||||
count += 1;
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let _ = tx.send(format!("Removed .git: {}", name));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = tx.send(format!("Done: removed .git from {} dirs", count));
|
||||
let _ = tx.send("DONE".into());
|
||||
});
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::LinkPath, .. } => {
|
||||
let src = expand_tilde(&value);
|
||||
if !src.exists() {
|
||||
state.status_msg = Some(format!("Path does not exist: {}", src.display()));
|
||||
return;
|
||||
}
|
||||
let name = src.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
if name.is_empty() { state.status_msg = Some("Invalid path".into()); return; }
|
||||
let link = state.workspace_root.join(&name);
|
||||
if link.exists() {
|
||||
state.status_msg = Some(format!("{name} already exists in workspace"));
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::os::unix::fs::symlink(&src, &link) {
|
||||
state.status_msg = Some(format!("symlink failed: {e}"));
|
||||
return;
|
||||
}
|
||||
// Ensure it's a git repo
|
||||
if !src.join(".git").exists() {
|
||||
let _ = std::process::Command::new("git").arg("init").current_dir(&src).output();
|
||||
}
|
||||
super::actions::refresh_repos(state);
|
||||
state.status_msg = Some(format!("Linked {name}"));
|
||||
}
|
||||
|
||||
Modal::Select { cursor, tag: SelectTag::CopyUrl(path), .. } => {
|
||||
use super::actions::{copy_to_clipboard, get_remote_url};
|
||||
use super::actions::to_web_url;
|
||||
let cmd = state.clipboard_cmd.clone();
|
||||
if let Some(raw_url) = get_remote_url(&path) {
|
||||
let url = if cursor == 0 { raw_url } else { to_web_url(&raw_url) };
|
||||
if copy_to_clipboard(&url, &cmd) {
|
||||
let label = if cursor == 0 { "SSH URL" } else { "HTTPS URL" };
|
||||
state.status_msg = Some(format!("{} copied!", label));
|
||||
} else {
|
||||
state.status_msg = Some(format!(" {}", url));
|
||||
}
|
||||
} else {
|
||||
state.status_msg = Some("No remote origin".into());
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
let modal = match super::modals_confirm_project::try_confirm(state, modal) { Some(m) => m, None => return };
|
||||
let modal = match super::modals_confirm_sync::try_confirm(state, modal) { Some(m) => m, None => return };
|
||||
let modal = match super::modals_confirm_settings::try_confirm(state, modal) { Some(m) => m, None => return };
|
||||
let _ = super::modals_confirm_repo::try_confirm(state, modal);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! `process_modal_confirm` arms for the Projects and Todos features: naming/
|
||||
//! labeling repos, creating/renaming/assigning/deleting projects, and the
|
||||
//! multi-step "new todo" / "edit todo" modal chains.
|
||||
|
||||
use super::state::{ConfirmTag, HubState, Modal, ProjAction, SelectTag, TextTag};
|
||||
use crate::projects;
|
||||
use crate::todos::{self, Todo, TodoStatus};
|
||||
|
||||
/// Handles one step of a Projects/Todos 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::Text { value, tag: TextTag::Label(ri), .. } => {
|
||||
let display = if value.trim().is_empty() {
|
||||
state.repos[ri].path.file_name().unwrap_or_default().to_string_lossy().to_string()
|
||||
} else { value.clone() };
|
||||
state.repos[ri].name = display.clone();
|
||||
let _ = crate::labels::save_label(&state.repos[ri].path.clone(), &value, &state.workspace_root);
|
||||
state.rebuild_display();
|
||||
None
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::ProjName, .. } => {
|
||||
if !value.trim().is_empty() {
|
||||
state.modal = Modal::Select {
|
||||
title: "Create project — Color".into(),
|
||||
options: super::modals::color_palette(),
|
||||
cursor: 0,
|
||||
tag: SelectTag::ProjColorCreate(value),
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::ProjColorCreate(name), .. } => {
|
||||
let color = super::modals::palette_hex(cursor);
|
||||
state.projects.push(crate::projects::Project { name, color, repos: vec![] });
|
||||
let _ = projects::save(&state.projects);
|
||||
state.rebuild_project_map();
|
||||
state.rebuild_display();
|
||||
None
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::ProjRenameName(pi), .. } => {
|
||||
let cur_cursor = super::modals::palette_cursor_for(&state.projects[pi].color);
|
||||
state.modal = Modal::Select {
|
||||
title: format!("Rename '{}' — Color", state.projects[pi].name),
|
||||
options: super::modals::color_palette(),
|
||||
cursor: cur_cursor,
|
||||
tag: SelectTag::ProjColorRename(pi, value),
|
||||
};
|
||||
None
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::ProjColorRename(pi, name), .. } => {
|
||||
let color = super::modals::palette_hex(cursor);
|
||||
state.projects[pi].name = name;
|
||||
state.projects[pi].color = color;
|
||||
let _ = projects::save(&state.projects);
|
||||
state.rebuild_project_map();
|
||||
state.rebuild_display();
|
||||
None
|
||||
}
|
||||
|
||||
Modal::Select { cursor, tag: SelectTag::ProjMenu, .. } => {
|
||||
if state.projects.is_empty() && cursor == 0 {
|
||||
state.modal = Modal::Text { title: "Create project — Name".into(), prompt: "Project name:".into(), value: String::new(), tag: TextTag::ProjName, max_len: Some(50) };
|
||||
return None;
|
||||
}
|
||||
match cursor {
|
||||
0 => {
|
||||
state.modal = Modal::Text { title: "Create project — Name".into(), prompt: "Project name:".into(), value: String::new(), tag: TextTag::ProjName, max_len: Some(50) };
|
||||
}
|
||||
1 => {
|
||||
let names: Vec<String> = state.projects.iter().map(|p| p.name.clone()).collect();
|
||||
state.modal = Modal::Select { title: "Assign repos to…".into(), options: names, cursor: 0, tag: SelectTag::ProjForAction(ProjAction::Assign) };
|
||||
}
|
||||
2 => {
|
||||
let names: Vec<String> = state.projects.iter().map(|p| p.name.clone()).collect();
|
||||
state.modal = Modal::Select { title: "Rename…".into(), options: names, cursor: 0, tag: SelectTag::ProjForAction(ProjAction::Rename) };
|
||||
}
|
||||
3 => {
|
||||
let names: Vec<String> = state.projects.iter().map(|p| p.name.clone()).collect();
|
||||
state.modal = Modal::Select { title: "Delete…".into(), options: names, cursor: 0, tag: SelectTag::ProjForAction(ProjAction::Delete) };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::ProjForAction(action), .. } => {
|
||||
let pi = cursor;
|
||||
match action {
|
||||
ProjAction::Assign => {
|
||||
let rel_paths: Vec<String> = state.repos.iter()
|
||||
.map(|r| projects::rel_key(&r.path, &state.workspace_root))
|
||||
.collect();
|
||||
let options: Vec<String> = state.repos.iter().zip(&rel_paths)
|
||||
.map(|(r, rel)| format!("{:<18} {}", r.name, rel))
|
||||
.collect();
|
||||
let checks: Vec<bool> = rel_paths.iter()
|
||||
.map(|rel| state.projects[pi].repos.contains(rel))
|
||||
.collect();
|
||||
state.modal = Modal::Multi { title: format!("Assign repos to '{}'", state.projects[pi].name), options, checks, cursor: 0, tag: super::state::MultiTag::ProjAssign(pi) };
|
||||
}
|
||||
ProjAction::Rename => {
|
||||
let cur_name = state.projects[pi].name.clone();
|
||||
state.modal = Modal::Text { title: "Rename project".into(), prompt: "New name:".into(), value: cur_name, tag: TextTag::ProjRenameName(pi), max_len: Some(50) };
|
||||
}
|
||||
ProjAction::Delete => {
|
||||
let name = state.projects[pi].name.clone();
|
||||
state.modal = Modal::Confirm { message: format!("Delete project '{}'?", name), tag: ConfirmTag::DeleteProject(pi) };
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Modal::Multi { checks, tag: super::state::MultiTag::ProjAssign(pi), .. } => {
|
||||
let rel_paths: Vec<String> = state.repos.iter()
|
||||
.map(|r| projects::rel_key(&r.path, &state.workspace_root))
|
||||
.collect();
|
||||
state.projects[pi].repos = rel_paths.into_iter().zip(checks.iter()).filter(|(_, &c)| c).map(|(p, _)| p).collect();
|
||||
let _ = projects::save(&state.projects);
|
||||
state.rebuild_project_map();
|
||||
state.rebuild_display();
|
||||
None
|
||||
}
|
||||
Modal::Confirm { tag: ConfirmTag::DeleteProject(pi), .. } => {
|
||||
state.projects.remove(pi);
|
||||
let _ = projects::save(&state.projects);
|
||||
state.rebuild_project_map();
|
||||
state.rebuild_display();
|
||||
None
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::TodoTitle, .. } => {
|
||||
if !value.trim().is_empty() {
|
||||
state.modal = Modal::Text { title: "New todo — Description".into(), prompt: "Description (optional):".into(), value: String::new(), tag: TextTag::TodoDesc(value), max_len: None };
|
||||
}
|
||||
None
|
||||
}
|
||||
Modal::Text { value: desc, tag: TextTag::TodoDesc(title), .. } => {
|
||||
let options = TodoStatus::all_labels().iter().map(|s| s.to_string()).collect();
|
||||
state.modal = Modal::Select { title: "New todo — Status".into(), options, cursor: 0, tag: SelectTag::TodoStatus { title, desc } };
|
||||
None
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::TodoStatus { title, desc }, .. } => {
|
||||
let mut opts: Vec<String> = state.projects.iter().map(|p| p.name.clone()).collect();
|
||||
opts.insert(0, "— no project —".into());
|
||||
state.modal = Modal::Select { title: "New todo — Project".into(), options: opts, cursor: 0, tag: SelectTag::TodoProject { title, desc, status_idx: cursor } };
|
||||
None
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::TodoProject { title, desc, status_idx }, .. } => {
|
||||
let project = if cursor == 0 { None } else { Some(state.projects[cursor - 1].name.clone()) };
|
||||
let id = todos::next_id(&state.todos);
|
||||
let todo = Todo::new(id, title, desc, TodoStatus::from_idx(status_idx), project);
|
||||
state.todos.push(todo);
|
||||
let _ = todos::save(&state.todos);
|
||||
state.rebuild_display();
|
||||
state.cursor = state.display.len().saturating_sub(1);
|
||||
None
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::TodoEditTitle(ti), .. } => {
|
||||
state.todos[ti].title = value.clone();
|
||||
let cur_desc = state.todos[ti].description.clone();
|
||||
state.todos[ti].touch();
|
||||
state.modal = Modal::Text { title: "Edit todo — Description".into(), prompt: "Description:".into(), value: cur_desc, tag: TextTag::TodoEditDesc(ti), max_len: None };
|
||||
None
|
||||
}
|
||||
Modal::Text { value: desc, tag: TextTag::TodoEditDesc(ti), .. } => {
|
||||
state.todos[ti].description = desc;
|
||||
state.todos[ti].touch();
|
||||
let _ = todos::save(&state.todos);
|
||||
state.rebuild_display();
|
||||
None
|
||||
}
|
||||
|
||||
Modal::Confirm { tag: ConfirmTag::DeleteTodo(ti), .. } => {
|
||||
state.todos.remove(ti);
|
||||
let _ = todos::save(&state.todos);
|
||||
state.rebuild_display();
|
||||
None
|
||||
}
|
||||
|
||||
other => Some(other),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! `process_modal_confirm` arms for repo-creation flows: the bulk-import
|
||||
//! affiliation picker, clone/init/link/import-directory, repo templates,
|
||||
//! the workspace-wide "remove .git" action, and the copy-URL picker.
|
||||
|
||||
use super::modals_spawn::{
|
||||
fetch_orgs_blocking, open_template_picker, spawn_bulk_import, spawn_create_repo,
|
||||
spawn_publish_existing,
|
||||
};
|
||||
use super::state::{ConfirmTag, HubState, Modal, SelectTag, TextTag};
|
||||
|
||||
/// Handles one step of a repo-creation, template-management, or workspace
|
||||
/// bulk-action modal chain. Returns `None` once `modal` has been consumed by
|
||||
/// one of this file's arms, or `Some(modal)` unchanged — this is the last
|
||||
/// file in `modals::process_modal_confirm`'s chain, so an unmatched modal is
|
||||
/// simply discarded by the caller either way.
|
||||
pub fn try_confirm(state: &mut HubState, modal: Modal) -> Option<Modal> {
|
||||
match modal {
|
||||
Modal::Multi { checks, tag: super::state::MultiTag::BulkImportAffiliation(acc_idx), .. } => {
|
||||
// "Repos I own" is always included regardless of how it's left
|
||||
// checked — bulk import without your own repos doesn't make sense.
|
||||
let affiliation = crate::providers::RepoAffiliation {
|
||||
owner: true,
|
||||
collaborator: checks.get(1).copied().unwrap_or(false),
|
||||
org_member: checks.get(2).copied().unwrap_or(false),
|
||||
};
|
||||
spawn_bulk_import(state, acc_idx, affiliation);
|
||||
None
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::CloneUrl, .. } => {
|
||||
if !value.trim().is_empty() {
|
||||
state.pending_clone = Some(value.trim().to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::InitName, .. } => {
|
||||
let name = value.trim().to_string();
|
||||
if name.is_empty() { return None; }
|
||||
|
||||
// Repos can be created under an org instead of the personal account —
|
||||
// ask first if the default account actually belongs to any.
|
||||
let orgs = fetch_orgs_blocking(state);
|
||||
if !orgs.is_empty() {
|
||||
let mut opts = vec!["@me (personal account)".to_string()];
|
||||
opts.extend(orgs);
|
||||
state.modal = Modal::Select {
|
||||
title: format!("Create '{}' — Owner", name),
|
||||
options: opts,
|
||||
cursor: 0,
|
||||
tag: SelectTag::InitOwner(name),
|
||||
};
|
||||
} else {
|
||||
open_template_picker(state, name, None);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
Modal::Select { cursor, options, tag: SelectTag::InitOwner(name), .. } => {
|
||||
let owner = if cursor == 0 { None } else { options.get(cursor).cloned() };
|
||||
open_template_picker(state, name, owner);
|
||||
None
|
||||
}
|
||||
|
||||
// ── Repo creation with template ───────────────────────────────────────
|
||||
Modal::Select { cursor, tag: SelectTag::InitTemplate { name: repo_name, owner }, .. } => {
|
||||
let template = if cursor == 0 { None } else { state.templates.get(cursor - 1).cloned() };
|
||||
let target = state.workspace_root.join(&repo_name);
|
||||
if let Err(e) = std::fs::create_dir_all(&target) {
|
||||
state.status_msg = Some(format!("mkdir failed: {e}"));
|
||||
return None;
|
||||
}
|
||||
let git_ok = std::process::Command::new("git")
|
||||
.arg("init").current_dir(&target).output()
|
||||
.map(|o| o.status.success()).unwrap_or(false);
|
||||
if !git_ok {
|
||||
state.status_msg = Some("git init failed".into());
|
||||
return None;
|
||||
}
|
||||
// create .vulp/meta.toml
|
||||
let vulp_dir = target.join(".vulp");
|
||||
let _ = std::fs::create_dir_all(&vulp_dir);
|
||||
let tmpl_name = template.as_ref().map(|t| t.name.as_str()).unwrap_or("blank");
|
||||
let meta = format!("created_at = \"{}\"\ntemplate = \"{}\"\n",
|
||||
chrono::Utc::now().format("%Y-%m-%d"), tmpl_name);
|
||||
let _ = std::fs::write(vulp_dir.join("meta.toml"), meta);
|
||||
|
||||
let commands = template.as_ref().map(|t| t.commands.clone()).unwrap_or_default();
|
||||
spawn_create_repo(
|
||||
state, target, repo_name.clone(),
|
||||
vec!["mkdir + git init ✓".into(), ".vulp/meta.toml ✓".into()],
|
||||
commands, owner,
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
// ── Import directory as repos ─────────────────────────────────────────
|
||||
Modal::Text { value, tag: TextTag::ImportDir, .. } => {
|
||||
let src = super::modals::expand_tilde(&value);
|
||||
if !src.exists() {
|
||||
state.status_msg = Some(format!("Path does not exist: {}", src.display()));
|
||||
return None;
|
||||
}
|
||||
let workspace = state.workspace_root.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
state.pull_all_rx = Some(rx);
|
||||
state.status_msg = Some(format!("Importing from {}...", src.display()));
|
||||
std::thread::spawn(move || {
|
||||
let Ok(entries) = std::fs::read_dir(&src) else {
|
||||
let _ = tx.send("ERROR: Cannot read directory".into());
|
||||
let _ = tx.send("DONE".into());
|
||||
return;
|
||||
};
|
||||
let mut linked = 0usize;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() { continue; }
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
if name.starts_with('.') { continue; }
|
||||
if !path.join(".git").exists() {
|
||||
let _ = std::process::Command::new("git")
|
||||
.arg("init").current_dir(&path).status();
|
||||
let _ = tx.send(format!("git init: {}", name));
|
||||
}
|
||||
let link = workspace.join(&name);
|
||||
if !link.exists() {
|
||||
if std::os::unix::fs::symlink(&path, &link).is_ok() {
|
||||
linked += 1;
|
||||
let _ = tx.send(format!("linked: {}", name));
|
||||
}
|
||||
} else {
|
||||
let _ = tx.send(format!("skip (exists): {}", name));
|
||||
}
|
||||
}
|
||||
let _ = tx.send(format!("Done: {} new repos linked", linked));
|
||||
let _ = tx.send("DONE".into());
|
||||
});
|
||||
None
|
||||
}
|
||||
|
||||
// ── Template management ───────────────────────────────────────────────
|
||||
Modal::Text { value, tag: TextTag::NewTemplateName, .. } => {
|
||||
let name = value.trim().to_string();
|
||||
if !name.is_empty() {
|
||||
state.modal = Modal::Text {
|
||||
title: format!("Template '{}' — Commands", name),
|
||||
prompt: "Commands (semicolon-separated, e.g. cargo init; echo ok):".into(),
|
||||
value: String::new(),
|
||||
tag: TextTag::NewTemplateCmds(name),
|
||||
max_len: None,
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
Modal::Text { value, tag: TextTag::NewTemplateCmds(name), .. } => {
|
||||
let commands: Vec<String> = value.split(';')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let t = super::state::RepoTemplate { name: name.clone(), description: String::new(), commands };
|
||||
state.templates.push(t);
|
||||
super::actions::save_templates(&state.templates);
|
||||
state.status_msg = Some(format!("Template '{}' saved", name));
|
||||
None
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::TemplateMenu(ti), .. } => {
|
||||
if cursor == 1 { // Delete
|
||||
let name = state.templates[ti].name.clone();
|
||||
state.modal = Modal::Confirm {
|
||||
message: format!("Delete template '{}'?", name),
|
||||
tag: ConfirmTag::DeleteTemplate(ti),
|
||||
};
|
||||
}
|
||||
// cursor 0 is "view commands" — just dismiss
|
||||
None
|
||||
}
|
||||
Modal::Confirm { tag: ConfirmTag::DeleteTemplate(ti), .. } => {
|
||||
let name = state.templates[ti].name.clone();
|
||||
state.templates.remove(ti);
|
||||
super::actions::save_templates(&state.templates);
|
||||
let max = 10 + 1 + state.templates.len();
|
||||
if state.hub_settings_cur >= max { state.hub_settings_cur = max.saturating_sub(1); }
|
||||
state.status_msg = Some(format!("Template '{}' deleted", name));
|
||||
None
|
||||
}
|
||||
|
||||
// ── Remove .git from all workspace repos ──────────────────────────────
|
||||
Modal::Confirm { tag: ConfirmTag::RemoveGitAll, .. } => {
|
||||
let workspace = state.workspace_root.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
state.pull_all_rx = Some(rx);
|
||||
state.status_msg = Some("Removing .git directories...".into());
|
||||
std::thread::spawn(move || {
|
||||
let Ok(entries) = std::fs::read_dir(&workspace) else {
|
||||
let _ = tx.send("DONE".into());
|
||||
return;
|
||||
};
|
||||
let mut count = 0usize;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() { continue; }
|
||||
let git_dir = path.join(".git");
|
||||
if git_dir.exists()
|
||||
&& std::fs::remove_dir_all(&git_dir).is_ok() {
|
||||
count += 1;
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let _ = tx.send(format!("Removed .git: {}", name));
|
||||
}
|
||||
}
|
||||
let _ = tx.send(format!("Done: removed .git from {} dirs", count));
|
||||
let _ = tx.send("DONE".into());
|
||||
});
|
||||
None
|
||||
}
|
||||
|
||||
Modal::Text { value, tag: TextTag::LinkPath, .. } => {
|
||||
let src = super::modals::expand_tilde(&value);
|
||||
if !src.exists() {
|
||||
state.status_msg = Some(format!("Path does not exist: {}", src.display()));
|
||||
return None;
|
||||
}
|
||||
let name = src.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
if name.is_empty() { state.status_msg = Some("Invalid path".into()); return None; }
|
||||
let link = state.workspace_root.join(&name);
|
||||
if link.exists() {
|
||||
state.status_msg = Some(format!("{name} already exists in workspace"));
|
||||
return None;
|
||||
}
|
||||
if let Err(e) = std::os::unix::fs::symlink(&src, &link) {
|
||||
state.status_msg = Some(format!("symlink failed: {e}"));
|
||||
return None;
|
||||
}
|
||||
if !src.join(".git").exists() {
|
||||
// No git history at all: fully onboard it, same as "Create new empty
|
||||
// repo" — stage everything that's already there, commit, create the
|
||||
// remote, push.
|
||||
spawn_create_repo(
|
||||
state, src, name,
|
||||
vec!["Linked into workspace ✓".into()],
|
||||
vec![], None,
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// Already has a .git — ask what to do with the existing history instead
|
||||
// of silently guessing. A `.git` dir with zero commits (e.g. a bare
|
||||
// `git init` with nothing committed yet) still offers "discard" as a
|
||||
// no-op-equivalent option, which is harmless.
|
||||
state.modal = Modal::Select {
|
||||
title: format!("'{}' already has a .git", name),
|
||||
options: vec![
|
||||
"Keep history — link only (push yourself later)".into(),
|
||||
"Keep history — link & publish to GitHub now".into(),
|
||||
"Discard history — start a fresh repo".into(),
|
||||
],
|
||||
cursor: 0,
|
||||
tag: SelectTag::LinkExisting { src, name },
|
||||
};
|
||||
None
|
||||
}
|
||||
|
||||
Modal::Select { cursor, tag: SelectTag::LinkExisting { src, name }, .. } => {
|
||||
match cursor {
|
||||
0 => {
|
||||
super::actions::refresh_repos(state);
|
||||
state.status_msg = Some(format!("Linked {name}"));
|
||||
}
|
||||
1 => {
|
||||
spawn_publish_existing(state, src, name, None);
|
||||
}
|
||||
2 => {
|
||||
let _ = std::fs::remove_dir_all(src.join(".git"));
|
||||
spawn_create_repo(
|
||||
state, src, name,
|
||||
vec!["Old .git removed ✓".into(), "Linked into workspace ✓".into()],
|
||||
vec![], None,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
Modal::Select { cursor, tag: SelectTag::CopyUrl(path), .. } => {
|
||||
use super::actions::{copy_to_clipboard, get_remote_url, to_web_url};
|
||||
let cmd = state.clipboard_cmd.clone();
|
||||
if let Some(raw_url) = get_remote_url(&path) {
|
||||
let url = if cursor == 0 { raw_url } else { to_web_url(&raw_url) };
|
||||
if copy_to_clipboard(&url, &cmd) {
|
||||
let label = if cursor == 0 { "SSH URL" } else { "HTTPS URL" };
|
||||
state.status_msg = Some(format!("{} copied!", label));
|
||||
} else {
|
||||
state.status_msg = Some(format!(" {}", url));
|
||||
}
|
||||
} else {
|
||||
state.status_msg = Some("No remote origin".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
other => Some(other),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! `process_modal_confirm` arms for the HubSettings tab: workspace root,
|
||||
//! editor, and clipboard-command edits, the settings-profile menu chain
|
||||
//! (save/copy-JSON/import-JSON/push), and the bulk-import account picker.
|
||||
|
||||
use super::modals_spawn::spawn_profile_pull;
|
||||
use super::state::{HubState, Modal, ProfileScopeAction, SelectTag, TextTag};
|
||||
|
||||
/// Handles one step of a HubSettings or settings-profile 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 {
|
||||
// ── Hub Settings editing ──────────────────────────────────────────────
|
||||
Modal::Text { value, tag: TextTag::SetWorkspace, .. } => {
|
||||
let path = super::modals::expand_tilde(&value);
|
||||
if !path.exists() {
|
||||
state.status_msg = Some(format!("Directory does not exist: {}", path.display()));
|
||||
return None;
|
||||
}
|
||||
if !path.is_dir() {
|
||||
state.status_msg = Some("Not a directory".into());
|
||||
return None;
|
||||
}
|
||||
state.workspace_root = path;
|
||||
super::actions::refresh_repos(state);
|
||||
state.status_msg = Some(format!(
|
||||
"Workspace changed to {} (to persist: edit ~/.vulp/config.toml)",
|
||||
state.workspace_root.display(),
|
||||
));
|
||||
None
|
||||
}
|
||||
Modal::Text { value, tag: TextTag::SetEditor, .. } => {
|
||||
if !value.trim().is_empty() {
|
||||
state.editor = value.trim().to_string();
|
||||
state.status_msg = Some(format!("Editor set to: {}", state.editor));
|
||||
}
|
||||
None
|
||||
}
|
||||
Modal::Text { value, tag: TextTag::SetClipboard, .. } => {
|
||||
state.clipboard_cmd = value.trim().to_string();
|
||||
state.status_msg = Some("Clipboard command updated".into());
|
||||
None
|
||||
}
|
||||
Modal::Select { cursor, options, tag: SelectTag::SetEditor, .. } => {
|
||||
let choice = options.get(cursor).cloned().unwrap_or_default();
|
||||
if choice == "Custom..." {
|
||||
state.modal = Modal::Text {
|
||||
title: "Editor".into(),
|
||||
prompt: "Command (e.g. nvim, code):".into(),
|
||||
value: state.editor.clone(),
|
||||
tag: TextTag::SetEditor,
|
||||
max_len: Some(60),
|
||||
};
|
||||
} else {
|
||||
state.editor = choice;
|
||||
state.status_msg = Some(format!("Editor set to: {}", state.editor));
|
||||
}
|
||||
None
|
||||
}
|
||||
Modal::Select { cursor, options, tag: SelectTag::SetClipboard, .. } => {
|
||||
let choice = options.get(cursor).cloned().unwrap_or_default();
|
||||
if choice == "Custom..." {
|
||||
state.modal = Modal::Text {
|
||||
title: "Clipboard Command".into(),
|
||||
prompt: "Command (e.g. xclip -sel clip):".into(),
|
||||
value: state.clipboard_cmd.clone(),
|
||||
tag: TextTag::SetClipboard,
|
||||
max_len: Some(60),
|
||||
};
|
||||
} else {
|
||||
state.clipboard_cmd = choice;
|
||||
state.status_msg = Some("Clipboard command updated".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── Settings Profiles & Sync ────────────────────────────────────────────
|
||||
Modal::Select { cursor, tag: SelectTag::ProfileMenu { profile_count }, .. } => {
|
||||
if cursor < profile_count {
|
||||
let profiles = crate::profile::list_profiles(&state.cfg);
|
||||
if let Some(name) = profiles.get(cursor) {
|
||||
match crate::profile::load_profile(&state.cfg, name) {
|
||||
Ok(snap) => {
|
||||
if let Err(e) = crate::profile::apply(&snap, &mut state.cfg) {
|
||||
state.status_msg = Some(format!("Failed to apply profile: {e:#}"));
|
||||
} else {
|
||||
state.editor = state.cfg.editor.clone();
|
||||
state.clipboard_cmd = state.cfg.clipboard_cmd.clone();
|
||||
super::actions::refresh_repos(state);
|
||||
state.status_msg = Some(format!("Switched to profile '{}'", name));
|
||||
}
|
||||
}
|
||||
Err(e) => state.status_msg = Some(format!("Failed to load profile '{}': {e:#}", name)),
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let scope_select = |title: String, tag_action: ProfileScopeAction| Modal::Select {
|
||||
title,
|
||||
options: vec!["Settings only".into(), "Settings + workspace structure".into()],
|
||||
cursor: 0,
|
||||
tag: SelectTag::ProfileScope(tag_action),
|
||||
};
|
||||
match cursor - profile_count {
|
||||
0 => {
|
||||
state.modal = Modal::Text {
|
||||
title: "Save Profile".into(),
|
||||
prompt: "Profile name:".into(),
|
||||
value: String::new(),
|
||||
tag: TextTag::ProfileSaveName,
|
||||
max_len: Some(40),
|
||||
};
|
||||
}
|
||||
1 => {
|
||||
state.modal = scope_select("Copy as JSON — scope".into(), ProfileScopeAction::CopyJson);
|
||||
}
|
||||
2 => {
|
||||
state.modal = Modal::Text {
|
||||
title: "Import Settings".into(),
|
||||
prompt: "Paste JSON:".into(),
|
||||
value: String::new(),
|
||||
tag: TextTag::ProfileImportJson,
|
||||
max_len: None,
|
||||
};
|
||||
}
|
||||
3 => {
|
||||
state.modal = scope_select(
|
||||
format!("Push to '{}' — scope", crate::profile::SETTINGS_REPO_NAME),
|
||||
ProfileScopeAction::PushRepo,
|
||||
);
|
||||
}
|
||||
4 => spawn_profile_pull(state),
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
Modal::Text { value, tag: TextTag::ProfileSaveName, .. } => {
|
||||
let name = value.trim().to_string();
|
||||
if name.is_empty() { return None; }
|
||||
state.modal = Modal::Select {
|
||||
title: format!("Save '{}' — scope", name),
|
||||
options: vec!["Settings only".into(), "Settings + workspace structure".into()],
|
||||
cursor: 0,
|
||||
tag: SelectTag::ProfileScope(ProfileScopeAction::SaveProfile(name)),
|
||||
};
|
||||
None
|
||||
}
|
||||
Modal::Text { value, tag: TextTag::ProfileImportJson, .. } => {
|
||||
match crate::profile::from_json(value.trim()) {
|
||||
Ok(snap) => {
|
||||
let has_structure = snap.structure.is_some();
|
||||
if let Err(e) = crate::profile::apply(&snap, &mut state.cfg) {
|
||||
state.status_msg = Some(format!("Failed to apply settings: {e:#}"));
|
||||
} else {
|
||||
state.editor = state.cfg.editor.clone();
|
||||
state.clipboard_cmd = state.cfg.clipboard_cmd.clone();
|
||||
if has_structure { super::actions::refresh_repos(state); }
|
||||
state.status_msg = Some("Settings imported".into());
|
||||
}
|
||||
}
|
||||
Err(e) => state.status_msg = Some(format!("Invalid settings JSON: {e:#}")),
|
||||
}
|
||||
None
|
||||
}
|
||||
Modal::Select { cursor, tag: SelectTag::ProfileScope(action), .. } => {
|
||||
let include_structure = cursor == 1;
|
||||
// Editor/clipboard live only in the in-session mirror fields until
|
||||
// now — fold them into cfg so the snapshot captures what's on screen.
|
||||
state.cfg.editor = state.editor.clone();
|
||||
state.cfg.clipboard_cmd = state.clipboard_cmd.clone();
|
||||
let snap = crate::profile::snapshot(&state.cfg, include_structure);
|
||||
match action {
|
||||
ProfileScopeAction::SaveProfile(name) => {
|
||||
match crate::profile::save_profile(&state.cfg, &name, &snap) {
|
||||
Ok(()) => state.status_msg = Some(format!("Profile '{}' saved", name)),
|
||||
Err(e) => state.status_msg = Some(format!("Failed to save profile: {e:#}")),
|
||||
}
|
||||
}
|
||||
ProfileScopeAction::CopyJson => {
|
||||
match crate::profile::to_json(&snap) {
|
||||
Ok(json) => {
|
||||
if crate::util::clipboard::copy_to_clipboard(&json, &state.clipboard_cmd) {
|
||||
state.status_msg = Some("Settings copied to clipboard as JSON".into());
|
||||
} else {
|
||||
state.status_msg = Some("Clipboard copy failed — check clipboard command in Settings".into());
|
||||
}
|
||||
}
|
||||
Err(e) => state.status_msg = Some(format!("Failed to serialize settings: {e:#}")),
|
||||
}
|
||||
}
|
||||
ProfileScopeAction::PushRepo => super::modals_spawn::spawn_profile_push(state, snap),
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── Bulk import ──────────────────────────────────────────────────────
|
||||
Modal::Select { cursor, tag: SelectTag::PullAllAccount, .. } => {
|
||||
super::modals_spawn::open_bulk_import_picker(state, cursor);
|
||||
None
|
||||
}
|
||||
|
||||
other => Some(other),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! `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::{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.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::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
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
+46
-552
@@ -1,24 +1,21 @@
|
||||
//! The project detail sub-view: a per-project dashboard aggregating commits,
|
||||
//! repos, activity stats and todos across every repo assigned to one
|
||||
//! project. Rendering lives in the sibling `project_detail_render` module;
|
||||
//! this file owns the state, event loop, and background data loaders.
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::projects::Project;
|
||||
use crate::status::RepoSummary;
|
||||
use crate::todos::{Todo, TodoStatus};
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Cell, Paragraph, Row, Table},
|
||||
Frame,
|
||||
};
|
||||
|
||||
// ── tabs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Which tab of the project detail view is showing.
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
enum ProjectTab { Overview, Commits, Repos, Stats, Todos, Settings }
|
||||
pub enum ProjectTab { Overview, Commits, Repos, Stats, Todos, Settings }
|
||||
|
||||
impl ProjectTab {
|
||||
fn next(self) -> Self {
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Overview => Self::Commits,
|
||||
Self::Commits => Self::Repos,
|
||||
@@ -28,7 +25,7 @@ impl ProjectTab {
|
||||
Self::Settings => Self::Overview,
|
||||
}
|
||||
}
|
||||
fn prev(self) -> Self {
|
||||
pub fn prev(self) -> Self {
|
||||
match self {
|
||||
Self::Overview => Self::Settings,
|
||||
Self::Commits => Self::Overview,
|
||||
@@ -40,46 +37,47 @@ impl ProjectTab {
|
||||
}
|
||||
}
|
||||
|
||||
// ── color palette ─────────────────────────────────────────────────────────────
|
||||
|
||||
const PROJ_PALETTE: &[(&str, &str)] = &[
|
||||
/// Fixed color choices offered by the rename/recolor settings picker —
|
||||
/// matches the palette used for project markers throughout the hub.
|
||||
pub const PROJ_PALETTE: &[(&str, &str)] = &[
|
||||
("#808080", "Gray"), ("#3b82f6", "Blue"), ("#22c55e", "Green"),
|
||||
("#ef4444", "Red"), ("#eab308", "Yellow"), ("#a855f7", "Purple"),
|
||||
("#f97316", "Orange"),("#ec4899", "Pink"), ("#06b6d4", "Cyan"),
|
||||
("#14b8a6", "Teal"), ("#f43f5e", "Rose"), ("#84cc16", "Lime"),
|
||||
];
|
||||
|
||||
// ── aggregated commit ─────────────────────────────────────────────────────────
|
||||
|
||||
struct AggCommit {
|
||||
repo: String,
|
||||
sha: String,
|
||||
ts: i64,
|
||||
age: String,
|
||||
author: String,
|
||||
msg: String,
|
||||
/// One commit, tagged with which repo it came from — used to merge commit
|
||||
/// history across every repo in the project into a single chronological feed.
|
||||
pub struct AggCommit {
|
||||
pub repo: String,
|
||||
#[allow(dead_code)] pub sha: String, // kept for future per-commit actions
|
||||
pub ts: i64,
|
||||
pub age: String,
|
||||
pub author: String,
|
||||
pub msg: String,
|
||||
}
|
||||
|
||||
// ── state ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
struct St {
|
||||
project: Project,
|
||||
repos: Vec<RepoSummary>,
|
||||
tab: ProjectTab,
|
||||
commits: Vec<AggCommit>,
|
||||
commits_rx: Option<std::sync::mpsc::Receiver<Vec<AggCommit>>>,
|
||||
repo_cursor: usize,
|
||||
commit_off: usize,
|
||||
todo_cursor: usize,
|
||||
stats_cursor: usize,
|
||||
todos: Vec<Todo>,
|
||||
activity: Vec<(String, usize)>,
|
||||
activity_rx: Option<std::sync::mpsc::Receiver<Vec<(String, usize)>>>,
|
||||
status_msg: Option<String>,
|
||||
quit: bool,
|
||||
cfg: AppConfig,
|
||||
rename_input: Option<String>,
|
||||
color_cursor: Option<usize>, // palette picker cursor (replaces hex text input)
|
||||
/// State for one project-detail session: the project being viewed, its
|
||||
/// repos, and whatever background data (aggregated commits, activity) has
|
||||
/// loaded so far.
|
||||
pub struct St {
|
||||
pub project: Project,
|
||||
pub repos: Vec<RepoSummary>,
|
||||
pub tab: ProjectTab,
|
||||
pub commits: Vec<AggCommit>,
|
||||
pub commits_rx: Option<std::sync::mpsc::Receiver<Vec<AggCommit>>>,
|
||||
pub repo_cursor: usize,
|
||||
pub commit_off: usize,
|
||||
pub todo_cursor: usize,
|
||||
pub stats_cursor: usize,
|
||||
pub todos: Vec<Todo>,
|
||||
pub activity: Vec<(String, usize)>,
|
||||
pub activity_rx: Option<std::sync::mpsc::Receiver<Vec<(String, usize)>>>,
|
||||
pub status_msg: Option<String>,
|
||||
pub quit: bool,
|
||||
pub cfg: AppConfig,
|
||||
pub rename_input: Option<String>,
|
||||
pub color_cursor: Option<usize>, // palette picker cursor (replaces hex text input)
|
||||
}
|
||||
|
||||
impl St {
|
||||
@@ -109,8 +107,9 @@ impl St {
|
||||
}
|
||||
}
|
||||
|
||||
// ── entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Runs the project detail sub-view until the user backs out. Reuses the
|
||||
/// caller's terminal handle when given one (avoids an alternate-screen
|
||||
/// flash when entering from the hub), otherwise sets up its own.
|
||||
pub async fn run(project: Project, repos: Vec<RepoSummary>, cfg: &AppConfig, maybe_term: Option<crate::tui::Tty>) -> Result<()> {
|
||||
let mut terminal = match maybe_term {
|
||||
Some(t) => t,
|
||||
@@ -131,7 +130,7 @@ async fn run_loop(st: &mut St, terminal: &mut crate::tui::Tty) -> Result<()> {
|
||||
poll_commits(st);
|
||||
poll_activity(st);
|
||||
|
||||
terminal.draw(|f| draw(f, st))?;
|
||||
terminal.draw(|f| super::project_detail_render::draw(f, st))?;
|
||||
|
||||
let event = crate::tui::next_event(250)?;
|
||||
let key = match event { Some(Event::Key(k)) => k, _ => continue };
|
||||
@@ -335,508 +334,3 @@ fn poll_activity(st: &mut St) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── rendering ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn draw(f: &mut Frame, st: &St) {
|
||||
let area = f.area();
|
||||
let vert = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(2)])
|
||||
.split(area);
|
||||
|
||||
render_topbar(f, st, vert[0]);
|
||||
|
||||
let blk = Block::default().borders(Borders::ALL);
|
||||
let inner = blk.inner(vert[1]);
|
||||
f.render_widget(blk, vert[1]);
|
||||
|
||||
match st.tab {
|
||||
ProjectTab::Overview => render_overview(f, st, inner),
|
||||
ProjectTab::Commits => render_commits(f, st, inner),
|
||||
ProjectTab::Repos => render_repos(f, st, inner),
|
||||
ProjectTab::Stats => render_stats(f, st, inner),
|
||||
ProjectTab::Todos => render_todos(f, st, inner),
|
||||
ProjectTab::Settings => render_settings(f, st, inner),
|
||||
}
|
||||
|
||||
render_bottombar(f, st, vert[2]);
|
||||
|
||||
// Modal overlays
|
||||
if let Some(ref input) = st.rename_input {
|
||||
draw_text_modal(f, area, "Rename Project", input, "Enter new name | Esc cancel");
|
||||
}
|
||||
if let Some(cursor) = st.color_cursor {
|
||||
draw_color_pick_modal(f, area, cursor, &st.project.color);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_topbar(f: &mut Frame, st: &St, area: Rect) {
|
||||
let (pr, pg, pb) = crate::projects::hex_to_rgb(&st.project.color);
|
||||
let proj_col = Color::Rgb(pr, pg, pb);
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
|
||||
let tab_s = |label: &str, on: bool| -> Span<'static> {
|
||||
let label = label.to_string();
|
||||
if on { Span::styled(format!(" {} ", label), Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)) }
|
||||
else { Span::styled(format!(" {} ", label), dim) }
|
||||
};
|
||||
|
||||
let dirty_c = st.repos.iter().filter(|r| r.is_dirty).count();
|
||||
let behind_c = st.repos.iter().filter(|r| r.behind > 0).count();
|
||||
let open_t = st.todos.iter().filter(|t| matches!(t.status, TodoStatus::Open)).count();
|
||||
let (health_icon, health_col) = if behind_c > 0 { ("↓", Color::Red) }
|
||||
else if dirty_c > 0 { ("⚠", Color::Yellow) }
|
||||
else { ("✓", Color::Green) };
|
||||
|
||||
let mut spans = vec![
|
||||
Span::styled(" ◀ ", dim),
|
||||
Span::styled("■ ", Style::default().fg(proj_col)),
|
||||
Span::styled(st.project.name.clone(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(
|
||||
format!(" · {} repo{} ", st.repos.len(), if st.repos.len() == 1 { "" } else { "s" }),
|
||||
dim,
|
||||
),
|
||||
tab_s("1 Overview", st.tab == ProjectTab::Overview),
|
||||
Span::raw(" "),
|
||||
tab_s("2 Commits", st.tab == ProjectTab::Commits),
|
||||
Span::raw(" "),
|
||||
tab_s("3 Repos", st.tab == ProjectTab::Repos),
|
||||
Span::raw(" "),
|
||||
tab_s("4 Stats", st.tab == ProjectTab::Stats),
|
||||
Span::raw(" "),
|
||||
tab_s("5 Todos", st.tab == ProjectTab::Todos),
|
||||
Span::raw(" "),
|
||||
tab_s("6 Settings", st.tab == ProjectTab::Settings),
|
||||
Span::styled(format!(" {} ", health_icon), Style::default().fg(health_col)),
|
||||
];
|
||||
if open_t > 0 {
|
||||
spans.push(Span::styled(
|
||||
format!("{} todo{}", open_t, if open_t == 1 { "" } else { "s" }),
|
||||
Style::default().fg(Color::Cyan),
|
||||
));
|
||||
}
|
||||
if let Some(ref msg) = st.status_msg {
|
||||
spans.push(Span::styled(format!(" {}", msg), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||
}
|
||||
|
||||
fn render_bottombar(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let key = |k: &str| Span::styled(format!("[{}] ", k), dim);
|
||||
let hint = match st.tab {
|
||||
ProjectTab::Repos => Line::from(vec![key("↑↓"), Span::styled("select ", dim), key("Enter"), Span::styled("open repo detail", dim)]),
|
||||
ProjectTab::Todos => Line::from(vec![key("↑↓"), Span::styled("select ", dim), key("Enter"), Span::styled("cycle status", dim)]),
|
||||
ProjectTab::Settings => Line::from(vec![key("Enter"), Span::styled("rename ", dim), key("c"), Span::styled("change color", dim)]),
|
||||
_ => Line::from(vec![key("↑↓"), Span::styled("scroll", dim)]),
|
||||
};
|
||||
f.render_widget(Paragraph::new(vec![
|
||||
Line::from(vec![key("←→/Tab"), Span::styled("tabs ", dim), key("1-6"), Span::styled("direct ", dim), key("q/Esc"), Span::styled("back", dim)]),
|
||||
hint,
|
||||
]), area);
|
||||
}
|
||||
|
||||
// ── tab renderers ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn render_overview(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let wht = Style::default().fg(Color::White);
|
||||
let (pr, pg, pb) = crate::projects::hex_to_rgb(&st.project.color);
|
||||
let proj_col = Color::Rgb(pr, pg, pb);
|
||||
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
|
||||
.split(area);
|
||||
|
||||
// LEFT: project info + health bar + repo list
|
||||
{
|
||||
let dirty_c = st.repos.iter().filter(|r| r.is_dirty).count();
|
||||
let behind_c = st.repos.iter().filter(|r| r.behind > 0).count();
|
||||
let ahead_c = st.repos.iter().filter(|r| r.ahead > 0).count();
|
||||
let total = st.repos.len();
|
||||
|
||||
let clean_n = if total > 0 { (20 * total.saturating_sub(dirty_c + behind_c)) / total } else { 20 };
|
||||
let dirty_n = if total > 0 { (20 * dirty_c) / total } else { 0 };
|
||||
let behind_n = 20usize.saturating_sub(clean_n + dirty_n);
|
||||
let health_bar = format!("{}{}{}", "█".repeat(clean_n), "▓".repeat(dirty_n), "░".repeat(behind_n));
|
||||
let health_col = if behind_c > 0 { Color::Red } else if dirty_c > 0 { Color::Yellow } else { Color::Green };
|
||||
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" ■ ", Style::default().fg(proj_col)),
|
||||
Span::styled(st.project.name.clone(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(
|
||||
format!(" {} repo{}", total, if total == 1 { "" } else { "s" }),
|
||||
dim,
|
||||
),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" Health ", dim),
|
||||
Span::styled(health_bar, Style::default().fg(health_col)),
|
||||
Span::styled(format!(" ↓{} ⚠{} ↑{}", behind_c, dirty_c, ahead_c), dim),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" Repos", yel)),
|
||||
Line::from(Span::styled(" ─────────────────────────────────────────", dim)),
|
||||
];
|
||||
|
||||
let avail = area.height.saturating_sub(6) as usize;
|
||||
for r in st.repos.iter().take(avail) {
|
||||
let status = if r.is_dirty { "⚠" } else if r.ahead > 0 { "↑" } else if r.behind > 0 { "↓" } else { "✓" };
|
||||
let col = if r.is_dirty { Color::Yellow } else if r.behind > 0 { Color::Red } else { Color::Green };
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} ", status), Style::default().fg(col)),
|
||||
Span::styled(format!("{:<18} ", r.name.chars().take(18).collect::<String>()), wht),
|
||||
Span::styled(format!("{:<9} ", r.branch.chars().take(9).collect::<String>()), dim),
|
||||
Span::styled(crate::status::format_age_pub(r.last_commit_ts), Style::default().fg(super::tabs::age_color(r.last_commit_ts))),
|
||||
]));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), cols[0]);
|
||||
}
|
||||
|
||||
// RIGHT: open todos + recent commits
|
||||
{
|
||||
let open_t: Vec<&Todo> = st.todos.iter()
|
||||
.filter(|t| matches!(t.status, TodoStatus::Open | TodoStatus::InProgress))
|
||||
.collect();
|
||||
|
||||
let mut lines: Vec<Line> = vec![];
|
||||
|
||||
lines.push(Line::from(Span::styled(" Todos", yel)));
|
||||
lines.push(Line::from(Span::styled(" ─────────────────────────────────", dim)));
|
||||
if open_t.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" No open todos", dim)));
|
||||
} else {
|
||||
let half_h = (area.height as usize / 2).saturating_sub(2);
|
||||
for t in open_t.iter().take(half_h) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} ", t.status.icon()), Style::default().fg(t.status.color())),
|
||||
Span::styled(t.title.chars().take(28).collect::<String>(), wht),
|
||||
]));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
|
||||
if !st.commits.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" Recent Commits", yel)));
|
||||
lines.push(Line::from(Span::styled(" ─────────────────────────────────", dim)));
|
||||
let rem_h = (area.height as usize).saturating_sub(lines.len() + 1);
|
||||
for c in st.commits.iter().take(rem_h) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:>5} ", c.age.chars().take(5).collect::<String>()), dim),
|
||||
Span::styled(format!("{:<10} ", c.repo.chars().take(10).collect::<String>()), Style::default().fg(Color::Cyan)),
|
||||
Span::styled(c.msg.chars().take(22).collect::<String>(), dim),
|
||||
]));
|
||||
}
|
||||
} else if st.commits_rx.is_some() {
|
||||
lines.push(Line::from(Span::styled(" Loading commits...", dim)));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), cols[1]);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_commits(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
|
||||
if st.commits_rx.is_some() {
|
||||
f.render_widget(Paragraph::new(" Loading commits from all repos...").style(dim), area);
|
||||
return;
|
||||
}
|
||||
if st.commits.is_empty() {
|
||||
f.render_widget(Paragraph::new(" No commits found").style(dim), area);
|
||||
return;
|
||||
}
|
||||
|
||||
let header = Row::new(vec!["REPO", "AGE", "AUTHOR", "MESSAGE"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let rows: Vec<Row> = st.commits.iter().skip(st.commit_off).take(avail)
|
||||
.map(|c| {
|
||||
Row::new(vec![
|
||||
Cell::from(c.repo.chars().take(14).collect::<String>()).style(yel),
|
||||
Cell::from(c.age.chars().take(8).collect::<String>()).style(dim),
|
||||
Cell::from(c.author.chars().take(12).collect::<String>()).style(dim),
|
||||
Cell::from(c.msg.chars().take(60).collect::<String>()),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [Constraint::Length(14), Constraint::Length(8), Constraint::Length(12), Constraint::Fill(1)]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_repos(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let header = Row::new(vec!["NAME", "BRANCH", "STATUS", "AGE", "LAST COMMIT"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let rows: Vec<Row> = st.repos.iter().enumerate().skip(st.repo_cursor.saturating_sub(avail / 2)).take(avail)
|
||||
.map(|(i, r)| {
|
||||
let is_cur = i == st.repo_cursor;
|
||||
let status = if r.is_dirty { format!("⚠ +{}-{}", r.ahead, r.behind) } else { format!("+{}-{}", r.ahead, r.behind) };
|
||||
let col = if r.is_dirty || r.behind > 0 { Color::Yellow } else { Color::White };
|
||||
Row::new(vec![
|
||||
Cell::from(r.name.chars().take(20).collect::<String>()).style(Style::default().fg(col)),
|
||||
Cell::from(r.branch.chars().take(12).collect::<String>()).style(dim),
|
||||
Cell::from(status).style(dim),
|
||||
Cell::from(crate::status::format_age_pub(r.last_commit_ts)).style(Style::default().fg(super::tabs::age_color(r.last_commit_ts))),
|
||||
Cell::from(r.last_commit_msg.chars().take(35).collect::<String>()).style(dim),
|
||||
]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() })
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [Constraint::Length(20), Constraint::Length(12), Constraint::Length(12), Constraint::Length(10), Constraint::Fill(1)]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_stats(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let cyn = Style::default().fg(Color::Cyan);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
|
||||
let mut lines: Vec<Line> = vec![];
|
||||
lines.push(Line::from(Span::styled(" Activity (last 1y, all repos combined)", yel)));
|
||||
lines.push(Line::from(Span::styled(" ──────────────────────────────────────────────────────", dim)));
|
||||
|
||||
if st.activity_rx.is_some() {
|
||||
lines.push(Line::from(Span::styled(" Computing activity...", cyn)));
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
return;
|
||||
}
|
||||
if st.activity.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" No activity data", dim)));
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
return;
|
||||
}
|
||||
|
||||
let max_count: usize = st.activity.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1);
|
||||
let bar_h = (area.height as usize).saturating_sub(6).max(3);
|
||||
let bar_w = (area.width as usize).saturating_sub(10).max(1);
|
||||
let n = st.activity.len();
|
||||
let step = if n > bar_w { (n / bar_w).max(1) } else { 1 };
|
||||
let sampled: Vec<&(String, usize)> = st.activity.iter().step_by(step).collect();
|
||||
let bar_count = sampled.len().min(bar_w);
|
||||
let bar_width = if bar_count == 0 { 1 } else { (bar_w / bar_count).max(1) };
|
||||
|
||||
for row in (0..bar_h).rev() {
|
||||
let threshold = (row * max_count) / bar_h;
|
||||
let mut spans: Vec<Span<'static>> = vec![];
|
||||
let y_label = if row == bar_h - 1 { format!(" {:>4} ", max_count) } else if row == 0 { " 0 ".to_string() } else { " ".to_string() };
|
||||
spans.push(Span::styled(y_label, dim));
|
||||
for j in 0..bar_count {
|
||||
let count = sampled.get(j).map(|(_, c)| *c).unwrap_or(0);
|
||||
let filled = count > threshold;
|
||||
for _ in 0..bar_width {
|
||||
if filled { spans.push(Span::styled("█", cyn)); }
|
||||
else { spans.push(Span::styled("░", dim)); }
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
// X axis labels
|
||||
let nth = (bar_count / 6).max(1);
|
||||
let mut x_spans: Vec<Span> = vec![Span::raw(" ")];
|
||||
for j in 0..bar_count {
|
||||
let label = if j % nth == 0 {
|
||||
let raw = sampled.get(j)
|
||||
.map(|(d, _)| d.chars().skip(5).take(bar_width.min(5)).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
format!("{:<width$}", raw, width = bar_width)
|
||||
} else {
|
||||
" ".repeat(bar_width)
|
||||
};
|
||||
x_spans.push(Span::styled(label, dim));
|
||||
}
|
||||
lines.push(Line::from(x_spans));
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
fn render_todos(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let header = Row::new(vec!["STATUS", "TITLE", "EST", "CREATED"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
if st.todos.is_empty() {
|
||||
f.render_widget(Paragraph::new(" No todos for this project").style(dim), area);
|
||||
return;
|
||||
}
|
||||
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let rows: Vec<Row> = st.todos.iter().enumerate()
|
||||
.skip(st.todo_cursor.saturating_sub(avail / 2)).take(avail)
|
||||
.map(|(i, t)| {
|
||||
let is_cur = i == st.todo_cursor;
|
||||
let status_str = format!("{} {}", t.status.icon(), t.status.label());
|
||||
let est_str = t.estimate.as_deref().unwrap_or("—").chars().take(10).collect::<String>();
|
||||
let created = crate::status::format_age_pub(t.created_at);
|
||||
Row::new(vec![
|
||||
Cell::from(status_str).style(Style::default().fg(t.status.color())),
|
||||
Cell::from(t.title.chars().take(55).collect::<String>()),
|
||||
Cell::from(est_str).style(dim),
|
||||
Cell::from(created).style(dim),
|
||||
]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() })
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [Constraint::Length(14), Constraint::Fill(1), Constraint::Length(10), Constraint::Length(10)]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_settings(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let cyn = Style::default().fg(Color::Cyan);
|
||||
let (pr, pg, pb) = crate::projects::hex_to_rgb(&st.project.color);
|
||||
let proj_col = Color::Rgb(pr, pg, pb);
|
||||
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(Span::styled(" Project Settings", yel)),
|
||||
Line::from(Span::styled(" ────────────────────────────────────────────", dim)),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" Name: ", dim),
|
||||
Span::styled(st.project.name.clone(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Color: ", dim),
|
||||
Span::styled(" ■ ", Style::default().fg(proj_col).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(st.project.color.clone(), Style::default().fg(proj_col)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Repos: ", dim),
|
||||
Span::styled(st.repos.len().to_string(), Style::default().fg(Color::White)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" Actions", yel)),
|
||||
Line::from(Span::styled(" ────────────────────────────────────────────", dim)),
|
||||
Line::from(vec![
|
||||
Span::styled(" [Enter] ", cyn),
|
||||
Span::styled("Rename project", Style::default().fg(Color::White)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" [c] ", cyn),
|
||||
Span::styled("Change color (palette picker)", Style::default().fg(Color::White)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" Color Palette", yel)),
|
||||
Line::from(Span::styled(" ────────────────────────────────────────────", dim)),
|
||||
];
|
||||
|
||||
// Show palette swatches
|
||||
let per_row = 4;
|
||||
for chunk_start in (0..PROJ_PALETTE.len()).step_by(per_row) {
|
||||
let mut spans: Vec<Span> = vec![Span::raw(" ")];
|
||||
for i in 0..per_row {
|
||||
if let Some((hex, name)) = PROJ_PALETTE.get(chunk_start + i) {
|
||||
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
|
||||
let c = Color::Rgb(cr, cg, cb);
|
||||
let active = *hex == st.project.color.as_str();
|
||||
spans.push(Span::styled(if active { "▶██◀ " } else { " ██ " }, Style::default().fg(c)));
|
||||
spans.push(Span::styled(format!("{:<8}", name.chars().take(6).collect::<String>()), if active { Style::default().fg(c).add_modifier(Modifier::BOLD) } else { dim }));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
if st.rename_input.is_some() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(" → Rename modal open — type name, Enter confirm, Esc cancel", cyn)));
|
||||
}
|
||||
if st.color_cursor.is_some() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(" → Color picker open — ←→↑↓ navigate, Enter confirm, Esc cancel", cyn)));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
fn draw_text_modal(f: &mut Frame, area: Rect, title: &str, input: &str, hint: &str) {
|
||||
use ratatui::widgets::Clear;
|
||||
let modal_area = crate::tui::centered_rect(50, 20, area);
|
||||
f.render_widget(Clear, modal_area);
|
||||
let blk = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" {} ", title))
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
let inner = blk.inner(modal_area);
|
||||
f.render_widget(blk, modal_area);
|
||||
f.render_widget(Paragraph::new(vec![
|
||||
Line::from(Span::styled(format!(" {}_", input), Style::default().fg(Color::White).add_modifier(Modifier::BOLD))),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(format!(" {}", hint), Style::default().fg(Color::DarkGray))),
|
||||
]), inner);
|
||||
}
|
||||
|
||||
fn draw_color_pick_modal(f: &mut Frame, area: Rect, cursor: usize, current_hex: &str) {
|
||||
use ratatui::widgets::Clear;
|
||||
let modal_area = crate::tui::centered_rect(70, 50, area);
|
||||
f.render_widget(Clear, modal_area);
|
||||
let blk = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Choose Color ←→↑↓ navigate · Enter confirm · Esc cancel ")
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
let inner = blk.inner(modal_area);
|
||||
f.render_widget(blk, modal_area);
|
||||
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let mut lines: Vec<Line> = vec![Line::from("")];
|
||||
|
||||
let per_row = 6usize;
|
||||
for row_start in (0..PROJ_PALETTE.len()).step_by(per_row) {
|
||||
// Swatch row
|
||||
let mut swatch_spans: Vec<Span> = vec![Span::raw(" ")];
|
||||
for i in 0..per_row {
|
||||
if let Some((hex, _)) = PROJ_PALETTE.get(row_start + i) {
|
||||
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
|
||||
let c = Color::Rgb(cr, cg, cb);
|
||||
let is_cur = row_start + i == cursor;
|
||||
let is_active = *hex == current_hex;
|
||||
let label = if is_cur { "▶████◀ " } else if is_active { " ████* " } else { " ████ " };
|
||||
swatch_spans.push(Span::styled(label, Style::default().fg(c).add_modifier(if is_cur { Modifier::BOLD } else { Modifier::empty() })));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(swatch_spans));
|
||||
|
||||
// Name row
|
||||
let mut name_spans: Vec<Span> = vec![Span::raw(" ")];
|
||||
for i in 0..per_row {
|
||||
if let Some((hex, name)) = PROJ_PALETTE.get(row_start + i) {
|
||||
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
|
||||
let c = Color::Rgb(cr, cg, cb);
|
||||
let is_cur = row_start + i == cursor;
|
||||
name_spans.push(Span::styled(
|
||||
format!("{:<9}", name.chars().take(7).collect::<String>()),
|
||||
if is_cur { Style::default().fg(c).add_modifier(Modifier::BOLD) } else { dim },
|
||||
));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(name_spans));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Show selected hex
|
||||
if let Some((hex, name)) = PROJ_PALETTE.get(cursor) {
|
||||
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" Selected: "),
|
||||
Span::styled(format!("■ {} — {}", hex, name), Style::default().fg(Color::Rgb(cr, cg, cb)).add_modifier(Modifier::BOLD)),
|
||||
]));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
//! Rendering for the project detail sub-view (`project_detail`): top/bottom
|
||||
//! bars, the six tabs (Overview, Commits, Repos, Stats, Todos, Settings),
|
||||
//! and the rename/color-picker modal overlays.
|
||||
|
||||
use super::project_detail::{AggCommit, ProjectTab, St, PROJ_PALETTE};
|
||||
use crate::todos::TodoStatus;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Cell, Paragraph, Row, Table},
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub fn draw(f: &mut Frame, st: &St) {
|
||||
let area = f.area();
|
||||
let vert = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(2)])
|
||||
.split(area);
|
||||
|
||||
render_topbar(f, st, vert[0]);
|
||||
|
||||
let blk = Block::default().borders(Borders::ALL);
|
||||
let inner = blk.inner(vert[1]);
|
||||
f.render_widget(blk, vert[1]);
|
||||
|
||||
match st.tab {
|
||||
ProjectTab::Overview => render_overview(f, st, inner),
|
||||
ProjectTab::Commits => render_commits(f, st, inner),
|
||||
ProjectTab::Repos => render_repos(f, st, inner),
|
||||
ProjectTab::Stats => render_stats(f, st, inner),
|
||||
ProjectTab::Todos => render_todos(f, st, inner),
|
||||
ProjectTab::Settings => render_settings(f, st, inner),
|
||||
}
|
||||
|
||||
render_bottombar(f, st, vert[2]);
|
||||
|
||||
// Modal overlays
|
||||
if let Some(ref input) = st.rename_input {
|
||||
draw_text_modal(f, area, "Rename Project", input, "Enter new name | Esc cancel");
|
||||
}
|
||||
if let Some(cursor) = st.color_cursor {
|
||||
draw_color_pick_modal(f, area, cursor, &st.project.color);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_topbar(f: &mut Frame, st: &St, area: Rect) {
|
||||
let (pr, pg, pb) = crate::projects::hex_to_rgb(&st.project.color);
|
||||
let proj_col = Color::Rgb(pr, pg, pb);
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
|
||||
let tab_s = |label: &str, on: bool| -> Span<'static> {
|
||||
let label = label.to_string();
|
||||
if on { Span::styled(format!(" {} ", label), Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)) }
|
||||
else { Span::styled(format!(" {} ", label), dim) }
|
||||
};
|
||||
|
||||
let dirty_c = st.repos.iter().filter(|r| r.is_dirty).count();
|
||||
let behind_c = st.repos.iter().filter(|r| r.behind > 0).count();
|
||||
let open_t = st.todos.iter().filter(|t| matches!(t.status, TodoStatus::Open)).count();
|
||||
let (health_icon, health_col) = if behind_c > 0 { ("↓", Color::Red) }
|
||||
else if dirty_c > 0 { ("⚠", Color::Yellow) }
|
||||
else { ("✓", Color::Green) };
|
||||
|
||||
let mut spans = vec![
|
||||
Span::styled(" ◀ ", dim),
|
||||
Span::styled("■ ", Style::default().fg(proj_col)),
|
||||
Span::styled(st.project.name.clone(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(
|
||||
format!(" · {} repo{} ", st.repos.len(), if st.repos.len() == 1 { "" } else { "s" }),
|
||||
dim,
|
||||
),
|
||||
tab_s("1 Overview", st.tab == ProjectTab::Overview),
|
||||
Span::raw(" "),
|
||||
tab_s("2 Commits", st.tab == ProjectTab::Commits),
|
||||
Span::raw(" "),
|
||||
tab_s("3 Repos", st.tab == ProjectTab::Repos),
|
||||
Span::raw(" "),
|
||||
tab_s("4 Stats", st.tab == ProjectTab::Stats),
|
||||
Span::raw(" "),
|
||||
tab_s("5 Todos", st.tab == ProjectTab::Todos),
|
||||
Span::raw(" "),
|
||||
tab_s("6 Settings", st.tab == ProjectTab::Settings),
|
||||
Span::styled(format!(" {} ", health_icon), Style::default().fg(health_col)),
|
||||
];
|
||||
if open_t > 0 {
|
||||
spans.push(Span::styled(
|
||||
format!("{} todo{}", open_t, if open_t == 1 { "" } else { "s" }),
|
||||
Style::default().fg(Color::Cyan),
|
||||
));
|
||||
}
|
||||
if let Some(ref msg) = st.status_msg {
|
||||
spans.push(Span::styled(format!(" {}", msg), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)));
|
||||
}
|
||||
f.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||
}
|
||||
|
||||
fn render_bottombar(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let key = |k: &str| Span::styled(format!("[{}] ", k), dim);
|
||||
let hint = match st.tab {
|
||||
ProjectTab::Repos => Line::from(vec![key("↑↓"), Span::styled("select ", dim), key("Enter"), Span::styled("open repo detail", dim)]),
|
||||
ProjectTab::Todos => Line::from(vec![key("↑↓"), Span::styled("select ", dim), key("Enter"), Span::styled("cycle status", dim)]),
|
||||
ProjectTab::Settings => Line::from(vec![key("Enter"), Span::styled("rename ", dim), key("c"), Span::styled("change color", dim)]),
|
||||
_ => Line::from(vec![key("↑↓"), Span::styled("scroll", dim)]),
|
||||
};
|
||||
f.render_widget(Paragraph::new(vec![
|
||||
Line::from(vec![key("←→/Tab"), Span::styled("tabs ", dim), key("1-6"), Span::styled("direct ", dim), key("q/Esc"), Span::styled("back", dim)]),
|
||||
hint,
|
||||
]), area);
|
||||
}
|
||||
|
||||
fn render_overview(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let wht = Style::default().fg(Color::White);
|
||||
let (pr, pg, pb) = crate::projects::hex_to_rgb(&st.project.color);
|
||||
let proj_col = Color::Rgb(pr, pg, pb);
|
||||
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
|
||||
.split(area);
|
||||
|
||||
// LEFT: project info + health bar + repo list
|
||||
{
|
||||
let dirty_c = st.repos.iter().filter(|r| r.is_dirty).count();
|
||||
let behind_c = st.repos.iter().filter(|r| r.behind > 0).count();
|
||||
let ahead_c = st.repos.iter().filter(|r| r.ahead > 0).count();
|
||||
let total = st.repos.len();
|
||||
|
||||
let clean_n = if total > 0 { (20 * total.saturating_sub(dirty_c + behind_c)) / total } else { 20 };
|
||||
let dirty_n = if total > 0 { (20 * dirty_c) / total } else { 0 };
|
||||
let behind_n = 20usize.saturating_sub(clean_n + dirty_n);
|
||||
let health_bar = format!("{}{}{}", "█".repeat(clean_n), "▓".repeat(dirty_n), "░".repeat(behind_n));
|
||||
let health_col = if behind_c > 0 { Color::Red } else if dirty_c > 0 { Color::Yellow } else { Color::Green };
|
||||
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" ■ ", Style::default().fg(proj_col)),
|
||||
Span::styled(st.project.name.clone(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(
|
||||
format!(" {} repo{}", total, if total == 1 { "" } else { "s" }),
|
||||
dim,
|
||||
),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" Health ", dim),
|
||||
Span::styled(health_bar, Style::default().fg(health_col)),
|
||||
Span::styled(format!(" ↓{} ⚠{} ↑{}", behind_c, dirty_c, ahead_c), dim),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" Repos", yel)),
|
||||
Line::from(Span::styled(" ─────────────────────────────────────────", dim)),
|
||||
];
|
||||
|
||||
let avail = area.height.saturating_sub(6) as usize;
|
||||
for r in st.repos.iter().take(avail) {
|
||||
let status = if r.is_dirty { "⚠" } else if r.ahead > 0 { "↑" } else if r.behind > 0 { "↓" } else { "✓" };
|
||||
let col = if r.is_dirty { Color::Yellow } else if r.behind > 0 { Color::Red } else { Color::Green };
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} ", status), Style::default().fg(col)),
|
||||
Span::styled(format!("{:<18} ", r.name.chars().take(18).collect::<String>()), wht),
|
||||
Span::styled(format!("{:<9} ", r.branch.chars().take(9).collect::<String>()), dim),
|
||||
Span::styled(crate::status::format_age_pub(r.last_commit_ts), Style::default().fg(super::tabs::age_color(r.last_commit_ts))),
|
||||
]));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), cols[0]);
|
||||
}
|
||||
|
||||
// RIGHT: open todos + recent commits
|
||||
{
|
||||
let open_t: Vec<&crate::todos::Todo> = st.todos.iter()
|
||||
.filter(|t| matches!(t.status, TodoStatus::Open | TodoStatus::InProgress))
|
||||
.collect();
|
||||
|
||||
let mut lines: Vec<Line> = vec![];
|
||||
|
||||
lines.push(Line::from(Span::styled(" Todos", yel)));
|
||||
lines.push(Line::from(Span::styled(" ─────────────────────────────────", dim)));
|
||||
if open_t.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" No open todos", dim)));
|
||||
} else {
|
||||
let half_h = (area.height as usize / 2).saturating_sub(2);
|
||||
for t in open_t.iter().take(half_h) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} ", t.status.icon()), Style::default().fg(t.status.color())),
|
||||
Span::styled(t.title.chars().take(28).collect::<String>(), wht),
|
||||
]));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
|
||||
if !st.commits.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" Recent Commits", yel)));
|
||||
lines.push(Line::from(Span::styled(" ─────────────────────────────────", dim)));
|
||||
let rem_h = (area.height as usize).saturating_sub(lines.len() + 1);
|
||||
for c in st.commits.iter().take(rem_h) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:>5} ", c.age.chars().take(5).collect::<String>()), dim),
|
||||
Span::styled(format!("{:<10} ", c.repo.chars().take(10).collect::<String>()), Style::default().fg(Color::Cyan)),
|
||||
Span::styled(c.msg.chars().take(22).collect::<String>(), dim),
|
||||
]));
|
||||
}
|
||||
} else if st.commits_rx.is_some() {
|
||||
lines.push(Line::from(Span::styled(" Loading commits...", dim)));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), cols[1]);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_commits(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
|
||||
if st.commits_rx.is_some() {
|
||||
f.render_widget(Paragraph::new(" Loading commits from all repos...").style(dim), area);
|
||||
return;
|
||||
}
|
||||
if st.commits.is_empty() {
|
||||
f.render_widget(Paragraph::new(" No commits found").style(dim), area);
|
||||
return;
|
||||
}
|
||||
|
||||
let header = Row::new(vec!["REPO", "AGE", "AUTHOR", "MESSAGE"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let rows: Vec<Row> = st.commits.iter().skip(st.commit_off).take(avail)
|
||||
.map(|c: &AggCommit| {
|
||||
Row::new(vec![
|
||||
Cell::from(c.repo.chars().take(14).collect::<String>()).style(yel),
|
||||
Cell::from(c.age.chars().take(8).collect::<String>()).style(dim),
|
||||
Cell::from(c.author.chars().take(12).collect::<String>()).style(dim),
|
||||
Cell::from(c.msg.chars().take(60).collect::<String>()),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [Constraint::Length(14), Constraint::Length(8), Constraint::Length(12), Constraint::Fill(1)]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_repos(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let header = Row::new(vec!["NAME", "BRANCH", "STATUS", "AGE", "LAST COMMIT"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let rows: Vec<Row> = st.repos.iter().enumerate().skip(st.repo_cursor.saturating_sub(avail / 2)).take(avail)
|
||||
.map(|(i, r)| {
|
||||
let is_cur = i == st.repo_cursor;
|
||||
let status = if r.is_dirty { format!("⚠ +{}-{}", r.ahead, r.behind) } else { format!("+{}-{}", r.ahead, r.behind) };
|
||||
let col = if r.is_dirty || r.behind > 0 { Color::Yellow } else { Color::White };
|
||||
Row::new(vec![
|
||||
Cell::from(r.name.chars().take(20).collect::<String>()).style(Style::default().fg(col)),
|
||||
Cell::from(r.branch.chars().take(12).collect::<String>()).style(dim),
|
||||
Cell::from(status).style(dim),
|
||||
Cell::from(crate::status::format_age_pub(r.last_commit_ts)).style(Style::default().fg(super::tabs::age_color(r.last_commit_ts))),
|
||||
Cell::from(r.last_commit_msg.chars().take(35).collect::<String>()).style(dim),
|
||||
]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() })
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [Constraint::Length(20), Constraint::Length(12), Constraint::Length(12), Constraint::Length(10), Constraint::Fill(1)]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_stats(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let cyn = Style::default().fg(Color::Cyan);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
|
||||
let mut lines: Vec<Line> = vec![];
|
||||
lines.push(Line::from(Span::styled(" Activity (last 1y, all repos combined)", yel)));
|
||||
lines.push(Line::from(Span::styled(" ──────────────────────────────────────────────────────", dim)));
|
||||
|
||||
if st.activity_rx.is_some() {
|
||||
lines.push(Line::from(Span::styled(" Computing activity...", cyn)));
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
return;
|
||||
}
|
||||
if st.activity.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" No activity data", dim)));
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
return;
|
||||
}
|
||||
|
||||
let max_count: usize = st.activity.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1);
|
||||
let bar_h = (area.height as usize).saturating_sub(6).max(3);
|
||||
let bar_w = (area.width as usize).saturating_sub(10).max(1);
|
||||
let n = st.activity.len();
|
||||
let step = if n > bar_w { (n / bar_w).max(1) } else { 1 };
|
||||
let sampled: Vec<&(String, usize)> = st.activity.iter().step_by(step).collect();
|
||||
let bar_count = sampled.len().min(bar_w);
|
||||
let bar_width = if bar_count == 0 { 1 } else { (bar_w / bar_count).max(1) };
|
||||
|
||||
for row in (0..bar_h).rev() {
|
||||
let threshold = (row * max_count) / bar_h;
|
||||
let mut spans: Vec<Span<'static>> = vec![];
|
||||
let y_label = if row == bar_h - 1 { format!(" {:>4} ", max_count) } else if row == 0 { " 0 ".to_string() } else { " ".to_string() };
|
||||
spans.push(Span::styled(y_label, dim));
|
||||
for j in 0..bar_count {
|
||||
let count = sampled.get(j).map(|(_, c)| *c).unwrap_or(0);
|
||||
let filled = count > threshold;
|
||||
for _ in 0..bar_width {
|
||||
if filled { spans.push(Span::styled("█", cyn)); }
|
||||
else { spans.push(Span::styled("░", dim)); }
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
// X axis labels
|
||||
let nth = (bar_count / 6).max(1);
|
||||
let mut x_spans: Vec<Span> = vec![Span::raw(" ")];
|
||||
for j in 0..bar_count {
|
||||
let label = if j % nth == 0 {
|
||||
let raw = sampled.get(j)
|
||||
.map(|(d, _)| d.chars().skip(5).take(bar_width.min(5)).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
format!("{:<width$}", raw, width = bar_width)
|
||||
} else {
|
||||
" ".repeat(bar_width)
|
||||
};
|
||||
x_spans.push(Span::styled(label, dim));
|
||||
}
|
||||
lines.push(Line::from(x_spans));
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
fn render_todos(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let header = Row::new(vec!["STATUS", "TITLE", "EST", "CREATED"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
if st.todos.is_empty() {
|
||||
f.render_widget(Paragraph::new(" No todos for this project").style(dim), area);
|
||||
return;
|
||||
}
|
||||
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let rows: Vec<Row> = st.todos.iter().enumerate()
|
||||
.skip(st.todo_cursor.saturating_sub(avail / 2)).take(avail)
|
||||
.map(|(i, t)| {
|
||||
let is_cur = i == st.todo_cursor;
|
||||
let status_str = format!("{} {}", t.status.icon(), t.status.label());
|
||||
let est_str = t.estimate.as_deref().unwrap_or("—").chars().take(10).collect::<String>();
|
||||
let created = crate::status::format_age_pub(t.created_at);
|
||||
Row::new(vec![
|
||||
Cell::from(status_str).style(Style::default().fg(t.status.color())),
|
||||
Cell::from(t.title.chars().take(55).collect::<String>()),
|
||||
Cell::from(est_str).style(dim),
|
||||
Cell::from(created).style(dim),
|
||||
]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() })
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [Constraint::Length(14), Constraint::Fill(1), Constraint::Length(10), Constraint::Length(10)]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_settings(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let cyn = Style::default().fg(Color::Cyan);
|
||||
let (pr, pg, pb) = crate::projects::hex_to_rgb(&st.project.color);
|
||||
let proj_col = Color::Rgb(pr, pg, pb);
|
||||
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(Span::styled(" Project Settings", yel)),
|
||||
Line::from(Span::styled(" ────────────────────────────────────────────", dim)),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" Name: ", dim),
|
||||
Span::styled(st.project.name.clone(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Color: ", dim),
|
||||
Span::styled(" ■ ", Style::default().fg(proj_col).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(st.project.color.clone(), Style::default().fg(proj_col)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Repos: ", dim),
|
||||
Span::styled(st.repos.len().to_string(), Style::default().fg(Color::White)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" Actions", yel)),
|
||||
Line::from(Span::styled(" ────────────────────────────────────────────", dim)),
|
||||
Line::from(vec![
|
||||
Span::styled(" [Enter] ", cyn),
|
||||
Span::styled("Rename project", Style::default().fg(Color::White)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" [c] ", cyn),
|
||||
Span::styled("Change color (palette picker)", Style::default().fg(Color::White)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" Color Palette", yel)),
|
||||
Line::from(Span::styled(" ────────────────────────────────────────────", dim)),
|
||||
];
|
||||
|
||||
// Show palette swatches
|
||||
let per_row = 4;
|
||||
for chunk_start in (0..PROJ_PALETTE.len()).step_by(per_row) {
|
||||
let mut spans: Vec<Span> = vec![Span::raw(" ")];
|
||||
for i in 0..per_row {
|
||||
if let Some((hex, name)) = PROJ_PALETTE.get(chunk_start + i) {
|
||||
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
|
||||
let c = Color::Rgb(cr, cg, cb);
|
||||
let active = *hex == st.project.color.as_str();
|
||||
spans.push(Span::styled(if active { "▶██◀ " } else { " ██ " }, Style::default().fg(c)));
|
||||
spans.push(Span::styled(format!("{:<8}", name.chars().take(6).collect::<String>()), if active { Style::default().fg(c).add_modifier(Modifier::BOLD) } else { dim }));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
if st.rename_input.is_some() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(" → Rename modal open — type name, Enter confirm, Esc cancel", cyn)));
|
||||
}
|
||||
if st.color_cursor.is_some() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(" → Color picker open — ←→↑↓ navigate, Enter confirm, Esc cancel", cyn)));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
fn draw_text_modal(f: &mut Frame, area: Rect, title: &str, input: &str, hint: &str) {
|
||||
use ratatui::widgets::Clear;
|
||||
let modal_area = crate::tui::centered_rect(50, 20, area);
|
||||
f.render_widget(Clear, modal_area);
|
||||
let blk = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" {} ", title))
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
let inner = blk.inner(modal_area);
|
||||
f.render_widget(blk, modal_area);
|
||||
f.render_widget(Paragraph::new(vec![
|
||||
Line::from(Span::styled(format!(" {}_", input), Style::default().fg(Color::White).add_modifier(Modifier::BOLD))),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(format!(" {}", hint), Style::default().fg(Color::DarkGray))),
|
||||
]), inner);
|
||||
}
|
||||
|
||||
fn draw_color_pick_modal(f: &mut Frame, area: Rect, cursor: usize, current_hex: &str) {
|
||||
use ratatui::widgets::Clear;
|
||||
let modal_area = crate::tui::centered_rect(70, 50, area);
|
||||
f.render_widget(Clear, modal_area);
|
||||
let blk = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Choose Color ←→↑↓ navigate · Enter confirm · Esc cancel ")
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
let inner = blk.inner(modal_area);
|
||||
f.render_widget(blk, modal_area);
|
||||
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let mut lines: Vec<Line> = vec![Line::from("")];
|
||||
|
||||
let per_row = 6usize;
|
||||
for row_start in (0..PROJ_PALETTE.len()).step_by(per_row) {
|
||||
// Swatch row
|
||||
let mut swatch_spans: Vec<Span> = vec![Span::raw(" ")];
|
||||
for i in 0..per_row {
|
||||
if let Some((hex, _)) = PROJ_PALETTE.get(row_start + i) {
|
||||
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
|
||||
let c = Color::Rgb(cr, cg, cb);
|
||||
let is_cur = row_start + i == cursor;
|
||||
let is_active = *hex == current_hex;
|
||||
let label = if is_cur { "▶████◀ " } else if is_active { " ████* " } else { " ████ " };
|
||||
swatch_spans.push(Span::styled(label, Style::default().fg(c).add_modifier(if is_cur { Modifier::BOLD } else { Modifier::empty() })));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(swatch_spans));
|
||||
|
||||
// Name row
|
||||
let mut name_spans: Vec<Span> = vec![Span::raw(" ")];
|
||||
for i in 0..per_row {
|
||||
if let Some((hex, name)) = PROJ_PALETTE.get(row_start + i) {
|
||||
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
|
||||
let c = Color::Rgb(cr, cg, cb);
|
||||
let is_cur = row_start + i == cursor;
|
||||
name_spans.push(Span::styled(
|
||||
format!("{:<9}", name.chars().take(7).collect::<String>()),
|
||||
if is_cur { Style::default().fg(c).add_modifier(Modifier::BOLD) } else { dim },
|
||||
));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(name_spans));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Show selected hex
|
||||
if let Some((hex, name)) = PROJ_PALETTE.get(cursor) {
|
||||
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" Selected: "),
|
||||
Span::styled(format!("■ {} — {}", hex, name), Style::default().fg(Color::Rgb(cr, cg, cb)).add_modifier(Modifier::BOLD)),
|
||||
]));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
+42
-21
@@ -1,3 +1,8 @@
|
||||
//! Top-level draw routine for the hub: the tab bar, the 73/27 split between
|
||||
//! the tab body (`tabs::render_list`) and the right info panel
|
||||
//! (`right_panel::render_right`), the bottom hint/debug bar, and the modal
|
||||
//! overlay.
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::{Color, Modifier, Style},
|
||||
@@ -10,20 +15,27 @@ use super::right_panel::render_right;
|
||||
use super::state::{HubState, SortMode, Tab};
|
||||
use super::tabs::render_list;
|
||||
|
||||
/// Full key-binding reference shown by the `?` help modal.
|
||||
/// KEEP IN SYNC with `keys_dispatch.rs` (and the `[m]` menu in
|
||||
/// `modals_spawn::open_action_menu`) — stale entries here are a bug.
|
||||
pub static HELP_LINES: &[&str] = &[
|
||||
"── Navigation ───────────────────────────────",
|
||||
" m Action menu for current tab/selection",
|
||||
" ↑↓ / jk Move cursor up/down",
|
||||
" Tab Switch tab (Repos → Projects → Todos → PRs → Issues)",
|
||||
" Enter Open repo/project detail view",
|
||||
" Tab / ←→ Switch tab",
|
||||
" 1-9 Jump straight to a tab",
|
||||
" Enter Open repo/project detail (browser for PRs/issues)",
|
||||
" q / Esc Quit (or clear active filter)",
|
||||
"",
|
||||
"── Filtering & Sorting ───────────────────────",
|
||||
" / Fuzzy filter repos (name, branch, owner, lang)",
|
||||
" S Cycle sort: Default → Name → Recent → Dirty↑ → Behind↑",
|
||||
" S Sort & filter menu",
|
||||
" d Toggle: show only dirty repos",
|
||||
" b Toggle: show only behind repos",
|
||||
" f Toggle: show only ahead repos",
|
||||
" a Toggle: show only archived repos (no commits >90d)",
|
||||
" a Toggle: show archived repos (no commits >90d)",
|
||||
" * Select repos by condition",
|
||||
" Ctrl+A Select / deselect all visible repos",
|
||||
"",
|
||||
"── Open / Navigate ───────────────────────────",
|
||||
" o Open repo in $EDITOR",
|
||||
@@ -31,8 +43,8 @@ pub static HELP_LINES: &[&str] = &[
|
||||
" W Open repo in browser (GitHub/GitLab)",
|
||||
" y Copy repo path to clipboard",
|
||||
" u Copy remote URL to clipboard",
|
||||
" Ctrl+U Copy URL picker (SSH vs HTTPS)",
|
||||
" g Open .gitignore in $EDITOR",
|
||||
" C Clone a repository into workspace",
|
||||
"",
|
||||
"── Repo Actions ──────────────────────────────",
|
||||
" s Sync (pull / commit+push) selected repos",
|
||||
@@ -40,6 +52,8 @@ pub static HELP_LINES: &[&str] = &[
|
||||
" B Toggle bookmark (★) — visible in table",
|
||||
" P Toggle pin (◆) — pinned repos shown at top",
|
||||
" n Rename/label repo display name",
|
||||
" N New repository (clone / create / link)",
|
||||
" R Refresh metadata from provider API",
|
||||
" Space Select repo for batch action",
|
||||
"",
|
||||
"── Projects & Todos ──────────────────────────",
|
||||
@@ -49,12 +63,17 @@ pub static HELP_LINES: &[&str] = &[
|
||||
" Space (Todos tab) Cycle todo status",
|
||||
" D / Del Delete todo or project",
|
||||
" T Scan repos for TODO/FIXME/HACK → import to Todos",
|
||||
" X (Todos tab) Toggle code-scan todos",
|
||||
"",
|
||||
"── Help ──────────────────────────────────────",
|
||||
" ? Show this help",
|
||||
" v (Overview) Open release notes in browser",
|
||||
" Esc / q Close modal or quit",
|
||||
];
|
||||
|
||||
/// Draws the entire hub frame: top tab bar, the current tab's body plus
|
||||
/// right panel (or the debug log, in debug mode), and either a contextual
|
||||
/// hint line or the active modal on top of everything.
|
||||
pub fn render_hub(f: &mut Frame, state: &mut HubState) {
|
||||
let area = f.area();
|
||||
let bottom_h: u16 = if state.debug_mode { 11 } else { 3 };
|
||||
@@ -70,7 +89,7 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) {
|
||||
Span::styled(format!(" {} ", label), Style::default().fg(Color::DarkGray))
|
||||
}
|
||||
};
|
||||
let n = state.repo_pos.len();
|
||||
let n = state.display.len();
|
||||
let pos_str = format!(" {}/{}", if n == 0 { 0 } else { state.cursor + 1 }, n);
|
||||
|
||||
let behind_c = state.repos.iter().filter(|r| r.behind > 0).count();
|
||||
@@ -86,7 +105,7 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) {
|
||||
let issue_badge = if !state.issues.is_empty() { format!("6 Issues({})", state.issues.len()) } else { "6 Issues".into() };
|
||||
|
||||
let mut top_spans = vec![
|
||||
Span::styled(" fxg ", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(" vulp ", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
tab_span("1 Overview", state.tab == Tab::Overview),
|
||||
Span::raw(" "),
|
||||
tab_span("2 Repos", state.tab == Tab::Repos),
|
||||
@@ -101,7 +120,9 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) {
|
||||
Span::raw(" "),
|
||||
tab_span("7 Stats", state.tab == Tab::Stats),
|
||||
Span::raw(" "),
|
||||
tab_span("8 Settings", state.tab == Tab::HubSettings),
|
||||
tab_span("8 Activity", state.tab == Tab::Activity),
|
||||
Span::raw(" "),
|
||||
tab_span("9 Settings", state.tab == Tab::HubSettings),
|
||||
Span::raw(" "),
|
||||
];
|
||||
|
||||
@@ -137,7 +158,7 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) {
|
||||
f.render_widget(Paragraph::new(Line::from(top_spans)), outer[0]);
|
||||
|
||||
if state.tab == Tab::Overview {
|
||||
super::tabs::render_overview_global(f, state, outer[1]);
|
||||
super::tabs_overview::render_overview_global(f, state, outer[1]);
|
||||
} else {
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
@@ -203,18 +224,18 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) {
|
||||
Line::from(""),
|
||||
]
|
||||
} else {
|
||||
match state.tab {
|
||||
Tab::PRs | Tab::Issues => vec![
|
||||
Line::from(vec![key("↑↓/jk"), Span::styled("navigate ", dim), key("Tab/←→"), Span::styled("switch tab ", dim), key("↵"), Span::styled("open in browser ", dim), key("R"), Span::styled("refresh API ", dim), key("?"), Span::styled("help ", dim), key("q"), Span::styled("quit", dim)]),
|
||||
Line::from(""),
|
||||
line3,
|
||||
],
|
||||
_ => vec![
|
||||
Line::from(vec![key("↑↓/jk"), Span::styled("navigate ", dim), key("Tab/←→"), Span::styled("tabs ", dim), key("/"), Span::styled("filter ", dim), key("S"), Span::styled("sort & filter ", dim), key("?"), Span::styled("help ", dim), key("q"), Span::styled("quit", dim)]),
|
||||
Line::from(vec![key("Space"), Span::styled("select ", dim), key("R"), Span::styled("API refresh ", dim), key("N"), Span::styled("new repo", dim)]),
|
||||
line3,
|
||||
],
|
||||
}
|
||||
vec![
|
||||
Line::from(vec![
|
||||
key("m"), Span::styled("actions ", dim),
|
||||
key("↑↓/jk"), Span::styled("navigate ", dim),
|
||||
key("Tab/←→"), Span::styled("tabs ", dim),
|
||||
key("/"), Span::styled("filter ", dim),
|
||||
key("?"), Span::styled("help ", dim),
|
||||
key("q"), Span::styled("quit", dim),
|
||||
]),
|
||||
Line::from(""),
|
||||
line3,
|
||||
]
|
||||
};
|
||||
f.render_widget(Paragraph::new(hint_lines), outer[2]);
|
||||
}
|
||||
|
||||
+45
-8
@@ -1,3 +1,8 @@
|
||||
//! The right-hand info panel shown beside the main list on every hub tab:
|
||||
//! per-row detail for the selected repo/project/todo/PR/issue plus a
|
||||
//! contextual "Actions" block underneath, or the sync/release confirmation
|
||||
//! overlay while an action is pending.
|
||||
|
||||
use crate::status::format_age_pub;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
@@ -11,30 +16,40 @@ use super::tabs::age_color;
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A `"key: value"` row with the key dimmed and column-aligned, and the
|
||||
/// value colored by `val_col`.
|
||||
pub fn r_kv<'a>(key: &'a str, val: &str, val_col: Color) -> Line<'a> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:<11}", key), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(val.to_string(), Style::default().fg(val_col)),
|
||||
])
|
||||
}
|
||||
/// Alias for [`r_kv`] used where the caller wants to make explicit that
|
||||
/// `val_col` is being chosen dynamically rather than a fixed constant.
|
||||
pub fn r_kv_color<'a>(key: &'a str, val: &str, val_col: Color) -> Line<'a> {
|
||||
r_kv(key, val, val_col)
|
||||
}
|
||||
/// A bold yellow section heading line (e.g. "Health", "Diff preview").
|
||||
pub fn r_section(title: &str) -> Line<'static> {
|
||||
Line::from(Span::styled(
|
||||
format!(" {}", title),
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
}
|
||||
/// A horizontal rule sized to `width`, used right under a panel's title row.
|
||||
pub fn r_sep(width: u16) -> Line<'static> {
|
||||
Line::from(Span::styled(
|
||||
" ".to_string() + &"─".repeat(width.saturating_sub(2) as usize),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
))
|
||||
}
|
||||
/// A dimmed informational/placeholder line (e.g. "← select a repository").
|
||||
pub fn r_dim(s: String) -> Line<'static> {
|
||||
Line::from(Span::styled(s, Style::default().fg(Color::DarkGray)))
|
||||
}
|
||||
/// One row of the "Actions" block: a bracketed single-key hint followed by
|
||||
/// its description. `key == '\n'` renders `desc` alone as a plain styled
|
||||
/// line instead, used for the visual separators between action groups.
|
||||
pub fn r_action_line<'a>(key: char, desc: &'a str, col: Color) -> Line<'a> {
|
||||
if key == '\n' {
|
||||
return Line::from(Span::styled(desc, Style::default().fg(col)));
|
||||
@@ -44,6 +59,8 @@ pub fn r_action_line<'a>(key: char, desc: &'a str, col: Color) -> Line<'a> {
|
||||
Span::styled(format!(" {}", desc), Style::default().fg(Color::White)),
|
||||
])
|
||||
}
|
||||
/// Like [`r_action_line`], but for actions whose key hint is a multi-char
|
||||
/// label (e.g. `"↵"`, `"Space"`, `"^A"`) rather than a single `char`.
|
||||
pub fn r_action_key<'a>(key: &'a str, desc: &'a str, col: Color) -> Line<'a> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" [{}]", key), Style::default().fg(col).add_modifier(Modifier::BOLD)),
|
||||
@@ -53,6 +70,9 @@ pub fn r_action_key<'a>(key: &'a str, desc: &'a str, col: Color) -> Line<'a> {
|
||||
|
||||
// ── render right ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Entry point for the right panel: routes to the confirmation overlay if an
|
||||
/// action is pending, otherwise draws the bordered panel and dispatches to
|
||||
/// the per-tab renderer (or nothing, for tabs that don't use this panel).
|
||||
pub fn render_right(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
if let Some(ref action) = state.confirming {
|
||||
render_confirm_overlay(f, state, area, action);
|
||||
@@ -69,16 +89,17 @@ pub fn render_right(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
Tab::Todos => render_right_todo(f, state, inner),
|
||||
Tab::PRs => render_right_pr(f, state, inner),
|
||||
Tab::Issues => render_right_issue(f, state, inner),
|
||||
Tab::Overview | Tab::Stats | Tab::HubSettings => {}
|
||||
Tab::Overview | Tab::Stats | Tab::Activity | Tab::HubSettings => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the right panel with a "are you sure" overlay listing up to 12
|
||||
/// of the targeted repos (with a "… and N more" tail) and the y/n prompt,
|
||||
/// while `state.confirming` is set.
|
||||
fn render_confirm_overlay(f: &mut Frame, state: &HubState, area: Rect, action: &PendingAction) {
|
||||
let targets = state.action_targets();
|
||||
let (title, verb) = match action {
|
||||
PendingAction::Sync => (" Sync ", "Sync"),
|
||||
PendingAction::Release => (" Release ", "Release"),
|
||||
};
|
||||
let PendingAction::Release = action;
|
||||
let (title, verb) = (" Release ", "Release");
|
||||
let blk = Block::default().borders(Borders::ALL).title(title)
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
let inner = blk.inner(area);
|
||||
@@ -107,6 +128,9 @@ fn render_confirm_overlay(f: &mut Frame, state: &HubState, area: Rect, action: &
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
/// Repo detail panel: branch/owner/project/visibility, ahead/behind + dirty
|
||||
/// status, file count, latest tag/language, last commit, an inline diff
|
||||
/// preview when the repo is dirty, and the full repo-actions key reference.
|
||||
fn render_right_repo(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let actions_h = 20u16;
|
||||
let layout = Layout::default()
|
||||
@@ -147,6 +171,9 @@ fn render_right_repo(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
};
|
||||
lines.push(r_kv("Changes", dirty_txt, dirty_col));
|
||||
lines.push(r_kv("Remote", sync_txt, sync_col));
|
||||
if r.stash_count > 0 {
|
||||
lines.push(r_kv("Stash", &format!("⌂ {} entr{}", r.stash_count, if r.stash_count == 1 { "y" } else { "ies" }), Color::Magenta));
|
||||
}
|
||||
|
||||
if let Some(ex) = state.focused_cache.get(&ri) {
|
||||
lines.push(r_kv("Files", &format!("{} tracked", ex.file_count), Color::DarkGray));
|
||||
@@ -236,6 +263,9 @@ fn render_right_repo(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
]), action_inner);
|
||||
}
|
||||
|
||||
/// Project detail panel: color/repo-count, an aggregate health line (clean,
|
||||
/// or counts of dirty/behind member repos), open-todo count, and a preview
|
||||
/// of up to 8 member repos flagged by their own dirty/behind state.
|
||||
fn render_right_project(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let actions_h = 7u16;
|
||||
let layout = Layout::default()
|
||||
@@ -313,6 +343,9 @@ fn render_right_project(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
]), action_inner);
|
||||
}
|
||||
|
||||
/// Todo detail panel: status, assigned project, the description text
|
||||
/// (word-wrapped to the panel width, up to 6 source lines), and the
|
||||
/// created/updated timeline.
|
||||
fn render_right_todo(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let actions_h = 6u16;
|
||||
let layout = Layout::default()
|
||||
@@ -338,7 +371,7 @@ fn render_right_todo(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
|
||||
if !t.description.is_empty() {
|
||||
lines.push(r_section("Description"));
|
||||
let max_w = area.width.saturating_sub(3) as usize;
|
||||
let max_w = (area.width.saturating_sub(3) as usize).max(1);
|
||||
for raw_line in t.description.lines().take(6) {
|
||||
for chunk in raw_line.chars().collect::<Vec<_>>().chunks(max_w).map(|c| c.iter().collect::<String>()) {
|
||||
lines.push(Line::from(Span::styled(format!(" {}", chunk), Style::default().fg(Color::DarkGray))));
|
||||
@@ -367,10 +400,12 @@ fn render_right_todo(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
]), action_inner);
|
||||
}
|
||||
|
||||
/// PR detail panel: repo, number, title (wrapped over two lines), author,
|
||||
/// updated-age, head→base branches, a draft badge, and labels.
|
||||
fn render_right_pr(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let di = state.cur_di();
|
||||
let pr = di.and_then(|d| {
|
||||
if let DisplayRow::Pr { idx } = &state.display[d] { state.prs.get(*idx) } else { None }
|
||||
if let DisplayRow::Pr { pr_idx } = &state.display[d] { state.prs.get(*pr_idx) } else { None }
|
||||
});
|
||||
|
||||
let mut lines: Vec<Line> = vec![r_section("Pull Request"), Line::from("")];
|
||||
@@ -400,10 +435,12 @@ fn render_right_pr(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
/// Issue detail panel: repo, number, title (wrapped over two lines), author,
|
||||
/// updated-age, comment count, and labels.
|
||||
fn render_right_issue(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let di = state.cur_di();
|
||||
let issue = di.and_then(|d| {
|
||||
if let DisplayRow::Issue { idx } = &state.display[d] { state.issues.get(*idx) } else { None }
|
||||
if let DisplayRow::Issue { issue_idx } = &state.display[d] { state.issues.get(*issue_idx) } else { None }
|
||||
});
|
||||
|
||||
let mut lines: Vec<Line> = vec![r_section("Issue"), Line::from("")];
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Session persistence (`~/.vulp/session.toml`): remembers the active tab
|
||||
//! and the repo under the cursor between runs. Deliberately tiny — filters
|
||||
//! and text search are NOT restored (surprising UX), only "where was I".
|
||||
|
||||
use super::state::Tab;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct Session {
|
||||
#[serde(default)]
|
||||
pub tab: u8,
|
||||
#[serde(default)]
|
||||
pub cursor_repo_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn path() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")).join(".vulp").join("session.toml")
|
||||
}
|
||||
|
||||
pub fn tab_to_u8(tab: Tab) -> u8 {
|
||||
match tab {
|
||||
Tab::Overview => 0, Tab::Repos => 1, Tab::Projects => 2, Tab::Todos => 3,
|
||||
Tab::PRs => 4, Tab::Issues => 5, Tab::Stats => 6, Tab::Activity => 7,
|
||||
Tab::HubSettings => 8,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn u8_to_tab(v: u8) -> Tab {
|
||||
match v {
|
||||
1 => Tab::Repos, 2 => Tab::Projects, 3 => Tab::Todos, 4 => Tab::PRs,
|
||||
5 => Tab::Issues, 6 => Tab::Stats, 7 => Tab::Activity, 8 => Tab::HubSettings,
|
||||
_ => Tab::Overview,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load() -> Session {
|
||||
let p = path();
|
||||
if !p.exists() { return Session::default(); }
|
||||
toml::from_str(&std::fs::read_to_string(p).unwrap_or_default()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(tab: Tab, cursor_repo_path: Option<PathBuf>) {
|
||||
let s = Session { tab: tab_to_u8(tab), cursor_repo_path };
|
||||
let p = path();
|
||||
if let Some(d) = p.parent() { let _ = std::fs::create_dir_all(d); }
|
||||
if let Ok(body) = toml::to_string_pretty(&s) { let _ = std::fs::write(p, body); }
|
||||
}
|
||||
+152
-200
@@ -1,3 +1,15 @@
|
||||
//! The hub's central state machine: `HubState` (everything the main TUI loop
|
||||
//! reads and mutates) plus the `Tab` enum selecting which view is active.
|
||||
//! The supporting type definitions this struct is built from — modal types,
|
||||
//! display-row addressing, background-thread messages, and a couple of
|
||||
//! standalone enums — live in the sibling `state_*` files and are re-exported
|
||||
//! below so the rest of `hub/` can keep importing everything from `state::`.
|
||||
|
||||
pub use super::state_bg::*;
|
||||
pub use super::state_misc::*;
|
||||
pub use super::state_modal::*;
|
||||
pub use super::state_rows::*;
|
||||
|
||||
use crate::projects::{self, Project};
|
||||
use crate::providers::{IssueDetail, PrDetail};
|
||||
use crate::status::{count_tracked_files, repo_recent_commits, RepoSummary};
|
||||
@@ -6,194 +18,23 @@ use ratatui::style::Color;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ── tabs ──────────────────────────────────────────────────────────────────────
|
||||
/// Payload of the startup release-info fetch: latest release, the release
|
||||
/// matching the running version, and per-account GitHub creation dates.
|
||||
pub type ReleaseFetch = (
|
||||
Result<crate::release_info::ReleaseInfo, String>,
|
||||
Result<crate::release_info::ReleaseInfo, String>,
|
||||
Vec<(String, String)>,
|
||||
);
|
||||
|
||||
/// Which top-level view of the hub is currently showing. Cycled with Tab/
|
||||
/// Shift-Tab or jumped to directly with the number keys shown in the tab bar.
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
pub enum Tab { Overview, Repos, Projects, Todos, PRs, Issues, Stats, HubSettings }
|
||||
|
||||
// ── global stats ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct GlobalStats {
|
||||
pub activity: Vec<(String, usize)>, // date → aggregated commits
|
||||
pub lang_dist: Vec<(String, usize)>, // lang → repo count
|
||||
pub top_repos: Vec<(String, usize)>, // name → commit count
|
||||
pub total_commits: usize,
|
||||
pub dirty_count: usize,
|
||||
pub behind_count: usize,
|
||||
pub day_pattern: [usize; 7], // Mon–Sun commit totals
|
||||
}
|
||||
|
||||
// ── sort mode ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum SortMode { Default, Name, Age, DirtyFirst, BehindFirst }
|
||||
|
||||
impl SortMode {
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Default => Self::Name,
|
||||
Self::Name => Self::Age,
|
||||
Self::Age => Self::DirtyFirst,
|
||||
Self::DirtyFirst => Self::BehindFirst,
|
||||
Self::BehindFirst=> Self::Default,
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "Default",
|
||||
Self::Name => "Name",
|
||||
Self::Age => "Recent",
|
||||
Self::DirtyFirst => "Dirty↑",
|
||||
Self::BehindFirst=> "Behind↑",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── modal system ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Modal {
|
||||
None,
|
||||
Text { title: String, prompt: String, value: String, tag: TextTag, max_len: Option<usize> },
|
||||
Select { title: String, options: Vec<String>, cursor: usize, tag: SelectTag },
|
||||
Multi { title: String, options: Vec<String>, checks: Vec<bool>, cursor: usize, tag: MultiTag },
|
||||
Confirm { message: String, tag: ConfirmTag },
|
||||
Info { title: String, lines: Vec<String>, scroll: usize },
|
||||
}
|
||||
|
||||
impl Modal {
|
||||
pub fn is_some(&self) -> bool { !matches!(self, Modal::None) }
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum TextTag {
|
||||
Label(usize),
|
||||
ProjName,
|
||||
ProjColor(String),
|
||||
ProjRenameName(usize),
|
||||
ProjRenameColor(usize, String),
|
||||
TodoTitle,
|
||||
TodoDesc(String),
|
||||
TodoEditTitle(usize),
|
||||
TodoEditDesc(usize, String),
|
||||
CloneUrl,
|
||||
InitName,
|
||||
LinkPath,
|
||||
// hub sync
|
||||
SyncMsg,
|
||||
// hub settings editing
|
||||
SetEditor,
|
||||
SetClipboard,
|
||||
SetWorkspace,
|
||||
// import directory
|
||||
ImportDir,
|
||||
// template creation
|
||||
NewTemplateName,
|
||||
NewTemplateCmds(String), // template name
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum SelectTag {
|
||||
ProjMenu,
|
||||
ProjForAction(ProjAction),
|
||||
TodoStatus { title: String, desc: String },
|
||||
TodoProject { title: String, desc: String, status_idx: usize },
|
||||
TodoChangeStatus(usize),
|
||||
SelectByCondition,
|
||||
SortFilter,
|
||||
NewRepo,
|
||||
// color palette for project create / rename
|
||||
ProjColorCreate(String),
|
||||
ProjColorRename(usize, String),
|
||||
// hub sync action picker
|
||||
SyncAction,
|
||||
// pull-all account picker
|
||||
PullAllAccount,
|
||||
// repo creation: pick template
|
||||
InitTemplate(String),
|
||||
// template management
|
||||
TemplateMenu(usize),
|
||||
// copy URL: SSH vs HTTPS
|
||||
CopyUrl(std::path::PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum ProjAction { Assign, Rename, Delete }
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum MultiTag {
|
||||
ProjAssign(usize),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ConfirmTag {
|
||||
DeleteProject(usize),
|
||||
DeleteTodo(usize),
|
||||
PullAll(usize), // index into state.accounts
|
||||
RemoveGitAll,
|
||||
DeleteTemplate(usize),
|
||||
}
|
||||
|
||||
// ── display rows ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum DisplayRow {
|
||||
Repo { repo_idx: usize },
|
||||
Project { proj_idx: usize },
|
||||
Todo { todo_idx: usize },
|
||||
Pr { idx: usize },
|
||||
Issue { idx: usize },
|
||||
}
|
||||
|
||||
// ── pending action ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum PendingAction { Sync, Release }
|
||||
|
||||
// ── focused extras (keyed by repo_idx, never cleared on scroll) ───────────────
|
||||
|
||||
pub enum DiffState {
|
||||
Loading,
|
||||
Ready(Vec<String>),
|
||||
}
|
||||
|
||||
pub struct FocusedExtra {
|
||||
pub commits: Vec<(String, String)>,
|
||||
pub file_count: usize,
|
||||
pub diff_preview: DiffState,
|
||||
}
|
||||
|
||||
// ── repo template ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RepoTemplate {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub commands: Vec<String>,
|
||||
}
|
||||
|
||||
// ── phase result ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum PhaseResult {
|
||||
None,
|
||||
OpenDetail,
|
||||
ExecSync,
|
||||
ExecRelease,
|
||||
ExecClone(String),
|
||||
ExecRefresh,
|
||||
ExecTemplateInit { path: PathBuf, commands: Vec<String> },
|
||||
ExecOpenEditor(PathBuf),
|
||||
}
|
||||
|
||||
// ── modal action ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub enum ModalAction { Pending, Confirm, Cancel }
|
||||
|
||||
// ── hub state ─────────────────────────────────────────────────────────────────
|
||||
pub enum Tab { Overview, Repos, Projects, Todos, PRs, Issues, Stats, Activity, HubSettings }
|
||||
|
||||
/// Everything the hub's main event loop (`tui_phase`) reads and mutates: the
|
||||
/// scanned repo/project/todo/PR/issue data, which tab and row are selected,
|
||||
/// in-flight background-thread receivers, and user settings. One instance
|
||||
/// lives for the lifetime of a single `vulp` TUI session.
|
||||
pub struct HubState {
|
||||
pub repos: Vec<RepoSummary>,
|
||||
pub projects: Vec<Project>,
|
||||
@@ -203,7 +44,6 @@ pub struct HubState {
|
||||
pub issues: Vec<IssueDetail>,
|
||||
pub tab: Tab,
|
||||
pub display: Vec<DisplayRow>,
|
||||
pub repo_pos: Vec<usize>,
|
||||
pub cursor: usize,
|
||||
pub offset: usize,
|
||||
pub visible_rows: usize,
|
||||
@@ -241,6 +81,24 @@ pub struct HubState {
|
||||
pub global_stats: Option<GlobalStats>,
|
||||
pub global_stats_rx: Option<std::sync::mpsc::Receiver<GlobalStats>>,
|
||||
pub global_stats_period: crate::detail::StatsPeriod,
|
||||
// Activity tab
|
||||
pub activity_feed: Option<Vec<crate::activity::ActivityEntry>>,
|
||||
pub activity_rx: Option<std::sync::mpsc::Receiver<Vec<crate::activity::ActivityEntry>>>,
|
||||
pub activity_filter: Option<crate::activity::ActivityKind>,
|
||||
// Overview "vulp log" tile: recent vulp-performed actions, newest first
|
||||
pub vulp_log: Vec<crate::activity::ActivityEntry>,
|
||||
/// Repo path to re-focus once the startup scan completes (session restore).
|
||||
pub pending_cursor_restore: Option<std::path::PathBuf>,
|
||||
// General Settings tile: release/update info, fetched once at startup
|
||||
pub latest_release: Option<Result<crate::release_info::ReleaseInfo, String>>,
|
||||
pub current_release: Option<Result<crate::release_info::ReleaseInfo, String>>,
|
||||
/// GitHub account-creation dates per account username ("since ..."),
|
||||
/// fetched in the same startup thread as the release info.
|
||||
pub account_since: HashMap<String, String>,
|
||||
/// (merged PRs, closed-unmerged PRs, closed issues) across all owners,
|
||||
/// from the provider search API during [R] refresh. None until fetched.
|
||||
pub closed_counts: Option<(u64, u64, u64)>,
|
||||
pub release_rx: Option<std::sync::mpsc::Receiver<ReleaseFetch>>,
|
||||
// Hub Settings tab
|
||||
pub hub_settings_cur: usize,
|
||||
// Loaded once at startup from auth data dir
|
||||
@@ -254,6 +112,16 @@ pub struct HubState {
|
||||
pub pending_phase: Option<PhaseResult>,
|
||||
// Pull-all background receiver: sends progress strings
|
||||
pub pull_all_rx: Option<std::sync::mpsc::Receiver<String>>,
|
||||
// API refresh ([R]) background receiver
|
||||
pub refresh_rx: Option<std::sync::mpsc::Receiver<RefreshMsg>>,
|
||||
// Repo creation background receiver: progress + final result, without leaving the TUI
|
||||
pub create_repo_rx: Option<std::sync::mpsc::Receiver<CreateRepoMsg>>,
|
||||
// Settings-profile pull-from-repo background receiver
|
||||
pub profile_pull_rx: Option<std::sync::mpsc::Receiver<ProfilePullMsg>>,
|
||||
// Code intelligence background receiver (tree-sitter parsing — CPU-bound, plain thread)
|
||||
pub codeintel_rx: Option<std::sync::mpsc::Receiver<CodeIntelMsg>>,
|
||||
// Full app config, needed by background threads that create/push repos via providers
|
||||
pub cfg: crate::config::AppConfig,
|
||||
}
|
||||
|
||||
impl HubState {
|
||||
@@ -277,6 +145,10 @@ impl HubState {
|
||||
true
|
||||
}
|
||||
|
||||
/// Recomputes `display` for whichever tab is active. Must be
|
||||
/// called after anything that changes what rows should be visible
|
||||
/// (filter/sort changed, data refreshed, tab switched) — `display` is not
|
||||
/// kept in sync automatically.
|
||||
pub fn rebuild_display(&mut self) {
|
||||
let mut d = Vec::new();
|
||||
match self.tab {
|
||||
@@ -312,23 +184,29 @@ impl HubState {
|
||||
}
|
||||
Tab::PRs => {
|
||||
for i in 0..self.prs.len() {
|
||||
d.push(DisplayRow::Pr { idx: i });
|
||||
d.push(DisplayRow::Pr { pr_idx: i });
|
||||
}
|
||||
}
|
||||
Tab::Issues => {
|
||||
for i in 0..self.issues.len() {
|
||||
d.push(DisplayRow::Issue { idx: i });
|
||||
d.push(DisplayRow::Issue { issue_idx: i });
|
||||
}
|
||||
}
|
||||
Tab::Overview | Tab::Stats | Tab::HubSettings => {
|
||||
Tab::Overview | Tab::Stats | Tab::Activity | Tab::HubSettings => {
|
||||
// These tabs render their own content; display list is empty
|
||||
}
|
||||
}
|
||||
self.repo_pos = (0..d.len()).collect();
|
||||
if self.cursor >= self.repo_pos.len().max(1) {
|
||||
self.cursor = self.repo_pos.len().saturating_sub(1);
|
||||
if self.cursor >= d.len().max(1) {
|
||||
self.cursor = d.len().saturating_sub(1);
|
||||
}
|
||||
self.offset = 0;
|
||||
// Keep the (possibly clamped) cursor inside the visible window —
|
||||
// otherwise a filter change can leave it off-screen until the first
|
||||
// arrow key.
|
||||
self.offset = if self.visible_rows > 0 && self.cursor >= self.visible_rows {
|
||||
self.cursor - self.visible_rows + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.display = d;
|
||||
}
|
||||
|
||||
@@ -337,7 +215,7 @@ impl HubState {
|
||||
}
|
||||
|
||||
pub fn cur_di(&self) -> Option<usize> {
|
||||
self.repo_pos.get(self.cursor).copied()
|
||||
if self.cursor < self.display.len() { Some(self.cursor) } else { None }
|
||||
}
|
||||
pub fn cur_repo_idx(&self) -> Option<usize> {
|
||||
match self.display.get(self.cur_di()?)? {
|
||||
@@ -381,6 +259,9 @@ impl HubState {
|
||||
Color::Rgb(r, g, b)
|
||||
}
|
||||
|
||||
/// Repos a bulk action (sync, label, etc.) should apply to: the explicit
|
||||
/// multi-select if there is one, else the single repo under the cursor,
|
||||
/// else every repo in the project under the cursor.
|
||||
pub fn action_targets(&self) -> Vec<usize> {
|
||||
if self.selected.len() > 1 {
|
||||
let mut v: Vec<usize> = self.selected.iter().copied().collect();
|
||||
@@ -398,19 +279,17 @@ impl HubState {
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self) {
|
||||
let n = self.repo_pos.len();
|
||||
let n = self.display.len();
|
||||
if n == 0 || self.cursor + 1 >= n { return; }
|
||||
self.cursor += 1;
|
||||
let di = self.repo_pos[self.cursor];
|
||||
if self.visible_rows > 0 && di >= self.offset + self.visible_rows {
|
||||
self.offset = di - self.visible_rows + 1;
|
||||
if self.visible_rows > 0 && self.cursor >= self.offset + self.visible_rows {
|
||||
self.offset = self.cursor - self.visible_rows + 1;
|
||||
}
|
||||
}
|
||||
pub fn scroll_up(&mut self) {
|
||||
if self.cursor == 0 { return; }
|
||||
self.cursor -= 1;
|
||||
let di = self.repo_pos[self.cursor];
|
||||
if di < self.offset { self.offset = di; }
|
||||
if self.cursor < self.offset { self.offset = self.cursor; }
|
||||
}
|
||||
pub fn toggle_select(&mut self) {
|
||||
if let Some(ri) = self.cur_repo_idx() {
|
||||
@@ -457,6 +336,16 @@ impl HubState {
|
||||
self.repos.dedup_by(|a, b| a.path == b.path);
|
||||
self.scan_rx = None;
|
||||
scan_done = true;
|
||||
// Session restore: put the cursor back on the repo it was
|
||||
// on last time, now that the final sort order is known.
|
||||
if let Some(want) = self.pending_cursor_restore.take() {
|
||||
self.rebuild_display();
|
||||
if let Some(pos) = self.display.iter().position(|row| {
|
||||
matches!(row, DisplayRow::Repo { repo_idx } if self.repos[*repo_idx].path == want)
|
||||
}) {
|
||||
self.cursor = pos;
|
||||
}
|
||||
}
|
||||
any_new = true;
|
||||
break;
|
||||
}
|
||||
@@ -530,6 +419,66 @@ impl HubState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the background activity-feed thread finished; if so, store the result.
|
||||
pub fn poll_activity(&mut self) {
|
||||
let done = match &self.activity_rx {
|
||||
None => return,
|
||||
Some(rx) => match rx.try_recv() {
|
||||
Ok(s) => Some(s),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return,
|
||||
},
|
||||
};
|
||||
if let Some(s) = done {
|
||||
self.activity_feed = Some(s);
|
||||
self.activity_rx = None;
|
||||
self.status_msg = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the background release-info fetch finished; if so, store the result.
|
||||
pub fn poll_release_info(&mut self) {
|
||||
let done = match &self.release_rx {
|
||||
None => return,
|
||||
Some(rx) => match rx.try_recv() {
|
||||
Ok(pair) => Some(pair),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return,
|
||||
},
|
||||
};
|
||||
if let Some((latest, current, since)) = done {
|
||||
self.latest_release = Some(latest);
|
||||
self.current_release = Some(current);
|
||||
self.account_since = since.into_iter().collect();
|
||||
self.release_rx = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a background codeintel job (list functions / find usages /
|
||||
/// build rename plan / repo stats) finished, and if so, show its result.
|
||||
pub fn poll_codeintel(&mut self) {
|
||||
let done = match &self.codeintel_rx {
|
||||
None => return,
|
||||
Some(rx) => match rx.try_recv() {
|
||||
Ok(msg) => Some(msg),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return,
|
||||
},
|
||||
};
|
||||
if let Some(msg) = done {
|
||||
self.codeintel_rx = None;
|
||||
self.status_msg = None;
|
||||
match msg {
|
||||
CodeIntelMsg::Info { title, lines } => {
|
||||
self.modal = Modal::Info { title, lines, scroll: 0 };
|
||||
}
|
||||
CodeIntelMsg::Preview { title, lines, plan } => {
|
||||
self.modal = Modal::RenamePreview { title, lines, scroll: 0, plan };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poll_diff(&mut self) {
|
||||
let rx_ready = if let Some((ri, rx)) = &self.diff_rx {
|
||||
match rx.try_recv() {
|
||||
@@ -549,6 +498,9 @@ impl HubState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Working-tree diff (vs. HEAD) for `path`, capped at 20 changed-file lines
|
||||
/// plus an insertions/deletions summary — enough for the focused-repo
|
||||
/// preview panel without rendering a full unbounded diff.
|
||||
fn diff_inner(path: &PathBuf) -> Vec<String> {
|
||||
let Ok(repo) = git2::Repository::open(path) else { return vec![]; };
|
||||
let Ok(head) = repo.head() else { return vec![]; };
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Messages and snapshots produced by hub background threads: aggregated
|
||||
//! workspace stats, the result of long-running repo-creation / settings-sync
|
||||
//! / code-intelligence jobs, and the loop-control signal (`PhaseResult`) that
|
||||
//! `tui_phase` uses to know when it needs to hand control back to its caller.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Workspace-wide numbers for the Stats and Overview tabs, computed once in
|
||||
/// the background (scanning every repo's git history) rather than recomputed
|
||||
/// every frame.
|
||||
pub struct GlobalStats {
|
||||
pub activity: Vec<(String, usize)>, // date → aggregated commits
|
||||
pub lang_dist: Vec<(String, usize)>, // lang → repo count
|
||||
pub top_repos: Vec<(String, usize)>, // name → commit count, top 5
|
||||
pub repo_commits: Vec<(String, usize)>, // name → commit count, every repo (sorted desc)
|
||||
pub total_commits: usize,
|
||||
pub dirty_count: usize,
|
||||
pub behind_count: usize,
|
||||
pub day_pattern: [usize; 7], // Mon–Sun commit totals
|
||||
}
|
||||
|
||||
/// Progress/result of the background API refresh ([R]): per-repo metadata,
|
||||
/// PR/issue lists and workspace-wide closed counters, fetched off-thread so
|
||||
/// the UI keeps drawing.
|
||||
pub enum RefreshMsg {
|
||||
Progress(String),
|
||||
Done {
|
||||
metas: Vec<(String, crate::cache::RepoMeta)>,
|
||||
prs: Vec<(String, Vec<crate::providers::PrDetail>)>,
|
||||
issues: Vec<(String, Vec<crate::providers::IssueDetail>)>,
|
||||
closed_counts: Option<(u64, u64, u64)>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What `tui_phase` should do once the current event-loop iteration ends.
|
||||
/// Most keys are handled in place and report `None`; the rest need the
|
||||
/// caller to take control away from the raw-mode TUI loop (e.g. to shell out
|
||||
/// for a sync, or to switch into the repo detail sub-view).
|
||||
#[derive(PartialEq)]
|
||||
pub enum PhaseResult {
|
||||
None,
|
||||
OpenDetail,
|
||||
ExecSync,
|
||||
ExecRelease,
|
||||
ExecClone(String),
|
||||
ExecOpenEditor(PathBuf),
|
||||
}
|
||||
|
||||
/// Progress/result of a background "create repo" job (git init, commit,
|
||||
/// push, create-on-provider). `Progress` lines accumulate in a `Modal::Info`
|
||||
/// while the job runs; `Done` replaces the modal with the final outcome.
|
||||
pub enum CreateRepoMsg {
|
||||
Progress(String),
|
||||
Done { success: bool, name: String, detail: String },
|
||||
}
|
||||
|
||||
// ── settings-profile pull-from-repo background messages ─────────────────────
|
||||
// Separate from CreateRepoMsg because a successful pull needs to hand the
|
||||
// fetched snapshot back to the main thread to be applied.
|
||||
|
||||
/// Progress/result of pulling a settings snapshot from the profile-sync repo.
|
||||
pub enum ProfilePullMsg {
|
||||
Done(Result<crate::profile::SettingsSnapshot, String>),
|
||||
}
|
||||
|
||||
// ── code intelligence background messages ───────────────────────────────────
|
||||
// Tree-sitter parsing a whole repo is CPU-bound, not I/O — runs on a plain
|
||||
// std::thread (no tokio runtime needed) and reports back over this channel.
|
||||
|
||||
/// Result of a background code-intelligence job (list functions / find
|
||||
/// usages / build a rename plan / repo stats).
|
||||
pub enum CodeIntelMsg {
|
||||
/// Read-only result: rendered as a `Modal::Info` (function list, usages, stats).
|
||||
Info { title: String, lines: Vec<String> },
|
||||
/// A rename/convention-conversion dry-run: rendered as `Modal::RenamePreview`,
|
||||
/// which requires an explicit Enter/y before `apply_rename_plan` ever runs.
|
||||
Preview { title: String, lines: Vec<String>, plan: crate::codeintel::RenamePlan },
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Small standalone types that don't fit the other state groupings: the
|
||||
//! repo-list sort order, and the user-defined repo template format
|
||||
//! (persisted to `templates.toml`).
|
||||
|
||||
/// Repo-list ordering for the Repos tab, cycled with a single key. Pinned
|
||||
/// repos always sort to the top regardless of this setting (see
|
||||
/// `HubState::rebuild_display`), so this only orders the unpinned remainder.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum SortMode { Default, Name, Age, DirtyFirst, BehindFirst }
|
||||
|
||||
impl SortMode {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "Default",
|
||||
Self::Name => "Name",
|
||||
Self::Age => "Recent",
|
||||
Self::DirtyFirst => "Dirty↑",
|
||||
Self::BehindFirst=> "Behind↑",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A user-defined command shortcut shown in the HubSettings tab and runnable
|
||||
/// against any repo. Persisted verbatim to `~/.vulp/templates.toml` — these
|
||||
/// field names are part of that on-disk format, so don't rename them without
|
||||
/// adding a `#[serde(rename = "...")]` alias for backward compatibility.
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RepoTemplate {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub commands: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Modal types for the hub: what's currently being asked of the user
|
||||
//! (`Modal`), and the per-modal-kind tag enums that record *what for*, so
|
||||
//! `process_modal_confirm` (in `modals.rs`) knows which action to run once
|
||||
//! the user confirms.
|
||||
|
||||
/// One modal currently on screen, or `None` if nothing is open. Each variant
|
||||
/// is a distinct input shape (free text, a picker list, a checklist, a yes/no
|
||||
/// prompt, a scrollable read-only message, or a rename/conversion preview);
|
||||
/// the `tag` field on the input variants records which specific flow is using
|
||||
/// that shape, since e.g. `Text` is reused for dozens of unrelated prompts.
|
||||
#[derive(Clone)]
|
||||
pub enum Modal {
|
||||
None,
|
||||
Text { title: String, prompt: String, value: String, tag: TextTag, max_len: Option<usize> },
|
||||
Select { title: String, options: Vec<String>, cursor: usize, tag: SelectTag },
|
||||
Multi { title: String, options: Vec<String>, checks: Vec<bool>, cursor: usize, tag: MultiTag },
|
||||
Confirm { message: String, tag: ConfirmTag },
|
||||
Info { title: String, lines: Vec<String>, scroll: usize },
|
||||
// Dry-run preview for a codeintel rename / naming-convention conversion —
|
||||
// Enter/y applies `plan`, Esc/n/q discards it untouched.
|
||||
RenamePreview { title: String, lines: Vec<String>, scroll: usize, plan: crate::codeintel::RenamePlan },
|
||||
}
|
||||
|
||||
impl Modal {
|
||||
pub fn is_some(&self) -> bool { !matches!(self, Modal::None) }
|
||||
}
|
||||
|
||||
/// What a `Modal::Text` prompt is *for* — read by `process_modal_confirm` to
|
||||
/// route the typed value to the right place once the user presses Enter.
|
||||
#[derive(Clone)]
|
||||
pub enum TextTag {
|
||||
Label(usize),
|
||||
ProjName,
|
||||
ProjRenameName(usize),
|
||||
TodoTitle,
|
||||
TodoDesc(String),
|
||||
TodoEditTitle(usize),
|
||||
TodoEditDesc(usize),
|
||||
CloneUrl,
|
||||
InitName,
|
||||
LinkPath,
|
||||
// hub sync
|
||||
SyncMsg,
|
||||
// hub settings editing
|
||||
SetEditor,
|
||||
SetClipboard,
|
||||
SetWorkspace,
|
||||
// import directory
|
||||
ImportDir,
|
||||
// template creation
|
||||
NewTemplateName,
|
||||
NewTemplateCmds(String), // template name
|
||||
// profiles & sync
|
||||
ProfileSaveName,
|
||||
ProfileImportJson,
|
||||
// Quick Actions panel — new PR / new issue, multi-step prompts
|
||||
PrRepo,
|
||||
PrTitle(String),
|
||||
PrHead(String, String),
|
||||
PrBase(String, String, String),
|
||||
IssueRepo,
|
||||
IssueTitle(String),
|
||||
// Code intelligence (Stats tab, [m]) — find usages / rename, scoped to one repo
|
||||
CodeIntelFindUsages { repo: std::path::PathBuf, name: String },
|
||||
CodeIntelRenameOld { repo: std::path::PathBuf, name: String },
|
||||
CodeIntelRenameNew { repo: std::path::PathBuf, name: String, old: String },
|
||||
}
|
||||
|
||||
/// One entry in the contextual `[m]` action menu. Most actions already have
|
||||
/// a key binding, so they re-dispatch a synthesized `KeyEvent` through
|
||||
/// `handle_global_key` — zero logic duplication. The remaining variants cover
|
||||
/// flows that have no direct key (the old Quick Actions entries) or lost
|
||||
/// theirs to the menu itself (CodeIntel's `m` on Stats).
|
||||
#[derive(Clone)]
|
||||
pub enum MenuAction {
|
||||
Key(crossterm::event::KeyEvent),
|
||||
NewPr,
|
||||
NewIssue,
|
||||
OpenAccountProfile,
|
||||
CodeIntelPicker,
|
||||
/// `git stash` / `git stash pop` on the repo under the cursor.
|
||||
StashSave(usize),
|
||||
StashPop(usize),
|
||||
}
|
||||
|
||||
/// What a `Modal::Select` list is *for*, mirroring `TextTag`.
|
||||
#[derive(Clone)]
|
||||
pub enum SelectTag {
|
||||
ProjMenu,
|
||||
ProjForAction(ProjAction),
|
||||
TodoStatus { title: String, desc: String },
|
||||
TodoProject { title: String, desc: String, status_idx: usize },
|
||||
SelectByCondition,
|
||||
SortFilter,
|
||||
NewRepo,
|
||||
// color palette for project create / rename
|
||||
ProjColorCreate(String),
|
||||
ProjColorRename(usize, String),
|
||||
// hub sync action picker
|
||||
SyncAction,
|
||||
// pull-all account picker
|
||||
PullAllAccount,
|
||||
// repo creation: pick owner (personal account vs an org), then template
|
||||
InitOwner(String),
|
||||
InitTemplate { name: String, owner: Option<String> },
|
||||
// template management
|
||||
TemplateMenu(usize),
|
||||
// copy URL: SSH vs HTTPS
|
||||
CopyUrl(std::path::PathBuf),
|
||||
// linking a folder that already has a .git: keep/publish/discard history
|
||||
LinkExisting { src: std::path::PathBuf, name: String },
|
||||
// editor / clipboard command pickers (last option "Custom..." falls back to Modal::Text)
|
||||
SetEditor,
|
||||
SetClipboard,
|
||||
// settings profiles & sync menu — carries how many profile entries lead
|
||||
// the options list, so the handler knows where the fixed actions start
|
||||
ProfileMenu { profile_count: usize },
|
||||
// "Settings only" vs "Settings + workspace structure" — asked before any
|
||||
// snapshot is taken, since the answer is never reachable from a Confirm
|
||||
// modal's "no" branch (Cancel never reaches process_modal_confirm).
|
||||
ProfileScope(ProfileScopeAction),
|
||||
// Contextual `[m]` action menu — options paired 1:1 with the actions
|
||||
ActionMenu(Vec<MenuAction>),
|
||||
// Code intelligence (Stats tab, [m])
|
||||
CodeIntelRepoPick,
|
||||
CodeIntelAction { repo: std::path::PathBuf, name: String },
|
||||
CodeIntelConvention { repo: std::path::PathBuf, name: String },
|
||||
}
|
||||
|
||||
/// Which settings snapshot a `SelectTag::ProfileScope` answer applies to.
|
||||
#[derive(Clone)]
|
||||
pub enum ProfileScopeAction {
|
||||
SaveProfile(String),
|
||||
CopyJson,
|
||||
PushRepo,
|
||||
}
|
||||
|
||||
/// Which bulk operation a `SelectTag::ProjForAction` picker is collecting repos for.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum ProjAction { Assign, Rename, Delete }
|
||||
|
||||
/// What a `Modal::Multi` checklist is *for*, mirroring `TextTag`/`SelectTag`.
|
||||
#[derive(Clone)]
|
||||
pub enum MultiTag {
|
||||
ProjAssign(usize),
|
||||
// bulk import: which repo categories to pull, carries the account index
|
||||
BulkImportAffiliation(usize),
|
||||
}
|
||||
|
||||
/// What a `Modal::Confirm` yes/no prompt is *for*, mirroring `TextTag`/`SelectTag`.
|
||||
#[derive(Clone)]
|
||||
pub enum ConfirmTag {
|
||||
DeleteProject(usize),
|
||||
DeleteTodo(usize),
|
||||
RemoveGitAll,
|
||||
DeleteTemplate(usize),
|
||||
}
|
||||
|
||||
/// Outcome of one key event while a modal is open, returned by
|
||||
/// `handle_modal_key` (in `modals.rs`) to tell the caller whether to keep
|
||||
/// editing, run `process_modal_confirm`, or drop the modal untouched.
|
||||
pub enum ModalAction { Pending, Confirm, Cancel }
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Row-addressing types: which concrete item a position in the hub's visible
|
||||
//! list (`DisplayRow`) or a queued confirmation (`PendingAction`) refers to,
|
||||
//! plus the per-repo "focused extra" data shown for whichever row the cursor
|
||||
//! is currently on.
|
||||
|
||||
/// One row in the currently-displayed list (`HubState::display`). Which tab
|
||||
/// is active determines which variant appears — a given `display` list is
|
||||
/// always one variant, never mixed.
|
||||
#[derive(Clone)]
|
||||
pub enum DisplayRow {
|
||||
Repo { repo_idx: usize },
|
||||
Project { proj_idx: usize },
|
||||
Todo { todo_idx: usize },
|
||||
Pr { pr_idx: usize },
|
||||
Issue { issue_idx: usize },
|
||||
}
|
||||
|
||||
/// A sync/release action the user confirmed but that needs the confirm modal
|
||||
/// closed first before `tui_phase` actually runs it, so the dialog isn't
|
||||
/// still on screen while the (potentially slow) operation executes.
|
||||
#[derive(Clone)]
|
||||
pub enum PendingAction { Release }
|
||||
|
||||
/// Background diff fetch for the currently-focused repo: `Loading` while the
|
||||
/// thread spawned by `HubState::update_focused_extra` is still running,
|
||||
/// `Ready` once it reports back.
|
||||
pub enum DiffState {
|
||||
Loading,
|
||||
Ready(Vec<String>),
|
||||
}
|
||||
|
||||
/// Expensive per-repo data (recent commits, file count, working-tree diff)
|
||||
/// computed lazily for whichever repo the cursor is hovering. Cached by
|
||||
/// `repo_idx` in `HubState::focused_cache` and never cleared on scroll —
|
||||
/// only a manual refresh recomputes it, so revisiting a repo is instant.
|
||||
pub struct FocusedExtra {
|
||||
#[allow(dead_code)] pub commits: Vec<(String, String)>, // kept: focused-repo data model
|
||||
pub file_count: usize,
|
||||
pub diff_preview: DiffState,
|
||||
}
|
||||
+17
-855
@@ -1,276 +1,34 @@
|
||||
use crate::status::format_age_pub;
|
||||
use crate::todos::TodoSource;
|
||||
//! Dispatch for the hub's per-tab rendering: routes to either a full-screen
|
||||
//! dashboard tab (Overview/Stats/Activity/HubSettings, in the sibling
|
||||
//! `tabs_*` files) or a scrollable table (Repos/Projects/Todos/PRs/Issues,
|
||||
//! in `tabs_tables`).
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Cell, Paragraph, Row, Table},
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
Frame,
|
||||
};
|
||||
use super::state::{DisplayRow, HubState, Tab};
|
||||
use super::state::{HubState, Tab};
|
||||
use super::tabs_overview::render_overview_global;
|
||||
use super::tabs_settings::render_settings_global;
|
||||
use super::tabs_stats::{render_activity_global, render_stats_global};
|
||||
use super::tabs_tables::{
|
||||
render_issues_table, render_prs_table, render_projects_table, render_repos_table, render_todos_table,
|
||||
};
|
||||
|
||||
/// Color-codes a timestamp's age for "freshness at a glance" — green/yellow/
|
||||
/// red roughly matching how stale a repo's last commit looks in practice.
|
||||
pub fn age_color(ts: i64) -> Color {
|
||||
let days = (chrono::Utc::now().timestamp() - ts) / 86400;
|
||||
if days < 7 { Color::Green } else if days < 30 { Color::Yellow } else { Color::Red }
|
||||
}
|
||||
|
||||
pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![
|
||||
" ", "NAME", "BRANCH", "PROJECT", "VISIB", "AGE", "STATUS", "RELEASE", "LICENSE", "STARS",
|
||||
]).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Repo { repo_idx: ri } = row else { return None; };
|
||||
let r = &state.repos[*ri];
|
||||
let is_cur = di == cur_di;
|
||||
let is_sel = state.selected.contains(ri);
|
||||
|
||||
let check_sty = if is_sel { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) };
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
||||
|
||||
let proj_name = state.repo_project(*ri).map(|p| p.name.as_str()).unwrap_or("—");
|
||||
let proj_color = state.repo_proj_color(*ri).unwrap_or(Color::DarkGray);
|
||||
|
||||
let vis = r.visibility.as_deref().unwrap_or("?");
|
||||
let rel = r.last_tag.as_deref().unwrap_or("—");
|
||||
let now_ts = chrono::Utc::now().timestamp();
|
||||
let is_stale = (now_ts - r.last_commit_ts) / 86400 > 90;
|
||||
let d_sym = if r.is_dirty { "⚠" } else if is_stale { "~" } else { "·" };
|
||||
let a_str = if r.ahead > 0 { format!("+{}", r.ahead) } else { String::new() };
|
||||
let b_str = if r.behind > 0 { format!("-{}", r.behind) } else { String::new() };
|
||||
let status_str = format!("{} {}{}", d_sym, a_str, b_str);
|
||||
let name_col = if is_cur || (!r.is_dirty && r.ahead == 0 && r.behind == 0) {
|
||||
Color::White
|
||||
} else {
|
||||
Color::Yellow
|
||||
};
|
||||
let lic_str = if r.license { "✓" } else { "—" };
|
||||
let stars_str = r.stars.map(|s| format!("{}", s)).unwrap_or_else(|| "—".into());
|
||||
let stars_col = if r.stars.is_some() { Color::Yellow } else { Color::DarkGray };
|
||||
let is_pinned = state.pins.contains(&r.path);
|
||||
let prefix = match (is_pinned, r.bookmarked) {
|
||||
(true, true) => "◆★",
|
||||
(true, false) => "◆ ",
|
||||
(false, true) => "★ ",
|
||||
(false, false)=> " ",
|
||||
};
|
||||
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!("{} {}", if is_cur { "▶" } else { " " }, if is_sel { "[✓]" } else { "[ ]" })).style(check_sty),
|
||||
Cell::from(format!("{}{}", prefix, r.name)).style(Style::default().fg(name_col)),
|
||||
Cell::from(r.branch.chars().take(9).collect::<String>()),
|
||||
Cell::from(proj_name.chars().take(21).collect::<String>()).style(Style::default().fg(proj_color)),
|
||||
Cell::from(vis).style(match vis { "public" => Style::default().fg(Color::Green), "private" => Style::default().fg(Color::Yellow), _ => Style::default().fg(Color::DarkGray) }),
|
||||
Cell::from(r.last_commit_age.clone()).style(Style::default().fg(age_color(r.last_commit_ts))),
|
||||
Cell::from(status_str).style(if r.is_dirty || r.behind > 0 { Style::default().fg(Color::Yellow) } else if is_stale { Style::default().fg(Color::Magenta).add_modifier(Modifier::DIM) } else { Style::default().fg(Color::DarkGray) }),
|
||||
Cell::from(rel.chars().take(7).collect::<String>()).style(Style::default().fg(Color::Cyan)),
|
||||
Cell::from(lic_str).style(Style::default().fg(if r.license { Color::Green } else { Color::DarkGray })),
|
||||
Cell::from(stars_str).style(Style::default().fg(stars_col)),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let widths = [
|
||||
Constraint::Length(6),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(22),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(8),
|
||||
];
|
||||
f.render_widget(Table::new(rows, widths).header(header), area);
|
||||
}
|
||||
|
||||
pub fn render_projects_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![" ", "PROJECT", "COLOR", "REPOS", "DIRTY", "BEHIND"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Project { proj_idx: pi } = row else { return None; };
|
||||
let p = &state.projects[*pi];
|
||||
let all_ri = state.project_repos(*pi);
|
||||
let dirty_n = all_ri.iter().filter(|&&ri| state.repos[ri].is_dirty).count();
|
||||
let behind_n = all_ri.iter().filter(|&&ri| state.repos[ri].behind > 0).count();
|
||||
let all_sel = !all_ri.is_empty() && all_ri.iter().all(|ri| state.selected.contains(ri));
|
||||
let any_sel = all_ri.iter().any(|ri| state.selected.contains(ri));
|
||||
let is_cur = di == cur_di;
|
||||
let color = state.proj_color(*pi);
|
||||
|
||||
let check_sty = if all_sel || any_sel { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) };
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else if behind_n > 0 { Style::default().fg(Color::Red) } else if dirty_n > 0 { Style::default().fg(Color::Yellow) } else { Style::default() };
|
||||
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!("{} {}", if is_cur { "▶" } else { " " }, if all_sel { "[✓]" } else if any_sel { "[◈]" } else { "[ ]" })).style(check_sty),
|
||||
Cell::from(p.name.clone()).style(Style::default().fg(color).add_modifier(Modifier::BOLD)),
|
||||
Cell::from(Line::from(vec![
|
||||
Span::styled("██ ", Style::default().fg(color)),
|
||||
Span::styled(p.color.clone(), Style::default().fg(color)),
|
||||
])),
|
||||
Cell::from(all_ri.len().to_string()),
|
||||
Cell::from(if dirty_n > 0 { dirty_n.to_string() } else { "·".into() }).style(if dirty_n > 0 { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::DarkGray) }),
|
||||
Cell::from(if behind_n > 0 { behind_n.to_string() } else { "·".into() }).style(if behind_n > 0 { Style::default().fg(Color::Red) } else { Style::default().fg(Color::DarkGray) }),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(Table::new(rows, [Constraint::Length(6), Constraint::Fill(1), Constraint::Length(9), Constraint::Length(7), Constraint::Length(6), Constraint::Length(7)]).header(header), area);
|
||||
}
|
||||
|
||||
pub fn render_todos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![" SRC", " STATUS", "TITLE", "REPO", "PROJECT", "EST", "DIFF", "CREATED"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Todo { todo_idx: ti } = row else { return None; };
|
||||
let t = &state.todos[*ti];
|
||||
let is_cur = di == cur_di;
|
||||
let status_color = t.status.color();
|
||||
let proj_name = t.project.as_deref().unwrap_or("—");
|
||||
let repo_name = t.repo.as_deref().unwrap_or("—");
|
||||
let created = format_age_pub(t.created_at);
|
||||
let est_str = t.estimate.as_deref().unwrap_or("—").chars().take(10).collect::<String>();
|
||||
let diff_str = match t.difficulty {
|
||||
Some(d) => format!("{}{}", "★".repeat(d.min(5) as usize), "☆".repeat(5usize.saturating_sub(d as usize))),
|
||||
None => "—".into(),
|
||||
};
|
||||
let title_short: String = if t.source == TodoSource::CodeScan {
|
||||
let raw = &t.title;
|
||||
let trimmed = if let Some(end) = raw.find(']') { raw[end+1..].trim() } else { raw.as_str() };
|
||||
trimmed.chars().take(55).collect()
|
||||
} else {
|
||||
t.title.chars().take(55).collect()
|
||||
};
|
||||
let src_icon = t.source.icon();
|
||||
let src_col = match t.source { TodoSource::Manual => Color::Cyan, TodoSource::CodeScan => Color::DarkGray };
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!(" {}", src_icon)).style(Style::default().fg(src_col)),
|
||||
Cell::from(format!(" {} {}", t.status.icon(), t.status.label()))
|
||||
.style(Style::default().fg(status_color)),
|
||||
Cell::from(title_short),
|
||||
Cell::from(repo_name).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(proj_name).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(est_str).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(diff_str).style(Style::default().fg(Color::Yellow)),
|
||||
Cell::from(created).style(Style::default().fg(Color::DarkGray)),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(4),
|
||||
Constraint::Length(14),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(7),
|
||||
Constraint::Length(10),
|
||||
]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn render_prs_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![" REPO", "#", "AGE", "AUTHOR", "TITLE", "DRAFT"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Pr { idx } = row else { return None; };
|
||||
let pr = &state.prs[*idx];
|
||||
let is_cur = di == cur_di;
|
||||
let age = format_age_pub(pr.updated_ts);
|
||||
let draft_s = if pr.draft { "draft" } else { "" };
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!(" {}", pr.repo.chars().take(14).collect::<String>()))
|
||||
.style(Style::default().fg(Color::Cyan)),
|
||||
Cell::from(format!("#{}", pr.number)).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(age).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(format!("@{}", pr.author.chars().take(12).collect::<String>()))
|
||||
.style(Style::default().fg(Color::Green)),
|
||||
Cell::from(pr.title.chars().take(60).collect::<String>()),
|
||||
Cell::from(draft_s).style(Style::default().fg(Color::DarkGray)),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(16), Constraint::Length(7), Constraint::Length(10),
|
||||
Constraint::Length(14), Constraint::Fill(1), Constraint::Length(6),
|
||||
]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn render_issues_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![" REPO", "#", "AGE", "AUTHOR", "TITLE", "CMT"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Issue { idx } = row else { return None; };
|
||||
let issue = &state.issues[*idx];
|
||||
let is_cur = di == cur_di;
|
||||
let age = format_age_pub(issue.updated_ts);
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!(" {}", issue.repo.chars().take(14).collect::<String>()))
|
||||
.style(Style::default().fg(Color::Cyan)),
|
||||
Cell::from(format!("#{}", issue.number)).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(age).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(format!("@{}", issue.author.chars().take(12).collect::<String>()))
|
||||
.style(Style::default().fg(Color::Yellow)),
|
||||
Cell::from(issue.title.chars().take(60).collect::<String>()),
|
||||
Cell::from(issue.comments.to_string()).style(Style::default().fg(Color::DarkGray)),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(16), Constraint::Length(7), Constraint::Length(10),
|
||||
Constraint::Length(14), Constraint::Fill(1), Constraint::Length(5),
|
||||
]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn render_list(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
// New full-screen tabs don't use the list
|
||||
match state.tab {
|
||||
Tab::Overview => { render_overview_global(f, state, area); return; }
|
||||
Tab::Stats => { render_stats_global(f, state, area); return; }
|
||||
Tab::Activity => { render_activity_global(f, state, area); return; }
|
||||
Tab::HubSettings => { render_settings_global(f, state, area); return; }
|
||||
_ => {}
|
||||
}
|
||||
@@ -296,599 +54,3 @@ pub fn render_list(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overview global tab ───────────────────────────────────────────────────────
|
||||
|
||||
pub fn render_overview_global(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
use ratatui::widgets::{Block, Borders};
|
||||
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let cyn = Style::default().fg(Color::Cyan);
|
||||
let grn = Style::default().fg(Color::Green);
|
||||
let red = Style::default().fg(Color::Red);
|
||||
let wht = Style::default().fg(Color::White);
|
||||
|
||||
// ── Shared stats ─────────────────────────────────────────────────────────
|
||||
let total_repos = state.repos.len();
|
||||
let dirty_c = state.repos.iter().filter(|r| r.is_dirty).count();
|
||||
let behind_c = state.repos.iter().filter(|r| r.behind > 0).count();
|
||||
let ahead_c = state.repos.iter().filter(|r| r.ahead > 0).count();
|
||||
let no_lic_c = state.repos.iter().filter(|r| !r.license).count();
|
||||
let open_todos = state.todos.iter().filter(|t| matches!(t.status, crate::todos::TodoStatus::Open)).count();
|
||||
let inprog_todos = state.todos.iter().filter(|t| matches!(t.status, crate::todos::TodoStatus::InProgress)).count();
|
||||
let done_todos = state.todos.iter().filter(|t| matches!(t.status, crate::todos::TodoStatus::Done)).count();
|
||||
let canc_todos = state.todos.iter().filter(|t| matches!(t.status, crate::todos::TodoStatus::Cancelled)).count();
|
||||
|
||||
// ── Bento rows: 32% / 42% / 26% ─────────────────────────────────────────
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(32), Constraint::Percentage(42), Constraint::Percentage(26)])
|
||||
.split(area);
|
||||
|
||||
// ── Row 1: App/Workspace (75%) + Account (25%) ───────────────────────────
|
||||
let row1 = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(75), Constraint::Percentage(25)])
|
||||
.split(rows[0]);
|
||||
|
||||
// Cell 1 — App / Workspace
|
||||
let app_blk = Block::default().borders(Borders::ALL)
|
||||
.title(Span::styled(" workspace ", yel))
|
||||
.border_style(dim);
|
||||
let app_inner = app_blk.inner(row1[0]);
|
||||
f.render_widget(app_blk, row1[0]);
|
||||
{
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let debug_flag = if state.debug_mode { " [debug]" } else { "" };
|
||||
let ws = state.workspace_root.display().to_string();
|
||||
let ws_short = if ws.starts_with("/home/") {
|
||||
ws.splitn(4, '/').nth(3).map(|s| format!("~/{}", s)).unwrap_or(ws.clone())
|
||||
} else { ws };
|
||||
let proj_c = state.projects.len();
|
||||
let prs_c = state.prs.len();
|
||||
let iss_c = state.issues.len();
|
||||
|
||||
let clean_c = total_repos.saturating_sub(dirty_c + behind_c);
|
||||
let bar_total = 20usize;
|
||||
let clean_n = if total_repos > 0 { bar_total * clean_c / total_repos } else { bar_total };
|
||||
let dirty_n = if total_repos > 0 { bar_total * dirty_c / total_repos } else { 0 };
|
||||
let behind_n = bar_total.saturating_sub(clean_n + dirty_n);
|
||||
let health_bar = format!("{}{}{}",
|
||||
"█".repeat(clean_n),
|
||||
"▓".repeat(dirty_n),
|
||||
"░".repeat(behind_n),
|
||||
);
|
||||
let health_col = if behind_c > 0 { Color::Red } else if dirty_c > 0 { Color::Yellow } else { Color::Green };
|
||||
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" fxg v{}{}", version, debug_flag), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(" workspace ", dim),
|
||||
Span::styled(ws_short, wht),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" Repos: {:>3}", total_repos), wht),
|
||||
Span::styled(format!(" Projects: {:>2}", proj_c), wht),
|
||||
Span::styled(format!(" Todos: {:>2} open", open_todos), if open_todos > 0 { cyn } else { dim }),
|
||||
Span::styled(format!(" PRs: {:>2}", prs_c), if prs_c > 0 { cyn } else { dim }),
|
||||
Span::styled(format!(" Issues: {:>2}", iss_c), if iss_c > 0 { Style::default().fg(Color::Yellow) } else { dim }),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Health ", dim),
|
||||
Span::styled(health_bar, Style::default().fg(health_col)),
|
||||
Span::styled(format!(" clean {}/{}", clean_c, total_repos), Style::default().fg(health_col)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" ↓ behind: {}", behind_c), if behind_c > 0 { red } else { dim }),
|
||||
Span::styled(format!(" ⚠ dirty: {}", dirty_c), if dirty_c > 0 { Style::default().fg(Color::Yellow) } else { dim }),
|
||||
Span::styled(format!(" ↑ ahead: {}", ahead_c), if ahead_c > 0 { grn } else { dim }),
|
||||
]),
|
||||
];
|
||||
|
||||
// Pad remaining space
|
||||
let used = lines.len();
|
||||
let avail = app_inner.height as usize;
|
||||
for _ in used..avail { lines.push(Line::from("")); }
|
||||
|
||||
f.render_widget(Paragraph::new(lines), app_inner);
|
||||
}
|
||||
|
||||
// Cell 2 — Account
|
||||
let acc_blk = Block::default().borders(Borders::ALL)
|
||||
.title(Span::styled(" account ", yel))
|
||||
.border_style(dim);
|
||||
let acc_inner = acc_blk.inner(row1[1]);
|
||||
f.render_widget(acc_blk, row1[1]);
|
||||
{
|
||||
let mut lines: Vec<Line> = vec![];
|
||||
if state.accounts.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" no accounts", dim)));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(" run:", dim)));
|
||||
lines.push(Line::from(Span::styled(" fxg auth import", Style::default().fg(Color::Cyan))));
|
||||
} else {
|
||||
for acc in &state.accounts {
|
||||
let token_ok = crate::auth::get_token(&acc.id()).is_ok();
|
||||
let tok_icon = if token_ok { "✓" } else { "✗" };
|
||||
let tok_sty = if token_ok { grn } else { red };
|
||||
let def_mark = if acc.is_default { " ★" } else { "" };
|
||||
let repo_cnt = state.repos.iter().filter(|r| r.owner.as_deref() == Some(acc.username.as_str())).count();
|
||||
let dirty_cnt = state.repos.iter().filter(|r| r.owner.as_deref() == Some(acc.username.as_str()) && r.is_dirty).count();
|
||||
let beh_cnt = state.repos.iter().filter(|r| r.owner.as_deref() == Some(acc.username.as_str()) && r.behind > 0).count();
|
||||
let pr_cnt = state.prs.iter().filter(|pr| pr.author == acc.username).count();
|
||||
let iss_cnt = state.issues.iter().filter(|i| i.author == acc.username).count();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" @{}{}", acc.username, def_mark), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:<8}", acc.provider), dim),
|
||||
Span::styled(tok_icon, tok_sty),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} repo{}", repo_cnt, if repo_cnt == 1 { "" } else { "s" }), wht),
|
||||
if dirty_cnt > 0 || beh_cnt > 0 {
|
||||
Span::styled(format!(" ↓{} ⚠{}", beh_cnt, dirty_cnt), Style::default().fg(Color::Yellow))
|
||||
} else {
|
||||
Span::styled(" ✓", grn)
|
||||
},
|
||||
]));
|
||||
if pr_cnt > 0 || iss_cnt > 0 {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} PR{} {} issue{}", pr_cnt, if pr_cnt == 1 { "" } else { "s" }, iss_cnt, if iss_cnt == 1 { "" } else { "s" }), dim),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), acc_inner);
|
||||
}
|
||||
|
||||
// ── Row 2: Health (50%) + Recent Activity (50%) ──────────────────────────
|
||||
let row2 = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(rows[1]);
|
||||
|
||||
// Cell 3 — Health Stats
|
||||
let health_blk = Block::default().borders(Borders::ALL)
|
||||
.title(Span::styled(" health ", yel))
|
||||
.border_style(dim);
|
||||
let health_inner = health_blk.inner(row2[0]);
|
||||
f.render_widget(health_blk, row2[0]);
|
||||
{
|
||||
// Language breakdown from global stats (if available)
|
||||
let top_langs: Vec<(String, usize)> = if let Some(gs) = &state.global_stats {
|
||||
gs.lang_dist.iter().take(3).map(|(l, c)| (l.clone(), *c)).collect()
|
||||
} else { vec![] };
|
||||
|
||||
let mut lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" Repos: {:>4}", total_repos), wht),
|
||||
Span::styled(format!(" Projects:{:>3}", state.projects.len()), wht),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" Dirty: {:>4}", dirty_c), if dirty_c > 0 { Style::default().fg(Color::Yellow) } else { dim }),
|
||||
Span::styled(format!(" Behind: {:>3}", behind_c), if behind_c > 0 { red } else { dim }),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" Ahead: {:>4}", ahead_c), if ahead_c > 0 { grn } else { dim }),
|
||||
Span::styled(format!(" No lic: {:>3}", no_lic_c), if no_lic_c > 0 { Style::default().fg(Color::Yellow) } else { dim }),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" Todos open:{:>4}", open_todos), cyn),
|
||||
Span::styled(format!(" In prog:{:>3}", inprog_todos), Style::default().fg(Color::Yellow)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" Done: {:>4}", done_todos), grn),
|
||||
Span::styled(format!(" Cancld: {:>3}", canc_todos), dim),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" PRs: {:>4}", state.prs.len()), cyn),
|
||||
Span::styled(format!(" Issues: {:>3}", state.issues.len()), Style::default().fg(Color::Yellow)),
|
||||
]),
|
||||
];
|
||||
if !top_langs.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(" Languages (top 3)", dim)));
|
||||
for (lang, cnt) in &top_langs {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:<10}", lang.chars().take(10).collect::<String>()), wht),
|
||||
Span::styled(format!("{} repo{}", cnt, if *cnt == 1 { "" } else { "s" }), dim),
|
||||
]));
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), health_inner);
|
||||
}
|
||||
|
||||
// Cell 4 — Recent Activity
|
||||
let act_blk = Block::default().borders(Borders::ALL)
|
||||
.title(Span::styled(" recent activity ", yel))
|
||||
.border_style(dim);
|
||||
let act_inner = act_blk.inner(row2[1]);
|
||||
f.render_widget(act_blk, row2[1]);
|
||||
{
|
||||
let mut sorted: Vec<&crate::status::RepoSummary> = state.repos.iter().collect();
|
||||
sorted.sort_by(|a, b| b.last_commit_ts.cmp(&a.last_commit_ts));
|
||||
let max_rows = act_inner.height as usize;
|
||||
let name_w = (act_inner.width as usize).saturating_sub(22).min(20).max(8);
|
||||
let msg_w = (act_inner.width as usize).saturating_sub(name_w + 10).max(5);
|
||||
let mut lines: Vec<Line> = sorted.iter().take(max_rows).map(|r| {
|
||||
let age_col = age_color(r.last_commit_ts);
|
||||
let name = r.name.chars().take(name_w).collect::<String>();
|
||||
let msg = r.last_commit_msg.chars().take(msg_w).collect::<String>();
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{:>5} ", format_age_pub(r.last_commit_ts)), Style::default().fg(age_col)),
|
||||
Span::styled(format!("{:<width$} ", name, width = name_w), wht),
|
||||
Span::styled(msg, dim),
|
||||
])
|
||||
}).collect();
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" no commits yet", dim)));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), act_inner);
|
||||
}
|
||||
|
||||
// ── Row 3: Todos (25%) + PRs/Issues (75%) ────────────────────────────────
|
||||
let row3 = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
|
||||
.split(rows[2]);
|
||||
|
||||
// Cell 5 — Todos summary
|
||||
let todo_blk = Block::default().borders(Borders::ALL)
|
||||
.title(Span::styled(" todos ", yel))
|
||||
.border_style(dim);
|
||||
let todo_inner = todo_blk.inner(row3[0]);
|
||||
f.render_widget(todo_blk, row3[0]);
|
||||
{
|
||||
let name_w = (todo_inner.width as usize).saturating_sub(4).max(5);
|
||||
let mut lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{:>3} ", open_todos), cyn),
|
||||
Span::styled("open ", dim),
|
||||
Span::styled(format!("{:>3} ", inprog_todos), Style::default().fg(Color::Yellow)),
|
||||
Span::styled("prog", dim),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{:>3} ", done_todos), grn),
|
||||
Span::styled("done ", dim),
|
||||
Span::styled(format!("{:>3} ", canc_todos), dim),
|
||||
Span::styled("canc", dim),
|
||||
]),
|
||||
];
|
||||
// Recent open/in-progress todos
|
||||
let urgent: Vec<&crate::todos::Todo> = state.todos.iter()
|
||||
.filter(|t| matches!(t.status, crate::todos::TodoStatus::Open | crate::todos::TodoStatus::InProgress)
|
||||
&& t.source == TodoSource::Manual)
|
||||
.take((todo_inner.height as usize).saturating_sub(4))
|
||||
.collect();
|
||||
if !urgent.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
for t in urgent {
|
||||
let icon = t.status.icon();
|
||||
let title = t.title.chars().take(name_w).collect::<String>();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("{} ", icon), Style::default().fg(t.status.color())),
|
||||
Span::styled(title, wht),
|
||||
]));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(" [4] Todos", dim)));
|
||||
f.render_widget(Paragraph::new(lines), todo_inner);
|
||||
}
|
||||
|
||||
// Cell 6 — PRs / Issues notifications
|
||||
let notif_blk = Block::default().borders(Borders::ALL)
|
||||
.title(Span::styled(" prs & issues ", yel))
|
||||
.border_style(dim);
|
||||
let notif_inner = notif_blk.inner(row3[1]);
|
||||
f.render_widget(notif_blk, row3[1]);
|
||||
{
|
||||
let max_rows = notif_inner.height.saturating_sub(1) as usize;
|
||||
let title_w = (notif_inner.width as usize).saturating_sub(40).min(40).max(10);
|
||||
|
||||
// Merge PRs and Issues into a unified notification stream, newest first.
|
||||
struct Notif<'a> {
|
||||
kind: &'a str,
|
||||
number: u64,
|
||||
repo: &'a str,
|
||||
title: &'a str,
|
||||
author: &'a str,
|
||||
ts: i64,
|
||||
}
|
||||
let mut notifs: Vec<Notif> = vec![];
|
||||
for pr in &state.prs {
|
||||
notifs.push(Notif { kind: "PR ", number: pr.number as u64, repo: &pr.repo, title: &pr.title, author: &pr.author, ts: pr.created_ts });
|
||||
}
|
||||
for iss in &state.issues {
|
||||
notifs.push(Notif { kind: "ISS", number: iss.number as u64, repo: &iss.repo, title: &iss.title, author: &iss.author, ts: iss.created_ts });
|
||||
}
|
||||
notifs.sort_by(|a, b| b.ts.cmp(&a.ts));
|
||||
|
||||
let mut lines: Vec<Line> = if notifs.is_empty() {
|
||||
vec![Line::from(Span::styled(" no open PRs or issues", dim))]
|
||||
} else {
|
||||
notifs.iter().take(max_rows).map(|n| {
|
||||
let age = format_age_pub(n.ts);
|
||||
let kind_col = if n.kind == "PR " { cyn } else { Style::default().fg(Color::Yellow) };
|
||||
let repo_w = 12usize;
|
||||
let repo = n.repo.chars().take(repo_w).collect::<String>();
|
||||
let title = n.title.chars().take(title_w).collect::<String>();
|
||||
let auth = n.author.chars().take(10).collect::<String>();
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {} #{:<4} ", n.kind, n.number), kind_col),
|
||||
Span::styled(format!("{:<width$} ", repo, width = repo_w), wht),
|
||||
Span::styled(format!("{:<width$} ", title, width = title_w), dim),
|
||||
Span::styled(format!("@{:<10} ", auth), dim),
|
||||
Span::styled(age, dim),
|
||||
])
|
||||
}).collect()
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" [5] PRs [6] Issues", dim),
|
||||
]));
|
||||
f.render_widget(Paragraph::new(lines), notif_inner);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats global tab ──────────────────────────────────────────────────────────
|
||||
|
||||
pub fn render_stats_global(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let cyn = Style::default().fg(Color::Cyan);
|
||||
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||
.split(area);
|
||||
|
||||
// LEFT: bar chart or loading
|
||||
let mut left_lines: Vec<Line> = vec![];
|
||||
|
||||
left_lines.push(Line::from(Span::styled(
|
||||
format!(" Global Statistics [7:{}] [p] change period", state.global_stats_period.label()),
|
||||
yel,
|
||||
)));
|
||||
left_lines.push(Line::from(Span::styled(" ─────────────────────────────────────────────────────────", dim)));
|
||||
|
||||
match &state.global_stats {
|
||||
None if state.global_stats_rx.is_some() => {
|
||||
left_lines.push(Line::from(Span::styled(" Computing stats from all repos (may take a moment)...", cyn)));
|
||||
}
|
||||
None => {
|
||||
left_lines.push(Line::from(Span::styled(" Press [p] or switch to this tab to load stats", dim)));
|
||||
}
|
||||
Some(gs) => {
|
||||
left_lines.push(Line::from(vec![
|
||||
Span::styled(format!(" Total commits this period: {}", gs.total_commits), Style::default().fg(Color::White)),
|
||||
]));
|
||||
left_lines.push(Line::from(""));
|
||||
|
||||
// Simple text bar chart
|
||||
let max_count: usize = gs.activity.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1);
|
||||
let bar_h = (area.height as usize).saturating_sub(10).max(5);
|
||||
let data = &gs.activity;
|
||||
let n = data.len();
|
||||
let bar_w = (cols[0].width as usize).saturating_sub(8).max(1);
|
||||
let step = if n > bar_w { (n / bar_w).max(1) } else { 1 };
|
||||
let sampled: Vec<&(String, usize)> = data.iter().step_by(step).collect();
|
||||
let bar_count = sampled.len().min(bar_w);
|
||||
// Stretch bars to fill available width when few data points
|
||||
let bar_width = if bar_count == 0 { 1 } else { (bar_w / bar_count).max(1) };
|
||||
|
||||
for row in (0..bar_h).rev() {
|
||||
let threshold = (row * max_count) / bar_h;
|
||||
let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
|
||||
let y_label = if row == bar_h - 1 { format!("{:>4} ", max_count) } else if row == 0 { " 0 ".to_string() } else { " ".to_string() };
|
||||
spans.push(Span::styled(y_label, dim));
|
||||
for j in 0..bar_count {
|
||||
let count = sampled.get(j).map(|(_, c)| *c).unwrap_or(0);
|
||||
let filled = count > threshold;
|
||||
for _ in 0..bar_width {
|
||||
if filled { spans.push(Span::styled("█", cyn)); }
|
||||
else { spans.push(Span::styled("░", dim)); }
|
||||
}
|
||||
}
|
||||
left_lines.push(Line::from(spans));
|
||||
}
|
||||
// X labels: one per bar, each bar_width chars wide
|
||||
let nth = (bar_count / 5).max(1);
|
||||
let mut x_spans: Vec<Span> = vec![Span::raw(" ")];
|
||||
for j in 0..bar_count {
|
||||
let label = if j % nth == 0 {
|
||||
let raw = sampled.get(j)
|
||||
.map(|(d, _)| d.chars().skip(5).take(bar_width.min(5)).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
format!("{:<width$}", raw, width = bar_width)
|
||||
} else {
|
||||
" ".repeat(bar_width)
|
||||
};
|
||||
x_spans.push(Span::styled(label, dim));
|
||||
}
|
||||
left_lines.push(Line::from(x_spans));
|
||||
}
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(left_lines), cols[0]);
|
||||
|
||||
// RIGHT: language breakdown + top repos
|
||||
let mut right_lines: Vec<Line> = vec![];
|
||||
|
||||
right_lines.push(Line::from(Span::styled(" Languages", yel)));
|
||||
right_lines.push(Line::from(Span::styled(" ────────────────────────────────", dim)));
|
||||
match &state.global_stats {
|
||||
None => { right_lines.push(Line::from(Span::styled(" Loading...", dim))); }
|
||||
Some(gs) => {
|
||||
let total_repos = state.repos.len().max(1);
|
||||
for (lang, cnt) in gs.lang_dist.iter().take(8) {
|
||||
let bar_len = (cnt * 16) / total_repos;
|
||||
let bar = "█".repeat(bar_len) + &"░".repeat(16usize.saturating_sub(bar_len));
|
||||
right_lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:<8} ", lang.chars().take(8).collect::<String>()), Style::default().fg(Color::White)),
|
||||
Span::styled(bar, cyn),
|
||||
Span::styled(format!(" {}", cnt), dim),
|
||||
]));
|
||||
}
|
||||
right_lines.push(Line::from(""));
|
||||
|
||||
right_lines.push(Line::from(Span::styled(" Top Repos (commits)", yel)));
|
||||
right_lines.push(Line::from(Span::styled(" ────────────────────────────────", dim)));
|
||||
for (name, cnt) in gs.top_repos.iter().take(5) {
|
||||
right_lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:<18} ", name.chars().take(18).collect::<String>()), Style::default().fg(Color::White)),
|
||||
Span::styled(format!("{}", cnt), cyn),
|
||||
]));
|
||||
}
|
||||
right_lines.push(Line::from(""));
|
||||
right_lines.push(Line::from(vec![
|
||||
Span::styled(" Health: ", dim),
|
||||
Span::styled(format!("{}↓ {}⚠", gs.behind_count, gs.dirty_count), Style::default().fg(if gs.behind_count > 0 || gs.dirty_count > 0 { Color::Yellow } else { Color::Green })),
|
||||
]));
|
||||
right_lines.push(Line::from(""));
|
||||
|
||||
right_lines.push(Line::from(Span::styled(" Day of Week", yel)));
|
||||
right_lines.push(Line::from(Span::styled(" ────────────────────────────────", dim)));
|
||||
let day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
let max_day = gs.day_pattern.iter().copied().max().unwrap_or(1).max(1);
|
||||
for (i, &cnt) in gs.day_pattern.iter().enumerate() {
|
||||
let bar_len = (cnt * 16) / max_day;
|
||||
let bar: String = "█".repeat(bar_len) + &"░".repeat(16usize.saturating_sub(bar_len));
|
||||
right_lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} ", day_names[i]), Style::default().fg(Color::White)),
|
||||
Span::styled(bar, cyn),
|
||||
Span::styled(format!(" {}", cnt), dim),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(right_lines), cols[1]);
|
||||
}
|
||||
|
||||
// ── Settings global tab ───────────────────────────────────────────────────────
|
||||
|
||||
pub fn render_settings_global(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let wht = Style::default().fg(Color::White);
|
||||
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||
.split(area);
|
||||
|
||||
let mut left_lines: Vec<Line> = vec![];
|
||||
left_lines.push(Line::from(Span::styled(" Workspace Settings", yel)));
|
||||
left_lines.push(Line::from(Span::styled(" ─────────────────────────────────────────────", dim)));
|
||||
|
||||
struct Item { label: &'static str, value: String }
|
||||
let editor = state.editor.clone();
|
||||
let clip = state.clipboard_cmd.clone();
|
||||
let workspace = state.workspace_root.to_string_lossy().to_string();
|
||||
|
||||
let acc_count = state.accounts.len().to_string();
|
||||
let items: &[Item] = &[
|
||||
Item { label: "Workspace root", value: workspace },
|
||||
Item { label: "Editor", value: editor },
|
||||
Item { label: "Clipboard cmd", value: clip },
|
||||
Item { label: "Total repos", value: state.repos.len().to_string() },
|
||||
Item { label: "Total projects", value: state.projects.len().to_string() },
|
||||
Item { label: "Total todos", value: state.todos.len().to_string() },
|
||||
Item { label: "Accounts", value: acc_count },
|
||||
Item { label: "Pull all GitHub repos", value: "[Enter] clone/pull all from account".into() },
|
||||
Item { label: "Import directory", value: "[Enter] git init + link all subfolders".into() },
|
||||
Item { label: "Remove .git (all)", value: "[Enter] ⚠ de-git all repos in workspace".into() },
|
||||
];
|
||||
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let sel = i == state.hub_settings_cur;
|
||||
let bg = if sel { Color::DarkGray } else { Color::Reset };
|
||||
left_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:>2}. ", i + 1),
|
||||
Style::default().fg(if sel { Color::Cyan } else { Color::DarkGray }).bg(bg),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{:<20}", item.label),
|
||||
Style::default().fg(Color::White).bg(bg).add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }),
|
||||
),
|
||||
Span::styled(
|
||||
item.value.chars().take(35).collect::<String>(),
|
||||
Style::default().fg(Color::DarkGray).bg(bg),
|
||||
),
|
||||
]));
|
||||
}
|
||||
left_lines.push(Line::from(""));
|
||||
|
||||
// Templates section
|
||||
left_lines.push(Line::from(Span::styled(" Templates", yel)));
|
||||
left_lines.push(Line::from(Span::styled(" ─────────────────────────────────────────────", dim)));
|
||||
|
||||
let new_tmpl_sel = state.hub_settings_cur == 10;
|
||||
let new_bg = if new_tmpl_sel { Color::DarkGray } else { Color::Reset };
|
||||
left_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:>2}. ", 11),
|
||||
Style::default().fg(if new_tmpl_sel { Color::Cyan } else { Color::DarkGray }).bg(new_bg),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{:<20}", "[+] New template"),
|
||||
Style::default().fg(Color::Green).bg(new_bg).add_modifier(if new_tmpl_sel { Modifier::BOLD } else { Modifier::empty() }),
|
||||
),
|
||||
]));
|
||||
|
||||
for (ti, t) in state.templates.iter().enumerate() {
|
||||
let idx = 11 + ti;
|
||||
let sel = idx == state.hub_settings_cur;
|
||||
let bg = if sel { Color::DarkGray } else { Color::Reset };
|
||||
let cmd_n = t.commands.len();
|
||||
let cmd_s = match cmd_n {
|
||||
0 => "(no commands)".to_string(),
|
||||
1 => "1 command".to_string(),
|
||||
n => format!("{} commands", n),
|
||||
};
|
||||
left_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:>2}. ", 12 + ti),
|
||||
Style::default().fg(if sel { Color::Cyan } else { Color::DarkGray }).bg(bg),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{:<20}", t.name.chars().take(20).collect::<String>()),
|
||||
Style::default().fg(Color::White).bg(bg).add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }),
|
||||
),
|
||||
Span::styled(
|
||||
cmd_s,
|
||||
Style::default().fg(Color::DarkGray).bg(bg),
|
||||
),
|
||||
]));
|
||||
}
|
||||
if state.templates.is_empty() {
|
||||
left_lines.push(Line::from(Span::styled(" (no templates yet)", dim)));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(left_lines), cols[0]);
|
||||
|
||||
let mut right_lines: Vec<Line> = vec![];
|
||||
right_lines.push(Line::from(Span::styled(" Hints", yel)));
|
||||
right_lines.push(Line::from(Span::styled(" ──────────────────────────────", dim)));
|
||||
right_lines.push(Line::from(Span::styled(" ↑↓ / jk navigate", dim)));
|
||||
right_lines.push(Line::from(Span::styled(" Enter edit / run item", dim)));
|
||||
right_lines.push(Line::from(Span::styled(" D delete template", dim)));
|
||||
right_lines.push(Line::from(""));
|
||||
right_lines.push(Line::from(Span::styled(" Items 2-3: editable in session.", dim)));
|
||||
right_lines.push(Line::from(Span::styled(" Item 8: pull all from GitHub.", dim)));
|
||||
right_lines.push(Line::from(Span::styled(" Item 9: git init + link dir.", dim)));
|
||||
right_lines.push(Line::from(Span::styled(" Item 10: remove .git ⚠", dim)));
|
||||
right_lines.push(Line::from(""));
|
||||
right_lines.push(Line::from(Span::styled(" Templates run on repo create.", dim)));
|
||||
right_lines.push(Line::from(Span::styled(" Cmds: semicolon-separated.", dim)));
|
||||
right_lines.push(Line::from(""));
|
||||
right_lines.push(Line::from(Span::styled(" Auth: fxg auth import", dim)));
|
||||
|
||||
f.render_widget(Paragraph::new(right_lines), cols[1]);
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
//! The hub's Overview tab: a bento-box dashboard (workspace info in columns,
|
||||
//! general settings, a merged activity feed, then todos/PRs/issues)
|
||||
//! summarizing the whole workspace at a glance, rather than listing
|
||||
//! individual rows like the other tabs.
|
||||
|
||||
use crate::status::format_age_pub;
|
||||
use crate::todos::TodoSource;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
use super::state::HubState;
|
||||
use super::tabs::age_color;
|
||||
|
||||
/// Color used for an activity-kind label in the merged feed.
|
||||
fn kind_color(kind: crate::activity::ActivityKind) -> Color {
|
||||
use crate::activity::ActivityKind as K;
|
||||
match kind {
|
||||
K::Commit => Color::Green,
|
||||
K::Push => Color::Cyan,
|
||||
K::Pull => Color::Blue,
|
||||
K::Release => Color::Magenta,
|
||||
K::NewRepo => Color::Green,
|
||||
K::Todo => Color::Yellow,
|
||||
K::Pr => Color::Cyan,
|
||||
K::Issue => Color::Yellow,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_overview_global(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
use ratatui::widgets::{Block, Borders};
|
||||
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let cyn = Style::default().fg(Color::Cyan);
|
||||
let grn = Style::default().fg(Color::Green);
|
||||
let red = Style::default().fg(Color::Red);
|
||||
let wht = Style::default().fg(Color::White);
|
||||
|
||||
// ── Shared stats ─────────────────────────────────────────────────────────
|
||||
let total_repos = state.repos.len();
|
||||
let dirty_c = state.repos.iter().filter(|r| r.is_dirty).count();
|
||||
let behind_c = state.repos.iter().filter(|r| r.behind > 0).count();
|
||||
let ahead_c = state.repos.iter().filter(|r| r.ahead > 0).count();
|
||||
let no_lic_c = state.repos.iter().filter(|r| !r.has_license).count();
|
||||
let open_todos = state.todos.iter().filter(|t| matches!(t.status, crate::todos::TodoStatus::Open)).count();
|
||||
let inprog_todos = state.todos.iter().filter(|t| matches!(t.status, crate::todos::TodoStatus::InProgress)).count();
|
||||
let done_todos = state.todos.iter().filter(|t| matches!(t.status, crate::todos::TodoStatus::Done)).count();
|
||||
let canc_todos = state.todos.iter().filter(|t| matches!(t.status, crate::todos::TodoStatus::Cancelled)).count();
|
||||
|
||||
// ── Bento rows: 36% / 38% / 26% ─────────────────────────────────────────
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(36), Constraint::Percentage(38), Constraint::Percentage(26)])
|
||||
.split(area);
|
||||
|
||||
// ── Row 1: Workspace (75%) + General Settings (25%) ──────────────────────
|
||||
let row1 = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(75), Constraint::Percentage(25)])
|
||||
.split(rows[0]);
|
||||
|
||||
// Cell 1 — Workspace: header line, then three inner columns
|
||||
let app_blk = Block::default().borders(Borders::ALL)
|
||||
.title(Span::styled(" workspace ", yel))
|
||||
.title_alignment(Alignment::Center)
|
||||
.border_style(dim);
|
||||
let app_inner = app_blk.inner(row1[0]);
|
||||
f.render_widget(app_blk, row1[0]);
|
||||
{
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let debug_flag = if state.debug_mode { " [debug]" } else { "" };
|
||||
let ws = state.workspace_root.display().to_string();
|
||||
let ws_short = if ws.starts_with("/home/") {
|
||||
ws.splitn(4, '/').nth(3).map(|s| format!("~/{}", s)).unwrap_or(ws.clone())
|
||||
} else { ws };
|
||||
|
||||
// Header line spans the whole tile, columns start below it.
|
||||
let header_area = Rect { height: 1, ..app_inner };
|
||||
f.render_widget(Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" vulp v{}{}", version, debug_flag), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(" workspace ", dim),
|
||||
Span::styled(ws_short, wht),
|
||||
])), header_area);
|
||||
|
||||
let body = Rect {
|
||||
y: app_inner.y + 2,
|
||||
height: app_inner.height.saturating_sub(2),
|
||||
..app_inner
|
||||
};
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(30), Constraint::Percentage(35), Constraint::Percentage(35),
|
||||
])
|
||||
.split(body);
|
||||
|
||||
// Col A — general counts, one stat per line, then health
|
||||
{
|
||||
let acct_names: std::collections::HashSet<&str> =
|
||||
state.accounts.iter().map(|a| a.username.as_str()).collect();
|
||||
let orgs: std::collections::HashSet<&str> = state.repos.iter()
|
||||
.filter_map(|r| r.owner.as_deref())
|
||||
.filter(|o| !acct_names.contains(o))
|
||||
.collect();
|
||||
let vulp_pushes = state.vulp_log.iter()
|
||||
.filter(|e| e.kind == crate::activity::ActivityKind::Push)
|
||||
.count();
|
||||
|
||||
let clean_c = total_repos.saturating_sub(dirty_c + behind_c);
|
||||
let bar_total = 12usize;
|
||||
let clean_n = if total_repos > 0 { bar_total * clean_c / total_repos } else { bar_total };
|
||||
let dirty_n = if total_repos > 0 { bar_total * dirty_c / total_repos } else { 0 };
|
||||
let behind_n = bar_total.saturating_sub(clean_n + dirty_n);
|
||||
let health_bar = format!("{}{}{}",
|
||||
"█".repeat(clean_n), "▓".repeat(dirty_n), "░".repeat(behind_n));
|
||||
let health_col = if behind_c > 0 { Color::Red } else if dirty_c > 0 { Color::Yellow } else { Color::Green };
|
||||
|
||||
let stat = |label: &str, value: String, style: Style| Line::from(vec![
|
||||
Span::styled(format!(" {:<14}", label), dim),
|
||||
Span::styled(value, style),
|
||||
]);
|
||||
|
||||
let commits_val = match &state.global_stats {
|
||||
Some(gs) => format!("{} ({})", gs.total_commits, state.global_stats_period.label()),
|
||||
None => "computing...".into(),
|
||||
};
|
||||
|
||||
let lines: Vec<Line> = vec![
|
||||
stat("Repositories", format!("{}", total_repos), wht),
|
||||
stat("Projects", format!("{}", state.projects.len()), wht),
|
||||
stat("Accounts", format!("{}", state.accounts.len()), wht),
|
||||
stat("Organizations",format!("{}", orgs.len()), wht),
|
||||
stat("Commits", commits_val, wht),
|
||||
stat("Pushes (vulp)",format!("{}", vulp_pushes), wht),
|
||||
stat("PRs", format!("{}", state.prs.len()), if state.prs.is_empty() { dim } else { cyn }),
|
||||
stat("Issues", format!("{}", state.issues.len()), if state.issues.is_empty() { dim } else { Style::default().fg(Color::Yellow) }),
|
||||
stat("Todos", format!("{}", open_todos), if open_todos == 0 { dim } else { cyn }),
|
||||
Line::from(vec![
|
||||
Span::styled(" Health ", dim),
|
||||
Span::styled(health_bar, Style::default().fg(health_col)),
|
||||
Span::styled(format!(" {}/{}", clean_c, total_repos), Style::default().fg(health_col)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" ↓{}", behind_c), if behind_c > 0 { red } else { dim }),
|
||||
Span::styled(format!(" ⚠{}", dirty_c), if dirty_c > 0 { Style::default().fg(Color::Yellow) } else { dim }),
|
||||
Span::styled(format!(" ↑{}", ahead_c), if ahead_c > 0 { grn } else { dim }),
|
||||
Span::styled(format!(" no-lic {}", no_lic_c), if no_lic_c > 0 { Style::default().fg(Color::Yellow) } else { dim }),
|
||||
]),
|
||||
];
|
||||
f.render_widget(Paragraph::new(lines), cols[0]);
|
||||
}
|
||||
|
||||
// Col B — project list
|
||||
{
|
||||
let max_rows = (cols[1].height as usize).saturating_sub(1);
|
||||
let name_w = (cols[1].width as usize).saturating_sub(8).max(5);
|
||||
let mut lines: Vec<Line> = vec![Line::from(Span::styled(" projects", dim))];
|
||||
if state.projects.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" no projects yet", dim)));
|
||||
lines.push(Line::from(Span::styled(" [p] create one", dim)));
|
||||
} else {
|
||||
for (pi, p) in state.projects.iter().take(max_rows).enumerate() {
|
||||
let repo_cnt = state.project_repos(pi).len();
|
||||
let name = p.name.chars().take(name_w).collect::<String>();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ● ", Style::default().fg(state.proj_color(pi))),
|
||||
Span::styled(format!("{:<width$} ", name, width = name_w), wht),
|
||||
Span::styled(format!("{} repo{}", repo_cnt, if repo_cnt == 1 { "" } else { "s" }), dim),
|
||||
]));
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), cols[1]);
|
||||
}
|
||||
|
||||
// Col C — top repos (and projects) by commits in the current period,
|
||||
// as many as fit
|
||||
{
|
||||
let period = state.global_stats_period.label();
|
||||
let name_w = (cols[2].width as usize).saturating_sub(10).max(5);
|
||||
let mut lines: Vec<Line> = vec![Line::from(Span::styled(format!(" top repos ({}) by commits", period), dim))];
|
||||
match &state.global_stats {
|
||||
None => lines.push(Line::from(Span::styled(" computing...", dim))),
|
||||
Some(gs) => {
|
||||
if gs.repo_commits.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" no commits in period", dim)));
|
||||
}
|
||||
let height = cols[2].height as usize;
|
||||
// Leave room for a top-projects section when projects exist.
|
||||
let proj_section = if state.projects.is_empty() { 0 } else { 4 };
|
||||
let repo_rows = height.saturating_sub(1 + proj_section).max(3);
|
||||
for (name, cnt) in gs.repo_commits.iter().take(repo_rows) {
|
||||
let n = name.chars().take(name_w).collect::<String>();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:<width$} ", n, width = name_w), wht),
|
||||
Span::styled(format!("{}", cnt), cyn),
|
||||
]));
|
||||
}
|
||||
if !state.projects.is_empty() {
|
||||
let commit_map: std::collections::HashMap<&str, usize> =
|
||||
gs.repo_commits.iter().map(|(n, c)| (n.as_str(), *c)).collect();
|
||||
let mut proj_commits: Vec<(&str, usize)> = state.projects.iter().enumerate()
|
||||
.map(|(pi, p)| {
|
||||
let sum = state.project_repos(pi).iter()
|
||||
.filter_map(|&ri| state.repos.get(ri))
|
||||
.filter_map(|r| commit_map.get(r.name.as_str()))
|
||||
.sum();
|
||||
(p.name.as_str(), sum)
|
||||
})
|
||||
.filter(|(_, c)| *c > 0)
|
||||
.collect();
|
||||
proj_commits.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
if !proj_commits.is_empty() {
|
||||
lines.push(Line::from(Span::styled(format!(" top projects ({})", period), dim)));
|
||||
for (name, cnt) in proj_commits.iter().take(3) {
|
||||
let n = name.chars().take(name_w).collect::<String>();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:<width$} ", n, width = name_w), wht),
|
||||
Span::styled(format!("{}", cnt), cyn),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), cols[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// Cell 2 — General Settings
|
||||
let acc_blk = Block::default().borders(Borders::ALL)
|
||||
.title(Span::styled(" general settings ", yel))
|
||||
.title_alignment(Alignment::Center)
|
||||
.border_style(dim);
|
||||
let acc_inner = acc_blk.inner(row1[1]);
|
||||
f.render_widget(acc_blk, row1[1]);
|
||||
{
|
||||
let mut lines: Vec<Line> = vec![];
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
// Version + provenance. Prefer real release metadata; fall back to
|
||||
// the compile-time build date when no GitHub release exists.
|
||||
lines.push(Line::from(Span::styled(format!(" vulp v{}", version), cyn)));
|
||||
if let Some(Ok(cur)) = &state.current_release {
|
||||
let date = cur.published_at.split('T').next().unwrap_or_default();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" released ", dim),
|
||||
Span::styled(date.to_string(), wht),
|
||||
Span::styled(" by ", dim),
|
||||
Span::styled(format!("@{}", cur.author), wht),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" built ", dim),
|
||||
Span::styled(env!("VULP_BUILD_DATE"), wht),
|
||||
Span::styled(" by ", dim),
|
||||
Span::styled(env!("CARGO_PKG_AUTHORS"), wht),
|
||||
]));
|
||||
}
|
||||
match &state.latest_release {
|
||||
Some(Ok(latest)) => {
|
||||
if crate::release_info::is_outdated(version, &latest.tag) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" ↑ {} available", latest.tag), Style::default().fg(Color::Yellow)),
|
||||
Span::styled(" — [v] notes", cyn),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(Span::styled(" ✓ no new updates available", grn)));
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
let msg = if e.contains("404") { "no releases published yet" } else { "update check failed (offline?)" };
|
||||
lines.push(Line::from(Span::styled(format!(" {}", msg), dim)));
|
||||
}
|
||||
None => {
|
||||
lines.push(Line::from(Span::styled(" checking for updates...", dim)));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
|
||||
if state.accounts.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" no accounts", dim)));
|
||||
lines.push(Line::from(Span::styled(" run: vulp auth import", Style::default().fg(Color::Cyan))));
|
||||
} else {
|
||||
for acc in &state.accounts {
|
||||
let token_ok = crate::auth::get_token(&acc.id()).is_ok();
|
||||
let def_mark = if acc.is_default { " ★" } else { "" };
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" account ", dim),
|
||||
Span::styled(format!("@{}{}", acc.username, def_mark), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(format!(" · {}", acc.provider), dim),
|
||||
if token_ok { Span::styled(" ✓", grn) } else { Span::styled(" ✗ token", red) },
|
||||
]));
|
||||
let mut details: Vec<String> = Vec::new();
|
||||
if let Some(since) = state.account_since.get(&acc.username) {
|
||||
details.push(format!("since {}", since));
|
||||
}
|
||||
if let Some(added) = &acc.added_at {
|
||||
details.push(format!("added to vulp {}", added));
|
||||
}
|
||||
if !details.is_empty() {
|
||||
lines.push(Line::from(Span::styled(format!(" {}", details.join(" · ")), dim)));
|
||||
}
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), acc_inner);
|
||||
}
|
||||
|
||||
// ── Row 2: Recent Activity — merged feed: latest git commit per repo +
|
||||
// vulp-performed actions (push/pull/release/new repo/todo/PR/issue) ─────
|
||||
let act_blk = Block::default().borders(Borders::ALL)
|
||||
.title(Span::styled(" recent activity ", yel))
|
||||
.title_alignment(Alignment::Center)
|
||||
.border_style(dim);
|
||||
let act_inner = act_blk.inner(rows[1]);
|
||||
f.render_widget(act_blk, rows[1]);
|
||||
{
|
||||
use crate::activity::ActivityKind;
|
||||
struct FeedRow<'a> { ts: i64, kind: ActivityKind, repo: &'a str, detail: &'a str }
|
||||
|
||||
let mut feed: Vec<FeedRow> = state.repos.iter()
|
||||
.filter(|r| r.last_commit_ts > 0)
|
||||
.map(|r| FeedRow { ts: r.last_commit_ts, kind: ActivityKind::Commit, repo: &r.name, detail: &r.last_commit_msg })
|
||||
.collect();
|
||||
// Commits come from git itself (authoritative, sees work done outside
|
||||
// vulp too) — take everything else from vulp's own action log.
|
||||
feed.extend(state.vulp_log.iter()
|
||||
.filter(|e| e.kind != ActivityKind::Commit)
|
||||
.map(|e| FeedRow { ts: e.ts, kind: e.kind, repo: &e.repo, detail: &e.detail }));
|
||||
feed.sort_by(|a, b| b.ts.cmp(&a.ts));
|
||||
|
||||
let max_rows = act_inner.height as usize;
|
||||
let age_w = 12usize;
|
||||
let kind_w = 10usize;
|
||||
let name_w = feed.iter().take(max_rows)
|
||||
.map(|r| r.repo.chars().count())
|
||||
.max()
|
||||
.unwrap_or(8)
|
||||
.min((act_inner.width as usize) * 2 / 5)
|
||||
.max(8);
|
||||
let msg_w = (act_inner.width as usize).saturating_sub(age_w + kind_w + name_w + 4).max(5);
|
||||
|
||||
let mut lines: Vec<Line> = feed.iter().take(max_rows).map(|r| {
|
||||
let name = r.repo.chars().take(name_w).collect::<String>();
|
||||
let detail = r.detail.chars().take(msg_w).collect::<String>();
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:<width$} ", format_age_pub(r.ts), width = age_w), Style::default().fg(age_color(r.ts))),
|
||||
Span::styled(format!("{:<width$} ", r.kind.label(), width = kind_w), Style::default().fg(kind_color(r.kind))),
|
||||
Span::styled(format!("{:<width$} ", name, width = name_w), wht),
|
||||
Span::styled(detail, dim),
|
||||
])
|
||||
}).collect();
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" no activity yet", dim)));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), act_inner);
|
||||
}
|
||||
|
||||
// ── Row 3: Todos (33%) + PRs (33%) + Issues (34%) ────────────────────────
|
||||
let row3 = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(33), Constraint::Percentage(33), Constraint::Percentage(34),
|
||||
])
|
||||
.split(rows[2]);
|
||||
|
||||
// Todos — stacked status counts, then most recent todos
|
||||
let todo_blk = Block::default().borders(Borders::ALL)
|
||||
.title(Span::styled(" todos ", yel))
|
||||
.title_alignment(Alignment::Center)
|
||||
.border_style(dim);
|
||||
let todo_inner = todo_blk.inner(row3[0]);
|
||||
f.render_widget(todo_blk, row3[0]);
|
||||
{
|
||||
let name_w = (todo_inner.width as usize).saturating_sub(4).max(5);
|
||||
let mut lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:>3} ", open_todos), cyn),
|
||||
Span::styled("open", dim),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:>3} ", inprog_todos), Style::default().fg(Color::Yellow)),
|
||||
Span::styled("in progress", dim),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:>3} ", done_todos), grn),
|
||||
Span::styled("done", dim),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:>3} ", canc_todos), dim),
|
||||
Span::styled("cancelled", dim),
|
||||
]),
|
||||
];
|
||||
let mut recent: Vec<&crate::todos::Todo> = state.todos.iter()
|
||||
.filter(|t| t.source == TodoSource::Manual)
|
||||
.collect();
|
||||
recent.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
let slots = (todo_inner.height as usize).saturating_sub(lines.len() + 1);
|
||||
if slots > 0 && !recent.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
for t in recent.iter().take(slots) {
|
||||
let icon = t.status.icon();
|
||||
let title = t.title.chars().take(name_w).collect::<String>();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} ", icon), Style::default().fg(t.status.color())),
|
||||
Span::styled(title, wht),
|
||||
]));
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), todo_inner);
|
||||
}
|
||||
|
||||
// PRs — status breakdown (incl. merged/closed from the search API when
|
||||
// refreshed), then recent titles
|
||||
let pr_blk = Block::default().borders(Borders::ALL)
|
||||
.title(Span::styled(" prs ", cyn))
|
||||
.title_alignment(Alignment::Center)
|
||||
.border_style(dim);
|
||||
let pr_inner = pr_blk.inner(row3[1]);
|
||||
f.render_widget(pr_blk, row3[1]);
|
||||
{
|
||||
let title_w = (pr_inner.width as usize).saturating_sub(9).max(6);
|
||||
let ready_c = state.prs.iter().filter(|p| !p.draft).count();
|
||||
let draft_c = state.prs.iter().filter(|p| p.draft).count();
|
||||
let mut lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:>3} ", ready_c), if ready_c == 0 { dim } else { cyn }),
|
||||
Span::styled("open", dim),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:>3} ", draft_c), if draft_c == 0 { dim } else { Style::default().fg(Color::Yellow) }),
|
||||
Span::styled("draft", dim),
|
||||
]),
|
||||
];
|
||||
match state.closed_counts {
|
||||
Some((merged, closed, _)) => {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:>3} ", merged), if merged == 0 { dim } else { Style::default().fg(Color::Magenta) }),
|
||||
Span::styled("merged", dim),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:>3} ", closed), dim),
|
||||
Span::styled("closed", dim),
|
||||
]));
|
||||
}
|
||||
None => lines.push(Line::from(Span::styled(" – merged/closed — [R] refresh", dim))),
|
||||
}
|
||||
let mut sorted: Vec<&crate::providers::PrDetail> = state.prs.iter().collect();
|
||||
sorted.sort_by(|a, b| b.created_ts.cmp(&a.created_ts));
|
||||
let slots = (pr_inner.height as usize).saturating_sub(lines.len() + 1);
|
||||
if slots > 0 && !sorted.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
for pr in sorted.iter().take(slots) {
|
||||
let title = pr.title.chars().take(title_w).collect::<String>();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" #{:<4} ", pr.number), cyn),
|
||||
Span::styled(title, wht),
|
||||
]));
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), pr_inner);
|
||||
}
|
||||
|
||||
// Issues — status breakdown, then recent titles
|
||||
let iss_yel = Style::default().fg(Color::Yellow);
|
||||
let iss_blk = Block::default().borders(Borders::ALL)
|
||||
.title(Span::styled(" issues ", iss_yel))
|
||||
.title_alignment(Alignment::Center)
|
||||
.border_style(dim);
|
||||
let iss_inner = iss_blk.inner(row3[2]);
|
||||
f.render_widget(iss_blk, row3[2]);
|
||||
{
|
||||
let title_w = (iss_inner.width as usize).saturating_sub(9).max(6);
|
||||
let unanswered_c = state.issues.iter().filter(|i| i.comments == 0).count();
|
||||
let mut lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:>3} ", state.issues.len()), if state.issues.is_empty() { dim } else { iss_yel }),
|
||||
Span::styled("open", dim),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:>3} ", unanswered_c), if unanswered_c == 0 { dim } else { red }),
|
||||
Span::styled("unanswered", dim),
|
||||
]),
|
||||
];
|
||||
match state.closed_counts {
|
||||
Some((_, _, closed_iss)) => {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:>3} ", closed_iss), if closed_iss == 0 { dim } else { grn }),
|
||||
Span::styled("closed", dim),
|
||||
]));
|
||||
}
|
||||
None => lines.push(Line::from(Span::styled(" – closed — [R] refresh", dim))),
|
||||
}
|
||||
let mut sorted: Vec<&crate::providers::IssueDetail> = state.issues.iter().collect();
|
||||
sorted.sort_by(|a, b| b.created_ts.cmp(&a.created_ts));
|
||||
let slots = (iss_inner.height as usize).saturating_sub(lines.len() + 1);
|
||||
if slots > 0 && !sorted.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
for iss in sorted.iter().take(slots) {
|
||||
let title = iss.title.chars().take(title_w).collect::<String>();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" #{:<4} ", iss.number), iss_yel),
|
||||
Span::styled(title, wht),
|
||||
]));
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), iss_inner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! The hub's HubSettings tab: workspace-level settings (root, editor,
|
||||
//! clipboard command, bulk import/de-git actions) and the user-defined
|
||||
//! repo template list, navigated with the same up/down + Enter pattern as
|
||||
//! every other list in the hub.
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
use super::state::HubState;
|
||||
|
||||
pub fn render_settings_global(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||
.split(area);
|
||||
|
||||
let mut left_lines: Vec<Line> = vec![];
|
||||
left_lines.push(Line::from(Span::styled(" Workspace Settings", yel)));
|
||||
left_lines.push(Line::from(Span::styled(" ─────────────────────────────────────────────", dim)));
|
||||
|
||||
struct Item { label: &'static str, value: String }
|
||||
let editor = state.editor.clone();
|
||||
let clip = state.clipboard_cmd.clone();
|
||||
let workspace = state.workspace_root.to_string_lossy().to_string();
|
||||
|
||||
let acc_count = state.accounts.len().to_string();
|
||||
let items: &[Item] = &[
|
||||
Item { label: "Workspace root", value: workspace },
|
||||
Item { label: "Editor", value: editor },
|
||||
Item { label: "Clipboard cmd", value: clip },
|
||||
Item { label: "Total repos", value: state.repos.len().to_string() },
|
||||
Item { label: "Total projects", value: state.projects.len().to_string() },
|
||||
Item { label: "Total todos", value: state.todos.len().to_string() },
|
||||
Item { label: "Accounts", value: acc_count },
|
||||
Item { label: "Bulk import repos", value: "[Enter] clone/pull all from account".into() },
|
||||
Item { label: "Import directory", value: "[Enter] git init + link all subfolders".into() },
|
||||
Item { label: "Remove .git (all)", value: "[Enter] ⚠ de-git all repos in workspace".into() },
|
||||
];
|
||||
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let sel = i == state.hub_settings_cur;
|
||||
let bg = if sel { Color::DarkGray } else { Color::Reset };
|
||||
left_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:>2}. ", i + 1),
|
||||
Style::default().fg(if sel { Color::Cyan } else { Color::DarkGray }).bg(bg),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{:<20}", item.label),
|
||||
Style::default().fg(Color::White).bg(bg).add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }),
|
||||
),
|
||||
Span::styled(
|
||||
item.value.chars().take(35).collect::<String>(),
|
||||
Style::default().fg(Color::DarkGray).bg(bg),
|
||||
),
|
||||
]));
|
||||
}
|
||||
left_lines.push(Line::from(""));
|
||||
|
||||
// Templates section
|
||||
left_lines.push(Line::from(Span::styled(" Templates", yel)));
|
||||
left_lines.push(Line::from(Span::styled(" ─────────────────────────────────────────────", dim)));
|
||||
|
||||
let new_tmpl_sel = state.hub_settings_cur == 10;
|
||||
let new_bg = if new_tmpl_sel { Color::DarkGray } else { Color::Reset };
|
||||
left_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:>2}. ", 11),
|
||||
Style::default().fg(if new_tmpl_sel { Color::Cyan } else { Color::DarkGray }).bg(new_bg),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{:<20}", "[+] New template"),
|
||||
Style::default().fg(Color::Green).bg(new_bg).add_modifier(if new_tmpl_sel { Modifier::BOLD } else { Modifier::empty() }),
|
||||
),
|
||||
]));
|
||||
|
||||
for (ti, t) in state.templates.iter().enumerate() {
|
||||
let idx = 11 + ti;
|
||||
let sel = idx == state.hub_settings_cur;
|
||||
let bg = if sel { Color::DarkGray } else { Color::Reset };
|
||||
let cmd_n = t.commands.len();
|
||||
let cmd_s = match cmd_n {
|
||||
0 => "(no commands)".to_string(),
|
||||
1 => "1 command".to_string(),
|
||||
n => format!("{} commands", n),
|
||||
};
|
||||
left_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:>2}. ", 12 + ti),
|
||||
Style::default().fg(if sel { Color::Cyan } else { Color::DarkGray }).bg(bg),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{:<20}", t.name.chars().take(20).collect::<String>()),
|
||||
Style::default().fg(Color::White).bg(bg).add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }),
|
||||
),
|
||||
Span::styled(
|
||||
cmd_s,
|
||||
Style::default().fg(Color::DarkGray).bg(bg),
|
||||
),
|
||||
]));
|
||||
}
|
||||
if state.templates.is_empty() {
|
||||
left_lines.push(Line::from(Span::styled(" (no templates yet)", dim)));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(left_lines), cols[0]);
|
||||
|
||||
let mut right_lines: Vec<Line> = vec![
|
||||
Line::from(Span::styled(" Hints", yel)),
|
||||
Line::from(Span::styled(" ──────────────────────────────", dim)),
|
||||
];
|
||||
right_lines.push(Line::from(Span::styled(" ↑↓ / jk navigate", dim)));
|
||||
right_lines.push(Line::from(Span::styled(" Enter edit / run item", dim)));
|
||||
right_lines.push(Line::from(Span::styled(" D delete template", dim)));
|
||||
right_lines.push(Line::from(""));
|
||||
right_lines.push(Line::from(Span::styled(" Items 2-3: editable in session.", dim)));
|
||||
right_lines.push(Line::from(Span::styled(" Item 8: bulk import from account.", dim)));
|
||||
right_lines.push(Line::from(Span::styled(" Item 9: git init + link dir.", dim)));
|
||||
right_lines.push(Line::from(Span::styled(" Item 10: remove .git ⚠", dim)));
|
||||
right_lines.push(Line::from(""));
|
||||
right_lines.push(Line::from(Span::styled(" Templates run on repo create.", dim)));
|
||||
right_lines.push(Line::from(Span::styled(" Cmds: semicolon-separated.", dim)));
|
||||
right_lines.push(Line::from(""));
|
||||
right_lines.push(Line::from(Span::styled(" Auth: vulp auth import", dim)));
|
||||
|
||||
f.render_widget(Paragraph::new(right_lines), cols[1]);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! The hub's Stats and Activity tabs: a workspace-wide commit bar chart plus
|
||||
//! language/top-repo/day-of-week breakdowns (Stats), and the merged
|
||||
//! commit/PR/issue/local-event feed (Activity).
|
||||
|
||||
use crate::status::format_age_pub;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Cell, Paragraph, Row, Table},
|
||||
Frame,
|
||||
};
|
||||
use super::state::HubState;
|
||||
|
||||
pub fn render_stats_global(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let cyn = Style::default().fg(Color::Cyan);
|
||||
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||
.split(area);
|
||||
|
||||
// LEFT: bar chart or loading
|
||||
let mut left_lines: Vec<Line> = vec![];
|
||||
|
||||
left_lines.push(Line::from(Span::styled(
|
||||
format!(" Global Statistics [7:{}] [p] change period", state.global_stats_period.label()),
|
||||
yel,
|
||||
)));
|
||||
left_lines.push(Line::from(Span::styled(" ─────────────────────────────────────────────────────────", dim)));
|
||||
|
||||
match &state.global_stats {
|
||||
None if state.global_stats_rx.is_some() => {
|
||||
left_lines.push(Line::from(Span::styled(" Computing stats from all repos (may take a moment)...", cyn)));
|
||||
}
|
||||
None => {
|
||||
left_lines.push(Line::from(Span::styled(" Press [p] or switch to this tab to load stats", dim)));
|
||||
}
|
||||
Some(gs) => {
|
||||
left_lines.push(Line::from(vec![
|
||||
Span::styled(format!(" Total commits this period: {}", gs.total_commits), Style::default().fg(Color::White)),
|
||||
]));
|
||||
left_lines.push(Line::from(""));
|
||||
|
||||
// Simple text bar chart
|
||||
let max_count: usize = gs.activity.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1);
|
||||
let bar_h = (area.height as usize).saturating_sub(10).max(5);
|
||||
let data = &gs.activity;
|
||||
let n = data.len();
|
||||
let bar_w = (cols[0].width as usize).saturating_sub(8).max(1);
|
||||
let step = if n > bar_w { (n / bar_w).max(1) } else { 1 };
|
||||
let sampled: Vec<&(String, usize)> = data.iter().step_by(step).collect();
|
||||
let bar_count = sampled.len().min(bar_w);
|
||||
// Stretch bars to fill available width when few data points
|
||||
let bar_width = if bar_count == 0 { 1 } else { (bar_w / bar_count).max(1) };
|
||||
|
||||
for row in (0..bar_h).rev() {
|
||||
let threshold = (row * max_count) / bar_h;
|
||||
let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
|
||||
let y_label = if row == bar_h - 1 { format!("{:>4} ", max_count) } else if row == 0 { " 0 ".to_string() } else { " ".to_string() };
|
||||
spans.push(Span::styled(y_label, dim));
|
||||
for j in 0..bar_count {
|
||||
let count = sampled.get(j).map(|(_, c)| *c).unwrap_or(0);
|
||||
let filled = count > threshold;
|
||||
for _ in 0..bar_width {
|
||||
if filled { spans.push(Span::styled("█", cyn)); }
|
||||
else { spans.push(Span::styled("░", dim)); }
|
||||
}
|
||||
}
|
||||
left_lines.push(Line::from(spans));
|
||||
}
|
||||
// X labels: one per bar, each bar_width chars wide
|
||||
let nth = (bar_count / 5).max(1);
|
||||
let mut x_spans: Vec<Span> = vec![Span::raw(" ")];
|
||||
for j in 0..bar_count {
|
||||
let label = if j % nth == 0 {
|
||||
let raw = sampled.get(j)
|
||||
.map(|(d, _)| d.chars().skip(5).take(bar_width.min(5)).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
format!("{:<width$}", raw, width = bar_width)
|
||||
} else {
|
||||
" ".repeat(bar_width)
|
||||
};
|
||||
x_spans.push(Span::styled(label, dim));
|
||||
}
|
||||
left_lines.push(Line::from(x_spans));
|
||||
}
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(left_lines), cols[0]);
|
||||
|
||||
// RIGHT: language breakdown + top repos
|
||||
let mut right_lines: Vec<Line> = vec![];
|
||||
|
||||
right_lines.push(Line::from(Span::styled(" Languages", yel)));
|
||||
right_lines.push(Line::from(Span::styled(" ────────────────────────────────", dim)));
|
||||
match &state.global_stats {
|
||||
None => { right_lines.push(Line::from(Span::styled(" Loading...", dim))); }
|
||||
Some(gs) => {
|
||||
let total_repos = state.repos.len().max(1);
|
||||
for (lang, cnt) in gs.lang_dist.iter().take(8) {
|
||||
let bar_len = (cnt * 16) / total_repos;
|
||||
let bar = "█".repeat(bar_len) + &"░".repeat(16usize.saturating_sub(bar_len));
|
||||
right_lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:<8} ", lang.chars().take(8).collect::<String>()), Style::default().fg(Color::White)),
|
||||
Span::styled(bar, cyn),
|
||||
Span::styled(format!(" {}", cnt), dim),
|
||||
]));
|
||||
}
|
||||
right_lines.push(Line::from(""));
|
||||
|
||||
right_lines.push(Line::from(Span::styled(" Top Repos (commits)", yel)));
|
||||
right_lines.push(Line::from(Span::styled(" ────────────────────────────────", dim)));
|
||||
for (name, cnt) in gs.top_repos.iter().take(5) {
|
||||
right_lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:<18} ", name.chars().take(18).collect::<String>()), Style::default().fg(Color::White)),
|
||||
Span::styled(format!("{}", cnt), cyn),
|
||||
]));
|
||||
}
|
||||
right_lines.push(Line::from(""));
|
||||
right_lines.push(Line::from(vec![
|
||||
Span::styled(" Health: ", dim),
|
||||
Span::styled(format!("{}↓ {}⚠", gs.behind_count, gs.dirty_count), Style::default().fg(if gs.behind_count > 0 || gs.dirty_count > 0 { Color::Yellow } else { Color::Green })),
|
||||
]));
|
||||
right_lines.push(Line::from(""));
|
||||
|
||||
right_lines.push(Line::from(Span::styled(" Day of Week", yel)));
|
||||
right_lines.push(Line::from(Span::styled(" ────────────────────────────────", dim)));
|
||||
let day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
let max_day = gs.day_pattern.iter().copied().max().unwrap_or(1).max(1);
|
||||
for (i, &cnt) in gs.day_pattern.iter().enumerate() {
|
||||
let bar_len = (cnt * 16) / max_day;
|
||||
let bar: String = "█".repeat(bar_len) + &"░".repeat(16usize.saturating_sub(bar_len));
|
||||
right_lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} ", day_names[i]), Style::default().fg(Color::White)),
|
||||
Span::styled(bar, cyn),
|
||||
Span::styled(format!(" {}", cnt), dim),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(right_lines), cols[1]);
|
||||
}
|
||||
|
||||
pub fn render_activity_global(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
use ratatui::widgets::{Block, Borders};
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let wht = Style::default().fg(Color::White);
|
||||
|
||||
let filter_label = state.activity_filter.map(|k| k.label()).unwrap_or("all");
|
||||
let title = format!(" activity — filter: {} ", filter_label);
|
||||
let blk = Block::default().borders(Borders::ALL)
|
||||
.title(Span::styled(title, yel))
|
||||
.title_alignment(Alignment::Center)
|
||||
.border_style(dim);
|
||||
let inner = blk.inner(area);
|
||||
f.render_widget(blk, area);
|
||||
|
||||
let Some(feed) = &state.activity_feed else {
|
||||
let msg = if state.activity_rx.is_some() { " Loading activity..." } else { " Press [Tab] or re-enter this tab to load activity" };
|
||||
f.render_widget(Paragraph::new(Line::from(Span::styled(msg, dim))), inner);
|
||||
return;
|
||||
};
|
||||
|
||||
let filtered: Vec<&crate::activity::ActivityEntry> = feed.iter()
|
||||
.filter(|e| state.activity_filter.map(|k| k == e.kind).unwrap_or(true))
|
||||
.collect();
|
||||
|
||||
let max_rows = inner.height.saturating_sub(1) as usize;
|
||||
let detail_w = (inner.width as usize).saturating_sub(36).max(10);
|
||||
let header = Row::new(vec![
|
||||
Cell::from("TIMESTAMP").style(dim), Cell::from("KIND").style(dim),
|
||||
Cell::from("REPO").style(dim), Cell::from("DETAIL").style(dim),
|
||||
]);
|
||||
let rows: Vec<Row> = filtered.iter().take(max_rows).map(|e| {
|
||||
let kind_col = match e.kind {
|
||||
crate::activity::ActivityKind::Commit => Style::default().fg(Color::White),
|
||||
crate::activity::ActivityKind::Push | crate::activity::ActivityKind::Pull => Style::default().fg(Color::Cyan),
|
||||
crate::activity::ActivityKind::Release => Style::default().fg(Color::Green),
|
||||
crate::activity::ActivityKind::NewRepo => Style::default().fg(Color::Magenta),
|
||||
crate::activity::ActivityKind::Todo => Style::default().fg(Color::Yellow),
|
||||
crate::activity::ActivityKind::Pr => Style::default().fg(Color::Cyan),
|
||||
crate::activity::ActivityKind::Issue => Style::default().fg(Color::Yellow),
|
||||
};
|
||||
let detail = e.detail.chars().take(detail_w).collect::<String>();
|
||||
Row::new(vec![
|
||||
Cell::from(format_age_pub(e.ts)).style(dim),
|
||||
Cell::from(e.kind.label()).style(kind_col),
|
||||
Cell::from(e.repo.chars().take(18).collect::<String>()).style(wht),
|
||||
Cell::from(detail).style(Style::default().fg(Color::Gray)),
|
||||
])
|
||||
}).collect();
|
||||
|
||||
if rows.is_empty() {
|
||||
f.render_widget(Paragraph::new(Line::from(Span::styled(" no activity recorded yet", dim))), inner);
|
||||
return;
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(9), Constraint::Length(9), Constraint::Length(18), Constraint::Fill(1),
|
||||
]).header(header),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
//! Table renderers for the hub's list-style tabs (Repos, Projects, Todos,
|
||||
//! PRs, Issues) — one row per visible `DisplayRow`, scrolled by
|
||||
//! `HubState::offset`. The full-screen tabs (Overview, Stats, Activity,
|
||||
//! HubSettings) have their own renderers in sibling `tabs_*` files.
|
||||
|
||||
use crate::status::format_age_pub;
|
||||
use crate::todos::TodoSource;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Cell, Row, Table},
|
||||
Frame,
|
||||
};
|
||||
use super::state::{DisplayRow, HubState};
|
||||
use super::tabs::age_color;
|
||||
|
||||
pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![
|
||||
" ", "NAME", "BRANCH", "PROJECT", "VISIB", "AGE", "STATUS", "RELEASE", "LICENSE", "STARS",
|
||||
]).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Repo { repo_idx: ri } = row else { return None; };
|
||||
let r = &state.repos[*ri];
|
||||
let is_cur = di == cur_di;
|
||||
let is_sel = state.selected.contains(ri);
|
||||
|
||||
let check_sty = if is_sel { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) };
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
||||
|
||||
let proj_name = state.repo_project(*ri).map(|p| p.name.as_str()).unwrap_or("—");
|
||||
let proj_color = state.repo_proj_color(*ri).unwrap_or(Color::DarkGray);
|
||||
|
||||
let vis = r.visibility.as_deref().unwrap_or("?");
|
||||
let rel = r.last_tag.as_deref().unwrap_or("—");
|
||||
let now_ts = chrono::Utc::now().timestamp();
|
||||
let is_stale = (now_ts - r.last_commit_ts) / 86400 > 90;
|
||||
let d_sym = if r.is_dirty { "⚠" } else if is_stale { "~" } else { "·" };
|
||||
let a_str = if r.ahead > 0 { format!("+{}", r.ahead) } else { String::new() };
|
||||
let b_str = if r.behind > 0 { format!("-{}", r.behind) } else { String::new() };
|
||||
let status_str = format!("{} {}{}", d_sym, a_str, b_str);
|
||||
let name_col = if is_cur || (!r.is_dirty && r.ahead == 0 && r.behind == 0) {
|
||||
Color::White
|
||||
} else {
|
||||
Color::Yellow
|
||||
};
|
||||
let lic_str = if r.has_license { "✓" } else { "—" };
|
||||
let stars_str = r.stars.map(|s| format!("{}", s)).unwrap_or_else(|| "—".into());
|
||||
let stars_col = if r.stars.is_some() { Color::Yellow } else { Color::DarkGray };
|
||||
let is_pinned = state.pins.contains(&r.path);
|
||||
let prefix = match (is_pinned, r.bookmarked) {
|
||||
(true, true) => "◆★",
|
||||
(true, false) => "◆ ",
|
||||
(false, true) => "★ ",
|
||||
(false, false)=> " ",
|
||||
};
|
||||
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!("{} {}", if is_cur { "▶" } else { " " }, if is_sel { "[✓]" } else { "[ ]" })).style(check_sty),
|
||||
Cell::from(format!("{}{}", prefix, r.name)).style(Style::default().fg(name_col)),
|
||||
Cell::from(r.branch.chars().take(9).collect::<String>()),
|
||||
Cell::from(proj_name.chars().take(21).collect::<String>()).style(Style::default().fg(proj_color)),
|
||||
Cell::from(vis).style(match vis { "public" => Style::default().fg(Color::Green), "private" => Style::default().fg(Color::Yellow), _ => Style::default().fg(Color::DarkGray) }),
|
||||
Cell::from(r.last_commit_age.clone()).style(Style::default().fg(age_color(r.last_commit_ts))),
|
||||
Cell::from(status_str).style(if r.is_dirty || r.behind > 0 { Style::default().fg(Color::Yellow) } else if is_stale { Style::default().fg(Color::Magenta).add_modifier(Modifier::DIM) } else { Style::default().fg(Color::DarkGray) }),
|
||||
Cell::from(rel.chars().take(7).collect::<String>()).style(Style::default().fg(Color::Cyan)),
|
||||
Cell::from(lic_str).style(Style::default().fg(if r.has_license { Color::Green } else { Color::DarkGray })),
|
||||
Cell::from(stars_str).style(Style::default().fg(stars_col)),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let widths = [
|
||||
Constraint::Length(6),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(22),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(8),
|
||||
];
|
||||
f.render_widget(Table::new(rows, widths).header(header), area);
|
||||
}
|
||||
|
||||
pub fn render_projects_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![" ", "PROJECT", "COLOR", "REPOS", "DIRTY", "BEHIND"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Project { proj_idx: pi } = row else { return None; };
|
||||
let p = &state.projects[*pi];
|
||||
let all_ri = state.project_repos(*pi);
|
||||
let dirty_n = all_ri.iter().filter(|&&ri| state.repos[ri].is_dirty).count();
|
||||
let behind_n = all_ri.iter().filter(|&&ri| state.repos[ri].behind > 0).count();
|
||||
let all_sel = !all_ri.is_empty() && all_ri.iter().all(|ri| state.selected.contains(ri));
|
||||
let any_sel = all_ri.iter().any(|ri| state.selected.contains(ri));
|
||||
let is_cur = di == cur_di;
|
||||
let color = state.proj_color(*pi);
|
||||
|
||||
let check_sty = if all_sel || any_sel { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) };
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else if behind_n > 0 { Style::default().fg(Color::Red) } else if dirty_n > 0 { Style::default().fg(Color::Yellow) } else { Style::default() };
|
||||
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!("{} {}", if is_cur { "▶" } else { " " }, if all_sel { "[✓]" } else if any_sel { "[◈]" } else { "[ ]" })).style(check_sty),
|
||||
Cell::from(p.name.clone()).style(Style::default().fg(color).add_modifier(Modifier::BOLD)),
|
||||
Cell::from(Line::from(vec![
|
||||
Span::styled("██ ", Style::default().fg(color)),
|
||||
Span::styled(p.color.clone(), Style::default().fg(color)),
|
||||
])),
|
||||
Cell::from(all_ri.len().to_string()),
|
||||
Cell::from(if dirty_n > 0 { dirty_n.to_string() } else { "·".into() }).style(if dirty_n > 0 { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::DarkGray) }),
|
||||
Cell::from(if behind_n > 0 { behind_n.to_string() } else { "·".into() }).style(if behind_n > 0 { Style::default().fg(Color::Red) } else { Style::default().fg(Color::DarkGray) }),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(Table::new(rows, [Constraint::Length(6), Constraint::Fill(1), Constraint::Length(9), Constraint::Length(7), Constraint::Length(6), Constraint::Length(7)]).header(header), area);
|
||||
}
|
||||
|
||||
pub fn render_todos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![" SRC", " STATUS", "TITLE", "REPO", "PROJECT", "EST", "DIFF", "CREATED"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Todo { todo_idx: ti } = row else { return None; };
|
||||
let t = &state.todos[*ti];
|
||||
let is_cur = di == cur_di;
|
||||
let status_color = t.status.color();
|
||||
let proj_name = t.project.as_deref().unwrap_or("—");
|
||||
let repo_name = t.repo.as_deref().unwrap_or("—");
|
||||
let created = format_age_pub(t.created_at);
|
||||
let est_str = t.estimate.as_deref().unwrap_or("—").chars().take(10).collect::<String>();
|
||||
let diff_str = match t.difficulty {
|
||||
Some(d) => format!("{}{}", "★".repeat(d.min(5) as usize), "☆".repeat(5usize.saturating_sub(d as usize))),
|
||||
None => "—".into(),
|
||||
};
|
||||
let title_short: String = if t.source == TodoSource::CodeScan {
|
||||
let raw = &t.title;
|
||||
let trimmed = if let Some(end) = raw.find(']') { raw[end+1..].trim() } else { raw.as_str() };
|
||||
trimmed.chars().take(55).collect()
|
||||
} else {
|
||||
t.title.chars().take(55).collect()
|
||||
};
|
||||
let src_icon = t.source.icon();
|
||||
let src_col = match t.source { TodoSource::Manual => Color::Cyan, TodoSource::CodeScan => Color::DarkGray };
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!(" {}", src_icon)).style(Style::default().fg(src_col)),
|
||||
Cell::from(format!(" {} {}", t.status.icon(), t.status.label()))
|
||||
.style(Style::default().fg(status_color)),
|
||||
Cell::from(title_short),
|
||||
Cell::from(repo_name).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(proj_name).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(est_str).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(diff_str).style(Style::default().fg(Color::Yellow)),
|
||||
Cell::from(created).style(Style::default().fg(Color::DarkGray)),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(4),
|
||||
Constraint::Length(14),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(7),
|
||||
Constraint::Length(10),
|
||||
]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn render_prs_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![" REPO", "#", "AGE", "AUTHOR", "TITLE", "DRAFT"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Pr { pr_idx } = row else { return None; };
|
||||
let pr = &state.prs[*pr_idx];
|
||||
let is_cur = di == cur_di;
|
||||
let age = format_age_pub(pr.updated_ts);
|
||||
let draft_s = if pr.draft { "draft" } else { "" };
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!(" {}", pr.repo.chars().take(14).collect::<String>()))
|
||||
.style(Style::default().fg(Color::Cyan)),
|
||||
Cell::from(format!("#{}", pr.number)).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(age).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(format!("@{}", pr.author.chars().take(12).collect::<String>()))
|
||||
.style(Style::default().fg(Color::Green)),
|
||||
Cell::from(pr.title.chars().take(60).collect::<String>()),
|
||||
Cell::from(draft_s).style(Style::default().fg(Color::DarkGray)),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(16), Constraint::Length(7), Constraint::Length(10),
|
||||
Constraint::Length(14), Constraint::Fill(1), Constraint::Length(6),
|
||||
]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn render_issues_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![" REPO", "#", "AGE", "AUTHOR", "TITLE", "CMT"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
.skip(state.offset).take(avail)
|
||||
.filter_map(|(di, row)| {
|
||||
let DisplayRow::Issue { issue_idx } = row else { return None; };
|
||||
let issue = &state.issues[*issue_idx];
|
||||
let is_cur = di == cur_di;
|
||||
let age = format_age_pub(issue.updated_ts);
|
||||
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
|
||||
Some(Row::new(vec![
|
||||
Cell::from(format!(" {}", issue.repo.chars().take(14).collect::<String>()))
|
||||
.style(Style::default().fg(Color::Cyan)),
|
||||
Cell::from(format!("#{}", issue.number)).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(age).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(format!("@{}", issue.author.chars().take(12).collect::<String>()))
|
||||
.style(Style::default().fg(Color::Yellow)),
|
||||
Cell::from(issue.title.chars().take(60).collect::<String>()),
|
||||
Cell::from(issue.comments.to_string()).style(Style::default().fg(Color::DarkGray)),
|
||||
]).style(row_sty))
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(16), Constraint::Length(7), Constraint::Length(10),
|
||||
Constraint::Length(14), Constraint::Fill(1), Constraint::Length(5),
|
||||
]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user