update
This commit is contained in:
1 parent
d4fdc9890a
commit
5787b14880
22 files changed
+5715
-659
No files matched your search
+5
-1
@@ -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
@@ -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])
|
||||||
|
}
|
||||||
+1408
-119
File diff suppressed because it is too large.
Load diff
+1276
-299
File diff suppressed because it is too large.
Load diff
+113
-18
@@ -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 {
|
||||||
|
|||||||
+305
-50
@@ -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::Overview => Tab::Repos,
|
||||||
Tab::Repos => Tab::Projects,
|
Tab::Repos => Tab::Projects,
|
||||||
Tab::Projects => Tab::Todos,
|
Tab::Projects => Tab::Todos,
|
||||||
Tab::Todos => Tab::PRs,
|
Tab::Todos => Tab::PRs,
|
||||||
Tab::PRs => Tab::Issues,
|
Tab::PRs => Tab::Issues,
|
||||||
Tab::Issues => Tab::Repos,
|
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::Repos => Tab::Overview,
|
||||||
Tab::Projects => Tab::Repos,
|
Tab::Projects => Tab::Repos,
|
||||||
Tab::Todos => Tab::Projects,
|
Tab::Todos => Tab::Projects,
|
||||||
Tab::PRs => Tab::Todos,
|
Tab::PRs => Tab::Todos,
|
||||||
Tab::Issues => Tab::PRs,
|
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
@@ -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;
|
||||||
|
|||||||
+408
-33
@@ -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 {
|
||||||
|
state.status_msg = Some("git init failed".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// create .fxg/meta.toml
|
||||||
|
let fxg_dir = target.join(".fxg");
|
||||||
|
let _ = std::fs::create_dir_all(&fxg_dir);
|
||||||
|
let tmpl_name = template.as_ref().map(|t| t.name.as_str()).unwrap_or("blank");
|
||||||
|
let meta = format!("created_at = \"{}\"\ntemplate = \"{}\"\n",
|
||||||
|
chrono::Utc::now().format("%Y-%m-%d"), tmpl_name);
|
||||||
|
let _ = std::fs::write(fxg_dir.join("meta.toml"), meta);
|
||||||
|
|
||||||
|
if let Some(t) = &template {
|
||||||
|
if !t.commands.is_empty() {
|
||||||
|
state.pending_phase = Some(PhaseResult::ExecTemplateInit {
|
||||||
|
path: target.clone(),
|
||||||
|
commands: t.commands.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
super::actions::refresh_repos(state);
|
super::actions::refresh_repos(state);
|
||||||
state.status_msg = Some(format!("Initialized {name}"));
|
state.status_msg = Some(format!("Created '{}'{}",
|
||||||
|
repo_name,
|
||||||
|
template.as_ref().map(|t| format!(" using '{}'", t.name)).unwrap_or_default(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(o) => {
|
|
||||||
state.status_msg = Some(format!("git init failed: {}", String::from_utf8_lossy(&o.stderr).trim()));
|
// ── 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;
|
||||||
}
|
}
|
||||||
Err(e) => { state.status_msg = Some(format!("git init error: {e}")); }
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+35
-11
@@ -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,21 +82,27 @@ 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(" "),
|
||||||
|
tab_span("4 Todos", state.tab == Tab::Todos),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
tab_span(&pr_badge, state.tab == Tab::PRs),
|
tab_span(&pr_badge, state.tab == Tab::PRs),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
tab_span(&issue_badge, state.tab == Tab::Issues),
|
tab_span(&issue_badge, state.tab == Tab::Issues),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
|
tab_span("7 Stats", state.tab == Tab::Stats),
|
||||||
|
Span::raw(" "),
|
||||||
|
tab_span("8 Settings", state.tab == Tab::HubSettings),
|
||||||
|
Span::raw(" "),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Active filter indicators — only shown when on, so they don't crowd the bar.
|
// Active filter indicators — only shown when on, so they don't crowd the bar.
|
||||||
@@ -130,6 +136,9 @@ 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]);
|
||||||
|
|
||||||
|
if state.tab == Tab::Overview {
|
||||||
|
super::tabs::render_overview_global(f, state, outer[1]);
|
||||||
|
} else {
|
||||||
let cols = Layout::default()
|
let cols = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Percentage(73), Constraint::Percentage(27)])
|
.constraints([Constraint::Percentage(73), Constraint::Percentage(27)])
|
||||||
@@ -141,6 +150,7 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) {
|
|||||||
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,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ pub fn render_right(f: &mut Frame, state: &HubState, area: Rect) {
|
|||||||
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
@@ -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() {
|
||||||
|
|||||||
+627
-13
@@ -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()
|
||||||
@@ -145,7 +147,11 @@ pub fn render_todos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
|||||||
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,6 +282,7 @@ 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;
|
||||||
@@ -276,5 +293,602 @@ pub fn render_list(f: &mut Frame, state: &HubState, area: Rect) {
|
|||||||
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,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> {
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ impl Provider for GiteaProvider {
|
|||||||
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![],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ impl Provider for GitLabProvider {
|
|||||||
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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ 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`.
|
||||||
|
|||||||
+134
-22
@@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn detect_language(path: &Path) -> Option<String> {
|
// Categories treated as media/binary assets — only displayed when they dominate (>80% of files).
|
||||||
// Fast path: check indicator files instead of scanning the full git index.
|
const ASSET_CATEGORIES: &[&str] = &[
|
||||||
const INDICATORS: &[(&str, &str)] = &[
|
"Images", "Textures", "Audio", "Video", "3D", "Fonts",
|
||||||
("Cargo.toml", "Rust"),
|
"PDF", "Docs", "Spreadsheets", "Slides", "eBook",
|
||||||
("go.mod", "Go"),
|
|
||||||
("pyproject.toml", "Python"),
|
|
||||||
("requirements.txt", "Python"),
|
|
||||||
("setup.py", "Python"),
|
|
||||||
("pom.xml", "Java"),
|
|
||||||
("build.gradle", "Java"),
|
|
||||||
("build.gradle.kts", "Kotlin"),
|
|
||||||
("Gemfile", "Ruby"),
|
|
||||||
("composer.json", "PHP"),
|
|
||||||
("Package.swift", "Swift"),
|
|
||||||
("mix.exs", "Elixir"),
|
|
||||||
("*.cabal", "Haskell"),
|
|
||||||
];
|
];
|
||||||
for (file, lang) in INDICATORS {
|
|
||||||
if path.join(file).exists() { return Some(lang.to_string()); }
|
pub fn detect_language(path: &Path) -> Option<String> {
|
||||||
|
let mut counts: HashMap<&'static str, usize> = HashMap::new();
|
||||||
|
let mut total = 0usize;
|
||||||
|
lang_walk(path, &mut counts, &mut total, 6);
|
||||||
|
if total == 0 { return None; }
|
||||||
|
|
||||||
|
let asset_total: usize = counts.iter()
|
||||||
|
.filter(|(k, _)| ASSET_CATEGORIES.contains(k))
|
||||||
|
.map(|(_, v)| *v)
|
||||||
|
.sum();
|
||||||
|
let assets_dominate = asset_total * 100 / total > 80;
|
||||||
|
|
||||||
|
// When assets don't dominate, exclude them from both the display and the percentage base.
|
||||||
|
let base = if assets_dominate { total } else { total - asset_total };
|
||||||
|
if base == 0 { return None; }
|
||||||
|
|
||||||
|
let threshold = ((base as f64 * 0.05) as usize).max(1);
|
||||||
|
let mut langs: Vec<(&str, usize)> = counts.into_iter()
|
||||||
|
.filter(|(k, c)| *c >= threshold && (assets_dominate || !ASSET_CATEGORIES.contains(k)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if langs.is_empty() { return None; }
|
||||||
|
langs.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
Some(langs.iter().take(4)
|
||||||
|
.map(|(lang, c)| format!("{} {}%", lang, c * 100 / base))
|
||||||
|
.collect::<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 ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
@@ -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))? {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user