This commit is contained in:
dualfroz committed 2026-06-19 01:10:19 +02:00
1 parent d4fdc9890a
commit 5787b14880
22 files changed
+5766 -710

No files matched your search

+5 -1
View File
@@ -34,7 +34,11 @@ pub fn accounts_path(cfg: &AppConfig) -> PathBuf {
} }
pub fn load_accounts(cfg: &AppConfig) -> Result<Vec<Account>> { pub fn load_accounts(cfg: &AppConfig) -> Result<Vec<Account>> {
let path = accounts_path(cfg); load_accounts_path(&cfg.data_dir)
}
pub fn load_accounts_path(data_dir: &std::path::Path) -> Result<Vec<Account>> {
let path = data_dir.join("accounts.toml");
if path.exists() { if path.exists() {
let raw = std::fs::read_to_string(&path)?; let raw = std::fs::read_to_string(&path)?;
let file: AccountsFile = toml::from_str(&raw)?; let file: AccountsFile = toml::from_str(&raw)?;
+310 -12
View File
@@ -1,11 +1,14 @@
use super::HistEntry; use super::{BranchEntry, ContribEntry, HistEntry, TreeNode};
use crate::status::format_age_pub; 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::git::{git_run, git_run_ok};
pub use crate::util::url::to_web_url; pub use crate::util::url::to_web_url;
pub use crate::util::clipboard::copy_auto as copy_to_clipboard; pub use crate::util::clipboard::copy_auto as copy_to_clipboard;
// ── commit history ────────────────────────────────────────────────────────────
pub fn build_history(path: &std::path::Path, limit: usize) -> Vec<HistEntry> { pub fn build_history(path: &std::path::Path, limit: usize) -> Vec<HistEntry> {
let repo = match Repository::open(path) { Ok(r) => r, Err(_) => return 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<HistEntry> {
commits commits
} }
pub fn count_contributors(path: &std::path::Path) -> usize { pub fn total_commits(path: &std::path::Path) -> usize {
std::process::Command::new("git") std::process::Command::new("git")
.arg("-C").arg(path).args(["shortlog", "-sn", "--all"]) .arg("-C").arg(path).args(["rev-list", "--count", "HEAD"])
.output() .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) .unwrap_or(0)
} }
@@ -76,13 +81,7 @@ pub fn count_stashes(path: &std::path::Path) -> usize {
.unwrap_or(0) .unwrap_or(0)
} }
pub fn count_local_branches(path: &std::path::Path) -> usize { // ── files ─────────────────────────────────────────────────────────────────────
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)
}
pub fn list_all_files(path: &std::path::Path) -> Vec<String> { pub fn list_all_files(path: &std::path::Path) -> Vec<String> {
let out = std::process::Command::new("git") let out = std::process::Command::new("git")
@@ -94,3 +93,302 @@ pub fn list_all_files(path: &std::path::Path) -> Vec<String> {
String::from_utf8_lossy(&out.stdout) String::from_utf8_lossy(&out.stdout)
.lines().map(|l| l.to_string()).collect() .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::<usize>().ok()?;
Some((file.to_string(), count))
})
.take(n)
.collect(),
Err(_) => vec![],
}
}
// ── remote URL ────────────────────────────────────────────────────────────────
pub fn remote_url(path: &std::path::Path) -> Option<String> {
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<BranchEntry> {
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::<String>()))
.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<ContribEntry> {
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::<usize>().ok()?;
Some(ContribEntry { name: name.to_string(), count })
})
.collect()
}
// ── commit activity (per period) ─────────────────────────────────────────────
fn git_activity_raw(path: &std::path::Path, since_days: Option<u64>, date_fmt: &str) -> HashMap<String, usize> {
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<i64> {
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<TreeNode> {
let mut dirs: BTreeSet<String> = 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<String, Vec<String>> = 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<String, Vec<String>>,
dirs: &BTreeSet<String>,
depth: usize,
out: &mut Vec<TreeNode>,
) {
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<String> {
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])
}
+1407 -118
View File
File diff suppressed because it is too large. Load diff
+1282 -305
View File
File diff suppressed because it is too large. Load diff
+113 -18
View File
@@ -8,35 +8,27 @@ use super::state::HubState;
// ── sync flow ───────────────────────────────────────────────────────────────── // ── 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(); let sel: Vec<&RepoSummary> = targets.iter().filter_map(|&i| repos.get(i)).collect();
if sel.is_empty() { return Ok(()); } 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); } for r in &sel { println!(" · {}", r.name); }
println!(); 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<String> = 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 { for r in &sel {
println!("\n ── {} ──", r.name); println!("\n ── {} ──", r.name);
match mode { match action {
"pull" => git_run(&r.path, &["pull"]), "pull" => { git_run(&r.path, &["pull"]); }
"commit + push" => { "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()? };
git_run(&r.path, &["add", "-A"]); 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"]), _ => { git_run(&r.path, &["push"]); }
} }
} }
println!("\n ✓ Done. Press Enter..."); println!("\n ✓ Done. Press Enter…");
let _ = std::io::stdin().lines().next(); let _ = std::io::stdin().lines().next();
Ok(()) Ok(())
} }
@@ -135,7 +127,7 @@ pub fn scan_todos_from_repos(state: &mut HubState) -> usize {
for r in &state.repos { for r in &state.repos {
for pattern in &["TODO", "FIXME", "HACK"] { 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) { for fr in results.iter().take(50) {
let short_file = std::path::Path::new(&fr.file) 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()); .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<PathBuf>) {
if let Ok(s) = toml::to_string_pretty(&data) { let _ = std::fs::write(p, s); } 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<super::state::RepoTemplate>,
}
pub fn load_templates() -> Vec<super::state::RepoTemplate> {
use super::state::RepoTemplate;
let p = templates_path();
if p.exists() {
let loaded = toml::from_str::<TemplatesFile>(&std::fs::read_to_string(&p).unwrap_or_default())
.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<super::state::RepoTemplate> {
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 ────────────────────────────────────────────────────────── // ── pins persistence ──────────────────────────────────────────────────────────
fn pins_path() -> PathBuf { fn pins_path() -> PathBuf {
+313 -58
View File
@@ -1,6 +1,6 @@
use anyhow::Result; use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyModifiers, MouseEventKind}; 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::modals::{handle_modal_key, process_modal_confirm};
use super::render::{render_hub, HELP_LINES}; use super::render::{render_hub, HELP_LINES};
use super::state::{ use super::state::{
@@ -9,12 +9,96 @@ use super::state::{
}; };
use crate::todos; use crate::todos;
pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> { 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<String>, 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<String, usize> = 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<String, usize> = 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<crate::tui::Tty>)> {
let mut terminal = crate::tui::setup()?; let mut terminal = crate::tui::setup()?;
let result = (|| -> Result<PhaseResult> { let result = (|| -> Result<PhaseResult> {
loop { loop {
state.poll_incoming(); state.poll_incoming();
state.poll_diff(); 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))?; terminal.draw(|f| render_hub(f, state))?;
state.update_focused_extra(); state.update_focused_extra();
@@ -45,6 +129,9 @@ pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
if let Some(url) = state.pending_clone.take() { if let Some(url) = state.pending_clone.take() {
return Ok(PhaseResult::ExecClone(url)); return Ok(PhaseResult::ExecClone(url));
} }
if let Some(phase) = state.pending_phase.take() {
return Ok(phase);
}
continue; continue;
} }
@@ -66,7 +153,7 @@ pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
if state.filtering { if state.filtering {
match key.code { match key.code {
KeyCode::Esc => { KeyCode::Esc | KeyCode::Char('/') => {
state.filtering = false; state.filtering = false;
state.filter_text.clear(); state.filter_text.clear();
state.rebuild_display(); state.rebuild_display();
@@ -94,8 +181,21 @@ pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
state.quit = true; return Ok(PhaseResult::None); state.quit = true; return Ok(PhaseResult::None);
} }
} }
KeyCode::Up | KeyCode::Char('k') => state.scroll_up(), KeyCode::Up | KeyCode::Char('k') => {
KeyCode::Down | KeyCode::Char('j') => state.scroll_down(), 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) => { KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let visible: Vec<usize> = state.display.iter().filter_map(|row| { let visible: Vec<usize> = state.display.iter().filter_map(|row| {
if let DisplayRow::Repo { repo_idx } = row { Some(*repo_idx) } else { None } if let DisplayRow::Repo { repo_idx } = row { Some(*repo_idx) } else { None }
@@ -141,25 +241,33 @@ pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
} }
KeyCode::Tab | KeyCode::Right => { KeyCode::Tab | KeyCode::Right => {
state.tab = match state.tab { state.tab = match state.tab {
Tab::Repos => Tab::Projects, Tab::Overview => Tab::Repos,
Tab::Projects => Tab::Todos, Tab::Repos => Tab::Projects,
Tab::Todos => Tab::PRs, Tab::Projects => Tab::Todos,
Tab::PRs => Tab::Issues, Tab::Todos => Tab::PRs,
Tab::Issues => Tab::Repos, 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.cursor = 0; state.offset = 0; state.selected.clear();
state.rebuild_display(); state.rebuild_display();
maybe_load_global_stats(state);
} }
KeyCode::Left => { KeyCode::Left => {
state.tab = match state.tab { state.tab = match state.tab {
Tab::Repos => Tab::Issues, Tab::Overview => Tab::HubSettings,
Tab::Projects => Tab::Repos, Tab::Repos => Tab::Overview,
Tab::Todos => Tab::Projects, Tab::Projects => Tab::Repos,
Tab::PRs => Tab::Todos, Tab::Todos => Tab::Projects,
Tab::Issues => Tab::PRs, 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.cursor = 0; state.offset = 0; state.selected.clear();
state.rebuild_display(); state.rebuild_display();
maybe_load_global_stats(state);
} }
KeyCode::Enter => { KeyCode::Enter => {
match state.tab { match state.tab {
@@ -175,7 +283,7 @@ pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
if let DisplayRow::Pr { idx } = &state.display[di] { if let DisplayRow::Pr { idx } = &state.display[di] {
let url = state.prs[*idx].url.clone(); let url = state.prs[*idx].url.clone();
if !url.is_empty() { 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()); state.status_msg = Some("Opening PR in browser...".into());
} }
} }
@@ -187,17 +295,119 @@ pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
if let DisplayRow::Issue { idx } = &state.display[di] { if let DisplayRow::Issue { idx } = &state.display[di] {
let url = state.issues[*idx].url.clone(); let url = state.issues[*idx].url.clone();
if !url.is_empty() { 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()); 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<String> = state.accounts.iter()
.map(|a| format!("@{} ({})", a.username, a.provider))
.collect();
state.modal = Modal::Select {
title: "Pull all repos — Account".into(),
options: opts,
cursor: 0,
tag: SelectTag::PullAllAccount,
};
}
}
8 => {
state.modal = Modal::Text {
title: "Import Directory as Repos".into(),
prompt: "Directory to scan (git init + link each subfolder):".into(),
value: String::new(),
tag: TextTag::ImportDir,
max_len: None,
};
}
9 => {
let n = state.repos.len();
state.modal = Modal::Confirm {
message: format!("DANGER: Delete .git from all {} repos in workspace? Cannot be undone!", n),
tag: ConfirmTag::RemoveGitAll,
};
}
10 => {
state.modal = Modal::Text {
title: "New Template — Name".into(),
prompt: "Template name:".into(),
value: String::new(),
tag: TextTag::NewTemplateName,
max_len: Some(50),
};
}
n if n >= 11 => {
let ti = n - 11;
if ti < state.templates.len() {
let t = &state.templates[ti];
let cmd_preview = t.commands.iter().take(2).cloned().collect::<Vec<_>>().join("; ");
let cmd_str = if cmd_preview.is_empty() { "(no commands)".into() } else { cmd_preview };
state.modal = Modal::Select {
title: format!("Template: {}", t.name),
options: vec![
format!("Commands: {}", cmd_str.chars().take(40).collect::<String>()),
"Delete template".into(),
],
cursor: 0,
tag: SelectTag::TemplateMenu(ti),
};
}
}
_ => {}
}
}
} }
} }
KeyCode::Char('s') => { 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') => { KeyCode::Char('r') => {
if !state.action_targets().is_empty() { state.confirming = Some(PendingAction::Release); } if !state.action_targets().is_empty() { state.confirming = Some(PendingAction::Release); }
@@ -205,9 +415,21 @@ pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
KeyCode::Char('n') => { KeyCode::Char('n') => {
if let Some(ri) = state.cur_repo_idx() { if let Some(ri) = state.cur_repo_idx() {
let cur = state.repos[ri].name.clone(); 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') => { KeyCode::Char('p') => {
let mut opts = vec!["Create project".to_string()]; let mut opts = vec!["Create project".to_string()];
if !state.projects.is_empty() { if !state.projects.is_empty() {
@@ -218,12 +440,12 @@ pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
state.modal = Modal::Select { title: "Projects".into(), options: opts, cursor: 0, tag: SelectTag::ProjMenu }; state.modal = Modal::Select { title: "Projects".into(), options: opts, cursor: 0, tag: SelectTag::ProjMenu };
} }
KeyCode::Char('+') => { 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') => { KeyCode::Char('e') => {
if let Some(ti) = state.cur_todo_idx() { if let Some(ti) = state.cur_todo_idx() {
let cur = state.todos[ti].title.clone(); 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) => { KeyCode::Delete | KeyCode::Char('D') if key.modifiers.contains(KeyModifiers::SHIFT) => {
@@ -268,15 +490,31 @@ pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
}; };
} }
KeyCode::Char('S') => { KeyCode::Char('S') => {
state.sort_mode = state.sort_mode.next(); let sm = state.sort_mode;
state.rebuild_display(); 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') => { KeyCode::Char('o') => {
if let Some(ri) = state.cur_repo_idx() { if let Some(ri) = state.cur_repo_idx() {
let path = state.repos[ri].path.clone(); let path = state.repos[ri].path.clone();
let editor = state.editor.clone(); state.pending_phase = Some(PhaseResult::ExecOpenEditor(path));
let _ = std::process::Command::new(&editor).arg(&path).spawn();
state.status_msg = Some(format!("Opened in {}", editor));
} }
} }
KeyCode::Char('y') => { KeyCode::Char('y') => {
@@ -290,13 +528,24 @@ pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
} }
} }
} }
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') => { KeyCode::Char('u') => {
if let Some(ri) = state.cur_repo_idx() { if let Some(ri) = state.cur_repo_idx() {
let path = state.repos[ri].path.clone(); let path = state.repos[ri].path.clone();
let cmd = state.clipboard_cmd.clone(); let cmd = state.clipboard_cmd.clone();
if let Some(url) = get_remote_url(&path) { if let Some(url) = get_remote_url(&path) {
if copy_to_clipboard(&url, &cmd) { if copy_to_clipboard(&url, &cmd) {
state.status_msg = Some("URL copied!".into()); state.status_msg = Some("SSH URL copied!".into());
} else { } else {
state.status_msg = Some(format!(" {}", url)); state.status_msg = Some(format!(" {}", url));
} }
@@ -342,15 +591,14 @@ pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
KeyCode::Char('g') => { KeyCode::Char('g') => {
if let Some(ri) = state.cur_repo_idx() { if let Some(ri) = state.cur_repo_idx() {
let gitignore = state.repos[ri].path.join(".gitignore"); let gitignore = state.repos[ri].path.join(".gitignore");
let editor = state.editor.clone(); state.pending_phase = Some(PhaseResult::ExecOpenEditor(gitignore));
let _ = std::process::Command::new(&editor).arg(&gitignore).spawn();
state.status_msg = Some("Opening .gitignore...".into()); state.status_msg = Some("Opening .gitignore...".into());
} }
} }
KeyCode::Char('O') => { KeyCode::Char('O') => {
if let Some(ri) = state.cur_repo_idx() { if let Some(ri) = state.cur_repo_idx() {
let path = state.repos[ri].path.clone(); let path = state.repos[ri].path.to_string_lossy().to_string();
let _ = std::process::Command::new("xdg-open").arg(&path).spawn(); crate::util::open_path(&path);
state.status_msg = Some("Opening in file manager...".into()); state.status_msg = Some("Opening in file manager...".into());
} }
} }
@@ -359,7 +607,7 @@ pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
let path = state.repos[ri].path.clone(); let path = state.repos[ri].path.clone();
if let Some(url) = get_remote_url(&path) { if let Some(url) = get_remote_url(&path) {
let web = to_web_url(&url); 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()); state.status_msg = Some("Opening in browser...".into());
} else { } else {
state.status_msg = Some("No remote origin".into()); state.status_msg = Some("No remote origin".into());
@@ -384,30 +632,17 @@ pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
state.filter_archived = !state.filter_archived; state.filter_archived = !state.filter_archived;
state.rebuild_display(); state.rebuild_display();
} }
KeyCode::Char('C') => { KeyCode::Char('N') => {
let prompt = format!("Git URL (clones into {}):", state.workspace_root.display()); state.modal = Modal::Select {
state.modal = Modal::Text { title: "New repository".into(),
title: "Clone repository".into(), options: vec![
prompt, "Clone from URL".into(),
value: String::new(), "Clone local folder (path)".into(),
tag: TextTag::CloneUrl, "Create new empty repo".into(),
}; "Link existing folder".into(),
} ],
KeyCode::Char('I') => { cursor: 0,
let prompt = format!("Folder name (created in {}):", state.workspace_root.display()); tag: SelectTag::NewRepo,
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('R') => { KeyCode::Char('R') => {
@@ -415,11 +650,31 @@ pub fn tui_phase(state: &mut HubState) -> Result<PhaseResult> {
return Ok(PhaseResult::ExecRefresh); 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)?; crate::tui::teardown(terminal)?;
result Ok((phase, None))
} }
+40 -74
View File
@@ -1,12 +1,13 @@
mod actions; mod actions;
mod keys; mod keys;
mod modals; mod modals;
mod project_detail;
mod render; mod render;
mod right_panel; mod right_panel;
mod state; mod state;
mod tabs; 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 keys::tui_phase;
use state::{HubState, Modal, PhaseResult, SortMode, Tab}; use state::{HubState, Modal, PhaseResult, SortMode, Tab};
@@ -15,13 +16,8 @@ use crate::config::AppConfig;
use crate::projects; use crate::projects;
use crate::providers::{IssueDetail, PrDetail}; use crate::providers::{IssueDetail, PrDetail};
use crate::status::RepoSummary; use crate::status::RepoSummary;
use crossterm::event::{Event, KeyCode}; use crossterm::event::KeyCode;
use ratatui::{ use ratatui::text::Line;
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Cell, Paragraph, Row, Table},
};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
pub async fn run_overview_tui( 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_mode = crate::debug::is_enabled();
let debug_log = crate::debug::take(); 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 { let mut state = HubState {
repos, projects, project_map, todos, repos, projects, project_map, todos,
prs: cached_prs, prs: cached_prs,
issues: cached_issues, issues: cached_issues,
tab: Tab::Repos, tab: Tab::Overview,
display: Vec::new(), repo_pos: Vec::new(), display: Vec::new(), repo_pos: Vec::new(),
cursor: 0, offset: 0, visible_rows: 0, cursor: 0, offset: 0, visible_rows: 0,
selected: HashSet::new(), selected: HashSet::new(),
@@ -80,6 +78,16 @@ pub async fn run_overview_tui(
scan_rx, scan_rx,
meta_cache, meta_cache,
diff_rx: None, 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 { for r in &mut state.repos {
r.bookmarked = state.bookmarks.contains(&r.path); r.bookmarked = state.bookmarks.contains(&r.path);
@@ -87,7 +95,7 @@ pub async fn run_overview_tui(
state.rebuild_display(); state.rebuild_display();
loop { loop {
let phase_result = tui_phase(&mut state)?; let (phase_result, handed_term) = tui_phase(&mut state)?;
if state.quit { break; } if state.quit { break; }
@@ -95,19 +103,21 @@ pub async fn run_overview_tui(
PhaseResult::OpenDetail => { PhaseResult::OpenDetail => {
state.open_detail = false; state.open_detail = false;
if let Some(ri) = state.cur_repo_idx() { 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() { } else if let Some(pi) = state.cur_proj_idx() {
let repo_indices = state.project_repos(pi); let repo_indices = state.project_repos(pi);
let proj_repos: Vec<RepoSummary> = let proj_repos: Vec<RepoSummary> =
repo_indices.iter().filter_map(|&ri| state.repos.get(ri)).cloned().collect(); 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 => { PhaseResult::ExecSync => {
state.exec_action = false; state.exec_action = false;
state.confirming = None; state.confirming = None;
let targets = state.action_targets(); 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(); state.selected.clear();
refresh_repos(&mut state); refresh_repos(&mut state);
} }
@@ -132,73 +142,29 @@ pub async fn run_overview_tui(
PhaseResult::ExecRefresh => { PhaseResult::ExecRefresh => {
exec_api_refresh(&mut state, cfg).await; 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 => {} PhaseResult::None => {}
} }
} }
Ok(()) Ok(())
} }
async fn run_project_detail(proj: &crate::projects::Project, repos: Vec<RepoSummary>, _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<Row> = 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::<String>()),
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::<String>()).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<()> { pub fn run_detail_tui(detail: crate::status::RepoDetail) -> Result<()> {
use crossterm::event::Event; use crossterm::event::Event;
+411 -36
View File
@@ -1,6 +1,17 @@
use crate::projects; use crate::projects;
use crate::todos::{self, Todo, TodoStatus}; use crate::todos::{self, Todo, TodoStatus};
use crossterm::event::KeyCode; 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::{ use ratatui::{
layout::Rect, layout::Rect,
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
@@ -12,19 +23,29 @@ use super::state::{
ConfirmTag, HubState, Modal, ModalAction, MultiTag, ConfirmTag, HubState, Modal, ModalAction, MultiTag,
ProjAction, SelectTag, TextTag, ProjAction, SelectTag, TextTag,
}; };
use super::actions::save_templates;
pub fn render_modal(f: &mut Frame, modal: &Modal, area: Rect) { pub fn render_modal(f: &mut Frame, modal: &Modal, area: Rect) {
match modal { match modal {
Modal::Text { title, prompt, value, .. } => { Modal::Text { title, prompt, value, max_len, .. } => {
let rect = centered_rect(60, 8, area); let rect = centered_rect(60, 8, area);
f.render_widget(Clear, rect); f.render_widget(Clear, rect);
let blk = Block::default().title(format!(" {} ", title)).borders(Borders::ALL).style(Style::default().bg(Color::Reset)); let blk = Block::default().title(format!(" {} ", title)).borders(Borders::ALL).style(Style::default().bg(Color::Reset));
let inner = blk.inner(rect); let inner = blk.inner(rect);
f.render_widget(blk, 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![ let lines = vec![
Line::from(""), Line::from(""),
Line::from(Span::styled(format!(" {}", prompt), Style::default().fg(Color::White))), 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(vec![Span::styled(" > ", Style::default().fg(Color::Cyan)), Span::raw(value.clone()), Span::styled("█", Style::default().fg(Color::Cyan))]),
Line::from(""), Line::from(""),
Line::from(Span::styled(" Enter = confirm Esc = cancel", Style::default().fg(Color::DarkGray))), 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<String> {
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 { pub fn centered_rect(pct_x: u16, height: u16, area: Rect) -> Rect {
let w = area.width * pct_x / 100; let w = area.width * pct_x / 100;
let x = area.x + (area.width.saturating_sub(w)) / 2; 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 { pub fn handle_modal_key(modal: &mut Modal, code: KeyCode) -> ModalAction {
match modal { match modal {
Modal::Text { value, .. } => match code { Modal::Text { value, max_len, .. } => match code {
KeyCode::Char(c) => { value.push(c); ModalAction::Pending } 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::Backspace => { value.pop(); ModalAction::Pending }
KeyCode::Enter => ModalAction::Confirm, KeyCode::Enter => ModalAction::Confirm,
KeyCode::Esc => ModalAction::Cancel, KeyCode::Esc => ModalAction::Cancel,
@@ -168,16 +220,16 @@ pub fn process_modal_confirm(state: &mut HubState) {
Modal::Text { value, tag: TextTag::ProjName, .. } => { Modal::Text { value, tag: TextTag::ProjName, .. } => {
if !value.trim().is_empty() { if !value.trim().is_empty() {
state.modal = Modal::Text { state.modal = Modal::Select {
title: "Create project — Color".into(), title: "Create project — Color".into(),
prompt: "Hex color (#rrggbb):".into(), options: color_palette(),
value: "#3498db".into(), cursor: 0,
tag: TextTag::ProjColor(value), tag: SelectTag::ProjColorCreate(value),
}; };
} }
} }
Modal::Text { value: color, tag: TextTag::ProjColor(name), .. } => { Modal::Select { cursor, tag: SelectTag::ProjColorCreate(name), .. } => {
let color = if color.starts_with('#') && color.len() == 7 { color } else { "#3498db".into() }; let color = palette_hex(cursor);
state.projects.push(crate::projects::Project { name, color, repos: vec![] }); state.projects.push(crate::projects::Project { name, color, repos: vec![] });
let _ = projects::save(&state.projects); let _ = projects::save(&state.projects);
state.rebuild_project_map(); state.rebuild_project_map();
@@ -185,16 +237,16 @@ pub fn process_modal_confirm(state: &mut HubState) {
} }
Modal::Text { value, tag: TextTag::ProjRenameName(pi), .. } => { Modal::Text { value, tag: TextTag::ProjRenameName(pi), .. } => {
let cur_color = state.projects[pi].color.clone(); let cur_cursor = palette_cursor_for(&state.projects[pi].color);
state.modal = Modal::Text { state.modal = Modal::Select {
title: format!("Rename '{}' — Color", state.projects[pi].name), title: format!("Rename '{}' — Color", state.projects[pi].name),
prompt: "New color (#rrggbb):".into(), options: color_palette(),
value: cur_color, cursor: cur_cursor,
tag: TextTag::ProjRenameColor(pi, value), tag: SelectTag::ProjColorRename(pi, value),
}; };
} }
Modal::Text { value: color, tag: TextTag::ProjRenameColor(pi, name), .. } => { Modal::Select { cursor, tag: SelectTag::ProjColorRename(pi, name), .. } => {
let color = if color.starts_with('#') && color.len() == 7 { color } else { state.projects[pi].color.clone() }; let color = palette_hex(cursor);
state.projects[pi].name = name; state.projects[pi].name = name;
state.projects[pi].color = color; state.projects[pi].color = color;
let _ = projects::save(&state.projects); let _ = projects::save(&state.projects);
@@ -204,12 +256,12 @@ pub fn process_modal_confirm(state: &mut HubState) {
Modal::Select { cursor, tag: SelectTag::ProjMenu, .. } => { Modal::Select { cursor, tag: SelectTag::ProjMenu, .. } => {
if state.projects.is_empty() && cursor == 0 { 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; return;
} }
match cursor { match cursor {
0 => { 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 => { 1 => {
let names: Vec<String> = state.projects.iter().map(|p| p.name.clone()).collect(); let names: Vec<String> = state.projects.iter().map(|p| p.name.clone()).collect();
@@ -243,7 +295,7 @@ pub fn process_modal_confirm(state: &mut HubState) {
} }
ProjAction::Rename => { ProjAction::Rename => {
let cur_name = state.projects[pi].name.clone(); 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 => { ProjAction::Delete => {
let name = state.projects[pi].name.clone(); 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, .. } => { Modal::Text { value, tag: TextTag::TodoTitle, .. } => {
if !value.trim().is_empty() { 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), .. } => { 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(); state.todos[ti].title = value.clone();
let cur_desc = state.todos[ti].description.clone(); let cur_desc = state.todos[ti].description.clone();
state.todos[ti].touch(); 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, _), .. } => { Modal::Text { value: desc, tag: TextTag::TodoEditDesc(ti, _), .. } => {
state.todos[ti].description = desc; state.todos[ti].description = desc;
@@ -309,6 +361,56 @@ pub fn process_modal_confirm(state: &mut HubState) {
let _ = todos::save(&state.todos); 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, .. } => { Modal::Select { cursor, tag: SelectTag::SelectByCondition, .. } => {
let repos = &state.repos; let repos = &state.repos;
let candidates: Vec<usize> = (0..repos.len()).filter(|&ri| state.passes_filter(ri)).collect(); let candidates: Vec<usize> = (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(); state.rebuild_display();
} }
// ── Hub sync via in-TUI modals ────────────────────────────────────────
Modal::Select { cursor, tag: SelectTag::SyncAction, .. } => {
use super::state::PhaseResult;
match cursor {
0 => { // Pull
state.pending_sync_action = Some("pull".into());
state.pending_phase = Some(PhaseResult::ExecSync);
}
1 => { // Commit+Push
state.pending_sync_action = Some("commit_push".into());
state.modal = Modal::Text {
title: "Sync — Commit Message".into(),
prompt: "Message (stages all changes):".into(),
value: "chore: update".into(),
tag: TextTag::SyncMsg,
max_len: None,
};
}
_ => { // Push only
state.pending_sync_action = Some("push_only".into());
state.pending_phase = Some(PhaseResult::ExecSync);
}
}
}
Modal::Text { value, tag: TextTag::SyncMsg, .. } => {
use super::state::PhaseResult;
state.pending_sync_msg = Some(value);
state.pending_phase = Some(PhaseResult::ExecSync);
}
// ── Hub Settings editing ──────────────────────────────────────────────
Modal::Text { value, tag: TextTag::SetWorkspace, .. } => {
let path = expand_tilde(&value);
if !path.exists() {
state.status_msg = Some(format!("Directory does not exist: {}", path.display()));
return;
}
if !path.is_dir() {
state.status_msg = Some("Not a directory".into());
return;
}
state.workspace_root = path;
super::actions::refresh_repos(state);
state.status_msg = Some(format!(
"Workspace changed to {} (to persist: edit ~/.fxg/config.toml)",
state.workspace_root.display(),
));
}
Modal::Text { value, tag: TextTag::SetEditor, .. } => {
if !value.trim().is_empty() {
state.editor = value.trim().to_string();
state.status_msg = Some(format!("Editor set to: {}", state.editor));
}
}
Modal::Text { value, tag: TextTag::SetClipboard, .. } => {
state.clipboard_cmd = value.trim().to_string();
state.status_msg = Some("Clipboard command updated".into());
}
// ── Pull-all repos ────────────────────────────────────────────────────
Modal::Select { cursor, tag: SelectTag::PullAllAccount, .. } => {
let acc_name = state.accounts.get(cursor).map(|a| format!("@{} ({})", a.username, a.provider)).unwrap_or_default();
state.modal = Modal::Confirm {
message: format!("Pull all repos for {}?", acc_name),
tag: ConfirmTag::PullAll(cursor),
};
}
Modal::Confirm { tag: ConfirmTag::PullAll(acc_idx), .. } => {
if let Some(acc) = state.accounts.get(acc_idx) {
let (tx, rx) = std::sync::mpsc::channel::<String>();
let workspace = state.workspace_root.clone();
let provider = acc.provider.clone();
let username = acc.username.clone();
state.pull_all_rx = Some(rx);
state.status_msg = Some(format!("Fetching repos for @{}...", username));
std::thread::spawn(move || {
let out = std::process::Command::new("gh")
.args(["api", "user/repos", "--paginate", "--jq", ".[].clone_url"])
.output();
let Ok(o) = out else {
let _ = tx.send("ERROR: gh CLI failed — is it installed and authenticated?".into());
return;
};
let urls: Vec<String> = String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|s| s.to_string())
.collect();
let total = urls.len();
let _ = tx.send(format!("Found {} repos for @{} ({})", total, username, provider));
for (i, url) in urls.iter().enumerate() {
let repo_name = url.rsplitn(2, '/').next().unwrap_or("").trim_end_matches(".git");
let target = workspace.join(repo_name);
let action = if target.exists() {
let _ = std::process::Command::new("git")
.args(["pull"]).current_dir(&target).status();
"pulled"
} else {
let _ = std::process::Command::new("git")
.args(["clone", url.as_str()]).current_dir(&workspace).status();
"cloned"
};
let _ = tx.send(format!("[{}/{}] {} {}", i + 1, total, action, repo_name));
}
let _ = tx.send("DONE".into());
});
}
}
Modal::Text { value, tag: TextTag::CloneUrl, .. } => { Modal::Text { value, tag: TextTag::CloneUrl, .. } => {
if !value.trim().is_empty() { if !value.trim().is_empty() {
state.pending_clone = Some(value.trim().to_string()); 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, .. } => { Modal::Text { value, tag: TextTag::InitName, .. } => {
let name = value.trim().to_string(); let name = value.trim().to_string();
if name.is_empty() { return; } 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) { if let Err(e) = std::fs::create_dir_all(&target) {
state.status_msg = Some(format!("mkdir failed: {e}")); state.status_msg = Some(format!("mkdir failed: {e}"));
return; return;
} }
let out = std::process::Command::new("git") let git_ok = std::process::Command::new("git")
.arg("init").current_dir(&target).output(); .arg("init").current_dir(&target).output()
match out { .map(|o| o.status.success()).unwrap_or(false);
Ok(o) if o.status.success() => { if !git_ok {
super::actions::refresh_repos(state); state.status_msg = Some("git init failed".into());
state.status_msg = Some(format!("Initialized {name}")); return;
}
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}")); }
} }
// create .fxg/meta.toml
let fxg_dir = target.join(".fxg");
let _ = std::fs::create_dir_all(&fxg_dir);
let tmpl_name = template.as_ref().map(|t| t.name.as_str()).unwrap_or("blank");
let meta = format!("created_at = \"{}\"\ntemplate = \"{}\"\n",
chrono::Utc::now().format("%Y-%m-%d"), tmpl_name);
let _ = std::fs::write(fxg_dir.join("meta.toml"), meta);
if let Some(t) = &template {
if !t.commands.is_empty() {
state.pending_phase = Some(PhaseResult::ExecTemplateInit {
path: target.clone(),
commands: t.commands.clone(),
});
}
}
super::actions::refresh_repos(state);
state.status_msg = Some(format!("Created '{}'{}",
repo_name,
template.as_ref().map(|t| format!(" using '{}'", t.name)).unwrap_or_default(),
));
}
// ── Import directory as repos ─────────────────────────────────────────
Modal::Text { value, tag: TextTag::ImportDir, .. } => {
let src = expand_tilde(&value);
if !src.exists() {
state.status_msg = Some(format!("Path does not exist: {}", src.display()));
return;
}
let workspace = state.workspace_root.clone();
let (tx, rx) = std::sync::mpsc::channel::<String>();
state.pull_all_rx = Some(rx);
state.status_msg = Some(format!("Importing from {}...", src.display()));
std::thread::spawn(move || {
let Ok(entries) = std::fs::read_dir(&src) else {
let _ = tx.send("ERROR: Cannot read directory".into());
let _ = tx.send("DONE".into());
return;
};
let mut linked = 0usize;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() { continue; }
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
if name.starts_with('.') { continue; }
if !path.join(".git").exists() {
let _ = std::process::Command::new("git")
.arg("init").current_dir(&path).status();
let _ = tx.send(format!("git init: {}", name));
}
let link = workspace.join(&name);
if !link.exists() {
if std::os::unix::fs::symlink(&path, &link).is_ok() {
linked += 1;
let _ = tx.send(format!("linked: {}", name));
}
} else {
let _ = tx.send(format!("skip (exists): {}", name));
}
}
let _ = tx.send(format!("Done: {} new repos linked", linked));
let _ = tx.send("DONE".into());
});
}
// ── Template management ───────────────────────────────────────────────
Modal::Text { value, tag: TextTag::NewTemplateName, .. } => {
let name = value.trim().to_string();
if !name.is_empty() {
state.modal = Modal::Text {
title: format!("Template '{}' — Commands", name),
prompt: "Commands (semicolon-separated, e.g. cargo init; echo ok):".into(),
value: String::new(),
tag: TextTag::NewTemplateCmds(name),
max_len: None,
};
}
}
Modal::Text { value, tag: TextTag::NewTemplateCmds(name), .. } => {
let commands: Vec<String> = value.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let t = super::state::RepoTemplate { name: name.clone(), description: String::new(), commands };
state.templates.push(t);
save_templates(&state.templates);
state.status_msg = Some(format!("Template '{}' saved", name));
}
Modal::Select { cursor, tag: SelectTag::TemplateMenu(ti), .. } => {
if cursor == 1 { // Delete
let name = state.templates[ti].name.clone();
state.modal = Modal::Confirm {
message: format!("Delete template '{}'?", name),
tag: ConfirmTag::DeleteTemplate(ti),
};
}
// cursor 0 is "view commands" — just dismiss
}
Modal::Confirm { tag: ConfirmTag::DeleteTemplate(ti), .. } => {
let name = state.templates[ti].name.clone();
state.templates.remove(ti);
save_templates(&state.templates);
let max = 10 + 1 + state.templates.len();
if state.hub_settings_cur >= max { state.hub_settings_cur = max.saturating_sub(1); }
state.status_msg = Some(format!("Template '{}' deleted", name));
}
// ── Remove .git from all workspace repos ──────────────────────────────
Modal::Confirm { tag: ConfirmTag::RemoveGitAll, .. } => {
let workspace = state.workspace_root.clone();
let (tx, rx) = std::sync::mpsc::channel::<String>();
state.pull_all_rx = Some(rx);
state.status_msg = Some("Removing .git directories...".into());
std::thread::spawn(move || {
let Ok(entries) = std::fs::read_dir(&workspace) else {
let _ = tx.send("DONE".into());
return;
};
let mut count = 0usize;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() { continue; }
let git_dir = path.join(".git");
if git_dir.exists() {
if std::fs::remove_dir_all(&git_dir).is_ok() {
count += 1;
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let _ = tx.send(format!("Removed .git: {}", name));
}
}
}
let _ = tx.send(format!("Done: removed .git from {} dirs", count));
let _ = tx.send("DONE".into());
});
} }
Modal::Text { value, tag: TextTag::LinkPath, .. } => { Modal::Text { value, tag: TextTag::LinkPath, .. } => {
let src = std::path::PathBuf::from(value.trim()); let src = expand_tilde(&value);
if !src.exists() { if !src.exists() {
state.status_msg = Some("Path does not exist".into()); state.status_msg = Some(format!("Path does not exist: {}", src.display()));
return; return;
} }
let name = src.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); 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}")); 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());
}
}
_ => {} _ => {}
} }
} }
+842
View File
@@ -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<RepoSummary>,
tab: ProjectTab,
commits: Vec<AggCommit>,
commits_rx: Option<std::sync::mpsc::Receiver<Vec<AggCommit>>>,
repo_cursor: usize,
commit_off: usize,
todo_cursor: usize,
stats_cursor: usize,
todos: Vec<Todo>,
activity: Vec<(String, usize)>,
activity_rx: Option<std::sync::mpsc::Receiver<Vec<(String, usize)>>>,
status_msg: Option<String>,
quit: bool,
cfg: AppConfig,
rename_input: Option<String>,
color_cursor: Option<usize>, // palette picker cursor (replaces hex text input)
}
impl St {
fn new(project: Project, repos: Vec<RepoSummary>, cfg: AppConfig) -> Self {
let pname = project.name.clone();
let all_todos = crate::todos::load_manual();
let todos: Vec<Todo> = 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<RepoSummary>, cfg: &AppConfig, maybe_term: Option<crate::tui::Tty>) -> 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<AggCommit> = 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<std::path::PathBuf> = 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<String, usize> = 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<Line> = vec![
Line::from(vec![
Span::styled(" ■ ", Style::default().fg(proj_col)),
Span::styled(st.project.name.clone(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
Span::styled(
format!(" {} repo{}", total, if total == 1 { "" } else { "s" }),
dim,
),
]),
Line::from(""),
Line::from(vec![
Span::styled(" Health ", dim),
Span::styled(health_bar, Style::default().fg(health_col)),
Span::styled(format!(" ↓{} ⚠{} ↑{}", behind_c, dirty_c, ahead_c), dim),
]),
Line::from(""),
Line::from(Span::styled(" Repos", yel)),
Line::from(Span::styled(" ─────────────────────────────────────────", dim)),
];
let avail = area.height.saturating_sub(6) as usize;
for r in st.repos.iter().take(avail) {
let status = if r.is_dirty { "⚠" } else if r.ahead > 0 { "↑" } else if r.behind > 0 { "↓" } else { "✓" };
let col = if r.is_dirty { Color::Yellow } else if r.behind > 0 { Color::Red } else { Color::Green };
lines.push(Line::from(vec![
Span::styled(format!(" {} ", status), Style::default().fg(col)),
Span::styled(format!("{:<18} ", r.name.chars().take(18).collect::<String>()), wht),
Span::styled(format!("{:<9} ", r.branch.chars().take(9).collect::<String>()), dim),
Span::styled(crate::status::format_age_pub(r.last_commit_ts), Style::default().fg(super::tabs::age_color(r.last_commit_ts))),
]));
}
f.render_widget(Paragraph::new(lines), cols[0]);
}
// RIGHT: open todos + recent commits
{
let open_t: Vec<&Todo> = st.todos.iter()
.filter(|t| matches!(t.status, TodoStatus::Open | TodoStatus::InProgress))
.collect();
let mut lines: Vec<Line> = vec![];
lines.push(Line::from(Span::styled(" Todos", yel)));
lines.push(Line::from(Span::styled(" ─────────────────────────────────", dim)));
if open_t.is_empty() {
lines.push(Line::from(Span::styled(" No open todos", dim)));
} else {
let half_h = (area.height as usize / 2).saturating_sub(2);
for t in open_t.iter().take(half_h) {
lines.push(Line::from(vec![
Span::styled(format!(" {} ", t.status.icon()), Style::default().fg(t.status.color())),
Span::styled(t.title.chars().take(28).collect::<String>(), wht),
]));
}
}
lines.push(Line::from(""));
if !st.commits.is_empty() {
lines.push(Line::from(Span::styled(" Recent Commits", yel)));
lines.push(Line::from(Span::styled(" ─────────────────────────────────", dim)));
let rem_h = (area.height as usize).saturating_sub(lines.len() + 1);
for c in st.commits.iter().take(rem_h) {
lines.push(Line::from(vec![
Span::styled(format!(" {:>5} ", c.age.chars().take(5).collect::<String>()), dim),
Span::styled(format!("{:<10} ", c.repo.chars().take(10).collect::<String>()), Style::default().fg(Color::Cyan)),
Span::styled(c.msg.chars().take(22).collect::<String>(), dim),
]));
}
} else if st.commits_rx.is_some() {
lines.push(Line::from(Span::styled(" Loading commits...", dim)));
}
f.render_widget(Paragraph::new(lines), cols[1]);
}
}
fn render_commits(f: &mut Frame, st: &St, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
if st.commits_rx.is_some() {
f.render_widget(Paragraph::new(" Loading commits from all repos...").style(dim), area);
return;
}
if st.commits.is_empty() {
f.render_widget(Paragraph::new(" No commits found").style(dim), area);
return;
}
let header = Row::new(vec!["REPO", "AGE", "AUTHOR", "MESSAGE"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let avail = area.height.saturating_sub(1) as usize;
let rows: Vec<Row> = st.commits.iter().skip(st.commit_off).take(avail)
.map(|c| {
Row::new(vec![
Cell::from(c.repo.chars().take(14).collect::<String>()).style(yel),
Cell::from(c.age.chars().take(8).collect::<String>()).style(dim),
Cell::from(c.author.chars().take(12).collect::<String>()).style(dim),
Cell::from(c.msg.chars().take(60).collect::<String>()),
])
})
.collect();
f.render_widget(
Table::new(rows, [Constraint::Length(14), Constraint::Length(8), Constraint::Length(12), Constraint::Fill(1)]).header(header),
area,
);
}
fn render_repos(f: &mut Frame, st: &St, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let header = Row::new(vec!["NAME", "BRANCH", "STATUS", "AGE", "LAST COMMIT"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let avail = area.height.saturating_sub(1) as usize;
let rows: Vec<Row> = st.repos.iter().enumerate().skip(st.repo_cursor.saturating_sub(avail / 2)).take(avail)
.map(|(i, r)| {
let is_cur = i == st.repo_cursor;
let status = if r.is_dirty { format!("⚠ +{}-{}", r.ahead, r.behind) } else { format!("+{}-{}", r.ahead, r.behind) };
let col = if r.is_dirty || r.behind > 0 { Color::Yellow } else { Color::White };
Row::new(vec![
Cell::from(r.name.chars().take(20).collect::<String>()).style(Style::default().fg(col)),
Cell::from(r.branch.chars().take(12).collect::<String>()).style(dim),
Cell::from(status).style(dim),
Cell::from(crate::status::format_age_pub(r.last_commit_ts)).style(Style::default().fg(super::tabs::age_color(r.last_commit_ts))),
Cell::from(r.last_commit_msg.chars().take(35).collect::<String>()).style(dim),
]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() })
})
.collect();
f.render_widget(
Table::new(rows, [Constraint::Length(20), Constraint::Length(12), Constraint::Length(12), Constraint::Length(10), Constraint::Fill(1)]).header(header),
area,
);
}
fn render_stats(f: &mut Frame, st: &St, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let cyn = Style::default().fg(Color::Cyan);
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let mut lines: Vec<Line> = vec![];
lines.push(Line::from(Span::styled(" Activity (last 1y, all repos combined)", yel)));
lines.push(Line::from(Span::styled(" ──────────────────────────────────────────────────────", dim)));
if st.activity_rx.is_some() {
lines.push(Line::from(Span::styled(" Computing activity...", cyn)));
f.render_widget(Paragraph::new(lines), area);
return;
}
if st.activity.is_empty() {
lines.push(Line::from(Span::styled(" No activity data", dim)));
f.render_widget(Paragraph::new(lines), area);
return;
}
let max_count: usize = st.activity.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1);
let bar_h = (area.height as usize).saturating_sub(6).max(3);
let bar_w = (area.width as usize).saturating_sub(10).max(1);
let n = st.activity.len();
let step = if n > bar_w { (n / bar_w).max(1) } else { 1 };
let sampled: Vec<&(String, usize)> = st.activity.iter().step_by(step).collect();
let bar_count = sampled.len().min(bar_w);
let bar_width = if bar_count == 0 { 1 } else { (bar_w / bar_count).max(1) };
for row in (0..bar_h).rev() {
let threshold = (row * max_count) / bar_h;
let mut spans: Vec<Span<'static>> = vec![];
let y_label = if row == bar_h - 1 { format!(" {:>4} ", max_count) } else if row == 0 { " 0 ".to_string() } else { " ".to_string() };
spans.push(Span::styled(y_label, dim));
for j in 0..bar_count {
let count = sampled.get(j).map(|(_, c)| *c).unwrap_or(0);
let filled = count > threshold;
for _ in 0..bar_width {
if filled { spans.push(Span::styled("█", cyn)); }
else { spans.push(Span::styled("░", dim)); }
}
}
lines.push(Line::from(spans));
}
// X axis labels
let nth = (bar_count / 6).max(1);
let mut x_spans: Vec<Span> = vec![Span::raw(" ")];
for j in 0..bar_count {
let label = if j % nth == 0 {
let raw = sampled.get(j)
.map(|(d, _)| d.chars().skip(5).take(bar_width.min(5)).collect::<String>())
.unwrap_or_default();
format!("{:<width$}", raw, width = bar_width)
} else {
" ".repeat(bar_width)
};
x_spans.push(Span::styled(label, dim));
}
lines.push(Line::from(x_spans));
f.render_widget(Paragraph::new(lines), area);
}
fn render_todos(f: &mut Frame, st: &St, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let header = Row::new(vec!["STATUS", "TITLE", "EST", "CREATED"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
if st.todos.is_empty() {
f.render_widget(Paragraph::new(" No todos for this project").style(dim), area);
return;
}
let avail = area.height.saturating_sub(1) as usize;
let rows: Vec<Row> = st.todos.iter().enumerate()
.skip(st.todo_cursor.saturating_sub(avail / 2)).take(avail)
.map(|(i, t)| {
let is_cur = i == st.todo_cursor;
let status_str = format!("{} {}", t.status.icon(), t.status.label());
let est_str = t.estimate.as_deref().unwrap_or("—").chars().take(10).collect::<String>();
let created = crate::status::format_age_pub(t.created_at);
Row::new(vec![
Cell::from(status_str).style(Style::default().fg(t.status.color())),
Cell::from(t.title.chars().take(55).collect::<String>()),
Cell::from(est_str).style(dim),
Cell::from(created).style(dim),
]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() })
})
.collect();
f.render_widget(
Table::new(rows, [Constraint::Length(14), Constraint::Fill(1), Constraint::Length(10), Constraint::Length(10)]).header(header),
area,
);
}
fn render_settings(f: &mut Frame, st: &St, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let cyn = Style::default().fg(Color::Cyan);
let (pr, pg, pb) = crate::projects::hex_to_rgb(&st.project.color);
let proj_col = Color::Rgb(pr, pg, pb);
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(" Project Settings", yel)),
Line::from(Span::styled(" ────────────────────────────────────────────", dim)),
Line::from(""),
Line::from(vec![
Span::styled(" Name: ", dim),
Span::styled(st.project.name.clone(), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(" Color: ", dim),
Span::styled(" ■ ", Style::default().fg(proj_col).add_modifier(Modifier::BOLD)),
Span::styled(st.project.color.clone(), Style::default().fg(proj_col)),
]),
Line::from(vec![
Span::styled(" Repos: ", dim),
Span::styled(st.repos.len().to_string(), Style::default().fg(Color::White)),
]),
Line::from(""),
Line::from(Span::styled(" Actions", yel)),
Line::from(Span::styled(" ────────────────────────────────────────────", dim)),
Line::from(vec![
Span::styled(" [Enter] ", cyn),
Span::styled("Rename project", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" [c] ", cyn),
Span::styled("Change color (palette picker)", Style::default().fg(Color::White)),
]),
Line::from(""),
Line::from(Span::styled(" Color Palette", yel)),
Line::from(Span::styled(" ────────────────────────────────────────────", dim)),
];
// Show palette swatches
let per_row = 4;
for chunk_start in (0..PROJ_PALETTE.len()).step_by(per_row) {
let mut spans: Vec<Span> = vec![Span::raw(" ")];
for i in 0..per_row {
if let Some((hex, name)) = PROJ_PALETTE.get(chunk_start + i) {
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
let c = Color::Rgb(cr, cg, cb);
let active = *hex == st.project.color.as_str();
spans.push(Span::styled(if active { "▶██◀ " } else { " ██ " }, Style::default().fg(c)));
spans.push(Span::styled(format!("{:<8}", name.chars().take(6).collect::<String>()), if active { Style::default().fg(c).add_modifier(Modifier::BOLD) } else { dim }));
}
}
lines.push(Line::from(spans));
}
if st.rename_input.is_some() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" → Rename modal open — type name, Enter confirm, Esc cancel", cyn)));
}
if st.color_cursor.is_some() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" → Color picker open — ←→↑↓ navigate, Enter confirm, Esc cancel", cyn)));
}
f.render_widget(Paragraph::new(lines), area);
}
fn draw_text_modal(f: &mut Frame, area: Rect, title: &str, input: &str, hint: &str) {
use ratatui::widgets::Clear;
let modal_area = crate::tui::centered_rect(50, 20, area);
f.render_widget(Clear, modal_area);
let blk = Block::default()
.borders(Borders::ALL)
.title(format!(" {} ", title))
.border_style(Style::default().fg(Color::Cyan));
let inner = blk.inner(modal_area);
f.render_widget(blk, modal_area);
f.render_widget(Paragraph::new(vec![
Line::from(Span::styled(format!(" {}_", input), Style::default().fg(Color::White).add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(Span::styled(format!(" {}", hint), Style::default().fg(Color::DarkGray))),
]), inner);
}
fn draw_color_pick_modal(f: &mut Frame, area: Rect, cursor: usize, current_hex: &str) {
use ratatui::widgets::Clear;
let modal_area = crate::tui::centered_rect(70, 50, area);
f.render_widget(Clear, modal_area);
let blk = Block::default()
.borders(Borders::ALL)
.title(" Choose Color ←→↑↓ navigate · Enter confirm · Esc cancel ")
.border_style(Style::default().fg(Color::Cyan));
let inner = blk.inner(modal_area);
f.render_widget(blk, modal_area);
let dim = Style::default().fg(Color::DarkGray);
let mut lines: Vec<Line> = vec![Line::from("")];
let per_row = 6usize;
for row_start in (0..PROJ_PALETTE.len()).step_by(per_row) {
// Swatch row
let mut swatch_spans: Vec<Span> = vec![Span::raw(" ")];
for i in 0..per_row {
if let Some((hex, _)) = PROJ_PALETTE.get(row_start + i) {
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
let c = Color::Rgb(cr, cg, cb);
let is_cur = row_start + i == cursor;
let is_active = *hex == current_hex;
let label = if is_cur { "▶████◀ " } else if is_active { " ████* " } else { " ████ " };
swatch_spans.push(Span::styled(label, Style::default().fg(c).add_modifier(if is_cur { Modifier::BOLD } else { Modifier::empty() })));
}
}
lines.push(Line::from(swatch_spans));
// Name row
let mut name_spans: Vec<Span> = vec![Span::raw(" ")];
for i in 0..per_row {
if let Some((hex, name)) = PROJ_PALETTE.get(row_start + i) {
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
let c = Color::Rgb(cr, cg, cb);
let is_cur = row_start + i == cursor;
name_spans.push(Span::styled(
format!("{:<9}", name.chars().take(7).collect::<String>()),
if is_cur { Style::default().fg(c).add_modifier(Modifier::BOLD) } else { dim },
));
}
}
lines.push(Line::from(name_spans));
lines.push(Line::from(""));
}
// Show selected hex
if let Some((hex, name)) = PROJ_PALETTE.get(cursor) {
let (cr, cg, cb) = crate::projects::hex_to_rgb(hex);
lines.push(Line::from(vec![
Span::raw(" Selected: "),
Span::styled(format!("■ {} — {}", hex, name), Style::default().fg(Color::Rgb(cr, cg, cb)).add_modifier(Modifier::BOLD)),
]));
}
f.render_widget(Paragraph::new(lines), inner);
}
+47 -23
View File
@@ -1,5 +1,5 @@
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Paragraph}, widgets::{Block, Borders, Paragraph},
@@ -7,7 +7,7 @@ use ratatui::{
}; };
use super::modals::render_modal; use super::modals::render_modal;
use super::right_panel::render_right; use super::right_panel::render_right;
use super::state::{HubState, Modal, SortMode, Tab}; use super::state::{HubState, SortMode, Tab};
use super::tabs::render_list; use super::tabs::render_list;
pub static HELP_LINES: &[&str] = &[ 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 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 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!("Issues({})", state.issues.len()) } else { "Issues".into() }; let issue_badge = if !state.issues.is_empty() { format!("6 Issues({})", state.issues.len()) } else { "6 Issues".into() };
let mut top_spans = vec![ let mut top_spans = vec![
Span::styled(" fxg ", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), 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(" "), Span::raw(" "),
tab_span("Projects", state.tab == Tab::Projects), tab_span("2 Repos", state.tab == Tab::Repos),
Span::raw(" "), Span::raw(" "),
tab_span("Todos", state.tab == Tab::Todos), tab_span("3 Projects", state.tab == Tab::Projects),
Span::raw(" "), Span::raw(" "),
tab_span(&pr_badge, state.tab == Tab::PRs), tab_span("4 Todos", state.tab == Tab::Todos),
Span::raw(" "), 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(" "), 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]); f.render_widget(Paragraph::new(Line::from(top_spans)), outer[0]);
let cols = Layout::default() if state.tab == Tab::Overview {
.direction(Direction::Horizontal) super::tabs::render_overview_global(f, state, outer[1]);
.constraints([Constraint::Percentage(73), Constraint::Percentage(27)]) } else {
.split(outer[1]); 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_blk = Block::default().borders(Borders::ALL);
let left_inner = left_blk.inner(cols[0]); let left_inner = left_blk.inner(cols[0]);
f.render_widget(left_blk, cols[0]); f.render_widget(left_blk, cols[0]);
state.visible_rows = left_inner.height.saturating_sub(1) as usize; state.visible_rows = left_inner.height.saturating_sub(1) as usize;
render_list(f, state, left_inner); render_list(f, state, left_inner);
render_right(f, state, cols[1]); render_right(f, state, cols[1]);
}
if state.debug_mode { if state.debug_mode {
let blk = Block::default().borders(Borders::TOP).title(" debug ") 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 key = |k: &str| Span::styled(format!("[{}] ", k), dim);
let sep = || Span::styled(" ", 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<Line> = if state.modal.is_some() { let hint_lines: Vec<Line> = if state.modal.is_some() {
vec![ vec![
Line::from(vec![key("↑↓/jk"), Span::styled("navigate ", dim), key("Enter"), Span::styled("confirm ", dim), key("Esc"), Span::styled("cancel", dim)]), 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![ 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(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(""),
Line::from(""), line3,
], ],
_ => vec![ _ => 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("↑↓/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("*"), 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("Space"), Span::styled("select ", dim), key("R"), Span::styled("API refresh ", dim), key("N"), Span::styled("new repo", dim)]),
Line::from(vec![key("C"), Span::styled("clone ", dim), key("I"), Span::styled("init repo ", dim), key("L"), Span::styled("link folder", dim)]), line3,
], ],
} }
}; };
+7 -8
View File
@@ -64,11 +64,12 @@ pub fn render_right(f: &mut Frame, state: &HubState, area: Rect) {
f.render_widget(blk, area); f.render_widget(blk, area);
match state.tab { match state.tab {
Tab::Repos => render_right_repo(f, state, inner), Tab::Repos => render_right_repo(f, state, inner),
Tab::Projects => render_right_project(f, state, inner), Tab::Projects => render_right_project(f, state, inner),
Tab::Todos => render_right_todo(f, state, inner), Tab::Todos => render_right_todo(f, state, inner),
Tab::PRs => render_right_pr(f, state, inner), Tab::PRs => render_right_pr(f, state, inner),
Tab::Issues => render_right_issue(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('p', "Manage projects", Color::White),
r_action_line('+', "New todo", Color::White), r_action_line('+', "New todo", Color::White),
r_action_line('\n', "─────────────────", Color::DarkGray), r_action_line('\n', "─────────────────", Color::DarkGray),
r_action_line('C', "Clone repo", Color::DarkGray), r_action_line('N', "New repo…", Color::DarkGray),
r_action_line('I', "Init new repo", Color::DarkGray),
r_action_line('L', "Link folder", Color::DarkGray),
]), action_inner); ]), action_inner);
} }
+100 -3
View File
@@ -9,7 +9,19 @@ use std::path::PathBuf;
// ── tabs ────────────────────────────────────────────────────────────────────── // ── tabs ──────────────────────────────────────────────────────────────────────
#[derive(PartialEq, Clone, Copy)] #[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 ───────────────────────────────────────────────────────────────── // ── sort mode ─────────────────────────────────────────────────────────────────
@@ -42,7 +54,7 @@ impl SortMode {
#[derive(Clone)] #[derive(Clone)]
pub enum Modal { pub enum Modal {
None, None,
Text { title: String, prompt: String, value: String, tag: TextTag }, Text { title: String, prompt: String, value: String, tag: TextTag, max_len: Option<usize> },
Select { title: String, options: Vec<String>, cursor: usize, tag: SelectTag }, Select { title: String, options: Vec<String>, cursor: usize, tag: SelectTag },
Multi { title: String, options: Vec<String>, checks: Vec<bool>, cursor: usize, tag: MultiTag }, Multi { title: String, options: Vec<String>, checks: Vec<bool>, cursor: usize, tag: MultiTag },
Confirm { message: String, tag: ConfirmTag }, Confirm { message: String, tag: ConfirmTag },
@@ -67,6 +79,17 @@ pub enum TextTag {
CloneUrl, CloneUrl,
InitName, InitName,
LinkPath, LinkPath,
// hub sync
SyncMsg,
// hub settings editing
SetEditor,
SetClipboard,
SetWorkspace,
// import directory
ImportDir,
// template creation
NewTemplateName,
NewTemplateCmds(String), // template name
} }
#[derive(Clone)] #[derive(Clone)]
@@ -77,6 +100,21 @@ pub enum SelectTag {
TodoProject { title: String, desc: String, status_idx: usize }, TodoProject { title: String, desc: String, status_idx: usize },
TodoChangeStatus(usize), TodoChangeStatus(usize),
SelectByCondition, 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)] #[derive(Clone, Copy, PartialEq)]
@@ -91,6 +129,9 @@ pub enum MultiTag {
pub enum ConfirmTag { pub enum ConfirmTag {
DeleteProject(usize), DeleteProject(usize),
DeleteTodo(usize), DeleteTodo(usize),
PullAll(usize), // index into state.accounts
RemoveGitAll,
DeleteTemplate(usize),
} }
// ── display rows ────────────────────────────────────────────────────────────── // ── display rows ──────────────────────────────────────────────────────────────
@@ -122,10 +163,30 @@ pub struct FocusedExtra {
pub diff_preview: DiffState, pub diff_preview: DiffState,
} }
// ── repo template ─────────────────────────────────────────────────────────────
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct RepoTemplate {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub commands: Vec<String>,
}
// ── phase result ────────────────────────────────────────────────────────────── // ── phase result ──────────────────────────────────────────────────────────────
#[derive(PartialEq)] #[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<String> },
ExecOpenEditor(PathBuf),
}
// ── modal action ────────────────────────────────────────────────────────────── // ── modal action ──────────────────────────────────────────────────────────────
@@ -176,6 +237,23 @@ pub struct HubState {
pub meta_cache: crate::cache::MetaCache, pub meta_cache: crate::cache::MetaCache,
/// Background diff computation in progress: (repo_idx, receiver). /// Background diff computation in progress: (repo_idx, receiver).
pub diff_rx: Option<(usize, std::sync::mpsc::Receiver<Vec<String>>)>, pub diff_rx: Option<(usize, std::sync::mpsc::Receiver<Vec<String>>)>,
// Global Stats tab
pub global_stats: Option<GlobalStats>,
pub global_stats_rx: Option<std::sync::mpsc::Receiver<GlobalStats>>,
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<crate::auth::Account>,
// User-defined repo templates
pub templates: Vec<RepoTemplate>,
// Pending sync input collected from in-TUI modals
pub pending_sync_action: Option<String>, // "pull" | "commit_push" | "push_only"
pub pending_sync_msg: Option<String>,
// PhaseResult deferred from modal confirm
pub pending_phase: Option<PhaseResult>,
// Pull-all background receiver: sends progress strings
pub pull_all_rx: Option<std::sync::mpsc::Receiver<String>>,
} }
impl HubState { impl HubState {
@@ -242,6 +320,9 @@ impl HubState {
d.push(DisplayRow::Issue { idx: i }); 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(); self.repo_pos = (0..d.len()).collect();
if self.cursor >= self.repo_pos.len().max(1) { 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. /// 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) { pub fn poll_diff(&mut self) {
let rx_ready = if let Some((ri, rx)) = &self.diff_rx { let rx_ready = if let Some((ri, rx)) = &self.diff_rx {
match rx.try_recv() { match rx.try_recv() {
+633 -19
View File
@@ -1,10 +1,10 @@
use crate::status::format_age_pub; use crate::status::format_age_pub;
use crate::todos::TodoSource; use crate::todos::TodoSource;
use ratatui::{ use ratatui::{
layout::{Constraint, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Cell, Row, Table}, widgets::{Cell, Paragraph, Row, Table},
Frame, Frame,
}; };
use super::state::{DisplayRow, HubState, Tab}; 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 cur_di = state.cur_di().unwrap_or(usize::MAX);
let header = Row::new(vec![ 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)); ]).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let rows: Vec<Row> = state.display.iter().enumerate() let rows: Vec<Row> = 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 vis = r.visibility.as_deref().unwrap_or("?");
let rel = r.last_tag.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 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 b_str = if r.behind > 0 { format!("-{}", r.behind) } else { String::new() };
let status_str = format!("{} {}{}", d_sym, a_str, b_str); 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!("{} {}", 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(format!("{}{}", prefix, r.name)).style(Style::default().fg(name_col)),
Cell::from(r.branch.chars().take(9).collect::<String>()), Cell::from(r.branch.chars().take(9).collect::<String>()),
Cell::from(proj_name.chars().take(10).collect::<String>()).style(Style::default().fg(proj_color)), Cell::from(proj_name.chars().take(21).collect::<String>()).style(Style::default().fg(proj_color)),
Cell::from(vis).style(match vis { "public" => Style::default().fg(Color::Green), "private" => Style::default().fg(Color::Yellow), _ => Style::default().fg(Color::DarkGray) }), Cell::from(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(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::<String>()).style(Style::default().fg(Color::Cyan)), Cell::from(rel.chars().take(7).collect::<String>()).style(Style::default().fg(Color::Cyan)),
Cell::from(lic_str).style(Style::default().fg(if r.license { Color::Green } else { Color::DarkGray })), Cell::from(lic_str).style(Style::default().fg(if r.license { Color::Green } else { Color::DarkGray })),
Cell::from(stars_str).style(Style::default().fg(stars_col)), 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::Length(6),
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(10), Constraint::Length(10),
Constraint::Length(11), Constraint::Length(22),
Constraint::Length(7),
Constraint::Length(9), Constraint::Length(9),
Constraint::Length(9), Constraint::Length(9),
Constraint::Length(9),
Constraint::Length(10),
Constraint::Length(9),
Constraint::Length(8), Constraint::Length(8),
Constraint::Length(4),
Constraint::Length(6),
]; ];
f.render_widget(Table::new(rows, widths).header(header), area); 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 avail = area.height.saturating_sub(1) as usize;
let cur_di = state.cur_di().unwrap_or(usize::MAX); 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)); .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let rows: Vec<Row> = state.display.iter().enumerate() let rows: Vec<Row> = 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 status_color = t.status.color();
let proj_name = t.project.as_deref().unwrap_or("—"); let proj_name = t.project.as_deref().unwrap_or("—");
let repo_name = t.repo.as_deref().unwrap_or("—"); let repo_name = t.repo.as_deref().unwrap_or("—");
let created = format_age_pub(t.created_at); let created = format_age_pub(t.created_at);
let updated = format_age_pub(t.updated_at); let est_str = t.estimate.as_deref().unwrap_or("—").chars().take(10).collect::<String>();
let diff_str = match t.difficulty {
Some(d) => format!("{}{}", "★".repeat(d.min(5) as usize), "☆".repeat(5usize.saturating_sub(d as usize))),
None => "—".into(),
};
let title_short: String = if t.source == TodoSource::CodeScan { let title_short: String = if t.source == TodoSource::CodeScan {
let raw = &t.title; let raw = &t.title;
let trimmed = if let Some(end) = raw.find(']') { raw[end+1..].trim() } else { raw.as_str() }; 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(title_short),
Cell::from(repo_name).style(Style::default().fg(Color::DarkGray)), Cell::from(repo_name).style(Style::default().fg(Color::DarkGray)),
Cell::from(proj_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(created).style(Style::default().fg(Color::DarkGray)),
Cell::from(updated).style(Style::default().fg(Color::DarkGray)),
]).style(row_sty)) ]).style(row_sty))
}) })
.collect(); .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(12), Constraint::Length(12),
Constraint::Length(10), Constraint::Length(10),
Constraint::Length(7),
Constraint::Length(10), Constraint::Length(10),
]).header(header), ]).header(header),
area, 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) { 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() { if state.display.is_empty() {
let msg = match state.tab { let msg = match state.tab {
Tab::Repos => " (no repos match current filters)", 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::Todos => " No todos — press [+] to create one",
Tab::PRs => " No PRs cached — press [R] to fetch from GitHub", Tab::PRs => " No PRs cached — press [R] to fetch from GitHub",
Tab::Issues => " No issues 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); f.render_widget(ratatui::widgets::Paragraph::new(msg).style(Style::default().fg(Color::DarkGray)), area);
return; return;
} }
match state.tab { match state.tab {
Tab::Repos => render_repos_table(f, state, area), Tab::Repos => render_repos_table(f, state, area),
Tab::Projects => render_projects_table(f, state, area), Tab::Projects => render_projects_table(f, state, area),
Tab::Todos => render_todos_table(f, state, area), Tab::Todos => render_todos_table(f, state, area),
Tab::PRs => render_prs_table(f, state, area), Tab::PRs => render_prs_table(f, state, area),
Tab::Issues => render_issues_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<Line> = vec![
Line::from(vec![
Span::styled(format!(" fxg v{}{}", version, debug_flag), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled(" workspace ", dim),
Span::styled(ws_short, wht),
]),
Line::from(""),
Line::from(vec![
Span::styled(format!(" Repos: {:>3}", total_repos), wht),
Span::styled(format!(" Projects: {:>2}", proj_c), wht),
Span::styled(format!(" Todos: {:>2} open", open_todos), if open_todos > 0 { cyn } else { dim }),
Span::styled(format!(" PRs: {:>2}", prs_c), if prs_c > 0 { cyn } else { dim }),
Span::styled(format!(" Issues: {:>2}", iss_c), if iss_c > 0 { Style::default().fg(Color::Yellow) } else { dim }),
]),
Line::from(vec![
Span::styled(" Health ", dim),
Span::styled(health_bar, Style::default().fg(health_col)),
Span::styled(format!(" clean {}/{}", clean_c, total_repos), Style::default().fg(health_col)),
]),
Line::from(vec![
Span::styled(format!(" ↓ behind: {}", behind_c), if behind_c > 0 { red } else { dim }),
Span::styled(format!(" ⚠ dirty: {}", dirty_c), if dirty_c > 0 { Style::default().fg(Color::Yellow) } else { dim }),
Span::styled(format!(" ↑ ahead: {}", ahead_c), if ahead_c > 0 { grn } else { dim }),
]),
];
// Pad remaining space
let used = lines.len();
let avail = app_inner.height as usize;
for _ in used..avail { lines.push(Line::from("")); }
f.render_widget(Paragraph::new(lines), app_inner);
}
// Cell 2 — Account
let acc_blk = Block::default().borders(Borders::ALL)
.title(Span::styled(" account ", yel))
.border_style(dim);
let acc_inner = acc_blk.inner(row1[1]);
f.render_widget(acc_blk, row1[1]);
{
let mut lines: Vec<Line> = vec![];
if state.accounts.is_empty() {
lines.push(Line::from(Span::styled(" no accounts", dim)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" run:", dim)));
lines.push(Line::from(Span::styled(" fxg auth import", Style::default().fg(Color::Cyan))));
} else {
for acc in &state.accounts {
let token_ok = crate::auth::get_token(&acc.id()).is_ok();
let tok_icon = if token_ok { "✓" } else { "✗" };
let tok_sty = if token_ok { grn } else { red };
let def_mark = if acc.is_default { " ★" } else { "" };
let repo_cnt = state.repos.iter().filter(|r| r.owner.as_deref() == Some(acc.username.as_str())).count();
let dirty_cnt = state.repos.iter().filter(|r| r.owner.as_deref() == Some(acc.username.as_str()) && r.is_dirty).count();
let beh_cnt = state.repos.iter().filter(|r| r.owner.as_deref() == Some(acc.username.as_str()) && r.behind > 0).count();
let pr_cnt = state.prs.iter().filter(|pr| pr.author == acc.username).count();
let iss_cnt = state.issues.iter().filter(|i| i.author == acc.username).count();
lines.push(Line::from(vec![
Span::styled(format!(" @{}{}", acc.username, def_mark), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
]));
lines.push(Line::from(vec![
Span::styled(format!(" {:<8}", acc.provider), dim),
Span::styled(tok_icon, tok_sty),
]));
lines.push(Line::from(vec![
Span::styled(format!(" {} repo{}", repo_cnt, if repo_cnt == 1 { "" } else { "s" }), wht),
if dirty_cnt > 0 || beh_cnt > 0 {
Span::styled(format!(" ↓{} ⚠{}", beh_cnt, dirty_cnt), Style::default().fg(Color::Yellow))
} else {
Span::styled(" ✓", grn)
},
]));
if pr_cnt > 0 || iss_cnt > 0 {
lines.push(Line::from(vec![
Span::styled(format!(" {} PR{} {} issue{}", pr_cnt, if pr_cnt == 1 { "" } else { "s" }, iss_cnt, if iss_cnt == 1 { "" } else { "s" }), dim),
]));
}
lines.push(Line::from(""));
}
}
f.render_widget(Paragraph::new(lines), acc_inner);
}
// ── Row 2: Health (50%) + Recent Activity (50%) ──────────────────────────
let row2 = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[1]);
// Cell 3 — Health Stats
let health_blk = Block::default().borders(Borders::ALL)
.title(Span::styled(" health ", yel))
.border_style(dim);
let health_inner = health_blk.inner(row2[0]);
f.render_widget(health_blk, row2[0]);
{
// Language breakdown from global stats (if available)
let top_langs: Vec<(String, usize)> = if let Some(gs) = &state.global_stats {
gs.lang_dist.iter().take(3).map(|(l, c)| (l.clone(), *c)).collect()
} else { vec![] };
let mut lines = vec![
Line::from(vec![
Span::styled(format!(" Repos: {:>4}", total_repos), wht),
Span::styled(format!(" Projects:{:>3}", state.projects.len()), wht),
]),
Line::from(vec![
Span::styled(format!(" Dirty: {:>4}", dirty_c), if dirty_c > 0 { Style::default().fg(Color::Yellow) } else { dim }),
Span::styled(format!(" Behind: {:>3}", behind_c), if behind_c > 0 { red } else { dim }),
]),
Line::from(vec![
Span::styled(format!(" Ahead: {:>4}", ahead_c), if ahead_c > 0 { grn } else { dim }),
Span::styled(format!(" No lic: {:>3}", no_lic_c), if no_lic_c > 0 { Style::default().fg(Color::Yellow) } else { dim }),
]),
Line::from(""),
Line::from(vec![
Span::styled(format!(" Todos open:{:>4}", open_todos), cyn),
Span::styled(format!(" In prog:{:>3}", inprog_todos), Style::default().fg(Color::Yellow)),
]),
Line::from(vec![
Span::styled(format!(" Done: {:>4}", done_todos), grn),
Span::styled(format!(" Cancld: {:>3}", canc_todos), dim),
]),
Line::from(vec![
Span::styled(format!(" PRs: {:>4}", state.prs.len()), cyn),
Span::styled(format!(" Issues: {:>3}", state.issues.len()), Style::default().fg(Color::Yellow)),
]),
];
if !top_langs.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" Languages (top 3)", dim)));
for (lang, cnt) in &top_langs {
lines.push(Line::from(vec![
Span::styled(format!(" {:<10}", lang.chars().take(10).collect::<String>()), wht),
Span::styled(format!("{} repo{}", cnt, if *cnt == 1 { "" } else { "s" }), dim),
]));
}
}
f.render_widget(Paragraph::new(lines), health_inner);
}
// Cell 4 — Recent Activity
let act_blk = Block::default().borders(Borders::ALL)
.title(Span::styled(" recent activity ", yel))
.border_style(dim);
let act_inner = act_blk.inner(row2[1]);
f.render_widget(act_blk, row2[1]);
{
let mut sorted: Vec<&crate::status::RepoSummary> = state.repos.iter().collect();
sorted.sort_by(|a, b| b.last_commit_ts.cmp(&a.last_commit_ts));
let max_rows = act_inner.height as usize;
let name_w = (act_inner.width as usize).saturating_sub(22).min(20).max(8);
let msg_w = (act_inner.width as usize).saturating_sub(name_w + 10).max(5);
let mut lines: Vec<Line> = sorted.iter().take(max_rows).map(|r| {
let age_col = age_color(r.last_commit_ts);
let name = r.name.chars().take(name_w).collect::<String>();
let msg = r.last_commit_msg.chars().take(msg_w).collect::<String>();
Line::from(vec![
Span::styled(format!("{:>5} ", format_age_pub(r.last_commit_ts)), Style::default().fg(age_col)),
Span::styled(format!("{:<width$} ", name, width = name_w), wht),
Span::styled(msg, dim),
])
}).collect();
if lines.is_empty() {
lines.push(Line::from(Span::styled(" no commits yet", dim)));
}
f.render_widget(Paragraph::new(lines), act_inner);
}
// ── Row 3: Todos (25%) + PRs/Issues (75%) ────────────────────────────────
let row3 = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
.split(rows[2]);
// Cell 5 — Todos summary
let todo_blk = Block::default().borders(Borders::ALL)
.title(Span::styled(" todos ", yel))
.border_style(dim);
let todo_inner = todo_blk.inner(row3[0]);
f.render_widget(todo_blk, row3[0]);
{
let name_w = (todo_inner.width as usize).saturating_sub(4).max(5);
let mut lines = vec![
Line::from(vec![
Span::styled(format!("{:>3} ", open_todos), cyn),
Span::styled("open ", dim),
Span::styled(format!("{:>3} ", inprog_todos), Style::default().fg(Color::Yellow)),
Span::styled("prog", dim),
]),
Line::from(vec![
Span::styled(format!("{:>3} ", done_todos), grn),
Span::styled("done ", dim),
Span::styled(format!("{:>3} ", canc_todos), dim),
Span::styled("canc", dim),
]),
];
// Recent open/in-progress todos
let urgent: Vec<&crate::todos::Todo> = state.todos.iter()
.filter(|t| matches!(t.status, crate::todos::TodoStatus::Open | crate::todos::TodoStatus::InProgress)
&& t.source == TodoSource::Manual)
.take((todo_inner.height as usize).saturating_sub(4))
.collect();
if !urgent.is_empty() {
lines.push(Line::from(""));
for t in urgent {
let icon = t.status.icon();
let title = t.title.chars().take(name_w).collect::<String>();
lines.push(Line::from(vec![
Span::styled(format!("{} ", icon), Style::default().fg(t.status.color())),
Span::styled(title, wht),
]));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" [4] Todos", dim)));
f.render_widget(Paragraph::new(lines), todo_inner);
}
// Cell 6 — PRs / Issues notifications
let notif_blk = Block::default().borders(Borders::ALL)
.title(Span::styled(" prs & issues ", yel))
.border_style(dim);
let notif_inner = notif_blk.inner(row3[1]);
f.render_widget(notif_blk, row3[1]);
{
let max_rows = notif_inner.height.saturating_sub(1) as usize;
let title_w = (notif_inner.width as usize).saturating_sub(40).min(40).max(10);
// Merge PRs and Issues into a unified notification stream, newest first.
struct Notif<'a> {
kind: &'a str,
number: u64,
repo: &'a str,
title: &'a str,
author: &'a str,
ts: i64,
}
let mut notifs: Vec<Notif> = vec![];
for pr in &state.prs {
notifs.push(Notif { kind: "PR ", number: pr.number as u64, repo: &pr.repo, title: &pr.title, author: &pr.author, ts: pr.created_ts });
}
for iss in &state.issues {
notifs.push(Notif { kind: "ISS", number: iss.number as u64, repo: &iss.repo, title: &iss.title, author: &iss.author, ts: iss.created_ts });
}
notifs.sort_by(|a, b| b.ts.cmp(&a.ts));
let mut lines: Vec<Line> = if notifs.is_empty() {
vec![Line::from(Span::styled(" no open PRs or issues", dim))]
} else {
notifs.iter().take(max_rows).map(|n| {
let age = format_age_pub(n.ts);
let kind_col = if n.kind == "PR " { cyn } else { Style::default().fg(Color::Yellow) };
let repo_w = 12usize;
let repo = n.repo.chars().take(repo_w).collect::<String>();
let title = n.title.chars().take(title_w).collect::<String>();
let auth = n.author.chars().take(10).collect::<String>();
Line::from(vec![
Span::styled(format!(" {} #{:<4} ", n.kind, n.number), kind_col),
Span::styled(format!("{:<width$} ", repo, width = repo_w), wht),
Span::styled(format!("{:<width$} ", title, width = title_w), dim),
Span::styled(format!("@{:<10} ", auth), dim),
Span::styled(age, dim),
])
}).collect()
};
lines.push(Line::from(vec![
Span::styled(" [5] PRs [6] Issues", dim),
]));
f.render_widget(Paragraph::new(lines), notif_inner);
}
}
// ── Stats global tab ──────────────────────────────────────────────────────────
pub fn render_stats_global(f: &mut Frame, state: &HubState, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let cyn = Style::default().fg(Color::Cyan);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
// LEFT: bar chart or loading
let mut left_lines: Vec<Line> = vec![];
left_lines.push(Line::from(Span::styled(
format!(" Global Statistics [7:{}] [p] change period", state.global_stats_period.label()),
yel,
)));
left_lines.push(Line::from(Span::styled(" ─────────────────────────────────────────────────────────", dim)));
match &state.global_stats {
None if state.global_stats_rx.is_some() => {
left_lines.push(Line::from(Span::styled(" Computing stats from all repos (may take a moment)...", cyn)));
}
None => {
left_lines.push(Line::from(Span::styled(" Press [p] or switch to this tab to load stats", dim)));
}
Some(gs) => {
left_lines.push(Line::from(vec![
Span::styled(format!(" Total commits this period: {}", gs.total_commits), Style::default().fg(Color::White)),
]));
left_lines.push(Line::from(""));
// Simple text bar chart
let max_count: usize = gs.activity.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1);
let bar_h = (area.height as usize).saturating_sub(10).max(5);
let data = &gs.activity;
let n = data.len();
let bar_w = (cols[0].width as usize).saturating_sub(8).max(1);
let step = if n > bar_w { (n / bar_w).max(1) } else { 1 };
let sampled: Vec<&(String, usize)> = data.iter().step_by(step).collect();
let bar_count = sampled.len().min(bar_w);
// Stretch bars to fill available width when few data points
let bar_width = if bar_count == 0 { 1 } else { (bar_w / bar_count).max(1) };
for row in (0..bar_h).rev() {
let threshold = (row * max_count) / bar_h;
let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
let y_label = if row == bar_h - 1 { format!("{:>4} ", max_count) } else if row == 0 { " 0 ".to_string() } else { " ".to_string() };
spans.push(Span::styled(y_label, dim));
for j in 0..bar_count {
let count = sampled.get(j).map(|(_, c)| *c).unwrap_or(0);
let filled = count > threshold;
for _ in 0..bar_width {
if filled { spans.push(Span::styled("█", cyn)); }
else { spans.push(Span::styled("░", dim)); }
}
}
left_lines.push(Line::from(spans));
}
// X labels: one per bar, each bar_width chars wide
let nth = (bar_count / 5).max(1);
let mut x_spans: Vec<Span> = vec![Span::raw(" ")];
for j in 0..bar_count {
let label = if j % nth == 0 {
let raw = sampled.get(j)
.map(|(d, _)| d.chars().skip(5).take(bar_width.min(5)).collect::<String>())
.unwrap_or_default();
format!("{:<width$}", raw, width = bar_width)
} else {
" ".repeat(bar_width)
};
x_spans.push(Span::styled(label, dim));
}
left_lines.push(Line::from(x_spans));
}
}
f.render_widget(Paragraph::new(left_lines), cols[0]);
// RIGHT: language breakdown + top repos
let mut right_lines: Vec<Line> = vec![];
right_lines.push(Line::from(Span::styled(" Languages", yel)));
right_lines.push(Line::from(Span::styled(" ────────────────────────────────", dim)));
match &state.global_stats {
None => { right_lines.push(Line::from(Span::styled(" Loading...", dim))); }
Some(gs) => {
let total_repos = state.repos.len().max(1);
for (lang, cnt) in gs.lang_dist.iter().take(8) {
let bar_len = (cnt * 16) / total_repos;
let bar = "█".repeat(bar_len) + &"░".repeat(16usize.saturating_sub(bar_len));
right_lines.push(Line::from(vec![
Span::styled(format!(" {:<8} ", lang.chars().take(8).collect::<String>()), Style::default().fg(Color::White)),
Span::styled(bar, cyn),
Span::styled(format!(" {}", cnt), dim),
]));
}
right_lines.push(Line::from(""));
right_lines.push(Line::from(Span::styled(" Top Repos (commits)", yel)));
right_lines.push(Line::from(Span::styled(" ────────────────────────────────", dim)));
for (name, cnt) in gs.top_repos.iter().take(5) {
right_lines.push(Line::from(vec![
Span::styled(format!(" {:<18} ", name.chars().take(18).collect::<String>()), Style::default().fg(Color::White)),
Span::styled(format!("{}", cnt), cyn),
]));
}
right_lines.push(Line::from(""));
right_lines.push(Line::from(vec![
Span::styled(" Health: ", dim),
Span::styled(format!("{}↓ {}⚠", gs.behind_count, gs.dirty_count), Style::default().fg(if gs.behind_count > 0 || gs.dirty_count > 0 { Color::Yellow } else { Color::Green })),
]));
right_lines.push(Line::from(""));
right_lines.push(Line::from(Span::styled(" Day of Week", yel)));
right_lines.push(Line::from(Span::styled(" ────────────────────────────────", dim)));
let day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
let max_day = gs.day_pattern.iter().copied().max().unwrap_or(1).max(1);
for (i, &cnt) in gs.day_pattern.iter().enumerate() {
let bar_len = (cnt * 16) / max_day;
let bar: String = "█".repeat(bar_len) + &"░".repeat(16usize.saturating_sub(bar_len));
right_lines.push(Line::from(vec![
Span::styled(format!(" {} ", day_names[i]), Style::default().fg(Color::White)),
Span::styled(bar, cyn),
Span::styled(format!(" {}", cnt), dim),
]));
}
}
}
f.render_widget(Paragraph::new(right_lines), cols[1]);
}
// ── Settings global tab ───────────────────────────────────────────────────────
pub fn render_settings_global(f: &mut Frame, state: &HubState, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let wht = Style::default().fg(Color::White);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
let mut left_lines: Vec<Line> = vec![];
left_lines.push(Line::from(Span::styled(" Workspace Settings", yel)));
left_lines.push(Line::from(Span::styled(" ─────────────────────────────────────────────", dim)));
struct Item { label: &'static str, value: String }
let editor = state.editor.clone();
let clip = state.clipboard_cmd.clone();
let workspace = state.workspace_root.to_string_lossy().to_string();
let acc_count = state.accounts.len().to_string();
let items: &[Item] = &[
Item { label: "Workspace root", value: workspace },
Item { label: "Editor", value: editor },
Item { label: "Clipboard cmd", value: clip },
Item { label: "Total repos", value: state.repos.len().to_string() },
Item { label: "Total projects", value: state.projects.len().to_string() },
Item { label: "Total todos", value: state.todos.len().to_string() },
Item { label: "Accounts", value: acc_count },
Item { label: "Pull all GitHub repos", value: "[Enter] clone/pull all from account".into() },
Item { label: "Import directory", value: "[Enter] git init + link all subfolders".into() },
Item { label: "Remove .git (all)", value: "[Enter] ⚠ de-git all repos in workspace".into() },
];
for (i, item) in items.iter().enumerate() {
let sel = i == state.hub_settings_cur;
let bg = if sel { Color::DarkGray } else { Color::Reset };
left_lines.push(Line::from(vec![
Span::styled(
format!(" {:>2}. ", i + 1),
Style::default().fg(if sel { Color::Cyan } else { Color::DarkGray }).bg(bg),
),
Span::styled(
format!("{:<20}", item.label),
Style::default().fg(Color::White).bg(bg).add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }),
),
Span::styled(
item.value.chars().take(35).collect::<String>(),
Style::default().fg(Color::DarkGray).bg(bg),
),
]));
}
left_lines.push(Line::from(""));
// Templates section
left_lines.push(Line::from(Span::styled(" Templates", yel)));
left_lines.push(Line::from(Span::styled(" ─────────────────────────────────────────────", dim)));
let new_tmpl_sel = state.hub_settings_cur == 10;
let new_bg = if new_tmpl_sel { Color::DarkGray } else { Color::Reset };
left_lines.push(Line::from(vec![
Span::styled(
format!(" {:>2}. ", 11),
Style::default().fg(if new_tmpl_sel { Color::Cyan } else { Color::DarkGray }).bg(new_bg),
),
Span::styled(
format!("{:<20}", "[+] New template"),
Style::default().fg(Color::Green).bg(new_bg).add_modifier(if new_tmpl_sel { Modifier::BOLD } else { Modifier::empty() }),
),
]));
for (ti, t) in state.templates.iter().enumerate() {
let idx = 11 + ti;
let sel = idx == state.hub_settings_cur;
let bg = if sel { Color::DarkGray } else { Color::Reset };
let cmd_n = t.commands.len();
let cmd_s = match cmd_n {
0 => "(no commands)".to_string(),
1 => "1 command".to_string(),
n => format!("{} commands", n),
};
left_lines.push(Line::from(vec![
Span::styled(
format!(" {:>2}. ", 12 + ti),
Style::default().fg(if sel { Color::Cyan } else { Color::DarkGray }).bg(bg),
),
Span::styled(
format!("{:<20}", t.name.chars().take(20).collect::<String>()),
Style::default().fg(Color::White).bg(bg).add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }),
),
Span::styled(
cmd_s,
Style::default().fg(Color::DarkGray).bg(bg),
),
]));
}
if state.templates.is_empty() {
left_lines.push(Line::from(Span::styled(" (no templates yet)", dim)));
}
f.render_widget(Paragraph::new(left_lines), cols[0]);
let mut right_lines: Vec<Line> = vec![];
right_lines.push(Line::from(Span::styled(" Hints", yel)));
right_lines.push(Line::from(Span::styled(" ──────────────────────────────", dim)));
right_lines.push(Line::from(Span::styled(" ↑↓ / jk navigate", dim)));
right_lines.push(Line::from(Span::styled(" Enter edit / run item", dim)));
right_lines.push(Line::from(Span::styled(" D delete template", dim)));
right_lines.push(Line::from(""));
right_lines.push(Line::from(Span::styled(" Items 2-3: editable in session.", dim)));
right_lines.push(Line::from(Span::styled(" Item 8: pull all from GitHub.", dim)));
right_lines.push(Line::from(Span::styled(" Item 9: git init + link dir.", dim)));
right_lines.push(Line::from(Span::styled(" Item 10: remove .git ⚠", dim)));
right_lines.push(Line::from(""));
right_lines.push(Line::from(Span::styled(" Templates run on repo create.", dim)));
right_lines.push(Line::from(Span::styled(" Cmds: semicolon-separated.", dim)));
right_lines.push(Line::from(""));
right_lines.push(Line::from(Span::styled(" Auth: fxg auth import", dim)));
f.render_widget(Paragraph::new(right_lines), cols[1]);
}
+47
View File
@@ -47,6 +47,53 @@ pub fn find_in_dir(pattern: &str, root: &Path, repo_name: &str) -> Vec<FindResul
results results
} }
// ── doc-files-only find (for TODO scanner — avoids self-referential hits in source) ──
pub fn find_in_doc_files(pattern: &str, root: &Path, repo_name: &str) -> Vec<FindResult> {
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<F: FnMut(std::path::PathBuf, String)>(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 ──────────────────────────────────────────────────────────────────── // ── rename ────────────────────────────────────────────────────────────────────
pub fn rename_in_dir(from: &str, to: &str, root: &Path, dry_run: bool) -> Vec<FindResult> { pub fn rename_in_dir(from: &str, to: &str, root: &Path, dry_run: bool) -> Vec<FindResult> {
+5 -3
View File
@@ -177,9 +177,11 @@ impl Provider for GiteaProvider {
.send().await?; .send().await?;
let v = Self::check(resp).await.context("repo_meta")?; let v = Self::check(resp).await.context("repo_meta")?;
Ok(RepoMeta { 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: v["open_pr_counter"].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, 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![],
}) })
} }
+20 -2
View File
@@ -145,10 +145,19 @@ impl Provider for GitHubProvider {
0 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 { 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, 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(()) 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<Vec<PrDetail>> { async fn list_pull_requests(&self, repo: &str) -> Result<Vec<PrDetail>> {
let resp = self.client let resp = self.client
.get(self.url(&format!("repos/{}/{}/pulls?state=open&per_page=100", self.owner, repo))) .get(self.url(&format!("repos/{}/{}/pulls?state=open&per_page=100", self.owner, repo)))
+5 -3
View File
@@ -179,9 +179,11 @@ impl Provider for GitLabProvider {
.send().await?; .send().await?;
let v = Self::check(resp).await.context("repo_meta")?; let v = Self::check(resp).await.context("repo_meta")?;
Ok(RepoMeta { 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: v["merge_requests_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, 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(),
}) })
} }
+11 -3
View File
@@ -27,9 +27,11 @@ pub struct PrInfo {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct RepoMeta { pub struct RepoMeta {
pub open_issues: u32, pub open_issues: u32,
pub open_prs: u32, pub open_prs: u32,
pub stars: u32, pub stars: u32,
pub description: Option<String>,
pub topics: Vec<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -137,6 +139,12 @@ pub trait Provider: Send + Sync {
let _ = repo; let _ = repo;
anyhow::bail!("archive_repo not supported by this provider") 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`. /// Build a boxed `Provider` from an `Account`.
+135 -23
View File
@@ -81,31 +81,143 @@ pub fn detect_license(path: &Path) -> bool {
.iter().any(|f| path.join(f).exists()) .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<String> { pub fn detect_language(path: &Path) -> Option<String> {
// Fast path: check indicator files instead of scanning the full git index. let mut counts: HashMap<&'static str, usize> = HashMap::new();
const INDICATORS: &[(&str, &str)] = &[ let mut total = 0usize;
("Cargo.toml", "Rust"), lang_walk(path, &mut counts, &mut total, 6);
("go.mod", "Go"), if total == 0 { return None; }
("pyproject.toml", "Python"),
("requirements.txt", "Python"), let asset_total: usize = counts.iter()
("setup.py", "Python"), .filter(|(k, _)| ASSET_CATEGORIES.contains(k))
("pom.xml", "Java"), .map(|(_, v)| *v)
("build.gradle", "Java"), .sum();
("build.gradle.kts", "Kotlin"), let assets_dominate = asset_total * 100 / total > 80;
("Gemfile", "Ruby"),
("composer.json", "PHP"), // When assets don't dominate, exclude them from both the display and the percentage base.
("Package.swift", "Swift"), let base = if assets_dominate { total } else { total - asset_total };
("mix.exs", "Elixir"), if base == 0 { return None; }
("*.cabal", "Haskell"),
]; let threshold = ((base as f64 * 0.05) as usize).max(1);
for (file, lang) in INDICATORS { let mut langs: Vec<(&str, usize)> = counts.into_iter()
if path.join(file).exists() { return Some(lang.to_string()); } .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::<Vec<_>>()
.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 ─────────────────────────────────────────────────────── // ── lazy per-repo extras ───────────────────────────────────────────────────────
+6
View File
@@ -79,6 +79,10 @@ pub struct Todo {
/// Manual (user-created) or CodeScan (found in source). /// Manual (user-created) or CodeScan (found in source).
#[serde(default)] #[serde(default)]
pub source: TodoSource, pub source: TodoSource,
#[serde(default)]
pub estimate: Option<String>,
#[serde(default)]
pub difficulty: Option<u8>,
pub created_at: i64, pub created_at: i64,
pub updated_at: i64, pub updated_at: i64,
} }
@@ -91,6 +95,7 @@ impl Todo {
let now = chrono::Utc::now().timestamp(); let now = chrono::Utc::now().timestamp();
Self { id, title, description, status, project, Self { id, title, description, status, project,
repo: None, source: TodoSource::Manual, repo: None, source: TodoSource::Manual,
estimate: None, difficulty: None,
created_at: now, updated_at: now } created_at: now, updated_at: now }
} }
@@ -101,6 +106,7 @@ impl Todo {
Self { id, title, description: String::new(), Self { id, title, description: String::new(),
status: TodoStatus::Open, project: None, status: TodoStatus::Open, project: None,
repo: Some(repo), source: TodoSource::CodeScan, repo: Some(repo), source: TodoSource::CodeScan,
estimate: None, difficulty: None,
created_at: now, updated_at: now } created_at: now, updated_at: now }
} }
+17 -1
View File
@@ -22,7 +22,7 @@ pub fn setup() -> Result<Tty> {
Ok(Terminal::new(backend)?) 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<()> { pub fn teardown(mut terminal: Tty) -> Result<()> {
disable_raw_mode()?; disable_raw_mode()?;
execute!( execute!(
@@ -34,6 +34,22 @@ pub fn teardown(mut terminal: Tty) -> Result<()> {
Ok(()) 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. /// Poll for the next crossterm event with a timeout.
pub fn next_event(timeout_ms: u64) -> Result<Option<Event>> { pub fn next_event(timeout_ms: u64) -> Result<Option<Event>> {
if event::poll(Duration::from_millis(timeout_ms))? { if event::poll(Duration::from_millis(timeout_ms))? {
+10
View File
@@ -1,3 +1,13 @@
pub mod clipboard; pub mod clipboard;
pub mod git; pub mod git;
pub mod url; 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();
}