From 5787b1488039a6e90468e6a24306fcc887bd21da Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 19 Jun 2026 01:10:19 +0200 Subject: [PATCH] update --- src/auth/mod.rs | 6 +- src/detail/git.rs | 322 +++++++- src/detail/mod.rs | 1525 +++++++++++++++++++++++++++++++--- src/detail/render.rs | 1587 +++++++++++++++++++++++++++++------- src/hub/actions.rs | 131 ++- src/hub/keys.rs | 371 +++++++-- src/hub/mod.rs | 114 +-- src/hub/modals.rs | 447 +++++++++- src/hub/project_detail.rs | 842 +++++++++++++++++++ src/hub/render.rs | 70 +- src/hub/right_panel.rs | 15 +- src/hub/state.rs | 103 ++- src/hub/tabs.rs | 652 ++++++++++++++- src/legacy/refactor/mod.rs | 47 ++ src/providers/gitea.rs | 8 +- src/providers/github.rs | 22 +- src/providers/gitlab.rs | 8 +- src/providers/mod.rs | 14 +- src/status/mod.rs | 158 +++- src/todos/mod.rs | 6 + src/tui/mod.rs | 18 +- src/util/mod.rs | 10 + 22 files changed, 5766 insertions(+), 710 deletions(-) create mode 100644 src/hub/project_detail.rs diff --git a/src/auth/mod.rs b/src/auth/mod.rs index f2d93f9..3418659 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -34,7 +34,11 @@ pub fn accounts_path(cfg: &AppConfig) -> PathBuf { } pub fn load_accounts(cfg: &AppConfig) -> Result> { - let path = accounts_path(cfg); + load_accounts_path(&cfg.data_dir) +} + +pub fn load_accounts_path(data_dir: &std::path::Path) -> Result> { + let path = data_dir.join("accounts.toml"); if path.exists() { let raw = std::fs::read_to_string(&path)?; let file: AccountsFile = toml::from_str(&raw)?; diff --git a/src/detail/git.rs b/src/detail/git.rs index a364208..6c7d0c8 100644 --- a/src/detail/git.rs +++ b/src/detail/git.rs @@ -1,11 +1,14 @@ -use super::HistEntry; +use super::{BranchEntry, ContribEntry, HistEntry, TreeNode}; use crate::status::format_age_pub; -use git2::Repository; +use git2::{BranchType, Repository}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; pub use crate::util::git::{git_run, git_run_ok}; pub use crate::util::url::to_web_url; pub use crate::util::clipboard::copy_auto as copy_to_clipboard; +// ── commit history ──────────────────────────────────────────────────────────── + pub fn build_history(path: &std::path::Path, limit: usize) -> Vec { let repo = match Repository::open(path) { Ok(r) => r, Err(_) => return vec![] }; @@ -52,11 +55,13 @@ pub fn build_history(path: &std::path::Path, limit: usize) -> Vec { commits } -pub fn count_contributors(path: &std::path::Path) -> usize { +pub fn total_commits(path: &std::path::Path) -> usize { std::process::Command::new("git") - .arg("-C").arg(path).args(["shortlog", "-sn", "--all"]) + .arg("-C").arg(path).args(["rev-list", "--count", "HEAD"]) .output() - .map(|o| String::from_utf8_lossy(&o.stdout).lines().count()) + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .and_then(|s| s.trim().parse().ok()) .unwrap_or(0) } @@ -76,13 +81,7 @@ pub fn count_stashes(path: &std::path::Path) -> usize { .unwrap_or(0) } -pub fn count_local_branches(path: &std::path::Path) -> usize { - std::process::Command::new("git") - .arg("-C").arg(path).args(["branch"]) - .output() - .map(|o| String::from_utf8_lossy(&o.stdout).lines().count()) - .unwrap_or(1) -} +// ── files ───────────────────────────────────────────────────────────────────── pub fn list_all_files(path: &std::path::Path) -> Vec { let out = std::process::Command::new("git") @@ -94,3 +93,302 @@ pub fn list_all_files(path: &std::path::Path) -> Vec { String::from_utf8_lossy(&out.stdout) .lines().map(|l| l.to_string()).collect() } + +pub fn count_lines_of_code(path: &std::path::Path) -> usize { + let out = std::process::Command::new("sh") + .arg("-c") + .arg("git ls-files | head -5000 | xargs wc -l 2>/dev/null | tail -1") + .current_dir(path) + .output(); + match out { + Ok(o) => { + let s = String::from_utf8_lossy(&o.stdout); + s.trim().split_whitespace().next().and_then(|n| n.parse().ok()).unwrap_or(0) + } + Err(_) => 0, + } +} + +pub fn largest_files(path: &std::path::Path, n: usize) -> Vec<(String, usize)> { + let out = std::process::Command::new("sh") + .arg("-c") + .arg("git ls-files | head -5000 | xargs wc -l 2>/dev/null | sort -rn") + .current_dir(path) + .output(); + match out { + Ok(o) => String::from_utf8_lossy(&o.stdout).lines() + .filter_map(|line| { + let line = line.trim(); + let mut parts = line.splitn(2, ' '); + let count_str = parts.next()?.trim(); + let file = parts.next()?.trim(); + if file == "total" || file.is_empty() { return None; } + let count = count_str.parse::().ok()?; + Some((file.to_string(), count)) + }) + .take(n) + .collect(), + Err(_) => vec![], + } +} + +// ── remote URL ──────────────────────────────────────────────────────────────── + +pub fn remote_url(path: &std::path::Path) -> Option { + let repo = Repository::open(path).ok()?; + let remote = repo.find_remote("origin").ok()?; + let url = remote.url()?; + Some(to_web_url(url)) +} + +// ── branches ───────────────────────────────────────────────────────────────── + +pub fn list_branches(path: &std::path::Path) -> Vec { + let repo = match Repository::open(path) { Ok(r) => r, Err(_) => return vec![] }; + let mut out = vec![]; + let branches = match repo.branches(Some(BranchType::Local)) { + Ok(b) => b, Err(_) => return vec![], + }; + for b in branches.flatten() { + let (branch, _) = b; + let name = match branch.name() { + Ok(Some(n)) => n.to_string(), + _ => continue, + }; + let is_current = branch.is_head(); + + let upstream = branch.upstream().ok() + .and_then(|u| u.name().ok().flatten().map(|n| n.to_string())); + + let (ahead, behind) = if let Some(ref up) = upstream { + if let (Ok(local_ref), Ok(up_ref)) = ( + repo.find_branch(&name, BranchType::Local).and_then(|b| b.into_reference().resolve()), + repo.find_reference(&format!("refs/remotes/{}", up)).and_then(|r| r.resolve()), + ) { + if let (Some(local_oid), Some(up_oid)) = (local_ref.target(), up_ref.target()) { + repo.graph_ahead_behind(local_oid, up_oid).unwrap_or((0, 0)) + } else { (0, 0) } + } else { (0, 0) } + } else { (0, 0) }; + + let (last_commit_age, last_commit_msg) = branch.get().peel_to_commit().ok() + .map(|c| (format_age_pub(c.time().seconds()), c.summary().unwrap_or("").chars().take(60).collect::())) + .unwrap_or_default(); + + out.push(BranchEntry { name, is_current, upstream, ahead, behind, last_commit_age, last_commit_msg }); + } + out.sort_by(|a, b| b.is_current.cmp(&a.is_current).then(a.name.cmp(&b.name))); + out +} + +// ── contributors ───────────────────────────────────────────────────────────── + +pub fn list_contributors(path: &std::path::Path) -> Vec { + let out = std::process::Command::new("git") + .arg("-C").arg(path).args(["shortlog", "-sn", "--all"]) + .output() + .unwrap_or_else(|_| std::process::Output { + status: std::process::ExitStatus::default(), stdout: vec![], stderr: vec![] + }); + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|line| { + let line = line.trim(); + let (count_str, name) = line.split_once('\t')?; + let count = count_str.trim().parse::().ok()?; + Some(ContribEntry { name: name.to_string(), count }) + }) + .collect() +} + +// ── commit activity (per period) ───────────────────────────────────────────── + +fn git_activity_raw(path: &std::path::Path, since_days: Option, date_fmt: &str) -> HashMap { + let since_arg = since_days.map(|d| format!("--since={} days ago", d)); + let mut cmd = std::process::Command::new("git"); + cmd.arg("-C").arg(path).arg("log"); + if let Some(ref s) = since_arg { cmd.arg(s); } + cmd.args([&format!("--date=format:{}", date_fmt), "--pretty=format:%ad"]); + let out = cmd.output().unwrap_or_else(|_| std::process::Output { + status: std::process::ExitStatus::default(), stdout: vec![], stderr: vec![] + }); + let mut counts = HashMap::new(); + for line in String::from_utf8_lossy(&out.stdout).lines() { + let label = line.trim().to_string(); + if !label.is_empty() { *counts.entry(label).or_insert(0usize) += 1; } + } + counts +} + +pub fn activity_1d(path: &std::path::Path) -> Vec<(String, usize)> { + let raw = git_activity_raw(path, Some(1), "%H"); + (0u32..24).map(|h| { + let label = format!("{:02}", h); + let count = raw.get(&label).copied().unwrap_or(0); + (label, count) + }).collect() +} + +pub fn activity_7d(path: &std::path::Path) -> Vec<(String, usize)> { + let raw = git_activity_raw(path, Some(7), "%Y-%m-%d"); + let now = chrono::Utc::now(); + (0i64..7).rev().map(|d| { + let dt = now - chrono::Duration::days(d); + let label = dt.format("%Y-%m-%d").to_string(); + let short = dt.format("%m-%d").to_string(); + let count = raw.get(&label).copied().unwrap_or(0); + (short, count) + }).collect() +} + +pub fn activity_30d(path: &std::path::Path) -> Vec<(String, usize)> { + let raw = git_activity_raw(path, Some(30), "%Y-%m-%d"); + let now = chrono::Utc::now(); + (0i64..30).rev().map(|d| { + let dt = now - chrono::Duration::days(d); + let label = dt.format("%Y-%m-%d").to_string(); + let short = dt.format("%d").to_string(); + let count = raw.get(&label).copied().unwrap_or(0); + (short, count) + }).collect() +} + +pub fn activity_90d(path: &std::path::Path) -> Vec<(String, usize)> { + let raw = git_activity_raw(path, Some(90), "%Y-%m-%d"); + let now = chrono::Utc::now(); + // Group into 7-day buckets (13 weeks) + (0usize..13).rev().map(|w| { + let end_d = (w * 7) as i64; + let start_d = end_d + 7; + let dt_end = now - chrono::Duration::days(end_d); + let dt_start = now - chrono::Duration::days(start_d - 1); + let label = format!("W{:02}", 13 - w); + let short = dt_start.format("%m-%d").to_string(); + let count: usize = (0..7).map(|i| { + let dt = now - chrono::Duration::days(end_d + i); + let k = dt.format("%Y-%m-%d").to_string(); + raw.get(&k).copied().unwrap_or(0) + }).sum(); + let _ = (label, dt_end); + (short, count) + }).collect() +} + +pub fn activity_1y(path: &std::path::Path) -> Vec<(String, usize)> { + let raw = git_activity_raw(path, Some(365), "%Y-%m"); + let now = chrono::Utc::now(); + (0i64..12).rev().map(|m| { + let dt = now - chrono::Duration::days(m * 30); + let label = dt.format("%Y-%m").to_string(); + let short = dt.format("%b").to_string(); + let count = raw.get(&label).copied().unwrap_or(0); + (short, count) + }).collect() +} + +pub fn activity_all(path: &std::path::Path) -> Vec<(String, usize)> { + let raw = git_activity_raw(path, None, "%Y"); + let mut result: Vec<(String, usize)> = raw.into_iter().collect(); + result.sort_by(|a, b| a.0.cmp(&b.0)); + result +} + +// ── repo age ───────────────────────────────────────────────────────────────── + +pub fn repo_first_commit_ts(path: &std::path::Path) -> Option { + let repo = Repository::open(path).ok()?; + let mut rw = repo.revwalk().ok()?; + rw.push_head().ok()?; + let oids: Vec<_> = rw.filter_map(|r| r.ok()).collect(); + let last_oid = oids.last()?; + let commit = repo.find_commit(*last_oid).ok()?; + Some(commit.time().seconds()) +} + +// ── file tree ──────────────────────────────────────────────────────────────── + +pub fn build_tree(all_files: &[String]) -> Vec { + let mut dirs: BTreeSet = BTreeSet::new(); + for f in all_files { + let parts: Vec<&str> = f.split('/').collect(); + for i in 1..parts.len() { + dirs.insert(parts[..i].join("/")); + } + } + + let mut dir_children: BTreeMap> = BTreeMap::new(); + dir_children.entry(String::new()).or_default(); + + for dir in &dirs { + let parent = dir.rfind('/').map(|i| &dir[..i]).unwrap_or("").to_string(); + dir_children.entry(parent).or_default().push(dir.clone()); + dir_children.entry(dir.clone()).or_default(); + } + for f in all_files { + let parent = f.rfind('/').map(|i| &f[..i]).unwrap_or("").to_string(); + dir_children.entry(parent).or_default().push(f.clone()); + } + + for children in dir_children.values_mut() { + children.sort_by(|a, b| { + let a_is_dir = dirs.contains(a.as_str()); + let b_is_dir = dirs.contains(b.as_str()); + b_is_dir.cmp(&a_is_dir).then(a.cmp(b)) + }); + children.dedup(); + } + + let mut result = Vec::new(); + flatten_tree("", &dir_children, &dirs, 0, &mut result); + result +} + +fn flatten_tree( + prefix: &str, + dir_children: &BTreeMap>, + dirs: &BTreeSet, + depth: usize, + out: &mut Vec, +) { + let children = match dir_children.get(prefix) { + Some(c) => c, + None => return, + }; + for child in children { + let is_dir = dirs.contains(child.as_str()); + let name = child.rfind('/').map(|i| &child[i+1..]).unwrap_or(child).to_string(); + out.push(TreeNode { name, rel_path: child.clone(), is_dir, depth, expanded: true }); + if is_dir { + flatten_tree(child, dir_children, dirs, depth + 1, out); + } + } +} + +// ── file preview ───────────────────────────────────────────────────────────── + +pub fn read_file_preview(repo_path: &std::path::Path, rel: &str, max_lines: usize) -> Vec { + let full = repo_path.join(rel); + let bytes = match std::fs::read(&full) { + Ok(b) => b, + Err(_) => return vec!["[cannot read file]".into()], + }; + if bytes.iter().take(512).any(|&b| b == 0) { + return vec!["[binary file]".into()]; + } + let text = String::from_utf8_lossy(&bytes); + text.lines().take(max_lines).map(|l| l.to_string()).collect() +} + +// ── branch actions ─────────────────────────────────────────────────────────── + +pub fn checkout_branch(path: &std::path::Path, name: &str) -> bool { + git_run_ok(path, &["checkout", name]) +} + +pub fn delete_branch(path: &std::path::Path, name: &str) -> bool { + git_run_ok(path, &["branch", "-d", name]) +} + +pub fn push_branch(path: &std::path::Path, name: &str) -> bool { + git_run_ok(path, &["push", "-u", "origin", name]) +} diff --git a/src/detail/mod.rs b/src/detail/mod.rs index 933b955..70120d5 100644 --- a/src/detail/mod.rs +++ b/src/detail/mod.rs @@ -1,18 +1,27 @@ mod git; mod render; +pub use git::{activity_1d, activity_7d, activity_30d, activity_90d, activity_1y, activity_all}; + +pub fn build_history_pub(path: &std::path::Path, limit: usize) -> Vec { + git::build_history(path, limit) +} + use crate::config::AppConfig; use crate::status::{count_tracked_files, repo_detail, repo_recent_commits, RepoSummary}; use anyhow::Result; use crossterm::event::{Event, KeyCode}; use git2::Repository; use git::{ - build_history, copy_to_clipboard, count_contributors, count_local_branches, - count_stashes, count_tags, git_run, git_run_ok, list_all_files, to_web_url, + build_history, build_tree, checkout_branch, copy_to_clipboard, count_lines_of_code, + count_stashes, count_tags, delete_branch, git_run, git_run_ok, largest_files, + list_all_files, list_branches, list_contributors, push_branch, read_file_preview, + remote_url, repo_first_commit_ts, to_web_url, total_commits, }; use render::draw; +use std::path::PathBuf; -// ── history entry (commits + release tags interleaved) ──────────────────────── +// ── history entry ───────────────────────────────────────────────────────────── #[derive(Clone)] pub enum HistEntry { @@ -24,159 +33,1239 @@ impl HistEntry { pub fn ts(&self) -> i64 { match self { Self::Commit { ts, .. } | Self::Tag { ts, .. } => *ts } } } +// ── new data structures ─────────────────────────────────────────────────────── + +#[derive(Clone)] +pub struct BranchEntry { + pub name: String, + pub is_current: bool, + pub upstream: Option, + pub ahead: usize, + pub behind: usize, + pub last_commit_age: String, + pub last_commit_msg: String, +} + +#[derive(Clone)] +pub struct TreeNode { + pub name: String, + pub rel_path: String, + pub is_dir: bool, + pub depth: usize, + pub expanded: bool, +} + +#[derive(Clone)] +pub struct ContribEntry { + pub name: String, + pub count: usize, +} + +#[derive(Clone)] +pub struct MonthlyCommits { + pub label: String, + pub count: usize, +} + +pub struct RepoSettings { + pub visibility: String, + pub description: String, + pub topics: Vec, + pub gh_auth_ok: bool, +} + +// ── stats period ────────────────────────────────────────────────────────────── + +#[derive(PartialEq, Clone, Copy)] +pub enum StatsPeriod { Day1, Day7, Day30, Day90, Year1, AllTime } + +impl StatsPeriod { + pub fn label(self) -> &'static str { + match self { + Self::Day1 => "1d", + Self::Day7 => "7d", + Self::Day30 => "30d", + Self::Day90 => "90d", + Self::Year1 => "1y", + Self::AllTime => "all", + } + } + pub fn next(self) -> Self { + match self { + Self::Day1 => Self::Day7, + Self::Day7 => Self::Day30, + Self::Day30 => Self::Day90, + Self::Day90 => Self::Year1, + Self::Year1 => Self::AllTime, + Self::AllTime => Self::Day1, + } + } + pub fn prev(self) -> Self { + match self { + Self::Day1 => Self::AllTime, + Self::Day7 => Self::Day1, + Self::Day30 => Self::Day7, + Self::Day90 => Self::Day30, + Self::Year1 => Self::Day90, + Self::AllTime => Self::Year1, + } + } +} + +// ── Settings API operations (sent to background thread) ────────────────────── + +pub enum SettingOp { + Visibility { private: bool }, + Description { text: String }, + Topics { topics: Vec }, + AddCollaborator { name: String, role: String }, + Archive, + Rename { new_name: String }, + Delete, +} + +// ── TUI modals ──────────────────────────────────────────────────────────────── + +pub enum DetailModal { + None, + NewBranch { input: String, error: Option }, + // generic option selector (tag identifies the action) + SelectOption { title: String, options: Vec, cursor: usize, tag: u8 }, + // generic text input (tag identifies the action) + EditText { title: String, prompt: String, input: String, tag: u8 }, + // add-collaborator step 2: name collected, now pick role + SelectRole { collab_name: String, cursor: usize }, + // dangerous confirm (archive, delete) + ConfirmAction { message: String, tag: u8 }, +} + // ── tabs ────────────────────────────────────────────────────────────────────── #[derive(PartialEq, Clone, Copy)] -pub enum Tab { Overview, History, Files } +pub enum Tab { Overview, Commits, Branches, Files, Stats, Settings, PRs, Issues, Todos } + +impl Tab { + fn next(self) -> Self { + match self { + Self::Overview => Self::Commits, + Self::Commits => Self::Branches, + Self::Branches => Self::Files, + Self::Files => Self::Stats, + Self::Stats => Self::Settings, + Self::Settings => Self::PRs, + Self::PRs => Self::Issues, + Self::Issues => Self::Todos, + Self::Todos => Self::Overview, + } + } + fn prev(self) -> Self { + match self { + Self::Overview => Self::Todos, + Self::Commits => Self::Overview, + Self::Branches => Self::Commits, + Self::Files => Self::Branches, + Self::Stats => Self::Files, + Self::Settings => Self::Stats, + Self::PRs => Self::Settings, + Self::Issues => Self::PRs, + Self::Todos => Self::Issues, + } + } +} // ── state ───────────────────────────────────────────────────────────────────── pub struct St { pub summary: RepoSummary, pub tab: Tab, - pub files: Vec<(char, String)>, - pub file_count: usize, - pub contributors: usize, - pub total_tags: usize, - pub recent_commits: Vec<(String, String)>, - pub stash_count: usize, - pub branch_count: usize, + pub pending_file: Option, + + // Commits tab pub history: Vec, pub hist_off: usize, - pub file_off: usize, - pub show_all_files: bool, + pub total_commits: usize, + + // Files (changed) + pub files: Vec<(char, String)>, + pub file_count: usize, + + // Branches tab + pub branches: Vec, + pub branch_cursor: usize, + pub branch_off: usize, + + // Files tab (tree) pub all_files: Vec, + pub tree_nodes: Vec, + pub tree_visible: Vec, + pub tree_cursor: usize, + pub tree_off: usize, + pub file_preview: Vec, + pub preview_off: usize, + + // Stats tab + pub contributors: Vec, + pub repo_age_days: u64, + pub stats_off: usize, + pub stats_period: StatsPeriod, + pub activity_data: Vec<(String, usize)>, // current period's activity + pub loc_total: usize, + pub top_files: Vec<(String, usize)>, // largest files by LOC + + // Tags/stashes (shared) + pub total_tags: usize, + pub stash_count: usize, + + // Overview tab + pub ov_recent: Vec<(String, String)>, pub ov_scroll: usize, + pub remote_url_str: Option, + + // Settings tab + pub settings: Option, + pub settings_cur: usize, + pub settings_rx: Option>>, + pub settings_err: Option, + pub api_rx: Option>>, + + // PRs / Issues / Todos tabs + pub repo_prs: Vec, + pub repo_issues: Vec, + pub repo_todos: Vec, + pub pr_cursor: usize, + pub issue_cursor: usize, + pub todo_cursor: usize, + pub prs_loaded: bool, + pub prs_issues_rx: Option, Vec)>>, + + // Shared + pub branch_count: usize, pub quit: bool, pub run_action: Option, pub status_msg: Option, + pub cfg_data_dir: PathBuf, + + // Pending values collected from in-TUI modals (sync / release) + pub pending_sync_mode: Option, + pub pending_commit_msg: Option, + pub pending_tag_name: Option, + pub pending_tag_msg: Option, + pub pending_push_tag: bool, + + // Modal + pub modal: DetailModal, +} + +// ── tree visibility rebuild ─────────────────────────────────────────────────── + +pub fn rebuild_tree_visible(nodes: &[TreeNode]) -> Vec { + let mut visible = vec![]; + let mut collapsed_depth: Option = None; + for (i, node) in nodes.iter().enumerate() { + if let Some(cd) = collapsed_depth { + if node.depth > cd { continue; } + else { collapsed_depth = None; } + } + visible.push(i); + if node.is_dir && !node.expanded { + collapsed_depth = Some(node.depth); + } + } + visible } // ── entry point ─────────────────────────────────────────────────────────────── -pub async fn run(summary: RepoSummary, cfg: &AppConfig) -> Result<()> { - let detail = repo_detail(&summary.path)?; - let path = summary.path.clone(); +pub async fn run( + summary: RepoSummary, + cfg: &AppConfig, + maybe_term: Option, +) -> Result<()> { + let path = summary.path.clone(); + let name = summary.name.clone(); + + // Reuse the handed terminal (no LeaveAlternateScreen flash) or create a + // new one. Either way, TUI is live before any blocking git calls. + let mut terminal = match maybe_term { + Some(t) => t, + None => crate::tui::setup()?, + }; + + // Show "Loading…" while we run the blocking git subprocesses. + terminal.draw(|f| { + use ratatui::widgets::Paragraph; + f.render_widget( + Paragraph::new(format!(" Loading {}…", name)) + .style(ratatui::style::Style::default().fg(ratatui::style::Color::DarkGray)), + f.area(), + ); + })?; + + let detail = repo_detail(&path).unwrap_or_else(|_| crate::status::RepoDetail { + summary: summary.clone(), + modified_files: vec![], + recent_commits: vec![], + meta_issues: None, meta_prs: None, meta_stars: None, + }); + let all_files = list_all_files(&path); + let tree_nodes = build_tree(&all_files); + let tree_visible = rebuild_tree_visible(&tree_nodes); + let contributors = list_contributors(&path); + let branches = list_branches(&path); + let branch_count = branches.len().max(1); + let first_ts = repo_first_commit_ts(&path).unwrap_or(0); + let repo_age_days = ((chrono::Utc::now().timestamp() - first_ts) / 86400).max(0) as u64; + let total = total_commits(&path); + let url_str = remote_url(&path); + let loc = count_lines_of_code(&path); + let top_f = largest_files(&path, 5); + let activity = activity_1y(&path); let mut st = St { + tab: Tab::Overview, files: detail.modified_files, file_count: count_tracked_files(&path), - contributors: count_contributors(&path), total_tags: count_tags(&path), - recent_commits: repo_recent_commits(&path, 8), stash_count: count_stashes(&path), - branch_count: count_local_branches(&path), - history: build_history(&path, 60), + branch_count, + history: build_history(&path, 200), hist_off: 0, - file_off: 0, - show_all_files: false, - all_files: list_all_files(&path), + total_commits: total, + branches, + branch_cursor: 0, + branch_off: 0, + all_files, + tree_nodes, + tree_visible, + tree_cursor: 0, + tree_off: 0, + file_preview: vec![], + preview_off: 0, + contributors, + repo_age_days, + stats_off: 0, + stats_period: StatsPeriod::Year1, + activity_data: activity, + loc_total: loc, + top_files: top_f, + ov_recent: repo_recent_commits(&path, 6), ov_scroll: 0, + remote_url_str: url_str, + settings: None, + settings_cur: 0, + settings_rx: None, + settings_err: None, + api_rx: None, + repo_prs: vec![], + repo_issues: vec![], + repo_todos: { + let rname = name.clone(); + crate::todos::load_manual().into_iter() + .filter(|t| t.repo.as_deref() == Some(&rname)) + .collect() + }, + pr_cursor: 0, + issue_cursor: 0, + todo_cursor: 0, + prs_loaded: false, + prs_issues_rx: None, quit: false, run_action: None, status_msg: None, - tab: Tab::Overview, + cfg_data_dir: cfg.data_dir.clone(), + pending_sync_mode: None, + pending_commit_msg: None, + pending_tag_name: None, + pending_tag_msg: None, + pending_push_tag: false, + modal: DetailModal::None, + pending_file: None, summary, }; - loop { - let action = tui_phase(&mut st)?; - if st.quit { break; } - if let Some(a) = action { - exec_action(&mut st, a).await?; - } - } - let _ = cfg; - Ok(()) -} - -fn tui_phase(st: &mut St) -> Result> { - let mut terminal = crate::tui::setup()?; - let result = (|| -> Result> { - loop { - terminal.draw(|f| draw(f, st))?; - let event = crate::tui::next_event(250)?; - let key = match event { Some(Event::Key(k)) => k, _ => continue }; - handle_key(key.code, st); - if st.quit { return Ok(None); } - if let Some(a) = st.run_action.take() { return Ok(Some(a)); } - } - })(); + // Main loop — terminal stays alive the whole time. + let result = run_detail_loop(&mut st, &mut terminal).await; crate::tui::teardown(terminal)?; result } +async fn run_detail_loop(st: &mut St, terminal: &mut crate::tui::Tty) -> Result<()> { + loop { + let action = tui_phase_inner(st, terminal)?; + if st.quit { break; } + if let Some(a) = action { + // Leave alternate screen for interactive prompts, then return. + crate::tui::leave_tui(terminal)?; + let r = exec_action(st, a).await; + crate::tui::enter_tui(terminal)?; + r?; + } + } + Ok(()) +} + +fn tui_phase_inner(st: &mut St, terminal: &mut crate::tui::Tty) -> Result> { + let path = st.summary.path.clone(); + loop { + poll_settings_rx(st); + poll_api_rx(st); + poll_prs_issues_rx(st); + terminal.draw(|f| draw(f, st))?; + let event = crate::tui::next_event(250)?; + let key = match event { Some(Event::Key(k)) => k, _ => continue }; + + if handle_modal_key(key.code, st) { + if st.quit { return Ok(None); } + if let Some(a) = st.run_action.take() { + if a == 40 { + if let Some(&ni) = st.tree_visible.get(st.tree_cursor) { + let node = &st.tree_nodes[ni]; + if !node.is_dir { + st.file_preview = read_file_preview(&path, &node.rel_path, 200); + } else { + st.file_preview = vec![]; + } + st.preview_off = 0; + } + } else if a == 41 { + st.activity_data = compute_activity(&path, st.stats_period); + } else if a == 50 { + spawn_settings_fetch(st); + } else if a == 80 { + spawn_prs_issues_fetch(st); + } else { + return Ok(Some(a)); + } + } + continue; + } + + handle_key(key.code, st); + if st.quit { return Ok(None); } + + if let Some(a) = st.run_action.take() { + if a == 40 { + if let Some(&ni) = st.tree_visible.get(st.tree_cursor) { + let node = &st.tree_nodes[ni]; + if !node.is_dir { + st.file_preview = read_file_preview(&path, &node.rel_path, 200); + } else { + st.file_preview = vec![]; + } + st.preview_off = 0; + } + continue; + } + if a == 41 { + st.activity_data = compute_activity(&path, st.stats_period); + continue; + } + if a == 50 { + spawn_settings_fetch(st); + continue; + } + if a == 80 { + spawn_prs_issues_fetch(st); + continue; + } + return Ok(Some(a)); + } + } +} + +fn compute_activity(path: &std::path::Path, period: StatsPeriod) -> Vec<(String, usize)> { + match period { + StatsPeriod::Day1 => activity_1d(path), + StatsPeriod::Day7 => activity_7d(path), + StatsPeriod::Day30 => activity_30d(path), + StatsPeriod::Day90 => activity_90d(path), + StatsPeriod::Year1 => activity_1y(path), + StatsPeriod::AllTime => activity_all(path), + } +} + +fn settings_fallback(st: &St) -> RepoSettings { + RepoSettings { + visibility: st.summary.visibility.clone().unwrap_or_else(|| "unknown".into()), + description: String::new(), + topics: vec![], + gh_auth_ok: false, + } +} + +fn settings_fallback_auth(st: &St) -> RepoSettings { + RepoSettings { + visibility: st.summary.visibility.clone().unwrap_or_else(|| "unknown".into()), + description: String::new(), + topics: vec![], + gh_auth_ok: true, + } +} + +fn poll_settings_rx(st: &mut St) { + let done = match &st.settings_rx { + None => return, + Some(rx) => match rx.try_recv() { + Ok(r) => Some(r), + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Err("Channel disconnected".into())), + }, + }; + st.settings_rx = None; + match done { + Some(Ok(s)) => { + st.settings = Some(s); + st.settings_err = None; + } + Some(Err(e)) => { + let auth_fail = e.contains("No GitHub account") || e.contains("no account") + || e.contains("no github") || e.contains("authentication"); + if auth_fail { + st.settings = Some(settings_fallback(st)); + st.settings_err = Some(e); + } else { + // API error, but we are authenticated — keep showing stale data, show error + if st.settings.is_none() { st.settings = Some(settings_fallback_auth(st)); } + st.settings_err = Some(format!("API error: {}", e)); + } + } + None => {} + } +} + +fn spawn_settings_fetch(st: &mut St) { + if st.settings_rx.is_some() { return; } // already in flight + let data_dir = st.cfg_data_dir.clone(); + let repo_name = st.summary.name.clone(); + let vis = st.summary.visibility.clone().unwrap_or_else(|| "unknown".into()); + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio rt"); + let result: Result = rt.block_on(async move { + let accounts = crate::auth::load_accounts_path(&data_dir) + .map_err(|e| e.to_string())?; + let account = crate::providers::default_account(&accounts, None) + .ok_or_else(|| "No GitHub account — run: fxg auth import".to_string())?; + let provider = crate::providers::make_provider(account) + .map_err(|e| e.to_string())?; + let meta = provider.repo_meta(&repo_name).await + .map_err(|e| e.to_string())?; + Ok(RepoSettings { + visibility: vis, + description: meta.description.unwrap_or_default(), + topics: meta.topics, + gh_auth_ok: true, + }) + }); + let _ = tx.send(result); + }); + + st.settings_rx = Some(rx); +} + +// ── settings modal opener ───────────────────────────────────────────────────── + +fn open_settings_modal(st: &mut St) { + let cur_vis = st.settings.as_ref().map(|s| s.visibility.clone()).or_else(|| st.summary.visibility.clone()).unwrap_or_default(); + let cur_desc = st.settings.as_ref().map(|s| s.description.clone()).unwrap_or_default(); + let cur_top = st.settings.as_ref().map(|s| s.topics.join(", ")).unwrap_or_default(); + let repo = st.summary.name.clone(); + match st.settings_cur { + 0 => { + let cursor = if cur_vis == "private" { 1 } else { 0 }; + st.modal = DetailModal::SelectOption { + title: "Visibility".into(), + options: vec!["public".into(), "private".into()], + cursor, tag: 60, + }; + } + 1 => { + st.modal = DetailModal::EditText { + title: "Description".into(), + prompt: "New description (blank to clear):".into(), + input: cur_desc, tag: 61, + }; + } + 2 => { + st.modal = DetailModal::EditText { + title: "Topics".into(), + prompt: "Comma-separated (e.g. rust, cli, tool). No spaces within words.".into(), + input: cur_top, tag: 62, + }; + } + 3 => { + st.modal = DetailModal::EditText { + title: "Add Collaborator".into(), + prompt: "GitHub username:".into(), + input: String::new(), tag: 63, + }; + } + 4 => { + st.modal = DetailModal::ConfirmAction { + message: format!("Archive '{}' on GitHub?", repo), + tag: 64, + }; + } + 5 => { + st.modal = DetailModal::EditText { + title: "Rename Repository".into(), + prompt: "New name:".into(), + input: repo, tag: 65, + }; + } + 6 => { + let name = st.summary.name.clone(); + st.modal = DetailModal::ConfirmAction { + message: format!("Permanently delete '{}' on GitHub?\nThis cannot be undone.", name), + tag: 66, + }; + } + 7 => { st.run_action = Some(107); } + 8 => { st.run_action = Some(108); } + 9 => { st.run_action = Some(109); } + 10 => { + let ok = git_run_ok(&st.summary.path, &["pull"]); + st.status_msg = Some(if ok { "Pulled successfully".into() } else { "Pull failed — check remote".into() }); + if ok { st.summary.behind = 0; } + } + 11 => { + st.modal = DetailModal::EditText { + title: "Commit".into(), + prompt: "Commit message (stages all changes):".into(), + input: String::new(), + tag: 70, + }; + } + 12 => { + let ok = git_run_ok(&st.summary.path, &["push"]); + st.status_msg = Some(if ok { "Pushed successfully".into() } else { "Push failed — check remote".into() }); + if ok { st.summary.ahead = 0; } + } + 13 => { + let ok = git_run_ok(&st.summary.path, &["stash"]); + st.status_msg = Some(if ok { "Stash saved".into() } else { "Stash failed".into() }); + if ok { st.summary.is_dirty = false; st.stash_count += 1; } + } + 14 => { + let ok = git_run_ok(&st.summary.path, &["stash", "pop"]); + st.status_msg = Some(if ok { "Stash popped".into() } else { "Stash pop failed — no stash?".into() }); + if ok && st.stash_count > 0 { st.stash_count -= 1; } + } + _ => {} + } +} + +// ── settings API background operations ─────────────────────────────────────── + +fn spawn_settings_op(st: &mut St, op: SettingOp) { + if st.api_rx.is_some() { + st.status_msg = Some("Another operation in progress...".into()); + return; + } + let data_dir = st.cfg_data_dir.clone(); + let repo_name = st.summary.name.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio rt"); + let result: Result = rt.block_on(async move { + let accounts = crate::auth::load_accounts_path(&data_dir) + .map_err(|e| e.to_string())?; + let account = crate::providers::default_account(&accounts, Some("github")) + .ok_or_else(|| "No GitHub account — run: fxg auth import".to_string())?; + let provider = crate::providers::make_provider(account) + .map_err(|e| e.to_string())?; + match op { + SettingOp::Visibility { private } => { + provider.update_visibility(&repo_name, private).await + .map(|_| format!("Visibility → {}", if private { "private" } else { "public" })) + .map_err(|e| e.to_string()) + } + SettingOp::Description { text } => { + provider.update_description(&repo_name, &text).await + .map(|_| "Description updated".to_string()) + .map_err(|e| e.to_string()) + } + SettingOp::Topics { topics } => { + provider.set_topics(&repo_name, &topics).await + .map(|_| "Topics updated".to_string()) + .map_err(|e| e.to_string()) + } + SettingOp::AddCollaborator { name, role } => { + provider.add_collaborator(&repo_name, &name, &role).await + .map(|_| format!("Invited @{} as {}", name, role)) + .map_err(|e| e.to_string()) + } + SettingOp::Archive => { + provider.archive_repo(&repo_name).await + .map(|_| "Repository archived".to_string()) + .map_err(|e| e.to_string()) + } + SettingOp::Rename { new_name } => { + provider.rename_repo(&repo_name, &new_name).await + .map(|_| format!("Renamed to '{}'", new_name)) + .map_err(|e| e.to_string()) + } + SettingOp::Delete => { + provider.delete_repo(&repo_name).await + .map(|_| "Repository deleted on GitHub (local copy still exists)".to_string()) + .map_err(|e| e.to_string()) + } + } + }); + let _ = tx.send(result); + }); + + st.api_rx = Some(rx); + st.status_msg = Some("Updating GitHub...".into()); +} + +fn poll_api_rx(st: &mut St) { + let done = match &st.api_rx { + None => return, + Some(rx) => match rx.try_recv() { + Ok(r) => Some(r), + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Err("Connection lost".into())), + }, + }; + if let Some(result) = done { + st.status_msg = Some(match result { + Ok(msg) => msg, + Err(msg) => format!("⚠ {}", msg), + }); + st.api_rx = None; + } +} + +fn poll_prs_issues_rx(st: &mut St) { + let done = match &st.prs_issues_rx { + None => return, + Some(rx) => match rx.try_recv() { + Ok(r) => Some(Ok(r)), + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Err(())), + }, + }; + st.prs_issues_rx = None; + st.prs_loaded = true; + if let Some(Ok((prs, issues))) = done { + st.repo_prs = prs; + st.repo_issues = issues; + } +} + +fn spawn_prs_issues_fetch(st: &mut St) { + if st.prs_issues_rx.is_some() || st.prs_loaded { return; } + let data_dir = st.cfg_data_dir.clone(); + let repo_name = st.summary.name.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("tokio rt"); + let result = rt.block_on(async move { + let accounts = crate::auth::load_accounts_path(&data_dir).ok()?; + let account = crate::providers::default_account(&accounts, None)?; + let provider = crate::providers::make_provider(account).ok()?; + let prs = provider.list_pull_requests(&repo_name).await.unwrap_or_default(); + let issues = provider.list_issues(&repo_name).await.unwrap_or_default(); + Some((prs, issues)) + }); + let _ = tx.send(result.unwrap_or_default()); + }); + + st.prs_issues_rx = Some(rx); + st.status_msg = Some("Fetching PRs and issues...".into()); +} + +// ── branch name validation ──────────────────────────────────────────────────── + +fn validate_branch_name(name: &str) -> Option { + if name.is_empty() { return Some("Name cannot be empty".into()); } + if name.contains(' ') { return Some("No spaces allowed".into()); } + if name.contains("..") { return Some("Cannot contain '..'".into()); } + if name.starts_with('/') || name.ends_with('/') { return Some("Cannot start or end with '/'".into()); } + if name.chars().any(|c| matches!(c, '~' | '^' | ':' | '?' | '*' | '[' | '\\')) { + return Some("Invalid character (~^:?*[\\)".into()); + } + None +} + +// ── modal key handling ──────────────────────────────────────────────────────── + +fn handle_modal_key(code: KeyCode, st: &mut St) -> bool { + match &mut st.modal { + DetailModal::None => return false, + + DetailModal::NewBranch { input, error } => { + match code { + KeyCode::Esc => { st.modal = DetailModal::None; } + KeyCode::Enter => { + let name = input.trim().to_string(); + if let Some(err) = validate_branch_name(&name) { + *error = Some(err); + } else { + let ok = git_run_ok(&st.summary.path, &["checkout", "-b", &name]); + st.status_msg = Some(if ok { + st.summary.branch = name.clone(); + st.branches = list_branches(&st.summary.path); + st.branch_count = st.branches.len(); + format!("Created & switched to '{}'", name) + } else { + "Failed to create branch".into() + }); + st.modal = DetailModal::None; + } + } + KeyCode::Backspace => { input.pop(); *error = None; } + KeyCode::Char(c) if input.len() < 80 => { input.push(c); *error = None; } + _ => {} + } + true + } + + DetailModal::SelectOption { options, cursor, tag, .. } => { + let max = options.len().saturating_sub(1); + match code { + KeyCode::Esc => { st.modal = DetailModal::None; } + KeyCode::Up => { *cursor = cursor.saturating_sub(1); } + KeyCode::Down => { *cursor = (*cursor + 1).min(max); } + KeyCode::Enter => { + let sel = options[*cursor].clone(); + let tag = *tag; + st.modal = DetailModal::None; + match tag { + 60 => { + let private = sel == "private"; + let label = sel.clone(); + if let Some(s) = &mut st.settings { s.visibility = label.clone(); } + st.summary.visibility = Some(label); + spawn_settings_op(st, SettingOp::Visibility { private }); + } + 90 => { + match sel.as_str() { + "Pull" => { + st.pending_sync_mode = Some("pull".into()); + st.run_action = Some(0); + } + "Commit + Push" => { + st.modal = DetailModal::EditText { + title: "Commit Message".into(), + prompt: "Message (stages all changes):".into(), + input: "chore: update".into(), + tag: 91, + }; + } + _ => { + st.pending_sync_mode = Some("push_only".into()); + st.run_action = Some(0); + } + } + } + _ => {} + } + } + _ => {} + } + true + } + + DetailModal::EditText { input, tag, .. } => { + match code { + KeyCode::Esc => { st.modal = DetailModal::None; } + KeyCode::Backspace => { input.pop(); } + KeyCode::Char(c) if input.len() < 200 => { input.push(c); } + KeyCode::Enter => { + let text = input.trim().to_string(); + let tag = *tag; + st.modal = DetailModal::None; + match tag { + 61 => { + if let Some(s) = &mut st.settings { s.description = text.clone(); } + spawn_settings_op(st, SettingOp::Description { text }); + } + 62 => { + let topics: Vec = text.split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect(); + let invalid = topics.iter().find(|t| t.contains(' ')); + if let Some(bad) = invalid { + st.status_msg = Some(format!("Invalid topic '{}': no spaces within words", bad)); + } else { + if let Some(s) = &mut st.settings { s.topics = topics.clone(); } + spawn_settings_op(st, SettingOp::Topics { topics }); + } + } + 63 => { // collab name → step 2: pick role + if !text.is_empty() { + st.modal = DetailModal::SelectRole { collab_name: text, cursor: 0 }; + } + } + 65 => { // rename + if !text.is_empty() { + spawn_settings_op(st, SettingOp::Rename { new_name: text }); + } + } + 70 => { // commit — stage all and commit with message + if !text.is_empty() { + let path = st.summary.path.clone(); + let add_ok = git_run_ok(&path, &["add", "-A"]); + let commit_ok = add_ok && git_run_ok(&path, &["commit", "-m", &text]); + st.status_msg = Some(if commit_ok { + st.summary.ahead += 1; + st.summary.is_dirty = false; + format!("Committed: {}", text.chars().take(50).collect::()) + } else if !add_ok { + "git add -A failed".into() + } else { + "Commit failed — nothing staged?".into() + }); + } + } + 91 => { // commit message for commit+push → trigger exec_action(0) + st.pending_sync_mode = Some("commit_push".into()); + st.pending_commit_msg = Some(text); + st.run_action = Some(0); + } + 92 => { // tag name → ask for tag message + if !text.is_empty() { + let default_msg = text.clone(); + st.pending_tag_name = Some(text); + st.modal = DetailModal::EditText { + title: "Release — Tag Message".into(), + prompt: "Tag message (blank = tag name):".into(), + input: default_msg, + tag: 93, + }; + } + } + 93 => { // tag message → confirm push? + let tag_msg = if text.is_empty() { + st.pending_tag_name.clone().unwrap_or_default() + } else { text }; + st.pending_tag_msg = Some(tag_msg); + let tag_display = st.pending_tag_name.as_deref().unwrap_or("").to_string(); + st.modal = DetailModal::ConfirmAction { + message: format!("Push tag '{}' to remote?", tag_display), + tag: 94, + }; + } + _ => {} + } + } + _ => {} + } + true + } + + DetailModal::SelectRole { collab_name, cursor } => { + const ROLES: &[&str] = &["pull", "push", "maintain", "admin"]; + match code { + KeyCode::Esc => { st.modal = DetailModal::None; } + KeyCode::Up => { *cursor = cursor.saturating_sub(1); } + KeyCode::Down => { *cursor = (*cursor + 1).min(ROLES.len() - 1); } + KeyCode::Enter => { + let name = collab_name.clone(); + let role = ROLES[*cursor].to_string(); + st.modal = DetailModal::None; + spawn_settings_op(st, SettingOp::AddCollaborator { name, role }); + } + _ => {} + } + true + } + + DetailModal::ConfirmAction { tag, .. } => { + match code { + KeyCode::Esc | KeyCode::Char('n') => { st.modal = DetailModal::None; } + KeyCode::Enter | KeyCode::Char('y') => { + let tag = *tag; + st.modal = DetailModal::None; + match tag { + 31 => { // delete branch + let path = st.summary.path.clone(); + if let Some(b) = st.branches.get(st.branch_cursor) { + let name = b.name.clone(); + let ok = delete_branch(&path, &name); + if ok { + st.branches = list_branches(&path); + st.branch_count = st.branches.len(); + st.branch_cursor = st.branch_cursor.min(st.branches.len().saturating_sub(1)); + st.status_msg = Some(format!("Deleted '{}'", name)); + } else { + st.status_msg = Some("Delete failed — unmerged changes?".into()); + } + } + } + 64 => { spawn_settings_op(st, SettingOp::Archive); } + 66 => { spawn_settings_op(st, SettingOp::Delete); } + 94 => { // push tag confirmed + st.pending_push_tag = true; + st.run_action = Some(1); + } + _ => {} + } + } + _ => {} + } + true + } + } +} + +// ── main key handling ───────────────────────────────────────────────────────── + fn handle_key(code: KeyCode, st: &mut St) { st.status_msg = None; match code { KeyCode::Char('q') | KeyCode::Esc => st.quit = true, - KeyCode::Tab => { - st.tab = match st.tab { Tab::Overview => Tab::History, Tab::History => Tab::Files, Tab::Files => Tab::Overview }; + // Tab switching: Tab, arrows, number keys + KeyCode::Tab | KeyCode::Right => { + st.tab = st.tab.next(); + maybe_load_settings(st); + maybe_load_prs_issues(st); } - KeyCode::Char('1') => st.tab = Tab::Overview, - KeyCode::Char('2') => st.tab = Tab::History, - KeyCode::Char('3') => st.tab = Tab::Files, + KeyCode::Left => { + st.tab = st.tab.prev(); + maybe_load_settings(st); + maybe_load_prs_issues(st); + } + KeyCode::Char('1') => { st.tab = Tab::Overview; } + KeyCode::Char('2') => { st.tab = Tab::Commits; } + KeyCode::Char('3') => { st.tab = Tab::Branches; } + KeyCode::Char('4') => { st.tab = Tab::Files; } + KeyCode::Char('5') => { st.tab = Tab::Stats; } + KeyCode::Char('6') => { + st.tab = Tab::Settings; + maybe_load_settings(st); + } + KeyCode::Char('7') => { + st.tab = Tab::PRs; + maybe_load_prs_issues(st); + } + KeyCode::Char('8') => { + st.tab = Tab::Issues; + maybe_load_prs_issues(st); + } + KeyCode::Char('9') => { st.tab = Tab::Todos; } + // Vertical navigation KeyCode::Down | KeyCode::Char('j') => match st.tab { - Tab::History => st.hist_off += 1, - Tab::Files => st.file_off += 1, - Tab::Overview => st.ov_scroll += 1, + Tab::Overview => st.ov_scroll = st.ov_scroll.saturating_add(1), + Tab::Commits => { + let max = st.history.len().saturating_sub(1); + st.hist_off = (st.hist_off + 1).min(max); + } + Tab::Branches => { + let max = st.branches.len().saturating_sub(1); + st.branch_cursor = (st.branch_cursor + 1).min(max); + } + Tab::Files => { + if st.tree_cursor + 1 < st.tree_visible.len() { + st.tree_cursor += 1; + st.run_action = Some(40); + } + } + Tab::Stats => st.stats_off = st.stats_off.saturating_add(1), + Tab::Settings => st.settings_cur = (st.settings_cur + 1).min(14), + Tab::PRs => st.pr_cursor = (st.pr_cursor + 1).min(st.repo_prs.len().saturating_sub(1)), + Tab::Issues => st.issue_cursor = (st.issue_cursor + 1).min(st.repo_issues.len().saturating_sub(1)), + Tab::Todos => st.todo_cursor = (st.todo_cursor + 1).min(st.repo_todos.len().saturating_sub(1)), }, KeyCode::Up | KeyCode::Char('k') => match st.tab { - Tab::History => st.hist_off = st.hist_off.saturating_sub(1), - Tab::Files => st.file_off = st.file_off.saturating_sub(1), - Tab::Overview => st.ov_scroll = st.ov_scroll.saturating_sub(1), + Tab::Overview => st.ov_scroll = st.ov_scroll.saturating_sub(1), + Tab::Commits => st.hist_off = st.hist_off.saturating_sub(1), + Tab::Branches => { st.branch_cursor = st.branch_cursor.saturating_sub(1); } + Tab::Files => { + if st.tree_cursor > 0 { + st.tree_cursor -= 1; + st.run_action = Some(40); + } + } + Tab::Stats => st.stats_off = st.stats_off.saturating_sub(1), + Tab::Settings => st.settings_cur = st.settings_cur.saturating_sub(1), + Tab::PRs => st.pr_cursor = st.pr_cursor.saturating_sub(1), + Tab::Issues => st.issue_cursor = st.issue_cursor.saturating_sub(1), + Tab::Todos => st.todo_cursor = st.todo_cursor.saturating_sub(1), }, KeyCode::PageDown => match st.tab { - Tab::History => st.hist_off = (st.hist_off + 10).min(st.history.len().saturating_sub(1)), - Tab::Files => st.file_off += 10, - Tab::Overview => st.ov_scroll += 5, + Tab::Commits => { + let max = st.history.len().saturating_sub(1); + st.hist_off = (st.hist_off + 10).min(max); + } + Tab::Files => { + let max = st.tree_visible.len().saturating_sub(1); + st.tree_cursor = (st.tree_cursor + 10).min(max); + st.run_action = Some(40); + } + Tab::PRs => st.pr_cursor = (st.pr_cursor + 10).min(st.repo_prs.len().saturating_sub(1)), + Tab::Issues => st.issue_cursor = (st.issue_cursor + 10).min(st.repo_issues.len().saturating_sub(1)), + Tab::Todos => st.todo_cursor = (st.todo_cursor + 10).min(st.repo_todos.len().saturating_sub(1)), + _ => {} }, KeyCode::PageUp => match st.tab { - Tab::History => st.hist_off = st.hist_off.saturating_sub(10), - Tab::Files => st.file_off = st.file_off.saturating_sub(10), - Tab::Overview => st.ov_scroll = st.ov_scroll.saturating_sub(5), + Tab::Commits => st.hist_off = st.hist_off.saturating_sub(10), + Tab::Files => { + st.tree_cursor = st.tree_cursor.saturating_sub(10); + st.run_action = Some(40); + } + Tab::PRs => st.pr_cursor = st.pr_cursor.saturating_sub(10), + Tab::Issues => st.issue_cursor = st.issue_cursor.saturating_sub(10), + Tab::Todos => st.todo_cursor = st.todo_cursor.saturating_sub(10), + _ => {} }, - KeyCode::Char('s') => { st.run_action = Some(0); } - KeyCode::Char('r') => { st.run_action = Some(1); } + // Enter + KeyCode::Enter => match st.tab { + Tab::Files => { + if let Some(&ni) = st.tree_visible.get(st.tree_cursor) { + let node = &mut st.tree_nodes[ni]; + if node.is_dir { + node.expanded = !node.expanded; + let new_vis = rebuild_tree_visible(&st.tree_nodes); + st.tree_visible = new_vis; + st.tree_cursor = st.tree_cursor.min(st.tree_visible.len().saturating_sub(1)); + st.file_preview = vec![]; + } + } + } + Tab::Branches => { st.run_action = Some(30); } + Tab::Settings => open_settings_modal(st), + Tab::PRs => { + if let Some(pr) = st.repo_prs.get(st.pr_cursor) { + let url = pr.url.clone(); + crate::util::open_path(&url); + st.status_msg = Some("Opening PR in browser...".into()); + } + } + Tab::Issues => { + if let Some(issue) = st.repo_issues.get(st.issue_cursor) { + let url = issue.url.clone(); + crate::util::open_path(&url); + st.status_msg = Some("Opening issue in browser...".into()); + } + } + Tab::Todos => { + use crate::todos::TodoStatus; + if let Some(t) = st.repo_todos.get_mut(st.todo_cursor) { + t.status = match t.status { + TodoStatus::Open => TodoStatus::InProgress, + TodoStatus::InProgress => TodoStatus::Done, + TodoStatus::Done => TodoStatus::Cancelled, + TodoStatus::Cancelled => TodoStatus::Open, + }; + t.touch(); + let all = crate::todos::load_manual(); + let updated_t = t.clone(); + let new_todos: Vec<_> = all.into_iter() + .map(|x| if x.id == updated_t.id { updated_t.clone() } else { x }) + .collect(); + let _ = crate::todos::save(&new_todos); + } + } + _ => {} + }, + + // Branch-specific keys + KeyCode::Char('D') if st.tab == Tab::Branches => { + if let Some(b) = st.branches.get(st.branch_cursor) { + if b.is_current { + st.status_msg = Some("Cannot delete the current branch".into()); + } else { + let name = b.name.clone(); + st.modal = DetailModal::ConfirmAction { + message: format!("Delete branch '{}'?", name), + tag: 31, + }; + } + } + } + KeyCode::Char('P') if st.tab == Tab::Branches => { st.run_action = Some(32); } + KeyCode::Char('n') if st.tab == Tab::Branches => { + st.modal = DetailModal::NewBranch { input: String::new(), error: None }; + } + + // Stats period change + KeyCode::Char('p') if st.tab == Tab::Stats => { + st.stats_period = st.stats_period.next(); + st.stats_off = 0; + st.run_action = Some(41); + } + KeyCode::Char('<') if st.tab == Tab::Stats => { + st.stats_period = st.stats_period.prev(); + st.stats_off = 0; + st.run_action = Some(41); + } + KeyCode::Char('>') if st.tab == Tab::Stats => { + st.stats_period = st.stats_period.next(); + st.stats_off = 0; + st.run_action = Some(41); + } + + // Preview scroll (Files tab, right panel) + KeyCode::Char(']') if st.tab == Tab::Files => { + st.preview_off = st.preview_off.saturating_add(5); + } + KeyCode::Char('[') if st.tab == Tab::Files => { + st.preview_off = st.preview_off.saturating_sub(5); + } + // Open selected file in editor (Files tab) + KeyCode::Char('e') if st.tab == Tab::Files => { + if let Some(&ni) = st.tree_visible.get(st.tree_cursor) { + let node = &st.tree_nodes[ni]; + if !node.is_dir { + st.pending_file = Some(st.summary.path.join(&node.rel_path)); + st.run_action = Some(110); + st.status_msg = Some("Opening file...".into()); + } + } + } + + // Global actions + KeyCode::Char('s') => { + st.modal = DetailModal::SelectOption { + title: "Sync".into(), + options: vec!["Pull".into(), "Commit + Push".into(), "Push only".into()], + cursor: 0, + tag: 90, + }; + } + KeyCode::Char('r') => { + st.modal = DetailModal::EditText { + title: "Release — Tag Name".into(), + prompt: "Tag name (e.g. v1.0.0):".into(), + input: String::new(), + tag: 92, + }; + } KeyCode::Char('W') => { if let Ok(repo) = Repository::open(&st.summary.path) { if let Ok(remote) = repo.find_remote("origin") { if let Some(url) = remote.url() { let web = to_web_url(url); - let _ = std::process::Command::new("xdg-open").arg(&web).spawn(); + crate::util::open_path(&web); st.status_msg = Some("Opening in browser...".into()); } } } } - KeyCode::Char('o') => { - let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".into()); - let _ = std::process::Command::new(&editor).arg(&st.summary.path).spawn(); - st.status_msg = Some(format!("Opened in {}", editor)); - } + KeyCode::Char('o') => { st.run_action = Some(106); } KeyCode::Char('y') => { let path = st.summary.path.to_string_lossy().to_string(); if copy_to_clipboard(&path) { st.status_msg = Some("Path copied!".into()); } } KeyCode::Char('u') => { - if let Ok(repo) = Repository::open(&st.summary.path) { - if let Ok(remote) = repo.find_remote("origin") { - if let Some(url) = remote.url() { - if copy_to_clipboard(url) { st.status_msg = Some("URL copied!".into()); } - } - } + if let Some(ref url) = st.remote_url_str.clone() { + if copy_to_clipboard(url) { st.status_msg = Some("URL copied!".into()); } } } KeyCode::Char('g') => { - let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".into()); - let gi = st.summary.path.join(".gitignore"); - let _ = std::process::Command::new(&editor).arg(&gi).spawn(); + st.run_action = Some(107); st.status_msg = Some("Opening .gitignore...".into()); } - KeyCode::Char('c') => { st.run_action = Some(10); } KeyCode::Char('S') => { let ok = git_run_ok(&st.summary.path, &["stash"]); st.status_msg = Some(if ok { "Stashed.".into() } else { "Stash failed.".into() }); @@ -187,58 +1276,258 @@ fn handle_key(code: KeyCode, st: &mut St) { st.status_msg = Some(if ok { "Stash applied.".into() } else { "No stash / error.".into() }); if ok { st.stash_count = st.stash_count.saturating_sub(1); } } - KeyCode::Char('a') if st.tab == Tab::Files => { - st.show_all_files = !st.show_all_files; - st.file_off = 0; - } _ => {} } } +fn maybe_load_settings(st: &mut St) { + if st.tab == Tab::Settings && st.settings.is_none() { + st.run_action = Some(50); + } +} + +fn maybe_load_prs_issues(st: &mut St) { + if (st.tab == Tab::PRs || st.tab == Tab::Issues) && !st.prs_loaded && st.prs_issues_rx.is_none() { + st.run_action = Some(80); + } +} + +// ── exec_action ─────────────────────────────────────────────────────────────── + async fn exec_action(st: &mut St, idx: usize) -> Result<()> { - let path = &st.summary.path; + let path = st.summary.path.clone(); match idx { + // ── 0: sync ────────────────────────────────────────────────────────── 0 => { println!("\n── Sync: {} ──\n", st.summary.name); - let mode = inquire::Select::new("Action:", vec!["pull", "commit + push", "push only"]) - .with_page_size(3).prompt_skippable()?; - if let Some(m) = mode { - match m { - "pull" => git_run(path, &["pull"]), - "commit + push" => { - let msg = inquire::Text::new("Commit message:").with_default("chore: update").prompt()?; - git_run(path, &["add", "-A"]); - git_run(path, &["commit", "-m", &msg]); - git_run(path, &["push"]); - } - _ => git_run(path, &["push"]), + let mode = st.pending_sync_mode.take().unwrap_or_else(|| "pull".into()); + let commit_msg = st.pending_commit_msg.take().unwrap_or_default(); + match mode.as_str() { + "pull" => { git_run(&path, &["pull"]); } + "commit_push" => { + git_run(&path, &["add", "-A"]); + git_run(&path, &["commit", "-m", &commit_msg]); + git_run(&path, &["push"]); + } + _ => { git_run(&path, &["push"]); } // push_only + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + } + + // ── 1: release ─────────────────────────────────────────────────────── + 1 => { + println!("\n── Release: {} ──\n", st.summary.name); + let tag_name = st.pending_tag_name.take().unwrap_or_default(); + let tag_msg = st.pending_tag_msg.take().unwrap_or_else(|| tag_name.clone()); + let do_push = std::mem::replace(&mut st.pending_push_tag, false); + if !tag_name.is_empty() { + git_run(&path, &["tag", "-a", &tag_name, "-m", &tag_msg]); + if do_push { + git_run(&path, &["push", "origin", &tag_name]); + } + st.history = build_history(&path, 200); + st.total_tags += 1; + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + } + + // ── 30: checkout branch ────────────────────────────────────────────── + 30 => { + if let Some(b) = st.branches.get(st.branch_cursor) { + let name = b.name.clone(); + if b.is_current { + st.status_msg = Some(format!("Already on '{}'", name)); + return Ok(()); + } + let ok = checkout_branch(&path, &name); + if ok { + st.summary.branch = name.clone(); + st.branches = list_branches(&path); + st.status_msg = Some(format!("Switched to '{}'", name)); + } else { + st.status_msg = Some("Checkout failed (uncommitted changes?)".into()); } } } - 1 => { - println!("\n── Release: {} ──\n", st.summary.name); - let tag = inquire::Text::new("Tag name (e.g. v1.0.0):").prompt()?; - let msg = inquire::Text::new("Tag message:").with_default(&tag).prompt()?; - git_run(path, &["tag", "-a", &tag, "-m", &msg]); - if inquire::Confirm::new("Push tag?").with_default(true).prompt()? { - git_run(path, &["push", "origin", &tag]); - } - st.history = build_history(path, 60); - st.total_tags += 1; + + // ── 31: delete branch (now handled in-TUI via ConfirmAction modal) ── + 31 => {} + + // ── 106-109: open in editor (leave_tui is handled by run_detail_loop) ─ + 106 => { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".into()); + let _ = std::process::Command::new(&editor).arg(&path).status(); } - 10 => { - println!("\n── New branch in: {} ──\n", st.summary.name); - let name = inquire::Text::new("Branch name:").prompt()?; - if !name.trim().is_empty() { - git_run(path, &["checkout", "-b", &name]); - st.branch_count += 1; - st.summary.branch = name; + 107 => { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".into()); + let _ = std::process::Command::new(&editor).arg(path.join(".gitignore")).status(); + } + 108 => { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".into()); + let _ = std::process::Command::new(&editor).arg(path.join("LICENSE")).status(); + } + 109 => { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".into()); + let readme = ["README.md", "README.rst", "README"].iter() + .map(|f| path.join(f)).find(|p| p.exists()) + .unwrap_or_else(|| path.join("README.md")); + let _ = std::process::Command::new(&editor).arg(&readme).status(); + } + + // ── 110: open pending_file in editor ──────────────────────────────── + 110 => { + if let Some(file) = st.pending_file.take() { + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".into()); + let _ = std::process::Command::new(&editor).arg(&file).status(); } } + + // ── 32: push branch ────────────────────────────────────────────────── + 32 => { + if let Some(b) = st.branches.get(st.branch_cursor) { + let name = b.name.clone(); + println!("\n── Push '{}' ──\n", name); + let ok = push_branch(&path, &name); + if ok { + st.branches = list_branches(&path); + st.status_msg = Some(format!("Pushed '{}'", name)); + } else { + st.status_msg = Some("Push failed".into()); + } + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + } + + // ── 50: load settings from GitHub API ─────────────────────────────── + 50 => { + let local_visibility = st.summary.visibility.clone().unwrap_or_else(|| "unknown".into()); + match try_load_settings(st).await { + Ok(_) => {} + Err(_) => { + st.settings = Some(RepoSettings { + visibility: local_visibility, + description: String::new(), + topics: vec![], + gh_auth_ok: false, + }); + } + } + } + + // ── 60: toggle visibility ──────────────────────────────────────────── + 60 => { + println!("\n── Visibility: {} ──\n", st.summary.name); + let choice = inquire::Select::new("Set visibility:", vec!["public", "private"]) + .prompt_skippable()?; + if let Some(vis) = choice { + let private = vis == "private"; + match try_make_provider(st).await { + Ok(provider) => match provider.update_visibility(&st.summary.name, private).await { + Ok(_) => { + let v = vis.to_string(); + st.summary.visibility = Some(v.clone()); + if let Some(s) = &mut st.settings { s.visibility = v; } + st.status_msg = Some(format!("Visibility → {}", vis)); + } + Err(e) => { println!("API error: {}", e); } + } + Err(e) => { println!("Auth error: {}", e); } + } + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + } + + // ── 61: edit description ───────────────────────────────────────────── + 61 => { + println!("\n── Description ──\n"); + let current = st.settings.as_ref().map(|s| s.description.as_str()).unwrap_or("").to_string(); + if let Some(desc) = inquire::Text::new("Description:").with_default(¤t).prompt_skippable()? { + match try_make_provider(st).await { + Ok(provider) => match provider.update_description(&st.summary.name, &desc).await { + Ok(_) => { + if let Some(s) = &mut st.settings { s.description = desc; } + st.status_msg = Some("Description updated".into()); + } + Err(e) => { println!("API error: {}", e); } + } + Err(e) => { println!("Auth error: {}", e); } + } + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + } + + // ── 62: edit topics ────────────────────────────────────────────────── + 62 => { + println!("\n── Topics ──\n"); + let current = st.settings.as_ref().map(|s| s.topics.join(", ")).unwrap_or_default(); + if let Some(raw) = inquire::Text::new("Topics (comma-separated):").with_default(¤t).prompt_skippable()? { + let topics: Vec = raw.split(',').map(|s| s.trim().to_lowercase().to_string()) + .filter(|s| !s.is_empty()).collect(); + match try_make_provider(st).await { + Ok(provider) => match provider.set_topics(&st.summary.name, &topics).await { + Ok(_) => { + if let Some(s) = &mut st.settings { s.topics = topics; } + st.status_msg = Some("Topics updated".into()); + } + Err(e) => { println!("API error: {}", e); } + } + Err(e) => { println!("Auth error: {}", e); } + } + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + } + + // ── 63: add collaborator ───────────────────────────────────────────── + 63 => { + println!("\n── Add Collaborator ──\n"); + if let Some(user) = inquire::Text::new("GitHub username:").prompt_skippable()? { + if !user.trim().is_empty() { + let role = inquire::Select::new("Role:", vec!["pull", "push", "admin"]) + .prompt_skippable()?; + if let Some(role) = role { + match try_make_provider(st).await { + Ok(provider) => match provider.add_collaborator(&st.summary.name, user.trim(), role).await { + Ok(_) => { st.status_msg = Some(format!("Invited {}", user.trim())); } + Err(e) => { println!("API error: {}", e); } + } + Err(e) => { println!("Auth error: {}", e); } + } + } + } + } + println!("\n Press Enter to return..."); + let _ = std::io::stdin().lines().next(); + } + _ => {} } - println!("\n Press Enter to return..."); - let _ = std::io::stdin().lines().next(); + Ok(()) +} + +// ── provider helpers ────────────────────────────────────────────────────────── + +async fn try_make_provider(st: &St) -> Result> { + let accounts = crate::auth::load_accounts_path(&st.cfg_data_dir)?; + let account = crate::providers::default_account(&accounts, Some("github")) + .ok_or_else(|| anyhow::anyhow!("no github account — run: fxg auth import"))?; + crate::providers::make_provider(account) +} + +async fn try_load_settings(st: &mut St) -> Result<()> { + let provider = try_make_provider(st).await?; + let meta = provider.repo_meta(&st.summary.name).await?; + st.settings = Some(RepoSettings { + visibility: st.summary.visibility.clone().unwrap_or_else(|| "unknown".into()), + description: meta.description.unwrap_or_default(), + topics: meta.topics, + gh_auth_ok: true, + }); Ok(()) } diff --git a/src/detail/render.rs b/src/detail/render.rs index 10f5baa..339f496 100644 --- a/src/detail/render.rs +++ b/src/detail/render.rs @@ -1,365 +1,1342 @@ -use super::{HistEntry, St, Tab}; +use super::{DetailModal, HistEntry, St, StatsPeriod, Tab}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table}, + widgets::{Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row, Table}, Frame, }; +// ── top-level draw ──────────────────────────────────────────────────────────── + pub fn draw(f: &mut Frame, st: &St) { let area = f.area(); let vert = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(2), Constraint::Fill(1), Constraint::Length(1)]) + .constraints([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(3)]) .split(area); - render_header(f, st, vert[0]); + render_topbar(f, st, vert[0]); - let body = Layout::default() + let cols = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Fill(1), Constraint::Length(24)]) + .constraints([Constraint::Percentage(73), Constraint::Percentage(27)]) .split(vert[1]); - match st.tab { - Tab::Overview => render_overview(f, st, body[0]), - Tab::History => render_history(f, st, body[0]), - Tab::Files => render_files(f, st, body[0]), - } - render_sidebar(f, st, body[1]); + let left_blk = Block::default().borders(Borders::ALL); + let left_inner = left_blk.inner(cols[0]); + f.render_widget(left_blk, cols[0]); - let hint = if let Some(ref msg) = st.status_msg { - format!(" {} — press any key to continue", msg) - } else { - match st.tab { - Tab::Overview => " [↑↓/jk] scroll [Tab/1-3] tab [q] back to hub".into(), - Tab::History => " [↑↓/jk] scroll [PgUp/Dn] fast [Tab/1-3] tab [q] back".into(), - Tab::Files => " [a] toggle all/changed [↑↓] scroll [g] edit .gitignore [q] back".into(), + match st.tab { + Tab::Overview => render_overview(f, st, left_inner), + Tab::Commits => render_commits(f, st, left_inner), + Tab::Branches => render_branches(f, st, left_inner), + Tab::Files => render_file_tree(f, st, left_inner), + Tab::Stats => render_stats(f, st, left_inner), + Tab::Settings => render_settings(f, st, left_inner), + Tab::PRs => render_prs(f, st, left_inner), + Tab::Issues => render_issues(f, st, left_inner), + Tab::Todos => render_repo_todos(f, st, left_inner), + } + + render_right(f, st, cols[1]); + render_bottombar(f, st, vert[2]); + + // Modal overlay — rendered last so it's on top + render_modal(f, st, area); +} + +// ── top bar ─────────────────────────────────────────────────────────────────── + +fn render_topbar(f: &mut Frame, st: &St, area: Rect) { + let r = &st.summary; + + 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), Style::default().fg(Color::DarkGray)) } }; - f.render_widget( - Paragraph::new(hint.as_str()).style(Style::default().fg(Color::DarkGray)), - vert[2], - ); -} -fn render_header(f: &mut Frame, st: &St, area: Rect) { - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Length(1)]) - .split(area); - - let r = &st.summary; - let owner_s = r.owner.as_deref().map(|o| format!(" @{}", o)).unwrap_or_default(); - let stars_s = r.stars.map(|s| format!(" ★ {}", s)).unwrap_or_default(); - let issues_s = r.open_issues.map(|i| if i > 0 { format!(" ⚠ {} issues", i) } else { String::new() }).unwrap_or_default(); - let prs_s = r.open_prs.map(|p| if p > 0 { format!(" ↔ {} PRs", p) } else { String::new() }).unwrap_or_default(); - - f.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(format!(" {}", r.name), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), - Span::styled(owner_s, Style::default().fg(Color::DarkGray)), - Span::styled(format!(" ⎇ {}", r.branch), Style::default().fg(Color::Cyan)), - if r.is_dirty { Span::styled(" ⚠ dirty", Style::default().fg(Color::Yellow)) } - else { Span::styled(" ✓ clean", Style::default().fg(Color::DarkGray)) }, - Span::styled(stars_s, Style::default().fg(Color::Yellow)), - Span::styled(issues_s, Style::default().fg(Color::Red)), - Span::styled(prs_s, Style::default().fg(Color::Cyan)), - ])), - rows[0], - ); - - let tab_s = |label: &str, on: bool| { - if on { Span::styled(format!(" {} ", label), Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)) } - else { Span::styled(format!(" {} ", label), Style::default().fg(Color::DarkGray)) } + let sync_span = match (r.ahead, r.behind) { + (0, 0) => Span::styled(" ✓", Style::default().fg(Color::DarkGray)), + (a, 0) => Span::styled(format!(" ↑{}", a), Style::default().fg(Color::Cyan)), + (0, b) => Span::styled(format!(" ↓{}", b), Style::default().fg(Color::Red)), + (a, b) => Span::styled(format!(" ↑{} ↓{}", a, b), Style::default().fg(Color::Yellow)), }; - f.render_widget( - Paragraph::new(Line::from(vec![ - tab_s("[1] Overview", st.tab == Tab::Overview), - Span::raw(" "), - tab_s("[2] History", st.tab == Tab::History), - Span::raw(" "), - tab_s("[3] Files", st.tab == Tab::Files), - ])), - rows[1], - ); -} - -fn render_sidebar(f: &mut Frame, st: &St, area: Rect) { - let blk = Block::default().title(" Actions ").borders(Borders::LEFT) - .border_style(Style::default().fg(Color::DarkGray)); - let inner = blk.inner(area); - f.render_widget(blk, area); - - let act = |key: &'static str, label: &'static str, col: Color| -> Line<'static> { - Line::from(vec![ - Span::styled(format!(" [{}]", key), Style::default().fg(col).add_modifier(Modifier::BOLD)), - Span::styled(format!(" {}", label), Style::default().fg(Color::White)), - ]) - }; - let sep = || Line::from(Span::styled(" ─────────────────", Style::default().fg(Color::DarkGray))); - let stat_line = |label: &str, val: String, col: Color| -> Line<'static> { - Line::from(vec![ - Span::styled(format!(" {:<10}", label), Style::default().fg(Color::DarkGray)), - Span::styled(val, Style::default().fg(col)), - ]) + let dirty_span = if r.is_dirty { + Span::styled(" ⚠ dirty", Style::default().fg(Color::Yellow)) + } else { + Span::styled(" ✓ clean", Style::default().fg(Color::DarkGray)) }; - let mut lines: Vec = vec![ - stat_line("Branch", st.summary.branch.chars().take(11).collect(), Color::Cyan), - stat_line("Tags", st.total_tags.to_string(), Color::Yellow), - stat_line("Stashes", st.stash_count.to_string(), Color::DarkGray), - stat_line("Branches", st.branch_count.to_string(), Color::DarkGray), - sep(), - act("s", "Sync", Color::Green), - act("r", "Release", Color::Green), - sep(), - act("o", "Editor", Color::Cyan), - act("W", "Browser", Color::Cyan), - act("y", "Copy path", Color::Cyan), - act("u", "Copy URL", Color::Cyan), - act("g", ".gitignore", Color::Cyan), - sep(), - act("c", "New branch", Color::White), - act("S", "Stash", Color::White), - act("A", "Pop stash", Color::White), - sep(), - act("q", "← Back", Color::DarkGray), + let mut spans = vec![ + Span::styled(" ← ", Style::default().fg(Color::DarkGray)), + Span::styled(r.name.clone(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Span::styled(format!(" ⎇ {}", r.branch), Style::default().fg(Color::Cyan)), + sync_span, + dirty_span, + Span::raw(" "), + tab_s("1 Overview", st.tab == Tab::Overview), + Span::raw(" "), + tab_s("2 Commits", st.tab == Tab::Commits), + Span::raw(" "), + tab_s("3 Branches", st.tab == Tab::Branches), + Span::raw(" "), + tab_s("4 Files", st.tab == Tab::Files), + Span::raw(" "), + tab_s("5 Stats", st.tab == Tab::Stats), + Span::raw(" "), + tab_s("6 Settings", st.tab == Tab::Settings), + Span::raw(" "), + tab_s("7 PRs", st.tab == Tab::PRs), + Span::raw(" "), + tab_s("8 Issues", st.tab == Tab::Issues), + Span::raw(" "), + tab_s("9 Todos", st.tab == Tab::Todos), ]; 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); +} + +// ── 1. Overview tab ─────────────────────────────────────────────────────────── + +fn render_overview(f: &mut Frame, st: &St, area: Rect) { + let r = &st.summary; + let dim = Style::default().fg(Color::DarkGray); + let wht = Style::default().fg(Color::White); + let cyn = Style::default().fg(Color::Cyan); + let yel = Style::default().fg(Color::Yellow); + let w = area.width.saturating_sub(2) as usize; + + let kv = |k: &str, v: &str, col: Color| -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {:<16}", k), dim), + Span::styled(v.chars().take(w.saturating_sub(18)).collect::(), Style::default().fg(col)), + ]) + }; + let sep = || dsep(area.width); + + let mut lines: Vec = vec![]; + + // ── location & links ── + lines.push(Line::from(Span::styled(" Location & Links", yel.add_modifier(Modifier::BOLD)))); + lines.push(sep()); + lines.push(kv("Path", &st.summary.path.to_string_lossy(), Color::DarkGray)); + if let Some(ref url) = st.remote_url_str { + lines.push(kv("GitHub URL", url, Color::Cyan)); + } else { + lines.push(kv("GitHub URL", "(no remote)", Color::DarkGray)); + } + lines.push(Line::from("")); + + // ── repository stats ── + lines.push(Line::from(Span::styled(" Repository", yel.add_modifier(Modifier::BOLD)))); + lines.push(sep()); + + let dirty_txt = if r.is_dirty { format!("⚠ {} changed", st.files.len()) } else { "✓ clean".into() }; + let sync_txt = match (r.ahead, r.behind) { + (0, 0) => "in sync".to_string(), + (a, 0) => format!("↑{} ahead", a), + (0, b) => format!("↓{} behind", b), + (a, b) => format!("↑{} ↓{}", a, b), + }; + let age_txt = if st.repo_age_days > 365 { + format!("{} years old", st.repo_age_days / 365) + } else if st.repo_age_days > 30 { + format!("{} months old", st.repo_age_days / 30) + } else { + format!("{} days old", st.repo_age_days) + }; + + lines.push(kv("Total commits", &st.total_commits.to_string(), Color::White)); + lines.push(kv("Total releases", &st.total_tags.to_string(), Color::Yellow)); + lines.push(kv("Branches", &st.branch_count.to_string(), Color::White)); + lines.push(kv("Tracked files", &st.file_count.to_string(), Color::DarkGray)); + lines.push(kv("Working tree", &dirty_txt, if r.is_dirty { Color::Yellow } else { Color::DarkGray })); + lines.push(kv("Remote", &sync_txt, if r.behind > 0 { Color::Red } else if r.ahead > 0 { Color::Cyan } else { Color::DarkGray })); + lines.push(kv("Age", &age_txt, Color::DarkGray)); + if let Some(lang) = &r.lang { + lines.push(kv("Languages", lang, Color::DarkGray)); + } + lines.push(Line::from("")); + + // ── latest version + recent releases ── + let tags: Vec<_> = st.history.iter().filter_map(|h| { + if let HistEntry::Tag { name, age, .. } = h { Some((name.clone(), age.clone())) } else { None } + }).collect(); + + if !tags.is_empty() { + lines.push(Line::from(Span::styled(" Releases", yel.add_modifier(Modifier::BOLD)))); lines.push(sep()); - let trunc: String = msg.chars().take(20).collect(); - lines.push(Line::from(Span::styled(format!(" {}", trunc), Style::default().fg(Color::Green)))); + for (name, age) in tags.iter().take(3) { + lines.push(Line::from(vec![ + Span::styled(format!(" {:<11} ", age), dim), + Span::styled(name.clone(), cyn), + ])); + } + lines.push(Line::from("")); + } + + // ── recent commits ── + if !st.ov_recent.is_empty() { + lines.push(Line::from(Span::styled(" Recent Commits", yel.add_modifier(Modifier::BOLD)))); + lines.push(sep()); + let skip = st.ov_scroll.min(st.ov_recent.len().saturating_sub(1)); + for (age, msg) in st.ov_recent.iter().skip(skip).take(5) { + let msg_t: String = msg.chars().take(w.saturating_sub(14)).collect(); + lines.push(Line::from(vec![ + Span::styled(format!(" {:<11} ", age), dim), + Span::styled(msg_t, wht), + ])); + } + lines.push(Line::from("")); + } + + // ── top contributors ── + if !st.contributors.is_empty() { + lines.push(Line::from(Span::styled(" Top Contributors", yel.add_modifier(Modifier::BOLD)))); + lines.push(sep()); + for c in st.contributors.iter().take(3) { + lines.push(Line::from(vec![ + Span::styled(format!(" {:<25}", c.name.chars().take(25).collect::()), wht), + Span::styled(format!("{} commits", c.count), dim), + ])); + } + lines.push(Line::from("")); + } + + // ── active branches ── + if !st.branches.is_empty() { + lines.push(Line::from(Span::styled(" Active Branches", yel.add_modifier(Modifier::BOLD)))); + lines.push(sep()); + for b in st.branches.iter().take(4) { + let marker = if b.is_current { "▶ " } else { " " }; + let sync = match (b.ahead, b.behind) { + (0, 0) => String::new(), + (a, 0) => format!(" ↑{}", a), + (0, b) => format!(" ↓{}", b), + (a, b) => format!(" ↑{}↓{}", a, b), + }; + lines.push(Line::from(vec![ + Span::styled(format!(" {}", marker), if b.is_current { cyn } else { dim }), + Span::styled(b.name.chars().take(25).collect::(), if b.is_current { cyn } else { wht }), + Span::styled(sync, dim), + ])); + } + } + + f.render_widget(Paragraph::new(lines), area); +} + +// ── 2. Commits tab ─────────────────────────────────────────────────────────── + +fn render_commits(f: &mut Frame, st: &St, area: Rect) { + let avail = area.height.saturating_sub(1) as usize; + let ahead = st.summary.ahead; // first N commits are local-only + let header = Row::new(vec!["", "SHA", "AGE", "AUTHOR", "MESSAGE"]) + .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + + let mut commit_idx = 0usize; // count Commit entries seen so far (for ahead marker) + let rows: Vec = st.history.iter() + .skip(st.hist_off) + .take(avail) + .map(|entry| { + match entry { + HistEntry::Tag { name, age, msg, .. } => Row::new(vec![ + Cell::from("tag").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Cell::from(name.clone()).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Cell::from(age.clone()).style(Style::default().fg(Color::DarkGray)), + Cell::from(""), + Cell::from(if msg.is_empty() { "(lightweight tag)".into() } else { msg.clone() }) + .style(Style::default().fg(Color::DarkGray)), + ]), + HistEntry::Commit { sha, age, author, msg, .. } => { + let is_local = commit_idx < ahead; + commit_idx += 1; + let marker = if is_local { "↑" } else { "●" }; + let marker_col = if is_local { Color::Cyan } else { Color::DarkGray }; + let msg_col = if is_local { Color::White } else { Color::Reset }; + Row::new(vec![ + Cell::from(marker).style(Style::default().fg(marker_col)), + Cell::from(sha.clone()).style(Style::default().fg(Color::DarkGray)), + Cell::from(age.clone()).style(Style::default().fg(Color::DarkGray)), + Cell::from(format!("@{}", author)).style(Style::default().fg(Color::Cyan)), + Cell::from(msg.clone()).style(Style::default().fg(msg_col)), + ]) + } + } + }) + .collect(); + + // Legend for local-only indicator + let legend = if ahead > 0 { + Rect::new(area.x, area.y + area.height.saturating_sub(1), area.width, 1) + } else { + Rect::new(0, 0, 0, 0) + }; + let table_area = if ahead > 0 { + Rect::new(area.x, area.y, area.width, area.height.saturating_sub(1)) + } else { + area + }; + + f.render_widget( + Table::new(rows, [ + Constraint::Length(2), + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(14), + Constraint::Fill(1), + ]).header(header), + table_area, + ); + + if ahead > 0 { + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" ↑ ", Style::default().fg(Color::Cyan)), + Span::styled(format!("{} local-only commit(s) not yet pushed", ahead), Style::default().fg(Color::DarkGray)), + ])), + legend, + ); + } +} + +// ── 3. Branches tab ────────────────────────────────────────────────────────── + +fn render_branches(f: &mut Frame, st: &St, area: Rect) { + if st.branches.is_empty() { + f.render_widget( + Paragraph::new(" No branches found").style(Style::default().fg(Color::DarkGray)), + area, + ); + return; + } + + let avail = area.height.saturating_sub(1) as usize; + let header = Row::new(vec!["", "BRANCH", "UPSTREAM", "+", "-", "AGE", "LAST COMMIT"]) + .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + + let scroll = st.branch_cursor.saturating_sub(avail.saturating_sub(1)); + + let rows: Vec = st.branches.iter() + .enumerate() + .skip(scroll) + .take(avail) + .map(|(i, b)| { + let is_sel = i + scroll == st.branch_cursor; + let sel_bg = if is_sel { Color::DarkGray } else { Color::Reset }; + let name_col= if b.is_current { Color::Cyan } else { Color::White }; + let cur_mark= if b.is_current { "▶" } else { " " }; + let ahead_col = if b.ahead > 0 { Color::Green } else { Color::DarkGray }; + let behind_col= if b.behind > 0 { Color::Red } else { Color::DarkGray }; + + let msg: String = b.last_commit_msg.chars() + .take(area.width.saturating_sub(70).max(10) as usize).collect(); + + Row::new(vec![ + Cell::from(cur_mark).style(Style::default().fg(Color::Cyan).bg(sel_bg)), + Cell::from(b.name.clone()).style(Style::default().fg(name_col).bg(sel_bg).add_modifier(if b.is_current { Modifier::BOLD } else { Modifier::empty() })), + Cell::from(b.upstream.as_deref().unwrap_or("—")).style(Style::default().fg(Color::DarkGray).bg(sel_bg)), + Cell::from(if b.ahead > 0 { b.ahead.to_string() } else { "—".into() }).style(Style::default().fg(ahead_col).bg(sel_bg)), + Cell::from(if b.behind > 0 { b.behind.to_string() } else { "—".into() }).style(Style::default().fg(behind_col).bg(sel_bg)), + Cell::from(b.last_commit_age.clone()).style(Style::default().fg(Color::DarkGray).bg(sel_bg)), + Cell::from(msg).style(Style::default().fg(Color::DarkGray).bg(sel_bg)), + ]) + }) + .collect(); + + f.render_widget( + Table::new(rows, [ + Constraint::Length(2), + Constraint::Length(24), + Constraint::Length(18), + Constraint::Length(5), + Constraint::Length(5), + Constraint::Length(10), + Constraint::Fill(1), + ]).header(header), + area, + ); +} + +// ── 4. Files tab (tree) ─────────────────────────────────────────────────────── + +fn render_file_tree(f: &mut Frame, st: &St, area: Rect) { + if st.tree_visible.is_empty() { + f.render_widget( + Paragraph::new(" No tracked files").style(Style::default().fg(Color::DarkGray)), + area, + ); + return; + } + + let avail = area.height as usize; + let scroll = st.tree_cursor.saturating_sub(avail.saturating_sub(1)); + + let dirty: std::collections::HashSet<&str> = st.files.iter().map(|(_, p)| p.as_str()).collect(); + + let items: Vec = st.tree_visible.iter() + .skip(scroll) + .take(avail) + .enumerate() + .map(|(row_i, &ni)| { + let node = &st.tree_nodes[ni]; + let is_sel = (scroll + row_i) == st.tree_cursor; + let indent: String = " ".repeat(node.depth); + let sel_bg = if is_sel { Color::DarkGray } else { Color::Reset }; + + let (prefix, name_col) = if node.is_dir { + let arrow = if node.expanded { "▼ " } else { "▶ " }; + (format!("{}{}", indent, arrow), Color::Yellow) + } else { + (format!("{}· ", indent), Color::White) + }; + + let name_str = if node.is_dir { format!("{}/", node.name) } else { node.name.clone() }; + + let dirty_mark = if !node.is_dir && dirty.contains(node.rel_path.as_str()) { + let status = st.files.iter().find(|(_, p)| p == &node.rel_path).map(|(c, _)| *c); + match status { + Some('M') => Span::styled(" M", Style::default().fg(Color::Yellow)), + Some('D') => Span::styled(" D", Style::default().fg(Color::Red)), + _ => Span::styled(" ?", Style::default().fg(Color::Green)), + } + } else { Span::raw("") }; + + ListItem::new(Line::from(vec![ + Span::styled(prefix, Style::default().fg(Color::DarkGray).bg(sel_bg)), + Span::styled(name_str, Style::default().fg(name_col).bg(sel_bg)), + dirty_mark, + ])) + }) + .collect(); + + f.render_widget(List::new(items), area); +} + +// ── 5. Stats tab ───────────────────────────────────────────────────────────── + +fn render_stats(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 all_periods = [ + StatsPeriod::Day1, StatsPeriod::Day7, StatsPeriod::Day30, + StatsPeriod::Day90, StatsPeriod::Year1, StatsPeriod::AllTime, + ]; + + // ── Period selector row ── + let mut period_spans: Vec = vec![Span::styled(" Period: ", dim)]; + for p in all_periods { + if p == st.stats_period { + period_spans.push(Span::styled( + format!("[{}]", p.label()), + Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD), + )); + } else { + period_spans.push(Span::styled(format!(" {} ", p.label()), dim)); + } + period_spans.push(Span::raw(" ")); + } + period_spans.push(Span::styled("[p] cycle [] prev/next", dim)); + + let period_h = 2u16; + let period_area = Rect::new(area.x, area.y, area.width, period_h); + + // ── Chart area ── + let chart_h = (area.height.saturating_sub(period_h + 2)).min(20).max(5); // 5..20 rows + let chart_area = Rect::new(area.x, area.y + period_h, area.width, chart_h); + + // ── Bottom metrics ── + let metrics_y = area.y + period_h + chart_h + 1; + let metrics_h = area.height.saturating_sub(period_h + chart_h + 2); + let metrics_area = if metrics_h > 0 { + Rect::new(area.x, metrics_y, area.width, metrics_h) + } else { + Rect::new(0, 0, 0, 0) + }; + + // Render period selector + f.render_widget(Paragraph::new(vec![ + Line::from(period_spans), + Line::from(dsep(area.width)), + ]), period_area); + + // Render vertical bar chart + render_bar_chart(f, &st.activity_data, chart_area); + + // Render bottom metrics + if metrics_h > 0 { + let kv = |k: &str, v: &str, col: Color| -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {:<18}", k), dim), + Span::styled(v.to_string(), Style::default().fg(col)), + ]) + }; + let avg_per_month = if st.repo_age_days > 0 { + st.total_commits * 30 / (st.repo_age_days as usize).max(1) + } else { 0 }; + let loc_str = if st.loc_total > 1000 { + format!("{}k", st.loc_total / 1000) + } else { + st.loc_total.to_string() + }; + + let mut mlines: Vec = vec![ + Line::from(Span::styled(" Key Metrics", yel)), + dsep(area.width), + kv("Total commits", &st.total_commits.to_string(), Color::White), + kv("Total releases", &st.total_tags.to_string(), Color::Yellow), + kv("Contributors", &st.contributors.len().to_string(), Color::White), + kv("Lines of code", &loc_str, Color::Cyan), + kv("Avg/month", &format!("{} commits", avg_per_month), Color::DarkGray), + ]; + if !st.top_files.is_empty() { + mlines.push(Line::from("")); + mlines.push(Line::from(Span::styled(" Largest Files (lines)", yel))); + mlines.push(dsep(area.width)); + for (name, lines) in st.top_files.iter().take(5) { + let short: String = name.chars().rev().take(30).collect::().chars().rev().collect(); + mlines.push(Line::from(vec![ + Span::styled(format!(" {:<32}", short), dim), + Span::styled(format!("{:>6}", lines), Style::default().fg(Color::Cyan)), + ])); + } + } + f.render_widget(Paragraph::new(mlines), metrics_area); + } +} + +fn render_bar_chart(f: &mut Frame, data: &[(String, usize)], area: Rect) { + if data.is_empty() || area.height < 3 || area.width < 10 { + return; + } + + let max_v = data.iter().map(|d| d.1).max().unwrap_or(1).max(1); + let axis_w = 6usize; // " 123 │" + let avail_w = area.width.saturating_sub(axis_w as u16) as usize; + let n_cols = data.len(); + // Each column: bar_chars + 1 space. bar_chars = col_w chars. + let col_w = ((avail_w).saturating_sub(n_cols)) / n_cols.max(1); + let col_w = col_w.max(1).min(4); + let chart_h = area.height.saturating_sub(2) as usize; // -1 for axis, -1 for labels + + let mut lines: Vec = vec![]; + + // Build chart rows from top to bottom + for row in 0..chart_h { + // row 0 = top (high values), row chart_h-1 = bottom (0) + let mut spans: Vec> = vec![]; + + // Y-axis label + let y_val = max_v * (chart_h - row) / chart_h; + let y_lbl = if row == 0 { + format!("{:>4} │", max_v) + } else if row == chart_h / 2 { + format!("{:>4} │", y_val) + } else if row == chart_h - 1 { + " 0 │".to_string() + } else { + " │".to_string() + }; + spans.push(Span::styled(y_lbl, Style::default().fg(Color::DarkGray))); + + for d in data.iter() { + // bar_h = how many rows this column fills from bottom + let bar_h = if max_v == 0 { 0 } else { (d.1 * chart_h / max_v).min(chart_h) }; + // Current row is filled if (row from bottom) < bar_h + // row from bottom = chart_h - 1 - row + let row_from_bottom = chart_h.saturating_sub(1).saturating_sub(row); + let filled = row_from_bottom < bar_h; + let bar_str = if filled { + "█".repeat(col_w) + } else { + " ".repeat(col_w) + }; + let bar_col = if filled { Color::Cyan } else { Color::Reset }; + spans.push(Span::styled(bar_str + " ", Style::default().fg(bar_col))); + } + lines.push(Line::from(spans)); + } + + // X-axis + let x_axis = format!(" └{}", "─".repeat((col_w + 1) * n_cols + 1)); + lines.push(Line::from(Span::styled(x_axis, Style::default().fg(Color::DarkGray)))); + + // Labels (show every Nth to avoid crowding) + let label_every = ((n_cols as u16 * (col_w as u16 + 1)) / area.width.max(1) + 1) as usize; + let mut label_spans: Vec> = vec![Span::raw(" ".to_string())]; + for (i, d) in data.iter().enumerate() { + let lbl = if i % label_every.max(1) == 0 { + format!("{:(), width = col_w + 1) + } else { + " ".repeat(col_w + 1) + }; + label_spans.push(Span::styled(lbl, Style::default().fg(Color::DarkGray))); + } + lines.push(Line::from(label_spans)); + + f.render_widget(Paragraph::new(lines), area); +} + +// ── 6. Settings tab ─────────────────────────────────────────────────────────── + +fn render_settings(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 red = Style::default().fg(Color::Red); + + let (vis, desc, topics_str, gh_ok) = match &st.settings { + None => { + let msg = if st.settings_rx.is_some() { + " Fetching from GitHub API..." + } else { + " (loading...)" + }; + f.render_widget(Paragraph::new(msg).style(dim), area); + return; + } + Some(s) => (s.visibility.clone(), s.description.clone(), s.topics.join(", "), s.gh_auth_ok), + }; + + let busy = st.api_rx.is_some(); + + struct Item { label: &'static str, value: String, hint: &'static str, danger: bool } + let items: &[Item] = &[ + Item { label: "Visibility", value: vis.clone(), hint: "toggle public / private", danger: false }, + Item { label: "Description", value: if desc.is_empty() { "(none)".into() } else { desc.chars().take(35).collect() }, hint: "edit text", danger: false }, + Item { label: "Topics", value: if topics_str.is_empty() { "(none)".into() } else { topics_str.chars().take(35).collect() }, hint: "edit list", danger: false }, + Item { label: "Collaborator", value: String::new(), hint: "invite user", danger: false }, + Item { label: "Archive", value: String::new(), hint: "archive repo on GitHub", danger: true }, + Item { label: "Rename", value: String::new(), hint: "rename on GitHub", danger: true }, + Item { label: "Delete", value: String::new(), hint: "delete on GitHub ⚠", danger: true }, + Item { label: ".gitignore", value: String::new(), hint: "open in $EDITOR", danger: false }, + Item { label: "LICENSE", value: String::new(), hint: "open in $EDITOR", danger: false }, + Item { label: "README", value: String::new(), hint: "open in $EDITOR", danger: false }, + ]; + + let mut lines: Vec = vec![]; + + if !gh_ok { + let msg = st.settings_err.as_deref() + .unwrap_or("No GitHub account — run: fxg auth import"); + lines.push(Line::from(Span::styled( + format!(" ⚠ {}", msg), + Style::default().fg(Color::Yellow), + ))); + lines.push(dsep(area.width)); + lines.push(Line::from("")); + } else if let Some(err) = &st.settings_err { + lines.push(Line::from(Span::styled( + format!(" ⚠ {}", err), + Style::default().fg(Color::Yellow), + ))); + lines.push(Line::from("")); + } + if busy { + lines.push(Line::from(Span::styled( + " Updating GitHub...", + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + } + + // Section: GitHub API + lines.push(Line::from(Span::styled(" GitHub API", yel))); + lines.push(dsep(area.width)); + for (i, item) in items[..7].iter().enumerate() { + lines.push(settings_row(i, item.label, &item.value, item.hint, item.danger, i == st.settings_cur)); + } + lines.push(Line::from("")); + + // Section: Local Files + lines.push(Line::from(Span::styled(" Local Files", yel))); + lines.push(dsep(area.width)); + for (i, item) in items[7..].iter().enumerate() { + lines.push(settings_row(7 + i, item.label, &item.value, item.hint, false, 7 + i == st.settings_cur)); + } + lines.push(Line::from("")); + + // Section: Git Operations + struct GitItem { label: &'static str, value: String, hint: &'static str } + let stash_hint = if st.stash_count > 0 { format!("{} stashed", st.stash_count) } else { String::new() }; + let git_items: &[GitItem] = &[ + GitItem { label: "Pull", value: String::new(), hint: "git pull (fetch + merge)" }, + GitItem { label: "Commit", value: String::new(), hint: "stage all & commit" }, + GitItem { label: "Push", value: if st.summary.ahead > 0 { format!("{} ahead", st.summary.ahead) } else { String::new() }, hint: "git push" }, + GitItem { label: "Stash", value: stash_hint.clone(), hint: "git stash" }, + GitItem { label: "Pop stash", value: stash_hint, hint: "git stash pop" }, + ]; + lines.push(Line::from(Span::styled(" Git Operations", yel))); + lines.push(dsep(area.width)); + for (i, item) in git_items.iter().enumerate() { + lines.push(settings_row(10 + i, item.label, &item.value, item.hint, false, 10 + i == st.settings_cur)); + } + + f.render_widget(Paragraph::new(lines), area); + + // Suppress unused variable warnings via inline references + let _ = (cyn, red); +} + +fn settings_row( + idx: usize, + label: &str, + value: &str, + hint: &str, + danger: bool, + sel: bool, +) -> Line<'static> { + let sel_bg = if sel { Color::DarkGray } else { Color::Reset }; + let num_col = if sel { Color::Cyan } else { Color::DarkGray }; + let lbl_col = if danger && !sel { Color::Red } else if sel { Color::White } else { Color::White }; + let hint_col = if danger { Color::Red } else { Color::DarkGray }; + + Line::from(vec![ + Span::styled( + format!(" {:>2}. ", idx + 1), + Style::default().fg(num_col).bg(sel_bg).add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }), + ), + Span::styled( + format!("{:<16}", label), + Style::default().fg(lbl_col).bg(sel_bg).add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }), + ), + Span::styled( + format!("{:<30}", value.chars().take(30).collect::()), + Style::default().fg(Color::DarkGray).bg(sel_bg), + ), + Span::styled( + hint.to_string(), + Style::default().fg(hint_col).bg(sel_bg), + ), + ]) +} + +// ── right panel router ──────────────────────────────────────────────────────── + +fn render_right(f: &mut Frame, st: &St, area: Rect) { + match st.tab { + Tab::Files => render_right_files(f, st, area), + Tab::Branches => render_right_branch(f, st, area), + Tab::Stats => render_right_stats(f, st, area), + _ => render_right_default(f, st, area), + } +} + +fn render_right_default(f: &mut Frame, st: &St, area: Rect) { + let blk = Block::default().borders(Borders::ALL); + let inner = blk.inner(area); + f.render_widget(blk, area); + + let actions_h = 16u16; + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Fill(1), Constraint::Length(actions_h)]) + .split(inner); + + render_info_block(f, st, layout[0]); + render_actions_block(f, layout[1]); +} + +fn render_right_files(f: &mut Frame, st: &St, area: Rect) { + let blk = Block::default().borders(Borders::ALL); + let inner = blk.inner(area); + f.render_widget(blk, area); + + let selected_node = st.tree_visible.get(st.tree_cursor).map(|&ni| &st.tree_nodes[ni]); + + let title = if let Some(node) = selected_node { + if node.is_dir { + format!(" {} {} ", if node.expanded { "▼" } else { "▶" }, node.name) + } else { + format!(" {} ", node.name) + } + } else { " Preview ".to_string() }; + + let title_blk = Block::default().borders(Borders::BOTTOM).title(title.as_str()) + .border_style(Style::default().fg(Color::DarkGray)); + let title_h = 2u16; + let title_area = Rect::new(inner.x, inner.y, inner.width, title_h); + let preview_area = Rect::new(inner.x, inner.y + title_h, inner.width, inner.height.saturating_sub(title_h)); + f.render_widget(title_blk, title_area); + + if let Some(node) = selected_node { + if node.is_dir { + f.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled("(directory)", Style::default().fg(Color::DarkGray))), + Line::from(Span::styled("[Enter] expand/collapse", Style::default().fg(Color::DarkGray))), + Line::from(Span::styled("[e] open in editor", Style::default().fg(Color::DarkGray))), + ]), + preview_area, + ); + } else if st.file_preview.is_empty() { + f.render_widget( + Paragraph::new("(navigate to load)").style(Style::default().fg(Color::DarkGray)), + preview_area, + ); + } else { + let avail = preview_area.height as usize; + let skip = st.preview_off.min(st.file_preview.len().saturating_sub(1)); + let items: Vec = st.file_preview.iter() + .skip(skip).take(avail).enumerate() + .map(|(i, line)| { + let ln = skip + i + 1; + ListItem::new(Line::from(vec![ + Span::styled(format!("{:>4} ", ln), Style::default().fg(Color::DarkGray)), + Span::styled( + line.chars().take(inner.width.saturating_sub(5) as usize).collect::(), + Style::default().fg(Color::White), + ), + ])) + }) + .collect(); + f.render_widget(List::new(items), preview_area); + } + } else { + f.render_widget( + Paragraph::new("(no file selected)").style(Style::default().fg(Color::DarkGray)), + preview_area, + ); + } +} + +fn render_right_branch(f: &mut Frame, st: &St, area: Rect) { + let blk = Block::default().borders(Borders::ALL); + let inner = blk.inner(area); + f.render_widget(blk, area); + + let dim = Style::default().fg(Color::DarkGray); + let kv = |k: &str, v: &str, col: Color| -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {:<11}", k), dim), + Span::styled(v.to_string(), Style::default().fg(col)), + ]) + }; + let mut lines: Vec = vec![]; + + if let Some(b) = st.branches.get(st.branch_cursor) { + lines.push(Line::from(Span::styled( + format!(" {}", b.name), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + ))); + lines.push(dsep(inner.width)); + lines.push(Line::from("")); + lines.push(kv("Status", if b.is_current { "✓ current" } else { "○ other" }, if b.is_current { Color::Cyan } else { Color::DarkGray })); + lines.push(kv("Upstream", b.upstream.as_deref().unwrap_or("(no tracking)"), Color::DarkGray)); + if b.upstream.is_some() { + lines.push(kv("Ahead", &b.ahead.to_string(), if b.ahead > 0 { Color::Green } else { Color::DarkGray })); + lines.push(kv("Behind", &b.behind.to_string(), if b.behind > 0 { Color::Red } else { Color::DarkGray })); + } + lines.push(kv("Age", &b.last_commit_age, Color::DarkGray)); + lines.push(Line::from("")); + let msg: String = b.last_commit_msg.chars().take(inner.width.saturating_sub(3) as usize).collect(); + lines.push(kv("Last", &msg, Color::DarkGray)); + } else { + lines.push(Line::from(Span::styled(" No branch selected", dim))); + } + + lines.push(Line::from("")); + lines.push(dsep(inner.width)); + lines.push(Line::from(Span::styled(" Actions", dim))); + lines.push(Line::from("")); + for (k, lbl) in [("Enter", "Checkout"), ("n", "New branch (modal)"), ("P", "Push"), ("D", "Delete")] { + lines.push(Line::from(vec![ + Span::styled(format!(" [{}]", k), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled(format!(" {}", lbl), Style::default().fg(Color::White)), + ])); } f.render_widget(Paragraph::new(lines), inner); } -fn render_overview(f: &mut Frame, st: &St, area: Rect) { - let cols = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(52), Constraint::Percentage(48)]) - .split(area); - - let r = &st.summary; - - let mut all_lines: Vec = vec![section("Identity"), Line::from("")]; - all_lines.push(kv("Path", &r.path.to_string_lossy())); - all_lines.push(kv("Owner", r.owner.as_deref().unwrap_or("—"))); - all_lines.push(kv("Visibility", r.visibility.as_deref().unwrap_or("unknown"))); - all_lines.push(kv("Language", r.lang.as_deref().unwrap_or("—"))); - all_lines.push(kv("License", if r.license { "✓ present" } else { "⚠ not found" })); - if let Some(s) = r.stars { all_lines.push(kv("Stars", &format!("★ {}", s))); } - if let Some(i) = r.open_issues { all_lines.push(kv("Issues", &format!("{} open", i))); } - if let Some(p) = r.open_prs { all_lines.push(kv("Open PRs", &format!("{}", p))); } - all_lines.push(Line::from("")); - - all_lines.push(section("Status")); - all_lines.push(Line::from("")); - let branch_detail = match (r.ahead, r.behind) { - (0, 0) => " (in sync)".to_string(), - (a, 0) => format!(" ↑{}", a), - (0, b) => format!(" ↓{}", b), - (a, b) => format!(" ↑{} ↓{}", a, b), - }; - all_lines.push(Line::from(vec![ - Span::styled(" Branch ", Style::default().fg(Color::DarkGray)), - Span::styled(r.branch.clone(), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), - Span::styled(branch_detail, Style::default().fg(Color::DarkGray)), - ])); - let (dirty_txt, dirty_col) = if r.is_dirty { - (format!("⚠ {} file(s)", st.files.len()), Color::Yellow) - } else { ("✓ clean".into(), Color::DarkGray) }; - all_lines.push(Line::from(vec![ - Span::styled(" Changes ", Style::default().fg(Color::DarkGray)), - Span::styled(dirty_txt, Style::default().fg(dirty_col)), - ])); - all_lines.push(Line::from("")); - - all_lines.push(section("Repository")); - all_lines.push(Line::from("")); - all_lines.push(kv("Files", &st.file_count.to_string())); - all_lines.push(kv("Branches", &st.branch_count.to_string())); - all_lines.push(kv("Contributors", &st.contributors.to_string())); - all_lines.push(kv("Stashes", &st.stash_count.to_string())); - all_lines.push(kv("Tags", &st.total_tags.to_string())); - all_lines.push(Line::from("")); - - all_lines.push(section("Last commit")); - all_lines.push(Line::from("")); - all_lines.push(kv("When", &r.last_commit_age)); - let msg_trunc: String = r.last_commit_msg.chars().take(46).collect(); - all_lines.push(Line::from(vec![ - Span::styled(" Message ", Style::default().fg(Color::DarkGray)), - Span::styled(format!("\"{}\"", msg_trunc), Style::default().fg(Color::White)), - ])); - - let skip = st.ov_scroll.min(all_lines.len().saturating_sub(1)); - let visible: Vec = all_lines.into_iter().skip(skip).collect(); - - f.render_widget( - Paragraph::new(visible).block(Block::default().borders(Borders::RIGHT)), - cols[0], - ); - - let mut right: Vec = vec![section("Recent commits"), Line::from("")]; - for (age, msg) in &st.recent_commits { - right.push(Line::from(vec![ - Span::styled(format!(" {:>8} ", age), Style::default().fg(Color::DarkGray)), - Span::raw(msg.chars().take(cols[1].width.saturating_sub(14) as usize).collect::()), - ])); - } - if !st.files.is_empty() { - right.push(Line::from("")); - right.push(section("Changed files")); - right.push(Line::from("")); - if st.file_count > 500 { - right.push(Line::from(Span::styled( - format!(" ⚠ {} tracked — check .gitignore!", st.file_count), - Style::default().fg(Color::Yellow), - ))); - } - for (ch, path) in st.files.iter().take(10) { - let col = match ch { 'M' => Color::Yellow, 'D' => Color::Red, _ => Color::Green }; - right.push(Line::from(vec![ - Span::styled(format!(" {} ", ch), Style::default().fg(col)), - Span::styled( - path.chars().take(cols[1].width.saturating_sub(6) as usize).collect::(), - Style::default().fg(Color::DarkGray), - ), - ])); - } - if st.files.len() > 10 { - right.push(Line::from(Span::styled( - format!(" … {} more", st.files.len() - 10), - Style::default().fg(Color::DarkGray), - ))); - } - } - f.render_widget(Paragraph::new(right), cols[1]); -} - -fn render_history(f: &mut Frame, st: &St, area: Rect) { - let blk = Block::default() - .title(format!(" History ({} entries) ", st.history.len())) - .borders(Borders::ALL); +fn render_right_stats(f: &mut Frame, st: &St, area: Rect) { + let blk = Block::default().borders(Borders::ALL); let inner = blk.inner(area); f.render_widget(blk, area); - let avail = inner.height.saturating_sub(1) as usize; - let header = Row::new(vec!["TYPE", "REF", "AGE", "AUTHOR / TAG MSG", "MESSAGE"]) + let dim = Style::default().fg(Color::DarkGray); + let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let kv = |k: &str, v: &str, col: Color| -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {:<13}", k), dim), + Span::styled(v.to_string(), Style::default().fg(col)), + ]) + }; + + let loc_str = if st.loc_total > 1000 { + format!("{}k lines", st.loc_total / 1000) + } else { + format!("{} lines", st.loc_total) + }; + + let mut lines: Vec = vec![ + Line::from(Span::styled(" Top Contributors", yel)), + dsep(inner.width), + Line::from(""), + ]; + for (i, c) in st.contributors.iter().take(8).enumerate() { + let name: String = c.name.chars().take((inner.width.saturating_sub(8)) as usize).collect(); + lines.push(Line::from(vec![ + Span::styled(format!(" {:>2}. ", i + 1), dim), + Span::styled(name, Style::default().fg(Color::White)), + ])); + lines.push(Line::from(vec![ + Span::styled(" ", dim), + Span::styled(format!("{} commits", c.count), dim), + ])); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(" Code Stats", yel))); + lines.push(dsep(inner.width)); + lines.push(kv("LOC", &loc_str, Color::Cyan)); + lines.push(kv("Commits", &st.total_commits.to_string(), Color::White)); + lines.push(kv("Tags", &st.total_tags.to_string(), Color::Yellow)); + if let Some(lang) = &st.summary.lang { + lines.push(kv("Languages", lang, Color::DarkGray)); + } + + f.render_widget(Paragraph::new(lines), inner); +} + +// ── info + actions (shared default right panel) ──────────────────────────── + +fn render_info_block(f: &mut Frame, st: &St, area: Rect) { + let r = &st.summary; + let w = area.width.saturating_sub(2) as usize; + let dim = Style::default().fg(Color::DarkGray); + let kv = |k: &str, v: &str, col: Color| -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {:<10}", k), dim), + Span::styled(v.chars().take(w.saturating_sub(11)).collect::(), Style::default().fg(col)), + ]) + }; + let sync_txt = match (r.ahead, r.behind) { + (0, 0) => "in sync".to_string(), + (a, 0) => format!("↑{} ahead", a), + (0, b) => format!("↓{} behind", b), + (a, b) => format!("↑{} ↓{}", a, b), + }; + + let mut info: Vec = vec![ + Line::from(Span::styled( + format!(" {}", r.name.chars().take(w).collect::()), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )), + dsep(area.width.saturating_add(2)), + Line::from(""), + kv("Remote", &sync_txt, if r.behind > 0 { Color::Red } else if r.ahead > 0 { Color::Cyan } else { Color::DarkGray }), + kv("Dirty", if r.is_dirty { "⚠ changes" } else { "✓ clean" }, if r.is_dirty { Color::Yellow } else { Color::DarkGray }), + Line::from(""), + kv("Owner", r.owner.as_deref().unwrap_or("—"), Color::White), + kv("Vis.", r.visibility.as_deref().unwrap_or("—"), match r.visibility.as_deref() { Some("public") => Color::Green, Some("private") => Color::Yellow, _ => Color::DarkGray }), + kv("License", if r.license { "✓" } else { "⚠ missing" }, if r.license { Color::DarkGray } else { Color::Yellow }), + ]; + if let Some(lang) = &r.lang { info.push(kv("Lang", lang, Color::DarkGray)); } + if let Some(s) = r.stars { info.push(kv("Stars", &format!("★ {}", s), Color::Yellow)); } + if let Some(i) = r.open_issues { info.push(kv("Issues", &i.to_string(), if i > 0 { Color::Red } else { Color::DarkGray })); } + if let Some(p) = r.open_prs { info.push(kv("PRs", &p.to_string(), if p > 0 { Color::Cyan } else { Color::DarkGray })); } + info.push(Line::from("")); + info.push(kv("Commits", &st.total_commits.to_string(), Color::White)); + info.push(kv("Files", &st.file_count.to_string(), Color::DarkGray)); + info.push(kv("Branches", &st.branch_count.to_string(), Color::DarkGray)); + info.push(kv("Tags", &st.total_tags.to_string(), Color::Yellow)); + info.push(kv("Stashes", &st.stash_count.to_string(), Color::DarkGray)); + if let Some(tag) = &r.last_tag { info.push(kv("Last tag", tag, Color::Cyan)); } + + f.render_widget(Paragraph::new(info), area); +} + +fn render_actions_block(f: &mut Frame, area: Rect) { + let act_blk = Block::default().borders(Borders::TOP).title(" Actions ").border_style(Style::default().fg(Color::DarkGray)); + let act_inner = act_blk.inner(area); + f.render_widget(act_blk, area); + + let act = |k: &'static str, label: &'static str, col: Color| -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" [{}]", k), Style::default().fg(col).add_modifier(Modifier::BOLD)), + Span::styled(format!(" {}", label), Style::default().fg(Color::White)), + ]) + }; + let div = || Line::from(Span::styled(" ─────────────────", Style::default().fg(Color::DarkGray))); + + f.render_widget(Paragraph::new(vec![ + act("s", "Sync", Color::Green), + act("r", "Release tag", Color::Green), + div(), + act("o", "Open in editor", Color::Cyan), + act("W", "Open browser", Color::Cyan), + act("y", "Copy path", Color::Cyan), + act("u", "Copy URL", Color::Cyan), + act("g", ".gitignore", Color::Cyan), + div(), + act("S", "Stash", Color::White), + act("A", "Pop stash", Color::White), + div(), + act("q", "← Back", Color::DarkGray), + ]), act_inner); +} + +// ── bottom bar ──────────────────────────────────────────────────────────────── + +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 line2: Line = match st.tab { + Tab::Overview => Line::from(vec![key("↑↓"), Span::styled("scroll", dim)]), + Tab::Commits => Line::from(vec![ + key("↑↓/jk"), Span::styled("scroll ", dim), + key("PgUp/Dn"), Span::styled("fast", dim), + ]), + Tab::Branches => Line::from(vec![ + key("↑↓"), Span::styled("select ", dim), + key("Enter"), Span::styled("checkout ", dim), + key("n"), Span::styled("new (modal) ", dim), + key("P"), Span::styled("push ", dim), + key("D"), Span::styled("delete", dim), + ]), + Tab::Files => Line::from(vec![ + key("↑↓"), Span::styled("navigate ", dim), + key("Enter"), Span::styled("expand/collapse ", dim), + key("e"), Span::styled("edit in $EDITOR ", dim), + key("[/]"), Span::styled("scroll preview", dim), + ]), + Tab::Stats => Line::from(vec![ + key("p"), Span::styled("cycle period ", dim), + key(""), Span::styled("prev/next period ", dim), + key("↑↓"), Span::styled("scroll contributors", dim), + ]), + Tab::Settings => Line::from(vec![ + key("↑↓"), Span::styled("select ", dim), + key("Enter"), Span::styled("edit/action", dim), + ]), + Tab::PRs | Tab::Issues => Line::from(vec![ + key("↑↓"), Span::styled("select ", dim), + key("Enter"), Span::styled("open in browser", dim), + ]), + Tab::Todos => Line::from(vec![ + key("↑↓"), Span::styled("select ", dim), + key("Enter"), Span::styled("cycle status", dim), + ]), + }; + + f.render_widget(Paragraph::new(vec![ + Line::from(vec![ + key("←→/Tab"), Span::styled("switch tab ", dim), + key("1-9"), Span::styled("direct ", dim), + key("q/Esc"), Span::styled("back ", dim), + key("s"), Span::styled("sync ", dim), + key("r"), Span::styled("release ", dim), + key("W"), Span::styled("browser", dim), + ]), + line2, + Line::from(""), + ]), area); +} + +// ── modal overlay ───────────────────────────────────────────────────────────── + +fn render_modal(f: &mut Frame, st: &St, area: Rect) { + match &st.modal { + DetailModal::None => {} + + DetailModal::NewBranch { input, error } => { + draw_text_modal_with_error(f, area, " New Branch ", input, "branch name", error.as_deref(), Color::Cyan); + } + + DetailModal::EditText { title, prompt, input, .. } => { + let prompt_owned = prompt.clone(); + draw_text_modal(f, area, &format!(" {} ", title), input, &prompt_owned, Color::Cyan); + } + + DetailModal::SelectOption { title, options, cursor, .. } => { + let h = (options.len() as u16 + 4).min(area.height.saturating_sub(4)); + let w = 44u16.min(area.width.saturating_sub(4)); + let x = area.x + (area.width.saturating_sub(w)) / 2; + let y = area.y + (area.height.saturating_sub(h)) / 2; + let modal_area = Rect::new(x, y, w, h); + 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); + + let mut lines: Vec = vec![Line::from("")]; + for (i, opt) in options.iter().enumerate() { + let sel = i == *cursor; + lines.push(Line::from(vec![ + Span::styled( + format!(" {} {}", if sel { "▶" } else { " " }, opt), + Style::default() + .fg(if sel { Color::Cyan } else { Color::White }) + .add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }), + ), + ])); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(" [↑↓] select [Enter] confirm [Esc] cancel", Style::default().fg(Color::DarkGray)))); + f.render_widget(Paragraph::new(lines), inner); + } + + DetailModal::SelectRole { collab_name, cursor } => { + const ROLES: &[&str] = &["pull", "push", "maintain", "admin"]; + let h = (ROLES.len() as u16 + 5).min(area.height.saturating_sub(4)); + let w = 44u16.min(area.width.saturating_sub(4)); + let x = area.x + (area.width.saturating_sub(w)) / 2; + let y = area.y + (area.height.saturating_sub(h)) / 2; + let modal_area = Rect::new(x, y, w, h); + f.render_widget(Clear, modal_area); + let blk = Block::default().borders(Borders::ALL) + .title(format!(" Role for @{} ", collab_name)) + .border_style(Style::default().fg(Color::Cyan)); + let inner = blk.inner(modal_area); + f.render_widget(blk, modal_area); + + let mut lines: Vec = vec![Line::from("")]; + for (i, role) in ROLES.iter().enumerate() { + let sel = i == *cursor; + lines.push(Line::from(Span::styled( + format!(" {} {}", if sel { "▶" } else { " " }, role), + Style::default().fg(if sel { Color::Cyan } else { Color::White }) + .add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }), + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(" [↑↓] select [Enter] invite [Esc] cancel", Style::default().fg(Color::DarkGray)))); + f.render_widget(Paragraph::new(lines), inner); + } + + DetailModal::ConfirmAction { message, .. } => { + let w = 52u16.min(area.width.saturating_sub(4)); + let h = 7u16; + let x = area.x + (area.width.saturating_sub(w)) / 2; + let y = area.y + (area.height.saturating_sub(h)) / 2; + let modal_area = Rect::new(x, y, w, h); + f.render_widget(Clear, modal_area); + let blk = Block::default().borders(Borders::ALL) + .title(" Confirm ") + .border_style(Style::default().fg(Color::Red)); + let inner = blk.inner(modal_area); + f.render_widget(blk, modal_area); + + let msg_clone = message.clone(); + f.render_widget( + Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled(format!(" {}", msg_clone), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))), + Line::from(""), + Line::from(Span::styled(" [y / Enter] confirm", Style::default().fg(Color::Red))), + Line::from(Span::styled(" [n / Esc] cancel", Style::default().fg(Color::DarkGray))), + ]), + inner, + ); + } + } +} + +fn draw_text_modal(f: &mut Frame, area: Rect, title: &str, input: &str, hint: &str, border_col: Color) { + draw_text_modal_with_error(f, area, title, input, hint, None, border_col); +} + +fn draw_text_modal_with_error(f: &mut Frame, area: Rect, title: &str, input: &str, hint: &str, error: Option<&str>, border_col: Color) { + let w = 60u16.min(area.width.saturating_sub(4)); + let h = if error.is_some() { 7u16 } else { 6u16 }; + let x = area.x + (area.width.saturating_sub(w)) / 2; + let y = area.y + (area.height.saturating_sub(h)) / 2; + let modal_area = Rect::new(x, y, w, h); + f.render_widget(Clear, modal_area); + + let border_col = if error.is_some() { Color::Red } else { border_col }; + let blk = Block::default().borders(Borders::ALL) + .title(title.to_string()) + .border_style(Style::default().fg(border_col)); + let inner = blk.inner(modal_area); + f.render_widget(blk, modal_area); + + let mut lines = vec![ + Line::from(Span::styled(format!(" {}", hint), Style::default().fg(Color::DarkGray))), + Line::from(Span::styled( + format!(" > {}█", input.chars().take((w as usize).saturating_sub(6)).collect::()), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled(" [Enter] confirm [Esc] cancel", Style::default().fg(Color::DarkGray))), + ]; + if let Some(err) = error { + lines.insert(2, Line::from(Span::styled(format!(" ⚠ {}", err), Style::default().fg(Color::Red)))); + } + f.render_widget(Paragraph::new(lines), inner); +} + +// ── PRs tab ────────────────────────────────────────────────────────────────── + +fn render_prs(f: &mut Frame, st: &St, area: Rect) { + let dim = Style::default().fg(Color::DarkGray); + let header = Row::new(vec!["#", "TITLE", "AUTHOR", "BRANCH", "AGE", "DRAFT"]) .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); - let rows: Vec = st.history.iter() - .skip(st.hist_off).take(avail) - .map(|entry| match entry { - HistEntry::Tag { name, age, msg, .. } => { - Row::new(vec![ - Cell::from("🔖 tag").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - Cell::from(name.clone()).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - Cell::from(age.clone()).style(Style::default().fg(Color::DarkGray)), - Cell::from(""), - Cell::from(if msg.is_empty() { "(lightweight tag)".to_string() } else { msg.clone() }) - .style(Style::default().fg(Color::DarkGray)), - ]).style(Style::default().bg(Color::Reset)) - } - HistEntry::Commit { sha, age, author, msg, .. } => { - Row::new(vec![ - Cell::from("●").style(Style::default().fg(Color::DarkGray)), - Cell::from(sha.clone()).style(Style::default().fg(Color::DarkGray)), - Cell::from(age.clone()).style(Style::default().fg(Color::DarkGray)), - Cell::from(format!("@{}", author)).style(Style::default().fg(Color::Cyan)), - Cell::from(msg.clone()), - ]) - } + if st.prs_issues_rx.is_some() { + f.render_widget(Paragraph::new(" Fetching PRs...").style(dim), area); + return; + } + if st.repo_prs.is_empty() { + let msg = if st.prs_loaded { " No open PRs" } else { " PRs not loaded — switch to this tab to load" }; + f.render_widget(Paragraph::new(msg).style(dim), area); + return; + } + + let avail = area.height.saturating_sub(1) as usize; + let pr_off = st.pr_cursor.saturating_sub(avail / 2); + let rows: Vec = st.repo_prs.iter().enumerate() + .skip(pr_off).take(avail) + .map(|(i, pr)| { + let is_cur = i + pr_off == st.pr_cursor; + let age = crate::status::format_age_pub(pr.created_ts); + let draft_str = if pr.draft { "draft" } else { "" }; + let branch_str = format!("{} → {}", pr.head_branch.chars().take(10).collect::(), pr.base_branch.chars().take(8).collect::()); + Row::new(vec![ + Cell::from(format!("#{}", pr.number)).style(Style::default().fg(Color::Cyan)), + Cell::from(pr.title.chars().take(45).collect::()), + Cell::from(pr.author.chars().take(14).collect::()).style(dim), + Cell::from(branch_str).style(dim), + Cell::from(age).style(dim), + Cell::from(draft_str).style(Style::default().fg(Color::Yellow)), + ]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() }) }) .collect(); f.render_widget( Table::new(rows, [ Constraint::Length(7), - Constraint::Length(9), - Constraint::Length(10), - Constraint::Length(18), Constraint::Fill(1), + Constraint::Length(14), + Constraint::Length(22), + Constraint::Length(10), + Constraint::Length(6), ]).header(header), - inner, + area, ); } -fn render_files(f: &mut Frame, st: &St, area: Rect) { - let title: &str = if st.show_all_files { - " All tracked files — [a] show changed " - } else { - " Changed files — [a] show all tracked " - }; +// ── Issues tab ──────────────────────────────────────────────────────────────── - let blk = Block::default().title(title).borders(Borders::ALL); - let inner = blk.inner(area); - f.render_widget(blk, area); +fn render_issues(f: &mut Frame, st: &St, area: Rect) { + let dim = Style::default().fg(Color::DarkGray); + let header = Row::new(vec!["#", "TITLE", "AUTHOR", "LABELS", "AGE", "CMT"]) + .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); - let mut warn_rows = 0usize; - if st.file_count > 500 && !st.show_all_files { - f.render_widget( - Paragraph::new(Line::from(Span::styled( - format!(" ⚠ {} tracked files — large repo, consider .gitignore [g] edit it", st.file_count), - Style::default().fg(Color::Yellow), - ))), - Rect::new(inner.x, inner.y, inner.width, 1), - ); - warn_rows = 1; + if st.prs_issues_rx.is_some() { + f.render_widget(Paragraph::new(" Fetching issues...").style(dim), area); + return; + } + if st.repo_issues.is_empty() { + let msg = if st.prs_loaded { " No open issues" } else { " Issues not loaded — switch to this tab to load" }; + f.render_widget(Paragraph::new(msg).style(dim), area); + return; } - let usable = Rect::new(inner.x, inner.y + warn_rows as u16, inner.width, inner.height.saturating_sub(warn_rows as u16)); + let avail = area.height.saturating_sub(1) as usize; + let issue_off = st.issue_cursor.saturating_sub(avail / 2); + let rows: Vec = st.repo_issues.iter().enumerate() + .skip(issue_off).take(avail) + .map(|(i, issue)| { + let is_cur = i + issue_off == st.issue_cursor; + let age = crate::status::format_age_pub(issue.created_ts); + let labels = issue.labels.join(", ").chars().take(18).collect::(); + Row::new(vec![ + Cell::from(format!("#{}", issue.number)).style(Style::default().fg(Color::Cyan)), + Cell::from(issue.title.chars().take(45).collect::()), + Cell::from(issue.author.chars().take(14).collect::()).style(dim), + Cell::from(labels).style(Style::default().fg(Color::Yellow)), + Cell::from(age).style(dim), + Cell::from(issue.comments.to_string()).style(dim), + ]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() }) + }) + .collect(); - if st.show_all_files { - let avail = usable.height as usize; - let items: Vec = st.all_files.iter() - .skip(st.file_off).take(avail) - .map(|p| ListItem::new(Line::from(Span::styled(format!(" {}", p), Style::default().fg(Color::DarkGray))))) - .collect(); - let count = Line::from(Span::styled( - format!(" {} files total (skip: {})", st.all_files.len(), st.file_off), - Style::default().fg(Color::DarkGray), - )); - let mut all_items = vec![ListItem::new(count)]; - all_items.extend(items); - f.render_widget(List::new(all_items), usable); - } else { - if st.files.is_empty() { - f.render_widget( - Paragraph::new(" (working tree clean)").style(Style::default().fg(Color::DarkGray)), - usable, - ); - return; - } - let avail = usable.height.saturating_sub(1) as usize; - let rows: Vec = st.files.iter() - .skip(st.file_off).take(avail) - .map(|(ch, p)| { - let col = match ch { 'M' => Color::Yellow, 'D' => Color::Red, _ => Color::Green }; - Row::new(vec![ - Cell::from(ch.to_string()).style(Style::default().fg(col)), - Cell::from(p.clone()), - ]) - }) - .collect(); - f.render_widget(Table::new(rows, [Constraint::Length(3), Constraint::Fill(1)]), usable); - } + f.render_widget( + Table::new(rows, [ + Constraint::Length(7), + Constraint::Fill(1), + Constraint::Length(14), + Constraint::Length(18), + Constraint::Length(10), + Constraint::Length(5), + ]).header(header), + area, + ); } -fn section(title: &str) -> Line<'static> { +// ── Todos tab (per-repo) ────────────────────────────────────────────────────── + +fn render_repo_todos(f: &mut Frame, st: &St, area: Rect) { + use crate::todos::TodoStatus; + let dim = Style::default().fg(Color::DarkGray); + let header = Row::new(vec!["STATUS", "TITLE", "EST", "DIFF", "CREATED"]) + .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); + + if st.repo_todos.is_empty() { + f.render_widget(Paragraph::new(" No todos for this repo").style(dim), area); + return; + } + + let avail = area.height.saturating_sub(1) as usize; + let todo_off = st.todo_cursor.saturating_sub(avail / 2); + let rows: Vec = st.repo_todos.iter().enumerate() + .skip(todo_off).take(avail) + .map(|(i, t)| { + let is_cur = i + todo_off == st.todo_cursor; + let est_str = t.estimate.as_deref().unwrap_or("—").chars().take(10).collect::(); + 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 status_col = t.status.color(); + let status_str = format!("{} {}", t.status.icon(), t.status.label()); + let created = crate::status::format_age_pub(t.created_at); + Row::new(vec![ + Cell::from(status_str).style(Style::default().fg(status_col)), + Cell::from(t.title.chars().take(50).collect::()), + Cell::from(est_str).style(dim), + Cell::from(diff_str).style(Style::default().fg(Color::Yellow)), + Cell::from(created).style(dim), + ]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() }) + }) + .collect(); + + let _ = TodoStatus::Open; // suppress unused import warning + f.render_widget( + Table::new(rows, [ + Constraint::Length(14), + Constraint::Fill(1), + Constraint::Length(10), + Constraint::Length(7), + Constraint::Length(10), + ]).header(header), + area, + ); +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +fn dsep(width: u16) -> Line<'static> { Line::from(Span::styled( - format!(" {}", title), - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + " ".to_string() + &"─".repeat(width.saturating_sub(2) as usize), + Style::default().fg(Color::DarkGray), )) } - -fn kv(key: &str, val: &str) -> Line<'static> { - Line::from(vec![ - Span::styled(format!(" {:<15}", key), Style::default().fg(Color::DarkGray)), - Span::raw(val.to_string()), - ]) -} diff --git a/src/hub/actions.rs b/src/hub/actions.rs index 95e6f39..923f7eb 100644 --- a/src/hub/actions.rs +++ b/src/hub/actions.rs @@ -8,35 +8,27 @@ use super::state::HubState; // ── sync flow ───────────────────────────────────────────────────────────────── -pub async fn run_sync_flow(repos: &[RepoSummary], targets: &[usize]) -> Result<()> { +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(()); } - println!("\n Syncing {} repo(s):", sel.len()); + + println!("\n Syncing {} repo(s) [{}]:", sel.len(), action); for r in &sel { println!(" · {}", r.name); } println!(); - let mode = match inquire::Select::new("Action:", vec!["pull", "commit + push", "push only"]) - .with_page_size(3).prompt_skippable()? { Some(m) => m, None => return Ok(()) }; - - let global_msg: Option = if mode == "commit + push" { - let m = inquire::Select::new("Commit message:", vec!["same for all", "per repo"]).with_page_size(2).prompt()?; - if m == "same for all" { Some(inquire::Text::new("Message:").with_default("chore: update").prompt()?) } else { None } - } else { None }; - for r in &sel { println!("\n ── {} ──", r.name); - match mode { - "pull" => git_run(&r.path, &["pull"]), - "commit + push" => { - let msg = match &global_msg { Some(m) => m.clone(), None => inquire::Text::new(&format!("Message for '{}':", r.name)).with_default("chore: update").prompt()? }; + match action { + "pull" => { git_run(&r.path, &["pull"]); } + "commit_push" => { git_run(&r.path, &["add", "-A"]); - git_run(&r.path, &["commit", "-m", &msg]); + git_run(&r.path, &["commit", "-m", commit_msg]); git_run(&r.path, &["push"]); } - _ => git_run(&r.path, &["push"]), + _ => { git_run(&r.path, &["push"]); } } } - println!("\n ✓ Done. Press Enter..."); + println!("\n ✓ Done. Press Enter…"); let _ = std::io::stdin().lines().next(); Ok(()) } @@ -135,7 +127,7 @@ 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_dir(pattern, &r.path, &r.name); + let results = crate::legacy::refactor::find_in_doc_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()); @@ -191,6 +183,109 @@ pub fn save_bookmarks(bk: &HashSet) { if let Ok(s) = toml::to_string_pretty(&data) { let _ = std::fs::write(p, s); } } +// ── templates persistence ───────────────────────────────────────────────────── + +fn templates_path() -> PathBuf { + dirs::home_dir().unwrap_or_default().join(".fxg").join("templates.toml") +} + +#[derive(serde::Serialize, serde::Deserialize, Default)] +struct TemplatesFile { + #[serde(default)] + templates: Vec, +} + +pub fn load_templates() -> Vec { + use super::state::RepoTemplate; + let p = templates_path(); + if p.exists() { + let loaded = toml::from_str::(&std::fs::read_to_string(&p).unwrap_or_default()) + .map(|f| f.templates) + .unwrap_or_default(); + if !loaded.is_empty() { return loaded; } + } + // First run — seed built-in stacks + let defaults = default_templates(); + save_templates(&defaults); + defaults +} + +fn default_templates() -> Vec { + use super::state::RepoTemplate; + vec![ + RepoTemplate { + name: "Rust".into(), + description: "Rust project with Cargo".into(), + commands: vec![ + "cargo init".into(), + "echo '.env' >> .gitignore".into(), + "printf '# TODO\\n\\n- [ ] Initial setup\\n' > TODO.md".into(), + "printf '# Changelog\\n\\n## [Unreleased]\\n' > CHANGELOG.md".into(), + ], + }, + RepoTemplate { + name: "Node.js".into(), + description: "Node.js project with npm".into(), + commands: vec![ + "npm init -y".into(), + "printf 'node_modules/\\n.env\\ndist/\\nbuild/\\n' > .gitignore".into(), + "printf '# TODO\\n\\n- [ ] Initial setup\\n' > TODO.md".into(), + "printf '# Changelog\\n\\n## [Unreleased]\\n' > CHANGELOG.md".into(), + ], + }, + RepoTemplate { + name: "React".into(), + description: "React + Vite app".into(), + commands: vec![ + "npm init -y".into(), + "npm install react react-dom".into(), + "npm install -D vite @vitejs/plugin-react".into(), + "printf 'node_modules/\\n.env\\ndist/\\n' > .gitignore".into(), + "mkdir -p src public".into(), + "printf '# TODO\\n\\n- [ ] Build React components\\n' > TODO.md".into(), + "printf '# Changelog\\n\\n## [Unreleased]\\n' > CHANGELOG.md".into(), + ], + }, + RepoTemplate { + name: "Python".into(), + description: "Python project with venv".into(), + commands: vec![ + "python3 -m venv .venv".into(), + "printf '.venv/\\n__pycache__/\\n*.pyc\\n.env\\ndist/\\nbuild/\\n*.egg-info/\\n' > .gitignore".into(), + "touch main.py requirements.txt".into(), + "printf '# TODO\\n\\n- [ ] Initial setup\\n' > TODO.md".into(), + "printf '# Changelog\\n\\n## [Unreleased]\\n' > CHANGELOG.md".into(), + ], + }, + RepoTemplate { + name: "Go".into(), + description: "Go module".into(), + commands: vec![ + "go mod init $(basename $PWD)".into(), + "touch main.go".into(), + "printf '# TODO\\n\\n- [ ] Initial setup\\n' > TODO.md".into(), + "printf '# Changelog\\n\\n## [Unreleased]\\n' > CHANGELOG.md".into(), + ], + }, + RepoTemplate { + name: "Laravel".into(), + description: "Laravel PHP project (requires Composer)".into(), + commands: vec![ + "composer create-project laravel/laravel .".into(), + "printf '# TODO\\n\\n- [ ] Configure application\\n' > TODO.md".into(), + "printf '# Changelog\\n\\n## [Unreleased]\\n' > CHANGELOG.md".into(), + ], + }, + ] +} + +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); } + let file = TemplatesFile { templates: templates.to_vec() }; + if let Ok(s) = toml::to_string_pretty(&file) { let _ = std::fs::write(p, s); } +} + // ── pins persistence ────────────────────────────────────────────────────────── fn pins_path() -> PathBuf { diff --git a/src/hub/keys.rs b/src/hub/keys.rs index 10baa3b..eebe477 100644 --- a/src/hub/keys.rs +++ b/src/hub/keys.rs @@ -1,6 +1,6 @@ use anyhow::Result; use crossterm::event::{Event, KeyCode, KeyModifiers, MouseEventKind}; -use super::actions::{copy_to_clipboard, get_remote_url, scan_todos_from_repos, save_bookmarks, save_pins, to_web_url}; +use super::actions::{copy_to_clipboard, get_remote_url, refresh_repos, scan_todos_from_repos, save_bookmarks, save_pins, to_web_url}; use super::modals::{handle_modal_key, process_modal_confirm}; use super::render::{render_hub, HELP_LINES}; use super::state::{ @@ -9,12 +9,96 @@ use super::state::{ }; use crate::todos; -pub fn tui_phase(state: &mut HubState) -> Result { +pub fn maybe_load_global_stats(state: &mut HubState) { + if state.tab != Tab::Stats { return; } + if state.global_stats.is_some() || state.global_stats_rx.is_some() { return; } + + let repos_snapshot: Vec<(String, std::path::PathBuf, Option, bool, usize)> = state.repos.iter() + .map(|r| (r.name.clone(), r.path.clone(), r.lang.clone(), r.is_dirty, r.behind)) + .collect(); + let dirty_count = state.repos.iter().filter(|r| r.is_dirty).count(); + let behind_count = state.repos.iter().filter(|r| r.behind > 0).count(); + let period = state.global_stats_period; + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + use super::state::GlobalStats; + let mut all_dates: std::collections::HashMap = std::collections::HashMap::new(); + let mut top_repos: Vec<(String, usize)> = vec![]; + let mut total_commits: usize = 0; + + for (name, path, _lang, _dirty, _behind) in &repos_snapshot { + let activity = match period { + crate::detail::StatsPeriod::Day1 => crate::detail::activity_1d(path), + crate::detail::StatsPeriod::Day7 => crate::detail::activity_7d(path), + crate::detail::StatsPeriod::Day30 => crate::detail::activity_30d(path), + crate::detail::StatsPeriod::Day90 => crate::detail::activity_90d(path), + crate::detail::StatsPeriod::Year1 => crate::detail::activity_1y(path), + crate::detail::StatsPeriod::AllTime => crate::detail::activity_all(path), + }; + let repo_total: usize = activity.iter().map(|(_, c)| c).sum(); + total_commits += repo_total; + if repo_total > 0 { top_repos.push((name.clone(), repo_total)); } + for (date, count) in activity { + *all_dates.entry(date).or_default() += count; + } + } + + 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") { + let wd = d.weekday().num_days_from_monday() as usize; + day_pattern[wd] += count; + } + } + + 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)); + top_repos.truncate(5); + + let mut lang_map: std::collections::HashMap = std::collections::HashMap::new(); + for (_, _, lang, _, _) in &repos_snapshot { + if let Some(l) = lang { *lang_map.entry(l.clone()).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, + dirty_count, behind_count, + day_pattern, + }); + }); + + state.global_stats_rx = Some(rx); + state.status_msg = Some("Computing global stats...".into()); +} + +pub fn tui_phase(state: &mut HubState) -> Result<(PhaseResult, Option)> { let mut terminal = crate::tui::setup()?; let result = (|| -> Result { loop { state.poll_incoming(); 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); + } + } terminal.draw(|f| render_hub(f, state))?; state.update_focused_extra(); @@ -45,6 +129,9 @@ pub fn tui_phase(state: &mut HubState) -> Result { 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; } @@ -66,7 +153,7 @@ pub fn tui_phase(state: &mut HubState) -> Result { if state.filtering { match key.code { - KeyCode::Esc => { + KeyCode::Esc | KeyCode::Char('/') => { state.filtering = false; state.filter_text.clear(); state.rebuild_display(); @@ -94,8 +181,21 @@ pub fn tui_phase(state: &mut HubState) -> Result { state.quit = true; return Ok(PhaseResult::None); } } - KeyCode::Up | KeyCode::Char('k') => state.scroll_up(), - KeyCode::Down | KeyCode::Char('j') => state.scroll_down(), + 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 = state.display.iter().filter_map(|row| { if let DisplayRow::Repo { repo_idx } = row { Some(*repo_idx) } else { None } @@ -141,25 +241,33 @@ pub fn tui_phase(state: &mut HubState) -> Result { } KeyCode::Tab | KeyCode::Right => { state.tab = match state.tab { - Tab::Repos => Tab::Projects, - Tab::Projects => Tab::Todos, - Tab::Todos => Tab::PRs, - Tab::PRs => Tab::Issues, - Tab::Issues => Tab::Repos, + 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::Repos => Tab::Issues, - Tab::Projects => Tab::Repos, - Tab::Todos => Tab::Projects, - Tab::PRs => Tab::Todos, - Tab::Issues => Tab::PRs, + 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 { @@ -175,7 +283,7 @@ pub fn tui_phase(state: &mut HubState) -> Result { if let DisplayRow::Pr { idx } = &state.display[di] { let url = state.prs[*idx].url.clone(); if !url.is_empty() { - let _ = std::process::Command::new("xdg-open").arg(&url).spawn(); + crate::util::open_path(&url); state.status_msg = Some("Opening PR in browser...".into()); } } @@ -187,17 +295,119 @@ pub fn tui_phase(state: &mut HubState) -> Result { if let DisplayRow::Issue { idx } = &state.display[di] { let url = state.issues[*idx].url.clone(); if !url.is_empty() { - let _ = std::process::Command::new("xdg-open").arg(&url).spawn(); + crate::util::open_path(&url); state.status_msg = Some("Opening issue in browser...".into()); } } } } - Tab::Todos => {} + 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 = 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::>().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::()), + "Delete template".into(), + ], + cursor: 0, + tag: SelectTag::TemplateMenu(ti), + }; + } + } + _ => {} + } + } } } KeyCode::Char('s') => { - if !state.action_targets().is_empty() { state.confirming = Some(PendingAction::Sync); } + 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); } @@ -205,9 +415,21 @@ pub fn tui_phase(state: &mut HubState) -> Result { 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) }; + 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() { @@ -218,12 +440,12 @@ pub fn tui_phase(state: &mut HubState) -> Result { 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 }; + 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) }; + 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) => { @@ -268,15 +490,31 @@ pub fn tui_phase(state: &mut HubState) -> Result { }; } KeyCode::Char('S') => { - state.sort_mode = state.sort_mode.next(); - state.rebuild_display(); + 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(); - let editor = state.editor.clone(); - let _ = std::process::Command::new(&editor).arg(&path).spawn(); - state.status_msg = Some(format!("Opened in {}", editor)); + state.pending_phase = Some(PhaseResult::ExecOpenEditor(path)); } } KeyCode::Char('y') => { @@ -290,13 +528,24 @@ pub fn tui_phase(state: &mut HubState) -> Result { } } } + 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("URL copied!".into()); + state.status_msg = Some("SSH URL copied!".into()); } else { state.status_msg = Some(format!(" {}", url)); } @@ -342,15 +591,14 @@ pub fn tui_phase(state: &mut HubState) -> Result { KeyCode::Char('g') => { if let Some(ri) = state.cur_repo_idx() { let gitignore = state.repos[ri].path.join(".gitignore"); - let editor = state.editor.clone(); - let _ = std::process::Command::new(&editor).arg(&gitignore).spawn(); + 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.clone(); - let _ = std::process::Command::new("xdg-open").arg(&path).spawn(); + 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()); } } @@ -359,7 +607,7 @@ pub fn tui_phase(state: &mut HubState) -> Result { let path = state.repos[ri].path.clone(); if let Some(url) = get_remote_url(&path) { let web = to_web_url(&url); - let _ = std::process::Command::new("xdg-open").arg(&web).spawn(); + crate::util::open_path(&web); state.status_msg = Some("Opening in browser...".into()); } else { state.status_msg = Some("No remote origin".into()); @@ -384,30 +632,17 @@ pub fn tui_phase(state: &mut HubState) -> Result { state.filter_archived = !state.filter_archived; state.rebuild_display(); } - KeyCode::Char('C') => { - let prompt = format!("Git URL (clones into {}):", state.workspace_root.display()); - state.modal = Modal::Text { - title: "Clone repository".into(), - prompt, - value: String::new(), - tag: TextTag::CloneUrl, - }; - } - KeyCode::Char('I') => { - let prompt = format!("Folder name (created in {}):", state.workspace_root.display()); - state.modal = Modal::Text { - title: "Init new repository".into(), - prompt, - value: String::new(), - tag: TextTag::InitName, - }; - } - KeyCode::Char('L') => { - state.modal = Modal::Text { - title: "Link existing folder".into(), - prompt: "Absolute path to folder:".into(), - value: String::new(), - tag: TextTag::LinkPath, + 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') => { @@ -415,11 +650,31 @@ pub fn tui_phase(state: &mut HubState) -> Result { 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(); } + _ => {} } } } })(); + let phase = result?; + // When opening detail, hand the terminal over instead of tearing down — + // detail will reuse it, eliminating the LeaveAlternateScreen flash. + if matches!(phase, PhaseResult::OpenDetail) { + return Ok((phase, Some(terminal))); + } crate::tui::teardown(terminal)?; - result + Ok((phase, None)) } diff --git a/src/hub/mod.rs b/src/hub/mod.rs index be294b1..7a2da18 100644 --- a/src/hub/mod.rs +++ b/src/hub/mod.rs @@ -1,12 +1,13 @@ mod actions; mod keys; mod modals; +mod project_detail; mod render; mod right_panel; mod state; mod tabs; -use actions::{exec_api_refresh, load_bookmarks, load_pins, refresh_repos, run_sync_flow}; +use actions::{exec_api_refresh, load_bookmarks, load_pins, load_templates, refresh_repos, run_sync_flow}; use keys::tui_phase; use state::{HubState, Modal, PhaseResult, SortMode, Tab}; @@ -15,13 +16,8 @@ use crate::config::AppConfig; use crate::projects; use crate::providers::{IssueDetail, PrDetail}; use crate::status::RepoSummary; -use crossterm::event::{Event, KeyCode}; -use ratatui::{ - layout::{Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Cell, Paragraph, Row, Table}, -}; +use crossterm::event::KeyCode; +use ratatui::text::Line; use std::collections::{HashMap, HashSet}; pub async fn run_overview_tui( @@ -51,12 +47,14 @@ pub async fn run_overview_tui( let debug_mode = crate::debug::is_enabled(); let debug_log = crate::debug::take(); + let accounts = crate::auth::load_accounts_path(&cfg.data_dir).unwrap_or_default(); + let templates = load_templates(); let mut state = HubState { repos, projects, project_map, todos, prs: cached_prs, issues: cached_issues, - tab: Tab::Repos, + tab: Tab::Overview, display: Vec::new(), repo_pos: Vec::new(), cursor: 0, offset: 0, visible_rows: 0, selected: HashSet::new(), @@ -80,6 +78,16 @@ pub async fn run_overview_tui( scan_rx, meta_cache, diff_rx: None, + global_stats: None, + global_stats_rx: None, + global_stats_period: crate::detail::StatsPeriod::Day30, + hub_settings_cur: 0, + accounts, + templates, + pending_sync_action: None, + pending_sync_msg: None, + pending_phase: None, + pull_all_rx: None, }; for r in &mut state.repos { r.bookmarked = state.bookmarks.contains(&r.path); @@ -87,7 +95,7 @@ pub async fn run_overview_tui( state.rebuild_display(); loop { - let phase_result = tui_phase(&mut state)?; + let (phase_result, handed_term) = tui_phase(&mut state)?; if state.quit { break; } @@ -95,19 +103,21 @@ pub async fn run_overview_tui( PhaseResult::OpenDetail => { state.open_detail = false; if let Some(ri) = state.cur_repo_idx() { - let _ = crate::status::detail::run(state.repos[ri].clone(), cfg).await; + let _ = crate::status::detail::run(state.repos[ri].clone(), cfg, handed_term).await; } else if let Some(pi) = state.cur_proj_idx() { let repo_indices = state.project_repos(pi); let proj_repos: Vec = repo_indices.iter().filter_map(|&ri| state.repos.get(ri)).cloned().collect(); - run_project_detail(&state.projects[pi], proj_repos, cfg).await?; + project_detail::run(state.projects[pi].clone(), proj_repos, cfg, handed_term).await?; } } PhaseResult::ExecSync => { state.exec_action = false; state.confirming = None; let targets = state.action_targets(); - run_sync_flow(&state.repos, &targets).await?; + let action = state.pending_sync_action.take().unwrap_or_else(|| "pull".into()); + let msg = state.pending_sync_msg.take().unwrap_or_default(); + run_sync_flow(&state.repos, &targets, &action, &msg).await?; state.selected.clear(); refresh_repos(&mut state); } @@ -132,73 +142,29 @@ pub async fn run_overview_tui( 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(); + } PhaseResult::None => {} } } Ok(()) } -async fn run_project_detail(proj: &crate::projects::Project, repos: Vec, _cfg: &AppConfig) -> Result<()> { - use tabs::age_color; - - let (pr, pg, pb) = crate::projects::hex_to_rgb(&proj.color); - let proj_color = ratatui::style::Color::Rgb(pr, pg, pb); - - let mut terminal = crate::tui::setup()?; - let result = (|| -> Result<()> { - loop { - let proj_name = proj.name.clone(); - let proj_hex = proj.color.clone(); - let repos_snap = repos.clone(); - terminal.draw(|f| { - let area = f.area(); - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Fill(1), Constraint::Length(1)]) - .split(area); - - f.render_widget(Paragraph::new(Line::from(vec![ - Span::styled(format!(" {} ", proj_name), Style::default().fg(proj_color).add_modifier(Modifier::BOLD)), - Span::styled(format!("({})", proj_hex), Style::default().fg(proj_color)), - Span::styled(format!(" {} repos", repos_snap.len()), Style::default().fg(Color::DarkGray)), - ])), layout[0]); - - let header = Row::new(vec!["NAME", "BRANCH", "STATUS", "AGE", "LAST COMMIT"]) - .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); - let rows: Vec = repos_snap.iter().map(|r| { - let status: String = if r.is_dirty { "⚠ dirty".into() } else if r.behind > 0 { format!("↓{}", r.behind) } else { "✓".into() }; - let name_col = if r.is_dirty || r.ahead > 0 || r.behind > 0 { Color::Yellow } else { Color::White }; - Row::new(vec![ - Cell::from(r.name.clone()).style(Style::default().fg(name_col)), - Cell::from(r.branch.chars().take(12).collect::()), - Cell::from(status), - Cell::from(r.last_commit_age.clone()).style(Style::default().fg(age_color(r.last_commit_ts))), - Cell::from(r.last_commit_msg.chars().take(40).collect::()).style(Style::default().fg(Color::DarkGray)), - ]) - }).collect(); - f.render_widget( - Table::new(rows, [Constraint::Fill(1), Constraint::Length(13), Constraint::Length(10), Constraint::Length(10), Constraint::Fill(2)]) - .header(header), - layout[1], - ); - f.render_widget( - Paragraph::new(" [q/Esc] back").style(Style::default().fg(Color::DarkGray)), - layout[2], - ); - })?; - - if let Some(Event::Key(k)) = crate::tui::next_event(250)? { - match k.code { - KeyCode::Char('q') | KeyCode::Esc => break, - _ => {} - } - } - } - Ok(()) - })(); - crate::tui::teardown(terminal)?; - result -} pub fn run_detail_tui(detail: crate::status::RepoDetail) -> Result<()> { use crossterm::event::Event; diff --git a/src/hub/modals.rs b/src/hub/modals.rs index 178f741..0785f1c 100644 --- a/src/hub/modals.rs +++ b/src/hub/modals.rs @@ -1,6 +1,17 @@ use crate::projects; use crate::todos::{self, Todo, TodoStatus}; use crossterm::event::KeyCode; + +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)) + } else if s.starts_with("~/") { + dirs::home_dir().map(|h| h.join(&s[2..])).unwrap_or_else(|| std::path::PathBuf::from(s)) + } else { + std::path::PathBuf::from(s) + } +} use ratatui::{ layout::Rect, style::{Color, Modifier, Style}, @@ -12,19 +23,29 @@ use super::state::{ ConfirmTag, HubState, Modal, ModalAction, MultiTag, ProjAction, SelectTag, TextTag, }; +use super::actions::save_templates; pub fn render_modal(f: &mut Frame, modal: &Modal, area: Rect) { match modal { - Modal::Text { title, prompt, value, .. } => { + Modal::Text { title, prompt, value, max_len, .. } => { let rect = centered_rect(60, 8, area); f.render_widget(Clear, rect); let blk = Block::default().title(format!(" {} ", title)).borders(Borders::ALL).style(Style::default().bg(Color::Reset)); let inner = blk.inner(rect); f.render_widget(blk, rect); + let counter_line = if let Some(max) = max_len { + let n = value.len(); + let style = if n >= *max { Style::default().fg(Color::Red) } + else if n * 10 >= max * 7 { Style::default().fg(Color::Yellow) } + else { Style::default().fg(Color::DarkGray) }; + Line::from(Span::styled(format!(" ({}/{})", n, max), style)) + } else { + Line::from("") + }; let lines = vec![ Line::from(""), Line::from(Span::styled(format!(" {}", prompt), Style::default().fg(Color::White))), - Line::from(""), + counter_line, Line::from(vec![Span::styled(" > ", Style::default().fg(Color::Cyan)), Span::raw(value.clone()), Span::styled("█", Style::default().fg(Color::Cyan))]), Line::from(""), Line::from(Span::styled(" Enter = confirm Esc = cancel", Style::default().fg(Color::DarkGray))), @@ -105,6 +126,34 @@ pub fn render_modal(f: &mut Frame, modal: &Modal, area: Rect) { } } +// ── Project color palette ───────────────────────────────────────────────────── + +const PALETTE: &[(&str, &str)] = &[ + ("#808080", "Gray (default)"), + ("#3b82f6", "Blue"), + ("#22c55e", "Green"), + ("#ef4444", "Red"), + ("#eab308", "Yellow"), + ("#a855f7", "Purple"), + ("#f97316", "Orange"), + ("#ec4899", "Pink"), + ("#06b6d4", "Cyan"), + ("#14b8a6", "Teal"), + ("#f43f5e", "Rose"), +]; + +pub fn color_palette() -> Vec { + PALETTE.iter().map(|(hex, name)| format!("{} {}", hex, name)).collect() +} + +pub fn palette_hex(cursor: usize) -> String { + PALETTE.get(cursor).map(|(h, _)| h.to_string()).unwrap_or_else(|| "#808080".into()) +} + +pub fn palette_cursor_for(hex: &str) -> usize { + PALETTE.iter().position(|(h, _)| *h == hex).unwrap_or(0) +} + 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; @@ -114,8 +163,11 @@ pub fn centered_rect(pct_x: u16, height: u16, area: Rect) -> Rect { pub fn handle_modal_key(modal: &mut Modal, code: KeyCode) -> ModalAction { match modal { - Modal::Text { value, .. } => match code { - KeyCode::Char(c) => { value.push(c); ModalAction::Pending } + Modal::Text { value, max_len, .. } => match code { + KeyCode::Char(c) => { + if max_len.map_or(true, |m| value.len() < m) { value.push(c); } + ModalAction::Pending + } KeyCode::Backspace => { value.pop(); ModalAction::Pending } KeyCode::Enter => ModalAction::Confirm, KeyCode::Esc => ModalAction::Cancel, @@ -168,16 +220,16 @@ pub fn process_modal_confirm(state: &mut HubState) { Modal::Text { value, tag: TextTag::ProjName, .. } => { if !value.trim().is_empty() { - state.modal = Modal::Text { + state.modal = Modal::Select { title: "Create project — Color".into(), - prompt: "Hex color (#rrggbb):".into(), - value: "#3498db".into(), - tag: TextTag::ProjColor(value), + options: color_palette(), + cursor: 0, + tag: SelectTag::ProjColorCreate(value), }; } } - Modal::Text { value: color, tag: TextTag::ProjColor(name), .. } => { - let color = if color.starts_with('#') && color.len() == 7 { color } else { "#3498db".into() }; + 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(); @@ -185,16 +237,16 @@ pub fn process_modal_confirm(state: &mut HubState) { } Modal::Text { value, tag: TextTag::ProjRenameName(pi), .. } => { - let cur_color = state.projects[pi].color.clone(); - state.modal = Modal::Text { + let cur_cursor = palette_cursor_for(&state.projects[pi].color); + state.modal = Modal::Select { title: format!("Rename '{}' — Color", state.projects[pi].name), - prompt: "New color (#rrggbb):".into(), - value: cur_color, - tag: TextTag::ProjRenameColor(pi, value), + options: color_palette(), + cursor: cur_cursor, + tag: SelectTag::ProjColorRename(pi, value), }; } - Modal::Text { value: color, tag: TextTag::ProjRenameColor(pi, name), .. } => { - let color = if color.starts_with('#') && color.len() == 7 { color } else { state.projects[pi].color.clone() }; + 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); @@ -204,12 +256,12 @@ pub fn process_modal_confirm(state: &mut HubState) { 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 }; + 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 }; + 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 = state.projects.iter().map(|p| p.name.clone()).collect(); @@ -243,7 +295,7 @@ pub fn process_modal_confirm(state: &mut HubState) { } 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) }; + 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(); @@ -269,7 +321,7 @@ pub fn process_modal_confirm(state: &mut HubState) { 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) }; + 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), .. } => { @@ -294,7 +346,7 @@ pub fn process_modal_confirm(state: &mut HubState) { 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) }; + 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; @@ -309,6 +361,56 @@ pub fn process_modal_confirm(state: &mut HubState) { 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 = (0..repos.len()).filter(|&ri| state.passes_filter(ri)).collect(); @@ -334,6 +436,115 @@ pub fn process_modal_confirm(state: &mut HubState) { 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::(); + 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::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()); @@ -343,29 +554,176 @@ pub fn process_modal_confirm(state: &mut HubState) { Modal::Text { value, tag: TextTag::InitName, .. } => { let name = value.trim().to_string(); if name.is_empty() { return; } - let target = state.workspace_root.join(&name); + 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 out = std::process::Command::new("git") - .arg("init").current_dir(&target).output(); - match out { - Ok(o) if o.status.success() => { - super::actions::refresh_repos(state); - state.status_msg = Some(format!("Initialized {name}")); - } - Ok(o) => { - state.status_msg = Some(format!("git init failed: {}", String::from_utf8_lossy(&o.stderr).trim())); - } - Err(e) => { state.status_msg = Some(format!("git init error: {e}")); } + 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::(); + 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 = 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::(); + 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 = std::path::PathBuf::from(value.trim()); + let src = expand_tilde(&value); if !src.exists() { - state.status_msg = Some("Path does not exist".into()); + 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(); @@ -387,6 +745,23 @@ pub fn process_modal_confirm(state: &mut HubState) { 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()); + } + } + _ => {} } } diff --git a/src/hub/project_detail.rs b/src/hub/project_detail.rs new file mode 100644 index 0000000..7ebeab9 --- /dev/null +++ b/src/hub/project_detail.rs @@ -0,0 +1,842 @@ +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 ────────────────────────────────────────────────────────────────────── + +#[derive(PartialEq, Clone, Copy)] +enum ProjectTab { Overview, Commits, Repos, Stats, Todos, Settings } + +impl ProjectTab { + fn next(self) -> Self { + match self { + Self::Overview => Self::Commits, + Self::Commits => Self::Repos, + Self::Repos => Self::Stats, + Self::Stats => Self::Todos, + Self::Todos => Self::Settings, + Self::Settings => Self::Overview, + } + } + fn prev(self) -> Self { + match self { + Self::Overview => Self::Settings, + Self::Commits => Self::Overview, + Self::Repos => Self::Commits, + Self::Stats => Self::Repos, + Self::Todos => Self::Stats, + Self::Settings => Self::Todos, + } + } +} + +// ── color palette ───────────────────────────────────────────────────────────── + +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, +} + +// ── state ───────────────────────────────────────────────────────────────────── + +struct St { + project: Project, + repos: Vec, + tab: ProjectTab, + commits: Vec, + commits_rx: Option>>, + repo_cursor: usize, + commit_off: usize, + todo_cursor: usize, + stats_cursor: usize, + todos: Vec, + activity: Vec<(String, usize)>, + activity_rx: Option>>, + status_msg: Option, + quit: bool, + cfg: AppConfig, + rename_input: Option, + color_cursor: Option, // palette picker cursor (replaces hex text input) +} + +impl St { + fn new(project: Project, repos: Vec, cfg: AppConfig) -> Self { + let pname = project.name.clone(); + let all_todos = crate::todos::load_manual(); + let todos: Vec = all_todos.into_iter() + .filter(|t| t.project.as_deref() == Some(&pname)) + .collect(); + Self { + project, repos, cfg, + tab: ProjectTab::Overview, + commits: vec![], + commits_rx: None, + repo_cursor: 0, + commit_off: 0, + todo_cursor: 0, + stats_cursor: 0, + todos, + activity: vec![], + activity_rx: None, + status_msg: None, + quit: false, + rename_input: None, + color_cursor: None, + } + } +} + +// ── entry point ─────────────────────────────────────────────────────────────── + +pub async fn run(project: Project, repos: Vec, cfg: &AppConfig, maybe_term: Option) -> Result<()> { + let mut terminal = match maybe_term { + Some(t) => t, + None => crate::tui::setup()?, + }; + let mut st = St::new(project, repos, cfg.clone()); + + // Spawn commits aggregation immediately + spawn_commits(&mut st); + + let result = run_loop(&mut st, &mut terminal).await; + crate::tui::teardown(terminal)?; + result +} + +async fn run_loop(st: &mut St, terminal: &mut crate::tui::Tty) -> Result<()> { + loop { + poll_commits(st); + poll_activity(st); + + terminal.draw(|f| draw(f, st))?; + + let event = crate::tui::next_event(250)?; + let key = match event { Some(Event::Key(k)) => k, _ => continue }; + + // Modal inputs (rename / color-palette) + if let Some(ref mut input) = st.rename_input { + match key.code { + KeyCode::Esc => { st.rename_input = None; } + KeyCode::Enter => { + let name = input.trim().to_string(); + if !name.is_empty() { + let mut projects = crate::projects::load(); + if let Some(p) = projects.iter_mut().find(|p| p.name == st.project.name) { + p.name = name.clone(); + } + let _ = crate::projects::save(&projects); + st.project.name = name; + st.status_msg = Some("Renamed project".into()); + } + st.rename_input = None; + } + KeyCode::Backspace => { input.pop(); } + KeyCode::Char(c) if input.len() < 80 => { input.push(c); } + _ => {} + } + continue; + } + if let Some(ref mut cur) = st.color_cursor { + let max = PROJ_PALETTE.len().saturating_sub(1); + match key.code { + KeyCode::Esc => { st.color_cursor = None; } + KeyCode::Left | KeyCode::Char('h') => { *cur = cur.saturating_sub(1); } + KeyCode::Right | KeyCode::Char('l') => { *cur = (*cur + 1).min(max); } + KeyCode::Up | KeyCode::Char('k') => { *cur = cur.saturating_sub(6); } + KeyCode::Down | KeyCode::Char('j') => { *cur = (*cur + 6).min(max); } + KeyCode::Enter => { + let (hex, _) = PROJ_PALETTE[*cur]; + let mut projects = crate::projects::load(); + if let Some(p) = projects.iter_mut().find(|p| p.name == st.project.name) { + p.color = hex.to_string(); + } + let _ = crate::projects::save(&projects); + st.project.color = hex.to_string(); + st.color_cursor = None; + st.status_msg = Some("Color updated".into()); + } + _ => {} + } + continue; + } + + handle_key(key.code, st).await; + if st.quit { break; } + } + Ok(()) +} + +async fn handle_key(code: KeyCode, st: &mut St) { + st.status_msg = None; + match code { + KeyCode::Char('q') | KeyCode::Esc => { st.quit = true; } + + KeyCode::Tab | KeyCode::Right => { + st.tab = st.tab.next(); + if st.tab == ProjectTab::Stats && st.activity.is_empty() && st.activity_rx.is_none() { + spawn_activity(st); + } + } + KeyCode::Left => { + st.tab = st.tab.prev(); + if st.tab == ProjectTab::Stats && st.activity.is_empty() && st.activity_rx.is_none() { + spawn_activity(st); + } + } + KeyCode::Char('1') => { st.tab = ProjectTab::Overview; } + KeyCode::Char('2') => { st.tab = ProjectTab::Commits; } + KeyCode::Char('3') => { st.tab = ProjectTab::Repos; } + KeyCode::Char('4') => { + st.tab = ProjectTab::Stats; + if st.activity.is_empty() && st.activity_rx.is_none() { spawn_activity(st); } + } + KeyCode::Char('5') => { st.tab = ProjectTab::Todos; } + KeyCode::Char('6') => { st.tab = ProjectTab::Settings; } + + KeyCode::Down | KeyCode::Char('j') => match st.tab { + ProjectTab::Repos => st.repo_cursor = (st.repo_cursor + 1).min(st.repos.len().saturating_sub(1)), + ProjectTab::Commits => { let max = st.commits.len().saturating_sub(1); st.commit_off = (st.commit_off + 1).min(max); } + ProjectTab::Todos => st.todo_cursor = (st.todo_cursor + 1).min(st.todos.len().saturating_sub(1)), + ProjectTab::Stats => st.stats_cursor = (st.stats_cursor + 1).min(st.repos.len().saturating_sub(1)), + _ => {} + }, + KeyCode::Up | KeyCode::Char('k') => match st.tab { + ProjectTab::Repos => st.repo_cursor = st.repo_cursor.saturating_sub(1), + ProjectTab::Commits => st.commit_off = st.commit_off.saturating_sub(1), + ProjectTab::Todos => st.todo_cursor = st.todo_cursor.saturating_sub(1), + ProjectTab::Stats => st.stats_cursor = st.stats_cursor.saturating_sub(1), + _ => {} + }, + + KeyCode::Enter => match st.tab { + ProjectTab::Repos => { + if let Some(r) = st.repos.get(st.repo_cursor).cloned() { + let _ = crate::detail::run(r, &st.cfg, None).await; + } + } + ProjectTab::Todos => { + if let Some(t) = st.todos.get_mut(st.todo_cursor) { + t.status = match t.status { + TodoStatus::Open => TodoStatus::InProgress, + TodoStatus::InProgress => TodoStatus::Done, + TodoStatus::Done => TodoStatus::Cancelled, + TodoStatus::Cancelled => TodoStatus::Open, + }; + t.touch(); + let all = crate::todos::load_manual(); + let updated = t.clone(); + let new_all: Vec<_> = all.into_iter().map(|x| if x.id == updated.id { updated.clone() } else { x }).collect(); + let _ = crate::todos::save(&new_all); + } + } + ProjectTab::Settings => { + if st.rename_input.is_none() && st.color_cursor.is_none() { + st.rename_input = Some(st.project.name.clone()); + } + } + _ => {} + }, + + KeyCode::Char('c') if st.tab == ProjectTab::Settings => { + let cur = PROJ_PALETTE.iter().position(|(h, _)| *h == st.project.color.as_str()).unwrap_or(0); + st.color_cursor = Some(cur); + } + + _ => {} + } +} + +// ── background loaders ──────────────────────────────────────────────────────── + +fn spawn_commits(st: &mut St) { + if st.commits_rx.is_some() { return; } + let paths: Vec<(String, std::path::PathBuf)> = st.repos.iter() + .map(|r| (r.name.clone(), r.path.clone())) + .collect(); + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let mut all: Vec = vec![]; + for (name, path) in &paths { + let hist = crate::detail::build_history_pub(path, 100); + for h in hist { + if let crate::detail::HistEntry::Commit { sha, ts, age, author, msg } = h { + all.push(AggCommit { repo: name.clone(), sha, ts, age, author, msg }); + } + } + } + all.sort_by(|a, b| b.ts.cmp(&a.ts)); + all.truncate(500); + let _ = tx.send(all); + }); + + st.commits_rx = Some(rx); +} + +fn spawn_activity(st: &mut St) { + if st.activity_rx.is_some() { return; } + let paths: Vec = st.repos.iter().map(|r| r.path.clone()).collect(); + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let mut date_map: std::collections::HashMap = std::collections::HashMap::new(); + for path in &paths { + for (date, count) in crate::detail::activity_1y(path) { + *date_map.entry(date).or_default() += count; + } + } + let mut activity: Vec<(String, usize)> = date_map.into_iter().collect(); + activity.sort_by(|a, b| a.0.cmp(&b.0)); + let _ = tx.send(activity); + }); + + st.activity_rx = Some(rx); +} + +fn poll_commits(st: &mut St) { + if let Some(rx) = &st.commits_rx { + match rx.try_recv() { + Ok(c) => { st.commits = c; st.commits_rx = None; } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { st.commits_rx = None; } + _ => {} + } + } +} + +fn poll_activity(st: &mut St) { + if let Some(rx) = &st.activity_rx { + match rx.try_recv() { + Ok(a) => { st.activity = a; st.activity_rx = None; } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { st.activity_rx = None; } + _ => {} + } + } +} + +// ── 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 = 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::()), wht), + Span::styled(format!("{:<9} ", r.branch.chars().take(9).collect::()), 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 = 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::(), 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::()), dim), + Span::styled(format!("{:<10} ", c.repo.chars().take(10).collect::()), Style::default().fg(Color::Cyan)), + Span::styled(c.msg.chars().take(22).collect::(), 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 = st.commits.iter().skip(st.commit_off).take(avail) + .map(|c| { + Row::new(vec![ + Cell::from(c.repo.chars().take(14).collect::()).style(yel), + Cell::from(c.age.chars().take(8).collect::()).style(dim), + Cell::from(c.author.chars().take(12).collect::()).style(dim), + Cell::from(c.msg.chars().take(60).collect::()), + ]) + }) + .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 = 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::()).style(Style::default().fg(col)), + Cell::from(r.branch.chars().take(12).collect::()).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::()).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 = 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> = 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 = 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::()) + .unwrap_or_default(); + format!("{: = 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::(); + 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::()), + 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 = 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 = 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::()), 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 = 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 = 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 = 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::()), + 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); +} diff --git a/src/hub/render.rs b/src/hub/render.rs index 619ab0b..0c80be2 100644 --- a/src/hub/render.rs +++ b/src/hub/render.rs @@ -1,5 +1,5 @@ use ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, + layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, @@ -7,7 +7,7 @@ use ratatui::{ }; use super::modals::render_modal; use super::right_panel::render_right; -use super::state::{HubState, Modal, SortMode, Tab}; +use super::state::{HubState, SortMode, Tab}; use super::tabs::render_list; pub static HELP_LINES: &[&str] = &[ @@ -82,20 +82,26 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) { }; let health_col = if behind_c > 0 { Color::Red } else if dirty_c > 0 { Color::Yellow } else { Color::Green }; - let pr_badge = if !state.prs.is_empty() { format!("PRs({})", state.prs.len()) } else { "PRs".into() }; - let issue_badge = if !state.issues.is_empty() { format!("Issues({})", state.issues.len()) } else { "Issues".into() }; + let pr_badge = if !state.prs.is_empty() { format!("5 PRs({})", state.prs.len()) } else { "5 PRs".into() }; + 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)), - tab_span("Repos", state.tab == Tab::Repos), + tab_span("1 Overview", state.tab == Tab::Overview), Span::raw(" "), - tab_span("Projects", state.tab == Tab::Projects), + tab_span("2 Repos", state.tab == Tab::Repos), Span::raw(" "), - tab_span("Todos", state.tab == Tab::Todos), + tab_span("3 Projects", state.tab == Tab::Projects), Span::raw(" "), - tab_span(&pr_badge, state.tab == Tab::PRs), + tab_span("4 Todos", state.tab == Tab::Todos), Span::raw(" "), - tab_span(&issue_badge, state.tab == Tab::Issues), + tab_span(&pr_badge, state.tab == Tab::PRs), + Span::raw(" "), + tab_span(&issue_badge, state.tab == Tab::Issues), + Span::raw(" "), + tab_span("7 Stats", state.tab == Tab::Stats), + Span::raw(" "), + tab_span("8 Settings", state.tab == Tab::HubSettings), Span::raw(" "), ]; @@ -130,17 +136,21 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) { f.render_widget(Paragraph::new(Line::from(top_spans)), outer[0]); - let cols = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(73), Constraint::Percentage(27)]) - .split(outer[1]); + if state.tab == Tab::Overview { + super::tabs::render_overview_global(f, state, outer[1]); + } else { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(73), Constraint::Percentage(27)]) + .split(outer[1]); - let left_blk = Block::default().borders(Borders::ALL); - let left_inner = left_blk.inner(cols[0]); - f.render_widget(left_blk, cols[0]); - state.visible_rows = left_inner.height.saturating_sub(1) as usize; - render_list(f, state, left_inner); - render_right(f, state, cols[1]); + let left_blk = Block::default().borders(Borders::ALL); + let left_inner = left_blk.inner(cols[0]); + f.render_widget(left_blk, cols[0]); + state.visible_rows = left_inner.height.saturating_sub(1) as usize; + render_list(f, state, left_inner); + render_right(f, state, cols[1]); + } if state.debug_mode { let blk = Block::default().borders(Borders::TOP).title(" debug ") @@ -160,6 +170,20 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) { let key = |k: &str| Span::styled(format!("[{}] ", k), dim); let sep = || Span::styled(" ", dim); + // Contextual line 3: scan progress or selection hint. + let line3 = if state.scan_rx.is_some() { + Line::from(Span::styled(" scanning repositories…", dim)) + } else if !state.selected.is_empty() { + Line::from(vec![ + Span::styled(format!(" {} selected", state.selected.len()), Style::default().fg(Color::Cyan)), + Span::styled(" — ", dim), + key("s"), Span::styled("sync ", dim), + key("r"), Span::styled("release", dim), + ]) + } else { + Line::from("") + }; + let hint_lines: Vec = if state.modal.is_some() { vec![ Line::from(vec![key("↑↓/jk"), Span::styled("navigate ", dim), key("Enter"), Span::styled("confirm ", dim), key("Esc"), Span::styled("cancel", dim)]), @@ -183,12 +207,12 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) { 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(""), - 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("d/b/f/a"), Span::styled("toggle ", dim), key("S"), Span::styled("sort ", dim), key("?"), Span::styled("help ", dim), key("q"), Span::styled("quit", dim)]), - Line::from(vec![key("Space"), Span::styled("select ", dim), key("*"), Span::styled("select by ", dim), key("^A"), Span::styled("all/none ", dim), key("R"), Span::styled("API refresh ", dim), key("T"), Span::styled("scan TODOs ", dim), key("X"), Span::styled("code TODOs", dim)]), - Line::from(vec![key("C"), Span::styled("clone ", dim), key("I"), Span::styled("init repo ", dim), key("L"), Span::styled("link folder", dim)]), + 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, ], } }; diff --git a/src/hub/right_panel.rs b/src/hub/right_panel.rs index ec2049c..d9af5c2 100644 --- a/src/hub/right_panel.rs +++ b/src/hub/right_panel.rs @@ -64,11 +64,12 @@ pub fn render_right(f: &mut Frame, state: &HubState, area: Rect) { f.render_widget(blk, area); match state.tab { - Tab::Repos => render_right_repo(f, state, inner), - Tab::Projects => render_right_project(f, state, inner), - 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::Repos => render_right_repo(f, state, inner), + Tab::Projects => render_right_project(f, state, inner), + 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 => {} } } @@ -231,9 +232,7 @@ fn render_right_repo(f: &mut Frame, state: &HubState, area: Rect) { r_action_line('p', "Manage projects", Color::White), r_action_line('+', "New todo", Color::White), r_action_line('\n', "─────────────────", Color::DarkGray), - r_action_line('C', "Clone repo", Color::DarkGray), - r_action_line('I', "Init new repo", Color::DarkGray), - r_action_line('L', "Link folder", Color::DarkGray), + r_action_line('N', "New repo…", Color::DarkGray), ]), action_inner); } diff --git a/src/hub/state.rs b/src/hub/state.rs index 156ce23..b3bc5f5 100644 --- a/src/hub/state.rs +++ b/src/hub/state.rs @@ -9,7 +9,19 @@ use std::path::PathBuf; // ── tabs ────────────────────────────────────────────────────────────────────── #[derive(PartialEq, Clone, Copy)] -pub enum Tab { Repos, Projects, Todos, PRs, Issues } +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 ───────────────────────────────────────────────────────────────── @@ -42,7 +54,7 @@ impl SortMode { #[derive(Clone)] pub enum Modal { None, - Text { title: String, prompt: String, value: String, tag: TextTag }, + Text { title: String, prompt: String, value: String, tag: TextTag, max_len: Option }, Select { title: String, options: Vec, cursor: usize, tag: SelectTag }, Multi { title: String, options: Vec, checks: Vec, cursor: usize, tag: MultiTag }, Confirm { message: String, tag: ConfirmTag }, @@ -67,6 +79,17 @@ pub enum TextTag { CloneUrl, InitName, LinkPath, + // hub sync + SyncMsg, + // hub settings editing + SetEditor, + SetClipboard, + SetWorkspace, + // import directory + ImportDir, + // template creation + NewTemplateName, + NewTemplateCmds(String), // template name } #[derive(Clone)] @@ -77,6 +100,21 @@ pub enum SelectTag { 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)] @@ -91,6 +129,9 @@ pub enum MultiTag { pub enum ConfirmTag { DeleteProject(usize), DeleteTodo(usize), + PullAll(usize), // index into state.accounts + RemoveGitAll, + DeleteTemplate(usize), } // ── display rows ────────────────────────────────────────────────────────────── @@ -122,10 +163,30 @@ pub struct FocusedExtra { 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, +} + // ── phase result ────────────────────────────────────────────────────────────── #[derive(PartialEq)] -pub enum PhaseResult { None, OpenDetail, ExecSync, ExecRelease, ExecClone(String), ExecRefresh } +pub enum PhaseResult { + None, + OpenDetail, + ExecSync, + ExecRelease, + ExecClone(String), + ExecRefresh, + ExecTemplateInit { path: PathBuf, commands: Vec }, + ExecOpenEditor(PathBuf), +} // ── modal action ────────────────────────────────────────────────────────────── @@ -176,6 +237,23 @@ pub struct HubState { pub meta_cache: crate::cache::MetaCache, /// Background diff computation in progress: (repo_idx, receiver). pub diff_rx: Option<(usize, std::sync::mpsc::Receiver>)>, + // Global Stats tab + pub global_stats: Option, + pub global_stats_rx: Option>, + pub global_stats_period: crate::detail::StatsPeriod, + // Hub Settings tab + pub hub_settings_cur: usize, + // Loaded once at startup from auth data dir + pub accounts: Vec, + // User-defined repo templates + pub templates: Vec, + // Pending sync input collected from in-TUI modals + pub pending_sync_action: Option, // "pull" | "commit_push" | "push_only" + pub pending_sync_msg: Option, + // PhaseResult deferred from modal confirm + pub pending_phase: Option, + // Pull-all background receiver: sends progress strings + pub pull_all_rx: Option>, } impl HubState { @@ -242,6 +320,9 @@ impl HubState { d.push(DisplayRow::Issue { idx: i }); } } + Tab::Overview | Tab::Stats | 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) { @@ -433,6 +514,22 @@ impl HubState { } /// Check if the background diff thread finished; if so, update the cache entry. + pub fn poll_global_stats(&mut self) { + let done = match &self.global_stats_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.global_stats = Some(s); + self.global_stats_rx = None; + self.status_msg = None; + } + } + pub fn poll_diff(&mut self) { let rx_ready = if let Some((ri, rx)) = &self.diff_rx { match rx.try_recv() { diff --git a/src/hub/tabs.rs b/src/hub/tabs.rs index 6724b34..ddf7da6 100644 --- a/src/hub/tabs.rs +++ b/src/hub/tabs.rs @@ -1,10 +1,10 @@ use crate::status::format_age_pub; use crate::todos::TodoSource; use ratatui::{ - layout::{Constraint, Rect}, + layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Cell, Row, Table}, + widgets::{Cell, Paragraph, Row, Table}, Frame, }; use super::state::{DisplayRow, HubState, Tab}; @@ -19,7 +19,7 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) { let cur_di = state.cur_di().unwrap_or(usize::MAX); let header = Row::new(vec![ - " ", "NAME", "BRANCH", "PROJECT", "VIS", "AGE", "STATUS", "REL", "LIC", "★", + " ", "NAME", "BRANCH", "PROJECT", "VISIB", "AGE", "STATUS", "RELEASE", "LICENSE", "STARS", ]).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); let rows: Vec = state.display.iter().enumerate() @@ -38,7 +38,9 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) { let vis = r.visibility.as_deref().unwrap_or("?"); let rel = r.last_tag.as_deref().unwrap_or("—"); - let d_sym = if r.is_dirty { "⚠" } else { "·" }; + 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); @@ -62,10 +64,10 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) { 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::()), - Cell::from(proj_name.chars().take(10).collect::()).style(Style::default().fg(proj_color)), + Cell::from(proj_name.chars().take(21).collect::()).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 { Style::default().fg(Color::DarkGray) }), + 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::()).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)), @@ -77,13 +79,13 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) { Constraint::Length(6), Constraint::Fill(1), Constraint::Length(10), - Constraint::Length(11), - Constraint::Length(7), + Constraint::Length(22), Constraint::Length(9), Constraint::Length(9), + Constraint::Length(9), + Constraint::Length(10), + Constraint::Length(9), Constraint::Length(8), - Constraint::Length(4), - Constraint::Length(6), ]; f.render_widget(Table::new(rows, widths).header(header), area); } @@ -132,7 +134,7 @@ 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", "CREATED", "UPDATED"]) + 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 = state.display.iter().enumerate() @@ -144,8 +146,12 @@ pub fn render_todos_table(f: &mut Frame, state: &HubState, area: Rect) { 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 updated = format_age_pub(t.updated_at); + let created = format_age_pub(t.created_at); + let est_str = t.estimate.as_deref().unwrap_or("—").chars().take(10).collect::(); + 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() }; @@ -163,8 +169,9 @@ pub fn render_todos_table(f: &mut Frame, state: &HubState, area: Rect) { 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)), - Cell::from(updated).style(Style::default().fg(Color::DarkGray)), ]).style(row_sty)) }) .collect(); @@ -177,6 +184,7 @@ pub fn render_todos_table(f: &mut Frame, state: &HubState, area: Rect) { Constraint::Length(12), Constraint::Length(12), Constraint::Length(10), + Constraint::Length(7), Constraint::Length(10), ]).header(header), area, @@ -259,6 +267,14 @@ pub fn render_issues_table(f: &mut Frame, state: &HubState, area: Rect) { } 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::HubSettings => { render_settings_global(f, state, area); return; } + _ => {} + } + if state.display.is_empty() { let msg = match state.tab { Tab::Repos => " (no repos match current filters)", @@ -266,15 +282,613 @@ pub fn render_list(f: &mut Frame, state: &HubState, area: Rect) { Tab::Todos => " No todos — press [+] to create one", Tab::PRs => " No PRs cached — press [R] to fetch from GitHub", Tab::Issues => " No issues cached — press [R] to fetch from GitHub", + _ => "", }; f.render_widget(ratatui::widgets::Paragraph::new(msg).style(Style::default().fg(Color::DarkGray)), area); return; } match state.tab { - Tab::Repos => render_repos_table(f, state, area), - Tab::Projects => render_projects_table(f, state, area), - Tab::Todos => render_todos_table(f, state, area), - Tab::PRs => render_prs_table(f, state, area), - Tab::Issues => render_issues_table(f, state, area), + Tab::Repos => render_repos_table(f, state, area), + Tab::Projects => render_projects_table(f, state, area), + Tab::Todos => render_todos_table(f, state, area), + Tab::PRs => render_prs_table(f, state, area), + Tab::Issues => render_issues_table(f, state, area), + _ => {} } } + +// ── 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 = 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 = 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::()), 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 = 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::(); + let msg = r.last_commit_msg.chars().take(msg_w).collect::(); + Line::from(vec![ + Span::styled(format!("{:>5} ", format_age_pub(r.last_commit_ts)), Style::default().fg(age_col)), + 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::(); + 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 = 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 = 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::(); + let title = n.title.chars().take(title_w).collect::(); + let auth = n.author.chars().take(10).collect::(); + Line::from(vec![ + Span::styled(format!(" {} #{:<4} ", n.kind, n.number), kind_col), + Span::styled(format!("{: = 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> = 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 = 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::()) + .unwrap_or_default(); + format!("{: = 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::()), 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::()), 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 = 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::(), + 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::()), + 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 = 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]); +} diff --git a/src/legacy/refactor/mod.rs b/src/legacy/refactor/mod.rs index 43c255f..3d3936b 100644 --- a/src/legacy/refactor/mod.rs +++ b/src/legacy/refactor/mod.rs @@ -47,6 +47,53 @@ pub fn find_in_dir(pattern: &str, root: &Path, repo_name: &str) -> Vec Vec { + let mut results = Vec::new(); + let rx = match regex_simple(pattern) { + Some(r) => r, + None => return results, + }; + walk_doc_files(root, &mut |file_path, rel| { + if let Ok(content) = std::fs::read_to_string(&file_path) { + for (i, line) in content.lines().enumerate() { + if rx(line) { + results.push(FindResult { + repo: repo_name.to_string(), + file: rel.clone(), + line: i + 1, + text: line.trim().chars().take(120).collect(), + }); + } + } + } + }); + results +} + +fn walk_doc_files(root: &Path, cb: &mut F) { + let Ok(entries) = std::fs::read_dir(root) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if !matches!(name.as_ref(), ".git" | "target" | "node_modules" | ".next" | "dist" | "build" | ".venv" | "vendor") { + walk_doc_files(&path, cb); + } + } else { + let ext = path.extension().map(|e| e.to_string_lossy().to_lowercase()); + let fname = path.file_name().unwrap_or_default().to_string_lossy().to_uppercase(); + let is_doc = matches!(ext.as_deref(), Some("md") | Some("txt") | Some("todo") | Some("org") | Some("rst") | Some("taskpaper")) + || matches!(fname.as_ref(), "TODO" | "TASKS" | "FIXME" | "NOTES" | "BUGS" | "ROADMAP"); + if is_doc { + let rel = path.to_string_lossy().to_string(); + cb(path, rel); + } + } + } +} + // ── rename ──────────────────────────────────────────────────────────────────── pub fn rename_in_dir(from: &str, to: &str, root: &Path, dry_run: bool) -> Vec { diff --git a/src/providers/gitea.rs b/src/providers/gitea.rs index 1696cdd..e7342e2 100644 --- a/src/providers/gitea.rs +++ b/src/providers/gitea.rs @@ -177,9 +177,11 @@ impl Provider for GiteaProvider { .send().await?; let v = Self::check(resp).await.context("repo_meta")?; Ok(RepoMeta { - open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32, - open_prs: v["open_pr_counter"].as_u64().unwrap_or(0) as u32, - stars: v["stars_count"].as_u64().unwrap_or(0) as u32, + open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32, + open_prs: v["open_pr_counter"].as_u64().unwrap_or(0) as u32, + stars: v["stars_count"].as_u64().unwrap_or(0) as u32, + description: v["description"].as_str().filter(|s| !s.is_empty()).map(String::from), + topics: vec![], }) } diff --git a/src/providers/github.rs b/src/providers/github.rs index 39e9742..d4c2667 100644 --- a/src/providers/github.rs +++ b/src/providers/github.rs @@ -145,10 +145,19 @@ impl Provider for GitHubProvider { 0 }; + let description = v["description"].as_str() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let topics = v["topics"].as_array() + .map(|arr| arr.iter().filter_map(|t| t.as_str().map(String::from)).collect()) + .unwrap_or_default(); + Ok(RepoMeta { - open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32, + open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32, open_prs, - stars: v["stargazers_count"].as_u64().unwrap_or(0) as u32, + stars: v["stargazers_count"].as_u64().unwrap_or(0) as u32, + description, + topics, }) } @@ -207,6 +216,15 @@ impl Provider for GitHubProvider { Ok(()) } + async fn update_description(&self, repo: &str, description: &str) -> Result<()> { + let body = serde_json::json!({ "description": description }); + let resp = self.client + .patch(self.url(&format!("repos/{}/{}", self.owner, repo))) + .json(&body).send().await?; + Self::check(resp).await.context("update_description")?; + Ok(()) + } + async fn list_pull_requests(&self, repo: &str) -> Result> { let resp = self.client .get(self.url(&format!("repos/{}/{}/pulls?state=open&per_page=100", self.owner, repo))) diff --git a/src/providers/gitlab.rs b/src/providers/gitlab.rs index f0ea641..6ca0f32 100644 --- a/src/providers/gitlab.rs +++ b/src/providers/gitlab.rs @@ -179,9 +179,11 @@ impl Provider for GitLabProvider { .send().await?; let v = Self::check(resp).await.context("repo_meta")?; Ok(RepoMeta { - open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32, - open_prs: v["merge_requests_count"].as_u64().unwrap_or(0) as u32, - stars: v["star_count"].as_u64().unwrap_or(0) as u32, + open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32, + open_prs: v["merge_requests_count"].as_u64().unwrap_or(0) as u32, + stars: v["star_count"].as_u64().unwrap_or(0) as u32, + description: v["description"].as_str().filter(|s| !s.is_empty()).map(String::from), + topics: v["topics"].as_array().map(|a| a.iter().filter_map(|t| t.as_str().map(String::from)).collect()).unwrap_or_default(), }) } diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 72a69e6..a33d6f4 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -27,9 +27,11 @@ pub struct PrInfo { #[derive(Debug, Clone, Default)] #[allow(dead_code)] pub struct RepoMeta { - pub open_issues: u32, - pub open_prs: u32, - pub stars: u32, + pub open_issues: u32, + pub open_prs: u32, + pub stars: u32, + pub description: Option, + pub topics: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -137,6 +139,12 @@ pub trait Provider: Send + Sync { let _ = repo; anyhow::bail!("archive_repo not supported by this provider") } + + /// Update the description of a repository. + async fn update_description(&self, repo: &str, description: &str) -> Result<()> { + let _ = (repo, description); + anyhow::bail!("update_description not supported by this provider") + } } /// Build a boxed `Provider` from an `Account`. diff --git a/src/status/mod.rs b/src/status/mod.rs index 3bb288e..bac9d3e 100644 --- a/src/status/mod.rs +++ b/src/status/mod.rs @@ -81,31 +81,143 @@ pub fn detect_license(path: &Path) -> bool { .iter().any(|f| path.join(f).exists()) } +// Categories treated as media/binary assets — only displayed when they dominate (>80% of files). +const ASSET_CATEGORIES: &[&str] = &[ + "Images", "Textures", "Audio", "Video", "3D", "Fonts", + "PDF", "Docs", "Spreadsheets", "Slides", "eBook", +]; + pub fn detect_language(path: &Path) -> Option { - // Fast path: check indicator files instead of scanning the full git index. - const INDICATORS: &[(&str, &str)] = &[ - ("Cargo.toml", "Rust"), - ("go.mod", "Go"), - ("pyproject.toml", "Python"), - ("requirements.txt", "Python"), - ("setup.py", "Python"), - ("pom.xml", "Java"), - ("build.gradle", "Java"), - ("build.gradle.kts", "Kotlin"), - ("Gemfile", "Ruby"), - ("composer.json", "PHP"), - ("Package.swift", "Swift"), - ("mix.exs", "Elixir"), - ("*.cabal", "Haskell"), - ]; - for (file, lang) in INDICATORS { - if path.join(file).exists() { return Some(lang.to_string()); } + let mut counts: HashMap<&'static str, usize> = HashMap::new(); + let mut total = 0usize; + lang_walk(path, &mut counts, &mut total, 6); + if total == 0 { return None; } + + let asset_total: usize = counts.iter() + .filter(|(k, _)| ASSET_CATEGORIES.contains(k)) + .map(|(_, v)| *v) + .sum(); + let assets_dominate = asset_total * 100 / total > 80; + + // When assets don't dominate, exclude them from both the display and the percentage base. + let base = if assets_dominate { total } else { total - asset_total }; + if base == 0 { return None; } + + let threshold = ((base as f64 * 0.05) as usize).max(1); + let mut langs: Vec<(&str, usize)> = counts.into_iter() + .filter(|(k, c)| *c >= threshold && (assets_dominate || !ASSET_CATEGORIES.contains(k))) + .collect(); + + if langs.is_empty() { return None; } + langs.sort_by(|a, b| b.1.cmp(&a.1)); + Some(langs.iter().take(4) + .map(|(lang, c)| format!("{} {}%", lang, c * 100 / base)) + .collect::>() + .join(" ")) +} + +fn ext_to_lang(ext: &str) -> Option<&'static str> { + Some(match ext { + // ── code ────────────────────────────────────────────────────────────── + "rs" => "Rust", + "go" => "Go", + "py" | "pyw" => "Python", + "js" | "mjs" | "cjs" | "jsx" => "JavaScript", + "ts" | "tsx" => "TypeScript", + "java" => "Java", + "kt" | "kts" => "Kotlin", + "rb" => "Ruby", + "php" => "PHP", + "swift" => "Swift", + "ex" | "exs" => "Elixir", + "hs" => "Haskell", + "c" | "h" => "C", + "cpp" | "cc" | "cxx" | "hxx" | "hpp" => "C++", + "cs" => "C#", + "sh" | "bash" | "zsh" | "fish" => "Shell", + "html" | "htm" => "HTML", + "css" | "scss" | "sass" | "less" => "CSS", + "lua" => "Lua", + "r" => "R", + "dart" => "Dart", + "scala" => "Scala", + "clj" | "cljs" => "Clojure", + "ml" | "mli" => "OCaml", + "nim" => "Nim", + "zig" => "Zig", + "vue" => "Vue", + "svelte" => "Svelte", + "tf" | "tfvars" => "Terraform", + "sql" => "SQL", + + // ── docs & text ─────────────────────────────────────────────────────── + "md" | "mdx" | "rst" => "Markdown", + "txt" | "rtf" | "log" => "Text", + "pdf" => "PDF", + "docx" | "doc" | "odt" | "pages" => "Docs", + "xlsx" | "xls" | "ods" | "numbers" | "csv" | "tsv" => "Spreadsheets", + "pptx" | "ppt" | "odp" | "keynote" => "Slides", + "epub" => "eBook", + + // ── images ──────────────────────────────────────────────────────────── + "png" | "jpg" | "jpeg" | "gif" | "webp" + | "svg" | "ico" | "bmp" | "tiff" | "tif" + | "tga" | "psd" | "xcf" | "raw" | "dng" + | "cr2" | "nef" | "heic" | "heif" | "avif" + | "apng" | "jxl" => "Images", + + // ── textures (GPU / game engine specific) ───────────────────────────── + "dds" | "ktx" | "ktx2" | "exr" | "hdr" + | "basis" | "bc7" | "astc" => "Textures", + + // ── audio ───────────────────────────────────────────────────────────── + "mp3" | "wav" | "ogg" | "flac" | "aac" + | "m4a" | "wma" | "opus" | "mid" | "midi" + | "aif" | "aiff" | "caf" | "ra" | "amr" => "Audio", + + // ── video ───────────────────────────────────────────────────────────── + "mp4" | "mkv" | "avi" | "mov" | "webm" + | "m4v" | "wmv" | "flv" | "ogv" | "mpg" + | "mpeg" | "m2ts" | "mts" | "vob" | "rm" => "Video", + + // ── 3d models ───────────────────────────────────────────────────────── + "obj" | "fbx" | "blend" | "gltf" | "glb" + | "stl" | "dae" | "3ds" | "ply" | "abc" + | "usd" | "usda" | "usdc" | "usdz" + | "max" | "ma" | "mb" | "c4d" | "lwo" => "3D", + + // ── fonts ───────────────────────────────────────────────────────────── + "ttf" | "otf" | "woff" | "woff2" | "eot" => "Fonts", + + // ── data / ml ───────────────────────────────────────────────────────── + "parquet" | "arrow" | "hdf5" | "h5" + | "npy" | "npz" | "pkl" | "mat" | "nc" => "Data", + + _ => return None, + }) +} + +fn lang_walk( + dir: &Path, + counts: &mut HashMap<&'static str, usize>, + total: &mut usize, + depth: u8, +) { + if depth == 0 || *total >= 5000 { return; } + let Ok(entries) = std::fs::read_dir(dir) else { return; }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if SKIP_DIRS.iter().any(|s| *s == name.as_ref()) { continue; } + lang_walk(&path, counts, total, depth - 1); + } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if let Some(lang) = ext_to_lang(ext) { + *counts.entry(lang).or_insert(0) += 1; + *total += 1; + } + } } - if path.join("package.json").exists() { - let lang = if path.join("tsconfig.json").exists() { "TypeScript" } else { "JavaScript" }; - return Some(lang.to_string()); - } - None } // ── lazy per-repo extras ─────────────────────────────────────────────────────── diff --git a/src/todos/mod.rs b/src/todos/mod.rs index 6b60ea3..cad29d3 100644 --- a/src/todos/mod.rs +++ b/src/todos/mod.rs @@ -79,6 +79,10 @@ pub struct Todo { /// Manual (user-created) or CodeScan (found in source). #[serde(default)] pub source: TodoSource, + #[serde(default)] + pub estimate: Option, + #[serde(default)] + pub difficulty: Option, pub created_at: i64, pub updated_at: i64, } @@ -91,6 +95,7 @@ impl Todo { let now = chrono::Utc::now().timestamp(); Self { id, title, description, status, project, repo: None, source: TodoSource::Manual, + estimate: None, difficulty: None, created_at: now, updated_at: now } } @@ -101,6 +106,7 @@ impl Todo { Self { id, title, description: String::new(), status: TodoStatus::Open, project: None, repo: Some(repo), source: TodoSource::CodeScan, + estimate: None, difficulty: None, created_at: now, updated_at: now } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 238ce2c..c50f516 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -22,7 +22,7 @@ pub fn setup() -> Result { Ok(Terminal::new(backend)?) } -/// Restore terminal to normal mode. +/// Restore terminal to normal mode (full teardown, consumes terminal). pub fn teardown(mut terminal: Tty) -> Result<()> { disable_raw_mode()?; execute!( @@ -34,6 +34,22 @@ pub fn teardown(mut terminal: Tty) -> Result<()> { Ok(()) } +/// Leave TUI mode temporarily (for interactive prompts) without destroying terminal. +pub fn leave_tui(terminal: &mut Tty) -> Result<()> { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; + terminal.show_cursor()?; + Ok(()) +} + +/// Re-enter TUI mode after leave_tui. +pub fn enter_tui(terminal: &mut Tty) -> Result<()> { + enable_raw_mode()?; + execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture)?; + terminal.hide_cursor()?; + Ok(()) +} + /// Poll for the next crossterm event with a timeout. pub fn next_event(timeout_ms: u64) -> Result> { if event::poll(Duration::from_millis(timeout_ms))? { diff --git a/src/util/mod.rs b/src/util/mod.rs index 71b63b6..667c017 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,3 +1,13 @@ pub mod clipboard; pub mod git; pub mod url; + +/// Open a file, directory, or URL with the system default application. +pub fn open_path(target: &str) { + #[cfg(target_os = "macos")] + let _ = std::process::Command::new("open").arg(target).spawn(); + #[cfg(target_os = "windows")] + let _ = std::process::Command::new("cmd").args(["/c", "start", "", target]).spawn(); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let _ = std::process::Command::new("xdg-open").arg(target).spawn(); +}