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

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

No files matched your search

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