Phases 0-1 + Sprint A: module split, CLI layer, org-refresh fix, hygiene
- LICENSE (MIT) + README truth-pass; docs/cli.md CLI contract - CLI subcommands (src/cli_cmds/) per "TUI for users, CLI for users+AI" - Hub/detail refactor: keymap dispatch, modal split, action enum, BgMsg bus - Fix: org repos were never refreshed (owner==username match); refresh now parallel — 49 repos in ~11s, surfaced 70 previously invisible PRs - Fix: 3 date bugs in detail/git.rs (format-local, Local::now, 1y month math) - Stash indicator + [m] actions, session persistence, gh CLI token fallback - Legacy UI modules removed; plan/ docs (roadmap, audit, debate, handoff)
This commit is contained in:
1 parent
5787b14880
commit
0468c032c6
100 files changed
+11614
-9840
No files matched your search
@@ -0,0 +1,241 @@
|
||||
//! `exec_action` — the repo-detail view's dispatcher for actions that need
|
||||
//! to leave the TUI's alternate screen (interactive `inquire` prompts,
|
||||
//! launching `$EDITOR`, blocking git subprocesses with visible output).
|
||||
//! `run_detail_loop` in `mod.rs` calls this between `leave_tui`/`enter_tui`,
|
||||
//! so everything here runs with the terminal back in normal/cooked mode.
|
||||
|
||||
use super::git::{build_history, checkout_branch, git_run, list_branches, push_branch};
|
||||
use super::settings_io::{try_load_settings, try_make_provider};
|
||||
use super::types::{RepoSettings, St};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Runs the action identified by `idx` (one of the numeric codes set on
|
||||
/// `st.run_action`/passed in from a modal). Numeric codes rather than an enum
|
||||
/// because they're produced from several different call sites (key handlers,
|
||||
/// modal confirms, settings-tab rows) that don't otherwise share a type.
|
||||
pub async fn exec_action(st: &mut St, idx: usize) -> Result<()> {
|
||||
let path = st.summary.path.clone();
|
||||
match idx {
|
||||
// ── 0: sync ──────────────────────────────────────────────────────────
|
||||
0 => {
|
||||
println!("\n── Sync: {} ──\n", st.summary.name);
|
||||
let mode = st.pending_sync_mode.take().unwrap_or_else(|| "pull".into());
|
||||
let commit_msg = st.pending_commit_msg.take().unwrap_or_default();
|
||||
match mode.as_str() {
|
||||
"pull" => { git_run(&path, &["pull"]); }
|
||||
"commit_push" => {
|
||||
git_run(&path, &["add", "-A"]);
|
||||
git_run(&path, &["commit", "-m", &commit_msg]);
|
||||
git_run(&path, &["push"]);
|
||||
}
|
||||
_ => { git_run(&path, &["push"]); } // push_only
|
||||
}
|
||||
println!("\n Press Enter to return...");
|
||||
let _ = std::io::stdin().lines().next();
|
||||
}
|
||||
|
||||
// ── 1: release ───────────────────────────────────────────────────────
|
||||
1 => {
|
||||
println!("\n── Release: {} ──\n", st.summary.name);
|
||||
let tag_name = st.pending_tag_name.take().unwrap_or_default();
|
||||
let tag_msg = st.pending_tag_msg.take().unwrap_or_else(|| tag_name.clone());
|
||||
let do_push = std::mem::replace(&mut st.pending_push_tag, false);
|
||||
if !tag_name.is_empty() {
|
||||
git_run(&path, &["tag", "-a", &tag_name, "-m", &tag_msg]);
|
||||
if do_push {
|
||||
git_run(&path, &["push", "origin", &tag_name]);
|
||||
}
|
||||
st.history = build_history(&path, 200);
|
||||
st.total_tags += 1;
|
||||
}
|
||||
println!("\n Press Enter to return...");
|
||||
let _ = std::io::stdin().lines().next();
|
||||
}
|
||||
|
||||
// ── 30: checkout branch ──────────────────────────────────────────────
|
||||
30 => {
|
||||
if let Some(b) = st.branches.get(st.branch_cursor) {
|
||||
let name = b.name.clone();
|
||||
if b.is_current {
|
||||
st.status_msg = Some(format!("Already on '{}'", name));
|
||||
return Ok(());
|
||||
}
|
||||
let ok = checkout_branch(&path, &name);
|
||||
if ok {
|
||||
st.summary.branch = name.clone();
|
||||
st.branches = list_branches(&path);
|
||||
st.branch_cursor = st.branches.iter().position(|b| b.is_current).unwrap_or(0);
|
||||
st.status_msg = Some(format!("Switched to '{}'", name));
|
||||
} else {
|
||||
st.status_msg = Some("Checkout failed (uncommitted changes?)".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 31: delete branch (handled in-TUI via ConfirmAction modal) ──────
|
||||
31 => {}
|
||||
|
||||
// ── 106-109: open in editor (leave_tui is handled by run_detail_loop) ─
|
||||
106 => {
|
||||
let editor = st.cfg_editor.clone();
|
||||
if let Err(e) = std::process::Command::new(&editor).arg(&path).status() {
|
||||
st.status_msg = Some(format!("Couldn't launch editor '{}': {}", editor, e));
|
||||
}
|
||||
}
|
||||
107 => {
|
||||
let editor = st.cfg_editor.clone();
|
||||
if let Err(e) = std::process::Command::new(&editor).arg(path.join(".gitignore")).status() {
|
||||
st.status_msg = Some(format!("Couldn't launch editor '{}': {}", editor, e));
|
||||
}
|
||||
}
|
||||
108 => {
|
||||
let editor = st.cfg_editor.clone();
|
||||
if let Err(e) = std::process::Command::new(&editor).arg(path.join("LICENSE")).status() {
|
||||
st.status_msg = Some(format!("Couldn't launch editor '{}': {}", editor, e));
|
||||
}
|
||||
}
|
||||
109 => {
|
||||
let editor = st.cfg_editor.clone();
|
||||
let readme = ["README.md", "README.rst", "README"].iter()
|
||||
.map(|f| path.join(f)).find(|p| p.exists())
|
||||
.unwrap_or_else(|| path.join("README.md"));
|
||||
if let Err(e) = std::process::Command::new(&editor).arg(&readme).status() {
|
||||
st.status_msg = Some(format!("Couldn't launch editor '{}': {}", editor, e));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 110: open pending_file in editor ────────────────────────────────
|
||||
110 => {
|
||||
if let Some(file) = st.pending_file.take() {
|
||||
let editor = st.cfg_editor.clone();
|
||||
if let Err(e) = std::process::Command::new(&editor).arg(&file).status() {
|
||||
st.status_msg = Some(format!("Couldn't launch editor '{}': {}", editor, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 32: push branch ──────────────────────────────────────────────────
|
||||
32 => {
|
||||
if let Some(b) = st.branches.get(st.branch_cursor) {
|
||||
let name = b.name.clone();
|
||||
println!("\n── Push '{}' ──\n", name);
|
||||
let ok = push_branch(&path, &name);
|
||||
if ok {
|
||||
st.branches = list_branches(&path);
|
||||
st.status_msg = Some(format!("Pushed '{}'", name));
|
||||
} else {
|
||||
st.status_msg = Some("Push failed".into());
|
||||
}
|
||||
}
|
||||
println!("\n Press Enter to return...");
|
||||
let _ = std::io::stdin().lines().next();
|
||||
}
|
||||
|
||||
// ── 50: load settings from GitHub API ───────────────────────────────
|
||||
50 => {
|
||||
let local_visibility = st.summary.visibility.clone().unwrap_or_else(|| "unknown".into());
|
||||
match try_load_settings(st).await {
|
||||
Ok(_) => {}
|
||||
Err(_) => {
|
||||
st.settings = Some(RepoSettings {
|
||||
visibility: local_visibility,
|
||||
description: String::new(),
|
||||
topics: vec![],
|
||||
gh_auth_ok: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 60: toggle visibility ────────────────────────────────────────────
|
||||
60 => {
|
||||
println!("\n── Visibility: {} ──\n", st.summary.name);
|
||||
let choice = inquire::Select::new("Set visibility:", vec!["public", "private"])
|
||||
.prompt_skippable()?;
|
||||
if let Some(vis) = choice {
|
||||
let private = vis == "private";
|
||||
match try_make_provider(st).await {
|
||||
Ok(provider) => match provider.update_visibility(&st.summary.name, private).await {
|
||||
Ok(_) => {
|
||||
let v = vis.to_string();
|
||||
st.summary.visibility = Some(v.clone());
|
||||
if let Some(s) = &mut st.settings { s.visibility = v; }
|
||||
st.status_msg = Some(format!("Visibility → {}", vis));
|
||||
}
|
||||
Err(e) => { println!("API error: {}", e); }
|
||||
}
|
||||
Err(e) => { println!("Auth error: {}", e); }
|
||||
}
|
||||
}
|
||||
println!("\n Press Enter to return...");
|
||||
let _ = std::io::stdin().lines().next();
|
||||
}
|
||||
|
||||
// ── 61: edit description ─────────────────────────────────────────────
|
||||
61 => {
|
||||
println!("\n── Description ──\n");
|
||||
let current = st.settings.as_ref().map(|s| s.description.as_str()).unwrap_or("").to_string();
|
||||
if let Some(desc) = inquire::Text::new("Description:").with_default(¤t).prompt_skippable()? {
|
||||
match try_make_provider(st).await {
|
||||
Ok(provider) => match provider.update_description(&st.summary.name, &desc).await {
|
||||
Ok(_) => {
|
||||
if let Some(s) = &mut st.settings { s.description = desc; }
|
||||
st.status_msg = Some("Description updated".into());
|
||||
}
|
||||
Err(e) => { println!("API error: {}", e); }
|
||||
}
|
||||
Err(e) => { println!("Auth error: {}", e); }
|
||||
}
|
||||
}
|
||||
println!("\n Press Enter to return...");
|
||||
let _ = std::io::stdin().lines().next();
|
||||
}
|
||||
|
||||
// ── 62: edit topics ──────────────────────────────────────────────────
|
||||
62 => {
|
||||
println!("\n── Topics ──\n");
|
||||
let current = st.settings.as_ref().map(|s| s.topics.join(", ")).unwrap_or_default();
|
||||
if let Some(raw) = inquire::Text::new("Topics (comma-separated):").with_default(¤t).prompt_skippable()? {
|
||||
let topics: Vec<String> = raw.split(',').map(|s| s.trim().to_lowercase().to_string())
|
||||
.filter(|s| !s.is_empty()).collect();
|
||||
match try_make_provider(st).await {
|
||||
Ok(provider) => match provider.set_topics(&st.summary.name, &topics).await {
|
||||
Ok(_) => {
|
||||
if let Some(s) = &mut st.settings { s.topics = topics; }
|
||||
st.status_msg = Some("Topics updated".into());
|
||||
}
|
||||
Err(e) => { println!("API error: {}", e); }
|
||||
}
|
||||
Err(e) => { println!("Auth error: {}", e); }
|
||||
}
|
||||
}
|
||||
println!("\n Press Enter to return...");
|
||||
let _ = std::io::stdin().lines().next();
|
||||
}
|
||||
|
||||
// ── 63: add collaborator ─────────────────────────────────────────────
|
||||
63 => {
|
||||
println!("\n── Add Collaborator ──\n");
|
||||
if let Some(user) = inquire::Text::new("GitHub username:").prompt_skippable()? {
|
||||
if !user.trim().is_empty() {
|
||||
let role = inquire::Select::new("Role:", vec!["pull", "push", "admin"])
|
||||
.prompt_skippable()?;
|
||||
if let Some(role) = role {
|
||||
match try_make_provider(st).await {
|
||||
Ok(provider) => match provider.add_collaborator(&st.summary.name, user.trim(), role).await {
|
||||
Ok(_) => { st.status_msg = Some(format!("Invited {}", user.trim())); }
|
||||
Err(e) => { println!("API error: {}", e); }
|
||||
}
|
||||
Err(e) => { println!("Auth error: {}", e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("\n Press Enter to return...");
|
||||
let _ = std::io::stdin().lines().next();
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+73
-11
@@ -1,3 +1,11 @@
|
||||
//! Git data access for the repo Detail view: commit/tag history, branch
|
||||
//! listing with ahead/behind counts, contributors, per-period commit
|
||||
//! activity (for the bar charts), the file tree, file preview, and the
|
||||
//! branch checkout/delete/push actions. Mixes `git2` for structured reads
|
||||
//! with shelling out to `git`/`sh` for the handful of queries (shortlog,
|
||||
//! line counts, date-bucketed log) that are far simpler as a one-line CLI
|
||||
//! pipe than as hand-rolled `git2` traversal.
|
||||
|
||||
use super::{BranchEntry, ContribEntry, HistEntry, TreeNode};
|
||||
use crate::status::format_age_pub;
|
||||
use git2::{BranchType, Repository};
|
||||
@@ -9,6 +17,11 @@ pub use crate::util::clipboard::copy_auto as copy_to_clipboard;
|
||||
|
||||
// ── commit history ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns up to `limit` recent commits merged with all tags, newest first.
|
||||
/// Tags are appended and re-sorted alongside commits (rather than queried
|
||||
/// separately by the caller) so the history view can show them inline at
|
||||
/// their true chronological position; the `limit + 20` truncation leaves
|
||||
/// room for tags that would otherwise push real commits out of the window.
|
||||
pub fn build_history(path: &std::path::Path, limit: usize) -> Vec<HistEntry> {
|
||||
let repo = match Repository::open(path) { Ok(r) => r, Err(_) => return vec![] };
|
||||
|
||||
@@ -50,11 +63,12 @@ pub fn build_history(path: &std::path::Path, limit: usize) -> Vec<HistEntry> {
|
||||
}
|
||||
|
||||
commits.extend(tags);
|
||||
commits.sort_by(|a, b| b.ts().cmp(&a.ts()));
|
||||
commits.sort_by_key(|c| std::cmp::Reverse(c.ts()));
|
||||
commits.truncate(limit + 20);
|
||||
commits
|
||||
}
|
||||
|
||||
/// Total commit count on the current branch (`git rev-list --count HEAD`).
|
||||
pub fn total_commits(path: &std::path::Path) -> usize {
|
||||
std::process::Command::new("git")
|
||||
.arg("-C").arg(path).args(["rev-list", "--count", "HEAD"])
|
||||
@@ -65,6 +79,7 @@ pub fn total_commits(path: &std::path::Path) -> usize {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Number of tags in the repo.
|
||||
pub fn count_tags(path: &std::path::Path) -> usize {
|
||||
std::process::Command::new("git")
|
||||
.arg("-C").arg(path).args(["tag", "-l"])
|
||||
@@ -73,6 +88,7 @@ pub fn count_tags(path: &std::path::Path) -> usize {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Number of stash entries.
|
||||
pub fn count_stashes(path: &std::path::Path) -> usize {
|
||||
std::process::Command::new("git")
|
||||
.arg("-C").arg(path).args(["stash", "list"])
|
||||
@@ -83,6 +99,7 @@ pub fn count_stashes(path: &std::path::Path) -> usize {
|
||||
|
||||
// ── files ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// All tracked file paths, relative to the repo root (`git ls-files`).
|
||||
pub fn list_all_files(path: &std::path::Path) -> Vec<String> {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C").arg(path).args(["ls-files"])
|
||||
@@ -94,6 +111,9 @@ pub fn list_all_files(path: &std::path::Path) -> Vec<String> {
|
||||
.lines().map(|l| l.to_string()).collect()
|
||||
}
|
||||
|
||||
/// Total line count across tracked files (capped at the first 5000 files,
|
||||
/// since `wc -l` over an unbounded file list on a huge repo would be slow
|
||||
/// for a number that's only used as a rough stat).
|
||||
pub fn count_lines_of_code(path: &std::path::Path) -> usize {
|
||||
let out = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
@@ -103,12 +123,14 @@ pub fn count_lines_of_code(path: &std::path::Path) -> usize {
|
||||
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)
|
||||
s.split_whitespace().next().and_then(|n| n.parse().ok()).unwrap_or(0)
|
||||
}
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `n` largest tracked files by line count, descending, for the repo
|
||||
/// stats view's "largest files" list.
|
||||
pub fn largest_files(path: &std::path::Path, n: usize) -> Vec<(String, usize)> {
|
||||
let out = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
@@ -134,6 +156,7 @@ pub fn largest_files(path: &std::path::Path, n: usize) -> Vec<(String, usize)> {
|
||||
|
||||
// ── remote URL ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The `origin` remote's URL, normalized to a browsable `https://` web URL.
|
||||
pub fn remote_url(path: &std::path::Path) -> Option<String> {
|
||||
let repo = Repository::open(path).ok()?;
|
||||
let remote = repo.find_remote("origin").ok()?;
|
||||
@@ -143,6 +166,8 @@ pub fn remote_url(path: &std::path::Path) -> Option<String> {
|
||||
|
||||
// ── branches ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Lists local branches with their upstream ahead/behind counts and last
|
||||
/// commit info, current branch first then alphabetically.
|
||||
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![];
|
||||
@@ -183,6 +208,7 @@ pub fn list_branches(path: &std::path::Path) -> Vec<BranchEntry> {
|
||||
|
||||
// ── contributors ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Contributors by commit count across all branches (`git shortlog -sn --all`).
|
||||
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"])
|
||||
@@ -203,12 +229,19 @@ pub fn list_contributors(path: &std::path::Path) -> Vec<ContribEntry> {
|
||||
|
||||
// ── commit activity (per period) ─────────────────────────────────────────────
|
||||
|
||||
/// Commit counts bucketed by `date_fmt` (a `git log --date=format:` string),
|
||||
/// optionally restricted to the last `since_days` days. The shared engine
|
||||
/// behind all `activity_*` functions below, which differ only in bucket
|
||||
/// width and how many buckets they show.
|
||||
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"]);
|
||||
// format-local: buckets by the *local* timezone — plain `format:` uses
|
||||
// whatever offset is embedded in each commit, so a commit from CI/UTC
|
||||
// (or GitHub web UI) would land in the wrong day.
|
||||
cmd.args([&format!("--date=format-local:{}", date_fmt), "--pretty=format:%ad"]);
|
||||
let out = cmd.output().unwrap_or_else(|_| std::process::Output {
|
||||
status: std::process::ExitStatus::default(), stdout: vec![], stderr: vec![]
|
||||
});
|
||||
@@ -220,6 +253,7 @@ fn git_activity_raw(path: &std::path::Path, since_days: Option<u64>, date_fmt: &
|
||||
counts
|
||||
}
|
||||
|
||||
/// Commits per hour over the last day, as 24 `"00"`–`"23"` buckets.
|
||||
pub fn activity_1d(path: &std::path::Path) -> Vec<(String, usize)> {
|
||||
let raw = git_activity_raw(path, Some(1), "%H");
|
||||
(0u32..24).map(|h| {
|
||||
@@ -229,9 +263,11 @@ pub fn activity_1d(path: &std::path::Path) -> Vec<(String, usize)> {
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Commits per day over the last 7 days, as `"MM-DD"` buckets.
|
||||
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();
|
||||
// Local, not Utc — labels must line up with format-local buckets.
|
||||
let now = chrono::Local::now();
|
||||
(0i64..7).rev().map(|d| {
|
||||
let dt = now - chrono::Duration::days(d);
|
||||
let label = dt.format("%Y-%m-%d").to_string();
|
||||
@@ -241,9 +277,11 @@ pub fn activity_7d(path: &std::path::Path) -> Vec<(String, usize)> {
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Commits per day over the last 30 days, as `"DD"` buckets.
|
||||
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();
|
||||
// Local, not Utc — labels must line up with format-local buckets.
|
||||
let now = chrono::Local::now();
|
||||
(0i64..30).rev().map(|d| {
|
||||
let dt = now - chrono::Duration::days(d);
|
||||
let label = dt.format("%Y-%m-%d").to_string();
|
||||
@@ -253,9 +291,12 @@ pub fn activity_30d(path: &std::path::Path) -> Vec<(String, usize)> {
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Commits over the last 90 days, grouped into 13 weekly buckets labeled by
|
||||
/// each week's start date.
|
||||
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();
|
||||
// Local, not Utc — labels must line up with format-local buckets.
|
||||
let now = chrono::Local::now();
|
||||
// Group into 7-day buckets (13 weeks)
|
||||
(0usize..13).rev().map(|w| {
|
||||
let end_d = (w * 7) as i64;
|
||||
@@ -274,18 +315,26 @@ pub fn activity_90d(path: &std::path::Path) -> Vec<(String, usize)> {
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Commits per month over the last year, as 3-letter month buckets.
|
||||
pub fn activity_1y(path: &std::path::Path) -> Vec<(String, usize)> {
|
||||
use chrono::Datelike;
|
||||
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();
|
||||
// Real month arithmetic — "now − m·30 days" drifts by a month within a
|
||||
// year (months are 28-31 days) and duplicates/skips labels.
|
||||
let now = chrono::Local::now();
|
||||
let (y0, m0) = (now.year(), now.month() as i32);
|
||||
(0i32..12).rev().map(|m| {
|
||||
let total = y0 * 12 + (m0 - 1) - m;
|
||||
let (y, mon) = (total.div_euclid(12), total.rem_euclid(12) + 1);
|
||||
let label = format!("{:04}-{:02}", y, mon);
|
||||
const MONTHS: [&str; 12] = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
let short = MONTHS[(mon - 1) as usize].to_string();
|
||||
let count = raw.get(&label).copied().unwrap_or(0);
|
||||
(short, count)
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Commits per year across the repo's full history, oldest first.
|
||||
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();
|
||||
@@ -295,6 +344,8 @@ pub fn activity_all(path: &std::path::Path) -> Vec<(String, usize)> {
|
||||
|
||||
// ── repo age ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Timestamp of the repo's very first commit, found by walking `HEAD`'s
|
||||
/// history to its end — used to compute 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()?;
|
||||
@@ -307,6 +358,10 @@ pub fn repo_first_commit_ts(path: &std::path::Path) -> Option<i64> {
|
||||
|
||||
// ── file tree ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Builds a flat, depth-ordered tree (directories before their children,
|
||||
/// each subtree alphabetical) from a flat list of repo-relative file paths
|
||||
/// — the shape the file-tree view renders directly without needing to walk
|
||||
/// a real nested structure at render time.
|
||||
pub fn build_tree(all_files: &[String]) -> Vec<TreeNode> {
|
||||
let mut dirs: BTreeSet<String> = BTreeSet::new();
|
||||
for f in all_files {
|
||||
@@ -343,6 +398,7 @@ pub fn build_tree(all_files: &[String]) -> Vec<TreeNode> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Recursively appends `prefix`'s children to `out` in display order, depth-first.
|
||||
fn flatten_tree(
|
||||
prefix: &str,
|
||||
dir_children: &BTreeMap<String, Vec<String>>,
|
||||
@@ -366,6 +422,9 @@ fn flatten_tree(
|
||||
|
||||
// ── file preview ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Reads the first `max_lines` lines of `rel` for the file-tree preview
|
||||
/// pane, detecting binary content via a null-byte check on the first 512
|
||||
/// bytes rather than rendering it as garbled text.
|
||||
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) {
|
||||
@@ -381,14 +440,17 @@ pub fn read_file_preview(repo_path: &std::path::Path, rel: &str, max_lines: usiz
|
||||
|
||||
// ── branch actions ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Checks out `name`, returning whether the command succeeded.
|
||||
pub fn checkout_branch(path: &std::path::Path, name: &str) -> bool {
|
||||
git_run_ok(path, &["checkout", name])
|
||||
}
|
||||
|
||||
/// Deletes local branch `name` (`git branch -d`, fails if unmerged).
|
||||
pub fn delete_branch(path: &std::path::Path, name: &str) -> bool {
|
||||
git_run_ok(path, &["branch", "-d", name])
|
||||
}
|
||||
|
||||
/// Pushes `name` to `origin`, setting it as the upstream (`git push -u`).
|
||||
pub fn push_branch(path: &std::path::Path, name: &str) -> bool {
|
||||
git_run_ok(path, &["push", "-u", "origin", name])
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
//! Key handling for the repo-detail view: validating new-branch names and
|
||||
//! dispatching key events either to the active modal (`handle_modal_key`) or
|
||||
//! to plain tab navigation and per-tab shortcuts (`handle_key`).
|
||||
|
||||
use super::git::{copy_to_clipboard, delete_branch, git_run_ok, list_branches, to_web_url};
|
||||
use super::settings_io::{open_settings_modal, spawn_settings_op};
|
||||
use super::types::{DetailModal, SettingOp, St, Tab};
|
||||
use super::{maybe_load_prs_issues, maybe_load_settings, rebuild_tree_visible};
|
||||
use crossterm::event::KeyCode;
|
||||
use git2::Repository;
|
||||
|
||||
// ── branch name validation ────────────────────────────────────────────────────
|
||||
|
||||
/// Checks a candidate branch name against the subset of git's ref-naming
|
||||
/// rules that are easy to violate by typing — full validation still happens
|
||||
/// in git itself when the `checkout -b` actually runs.
|
||||
pub fn validate_branch_name(name: &str) -> Option<String> {
|
||||
if name.is_empty() { return Some("Name cannot be empty".into()); }
|
||||
if name.contains(' ') { return Some("No spaces allowed".into()); }
|
||||
if name.contains("..") { return Some("Cannot contain '..'".into()); }
|
||||
if name.starts_with('/') || name.ends_with('/') { return Some("Cannot start or end with '/'".into()); }
|
||||
if name.chars().any(|c| matches!(c, '~' | '^' | ':' | '?' | '*' | '[' | '\\')) {
|
||||
return Some("Invalid character (~^:?*[\\)".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── modal key handling ────────────────────────────────────────────────────────
|
||||
|
||||
/// Routes `code` to whichever modal variant is currently open. Returns
|
||||
/// `false` (and does nothing) when there is no modal, so the caller can fall
|
||||
/// through to `handle_key` for normal tab/key handling.
|
||||
pub fn handle_modal_key(code: KeyCode, st: &mut St) -> bool {
|
||||
match &mut st.modal {
|
||||
DetailModal::None => false,
|
||||
|
||||
DetailModal::NewBranch { input, error } => {
|
||||
match code {
|
||||
KeyCode::Esc => { st.modal = DetailModal::None; }
|
||||
KeyCode::Enter => {
|
||||
let name = input.trim().to_string();
|
||||
if let Some(err) = validate_branch_name(&name) {
|
||||
*error = Some(err);
|
||||
} else {
|
||||
let ok = git_run_ok(&st.summary.path, &["checkout", "-b", &name]);
|
||||
st.status_msg = Some(if ok {
|
||||
st.summary.branch = name.clone();
|
||||
st.branches = list_branches(&st.summary.path);
|
||||
st.branch_count = st.branches.len();
|
||||
st.branch_cursor = st.branches.iter().position(|b| b.is_current).unwrap_or(0);
|
||||
format!("Created & switched to '{}'", name)
|
||||
} else {
|
||||
"Failed to create branch".into()
|
||||
});
|
||||
st.modal = DetailModal::None;
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => { input.pop(); *error = None; }
|
||||
KeyCode::Char(c) if input.len() < 80 => { input.push(c); *error = None; }
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
DetailModal::SelectOption { options, cursor, tag, .. } => {
|
||||
let max = options.len().saturating_sub(1);
|
||||
match code {
|
||||
KeyCode::Esc => { st.modal = DetailModal::None; }
|
||||
KeyCode::Up => { *cursor = cursor.saturating_sub(1); }
|
||||
KeyCode::Down => { *cursor = (*cursor + 1).min(max); }
|
||||
KeyCode::Enter => {
|
||||
let sel = options[*cursor].clone();
|
||||
let tag = *tag;
|
||||
st.modal = DetailModal::None;
|
||||
match tag {
|
||||
60 => {
|
||||
let private = sel == "private";
|
||||
let label = sel.clone();
|
||||
if let Some(s) = &mut st.settings { s.visibility = label.clone(); }
|
||||
st.summary.visibility = Some(label);
|
||||
spawn_settings_op(st, SettingOp::Visibility { private });
|
||||
}
|
||||
90 => {
|
||||
match sel.as_str() {
|
||||
"Pull" => {
|
||||
st.pending_sync_mode = Some("pull".into());
|
||||
st.run_action = Some(0);
|
||||
}
|
||||
"Commit + Push" => {
|
||||
st.modal = DetailModal::EditText {
|
||||
title: "Commit Message".into(),
|
||||
prompt: "Message (stages all changes):".into(),
|
||||
input: "chore: update".into(),
|
||||
tag: 91,
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
st.pending_sync_mode = Some("push_only".into());
|
||||
st.run_action = Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
DetailModal::EditText { input, tag, .. } => {
|
||||
match code {
|
||||
KeyCode::Esc => { st.modal = DetailModal::None; }
|
||||
KeyCode::Backspace => { input.pop(); }
|
||||
KeyCode::Char(c) if input.len() < 200 => { input.push(c); }
|
||||
KeyCode::Enter => {
|
||||
let text = input.trim().to_string();
|
||||
let tag = *tag;
|
||||
st.modal = DetailModal::None;
|
||||
match tag {
|
||||
61 => {
|
||||
if let Some(s) = &mut st.settings { s.description = text.clone(); }
|
||||
spawn_settings_op(st, SettingOp::Description { text });
|
||||
}
|
||||
62 => {
|
||||
let topics: Vec<String> = text.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let invalid = topics.iter().find(|t| t.contains(' '));
|
||||
if let Some(bad) = invalid {
|
||||
st.status_msg = Some(format!("Invalid topic '{}': no spaces within words", bad));
|
||||
} else {
|
||||
if let Some(s) = &mut st.settings { s.topics = topics.clone(); }
|
||||
spawn_settings_op(st, SettingOp::Topics { topics });
|
||||
}
|
||||
}
|
||||
63 => { // collab name → step 2: pick role
|
||||
if !text.is_empty() {
|
||||
st.modal = DetailModal::SelectRole { collab_name: text, cursor: 0 };
|
||||
}
|
||||
}
|
||||
65 => { // rename
|
||||
if !text.is_empty() {
|
||||
spawn_settings_op(st, SettingOp::Rename { new_name: text });
|
||||
}
|
||||
}
|
||||
70 => { // commit — stage all and commit with message
|
||||
if !text.is_empty() {
|
||||
let path = st.summary.path.clone();
|
||||
let add_ok = git_run_ok(&path, &["add", "-A"]);
|
||||
let commit_ok = add_ok && git_run_ok(&path, &["commit", "-m", &text]);
|
||||
st.status_msg = Some(if commit_ok {
|
||||
st.summary.ahead += 1;
|
||||
st.summary.is_dirty = false;
|
||||
format!("Committed: {}", text.chars().take(50).collect::<String>())
|
||||
} else if !add_ok {
|
||||
"git add -A failed".into()
|
||||
} else {
|
||||
"Commit failed — nothing staged?".into()
|
||||
});
|
||||
}
|
||||
}
|
||||
91 => { // commit message for commit+push → trigger exec_action(0)
|
||||
st.pending_sync_mode = Some("commit_push".into());
|
||||
st.pending_commit_msg = Some(text);
|
||||
st.run_action = Some(0);
|
||||
}
|
||||
92 => { // tag name → ask for tag message
|
||||
if !text.is_empty() {
|
||||
let default_msg = text.clone();
|
||||
st.pending_tag_name = Some(text);
|
||||
st.modal = DetailModal::EditText {
|
||||
title: "Release — Tag Message".into(),
|
||||
prompt: "Tag message (blank = tag name):".into(),
|
||||
input: default_msg,
|
||||
tag: 93,
|
||||
};
|
||||
}
|
||||
}
|
||||
93 => { // tag message → confirm push?
|
||||
let tag_msg = if text.is_empty() {
|
||||
st.pending_tag_name.clone().unwrap_or_default()
|
||||
} else { text };
|
||||
st.pending_tag_msg = Some(tag_msg);
|
||||
let tag_display = st.pending_tag_name.as_deref().unwrap_or("").to_string();
|
||||
st.modal = DetailModal::ConfirmAction {
|
||||
message: format!("Push tag '{}' to remote?", tag_display),
|
||||
tag: 94,
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
DetailModal::SelectRole { collab_name, cursor } => {
|
||||
const ROLES: &[&str] = &["pull", "push", "maintain", "admin"];
|
||||
match code {
|
||||
KeyCode::Esc => { st.modal = DetailModal::None; }
|
||||
KeyCode::Up => { *cursor = cursor.saturating_sub(1); }
|
||||
KeyCode::Down => { *cursor = (*cursor + 1).min(ROLES.len() - 1); }
|
||||
KeyCode::Enter => {
|
||||
let name = collab_name.clone();
|
||||
let role = ROLES[*cursor].to_string();
|
||||
st.modal = DetailModal::None;
|
||||
spawn_settings_op(st, SettingOp::AddCollaborator { name, role });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
DetailModal::ConfirmAction { tag, .. } => {
|
||||
match code {
|
||||
KeyCode::Esc | KeyCode::Char('n') => { st.modal = DetailModal::None; }
|
||||
KeyCode::Enter | KeyCode::Char('y') => {
|
||||
let tag = *tag;
|
||||
st.modal = DetailModal::None;
|
||||
match tag {
|
||||
31 => { // delete branch
|
||||
let path = st.summary.path.clone();
|
||||
if let Some(b) = st.branches.get(st.branch_cursor) {
|
||||
let name = b.name.clone();
|
||||
let ok = delete_branch(&path, &name);
|
||||
if ok {
|
||||
st.branches = list_branches(&path);
|
||||
st.branch_count = st.branches.len();
|
||||
st.branch_cursor = st.branch_cursor.min(st.branches.len().saturating_sub(1));
|
||||
st.status_msg = Some(format!("Deleted '{}'", name));
|
||||
} else {
|
||||
st.status_msg = Some("Delete failed — unmerged changes?".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
64 => { spawn_settings_op(st, SettingOp::Archive); }
|
||||
66 => { spawn_settings_op(st, SettingOp::Delete); }
|
||||
94 => { // push tag confirmed
|
||||
st.pending_push_tag = true;
|
||||
st.run_action = Some(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── main key handling ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Dispatches a non-modal key event: tab switching, per-tab vertical/page
|
||||
/// navigation, and the global shortcuts (sync, release, open-in-browser,
|
||||
/// clipboard copies, stash) listed in the detail view's help text.
|
||||
pub fn handle_key(code: KeyCode, st: &mut St) {
|
||||
st.status_msg = None;
|
||||
match code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => st.quit = true,
|
||||
|
||||
// Tab switching: Tab, arrows, number keys
|
||||
KeyCode::Tab | KeyCode::Right => {
|
||||
st.tab = st.tab.next();
|
||||
maybe_load_settings(st);
|
||||
maybe_load_prs_issues(st);
|
||||
}
|
||||
KeyCode::Left => {
|
||||
st.tab = st.tab.prev();
|
||||
maybe_load_settings(st);
|
||||
maybe_load_prs_issues(st);
|
||||
}
|
||||
KeyCode::Char('1') => { st.tab = Tab::Overview; }
|
||||
KeyCode::Char('2') => { st.tab = Tab::Commits; }
|
||||
KeyCode::Char('3') => { st.tab = Tab::Branches; }
|
||||
KeyCode::Char('4') => { st.tab = Tab::Files; }
|
||||
KeyCode::Char('5') => { st.tab = Tab::Stats; }
|
||||
KeyCode::Char('6') => {
|
||||
st.tab = Tab::Settings;
|
||||
maybe_load_settings(st);
|
||||
}
|
||||
KeyCode::Char('7') => {
|
||||
st.tab = Tab::PRs;
|
||||
maybe_load_prs_issues(st);
|
||||
}
|
||||
KeyCode::Char('8') => {
|
||||
st.tab = Tab::Issues;
|
||||
maybe_load_prs_issues(st);
|
||||
}
|
||||
KeyCode::Char('9') => { st.tab = Tab::Todos; }
|
||||
|
||||
// Vertical navigation
|
||||
KeyCode::Down | KeyCode::Char('j') => match st.tab {
|
||||
Tab::Overview => st.ov_off = st.ov_off.saturating_add(1),
|
||||
Tab::Commits => {
|
||||
let max = st.history.len().saturating_sub(1);
|
||||
st.hist_off = (st.hist_off + 1).min(max);
|
||||
}
|
||||
Tab::Branches => {
|
||||
let max = st.branches.len().saturating_sub(1);
|
||||
st.branch_cursor = (st.branch_cursor + 1).min(max);
|
||||
}
|
||||
Tab::Files => {
|
||||
if st.tree_cursor + 1 < st.tree_visible.len() {
|
||||
st.tree_cursor += 1;
|
||||
st.run_action = Some(40);
|
||||
}
|
||||
}
|
||||
Tab::Stats => st.stats_off = st.stats_off.saturating_add(1),
|
||||
Tab::Settings => st.settings_cursor = (st.settings_cursor + 1).min(14),
|
||||
Tab::PRs => st.pr_cursor = (st.pr_cursor + 1).min(st.repo_prs.len().saturating_sub(1)),
|
||||
Tab::Issues => st.issue_cursor = (st.issue_cursor + 1).min(st.repo_issues.len().saturating_sub(1)),
|
||||
Tab::Todos => st.todo_cursor = (st.todo_cursor + 1).min(st.repo_todos.len().saturating_sub(1)),
|
||||
},
|
||||
KeyCode::Up | KeyCode::Char('k') => match st.tab {
|
||||
Tab::Overview => st.ov_off = st.ov_off.saturating_sub(1),
|
||||
Tab::Commits => st.hist_off = st.hist_off.saturating_sub(1),
|
||||
Tab::Branches => { st.branch_cursor = st.branch_cursor.saturating_sub(1); }
|
||||
Tab::Files => {
|
||||
if st.tree_cursor > 0 {
|
||||
st.tree_cursor -= 1;
|
||||
st.run_action = Some(40);
|
||||
}
|
||||
}
|
||||
Tab::Stats => st.stats_off = st.stats_off.saturating_sub(1),
|
||||
Tab::Settings => st.settings_cursor = st.settings_cursor.saturating_sub(1),
|
||||
Tab::PRs => st.pr_cursor = st.pr_cursor.saturating_sub(1),
|
||||
Tab::Issues => st.issue_cursor = st.issue_cursor.saturating_sub(1),
|
||||
Tab::Todos => st.todo_cursor = st.todo_cursor.saturating_sub(1),
|
||||
},
|
||||
KeyCode::PageDown => match st.tab {
|
||||
Tab::Commits => {
|
||||
let max = st.history.len().saturating_sub(1);
|
||||
st.hist_off = (st.hist_off + 10).min(max);
|
||||
}
|
||||
Tab::Files => {
|
||||
let max = st.tree_visible.len().saturating_sub(1);
|
||||
st.tree_cursor = (st.tree_cursor + 10).min(max);
|
||||
st.run_action = Some(40);
|
||||
}
|
||||
Tab::PRs => st.pr_cursor = (st.pr_cursor + 10).min(st.repo_prs.len().saturating_sub(1)),
|
||||
Tab::Issues => st.issue_cursor = (st.issue_cursor + 10).min(st.repo_issues.len().saturating_sub(1)),
|
||||
Tab::Todos => st.todo_cursor = (st.todo_cursor + 10).min(st.repo_todos.len().saturating_sub(1)),
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::PageUp => match st.tab {
|
||||
Tab::Commits => st.hist_off = st.hist_off.saturating_sub(10),
|
||||
Tab::Files => {
|
||||
st.tree_cursor = st.tree_cursor.saturating_sub(10);
|
||||
st.run_action = Some(40);
|
||||
}
|
||||
Tab::PRs => st.pr_cursor = st.pr_cursor.saturating_sub(10),
|
||||
Tab::Issues => st.issue_cursor = st.issue_cursor.saturating_sub(10),
|
||||
Tab::Todos => st.todo_cursor = st.todo_cursor.saturating_sub(10),
|
||||
_ => {}
|
||||
},
|
||||
|
||||
// Enter
|
||||
KeyCode::Enter => match st.tab {
|
||||
Tab::Files => {
|
||||
if let Some(&ni) = st.tree_visible.get(st.tree_cursor) {
|
||||
let node = &mut st.tree_nodes[ni];
|
||||
if node.is_dir {
|
||||
node.expanded = !node.expanded;
|
||||
let new_vis = rebuild_tree_visible(&st.tree_nodes);
|
||||
st.tree_visible = new_vis;
|
||||
st.tree_cursor = st.tree_cursor.min(st.tree_visible.len().saturating_sub(1));
|
||||
st.file_preview = vec![];
|
||||
}
|
||||
}
|
||||
}
|
||||
Tab::Branches => { st.run_action = Some(30); }
|
||||
Tab::Settings => open_settings_modal(st),
|
||||
Tab::PRs => {
|
||||
if let Some(pr) = st.repo_prs.get(st.pr_cursor) {
|
||||
let url = pr.url.clone();
|
||||
crate::util::open_path(&url);
|
||||
st.status_msg = Some("Opening PR in browser...".into());
|
||||
}
|
||||
}
|
||||
Tab::Issues => {
|
||||
if let Some(issue) = st.repo_issues.get(st.issue_cursor) {
|
||||
let url = issue.url.clone();
|
||||
crate::util::open_path(&url);
|
||||
st.status_msg = Some("Opening issue in browser...".into());
|
||||
}
|
||||
}
|
||||
Tab::Todos => {
|
||||
use crate::todos::TodoStatus;
|
||||
if let Some(t) = st.repo_todos.get_mut(st.todo_cursor) {
|
||||
t.status = match t.status {
|
||||
TodoStatus::Open => TodoStatus::InProgress,
|
||||
TodoStatus::InProgress => TodoStatus::Done,
|
||||
TodoStatus::Done => TodoStatus::Cancelled,
|
||||
TodoStatus::Cancelled => TodoStatus::Open,
|
||||
};
|
||||
t.touch();
|
||||
let all = crate::todos::load_manual();
|
||||
let updated_t = t.clone();
|
||||
let new_todos: Vec<_> = all.into_iter()
|
||||
.map(|x| if x.id == updated_t.id { updated_t.clone() } else { x })
|
||||
.collect();
|
||||
let _ = crate::todos::save(&new_todos);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
||||
// Branch-specific keys
|
||||
KeyCode::Char('D') if st.tab == Tab::Branches => {
|
||||
if let Some(b) = st.branches.get(st.branch_cursor) {
|
||||
if b.is_current {
|
||||
st.status_msg = Some("Cannot delete the current branch".into());
|
||||
} else {
|
||||
let name = b.name.clone();
|
||||
st.modal = DetailModal::ConfirmAction {
|
||||
message: format!("Delete branch '{}'?", name),
|
||||
tag: 31,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('P') if st.tab == Tab::Branches => { st.run_action = Some(32); }
|
||||
KeyCode::Char('n') if st.tab == Tab::Branches => {
|
||||
st.modal = DetailModal::NewBranch { input: String::new(), error: None };
|
||||
}
|
||||
|
||||
// Stats period change
|
||||
KeyCode::Char('p') if st.tab == Tab::Stats => {
|
||||
st.stats_period = st.stats_period.next();
|
||||
st.stats_off = 0;
|
||||
st.run_action = Some(41);
|
||||
}
|
||||
KeyCode::Char('<') if st.tab == Tab::Stats => {
|
||||
st.stats_period = st.stats_period.prev();
|
||||
st.stats_off = 0;
|
||||
st.run_action = Some(41);
|
||||
}
|
||||
KeyCode::Char('>') if st.tab == Tab::Stats => {
|
||||
st.stats_period = st.stats_period.next();
|
||||
st.stats_off = 0;
|
||||
st.run_action = Some(41);
|
||||
}
|
||||
|
||||
// Preview scroll (Files tab, right panel)
|
||||
KeyCode::Char(']') if st.tab == Tab::Files => {
|
||||
st.preview_off = st.preview_off.saturating_add(5);
|
||||
}
|
||||
KeyCode::Char('[') if st.tab == Tab::Files => {
|
||||
st.preview_off = st.preview_off.saturating_sub(5);
|
||||
}
|
||||
// Open selected file in editor (Files tab)
|
||||
KeyCode::Char('e') if st.tab == Tab::Files => {
|
||||
if let Some(&ni) = st.tree_visible.get(st.tree_cursor) {
|
||||
let node = &st.tree_nodes[ni];
|
||||
if !node.is_dir {
|
||||
st.pending_file = Some(st.summary.path.join(&node.rel_path));
|
||||
st.run_action = Some(110);
|
||||
st.status_msg = Some("Opening file...".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global actions
|
||||
KeyCode::Char('s') => {
|
||||
st.modal = DetailModal::SelectOption {
|
||||
title: "Sync".into(),
|
||||
options: vec!["Pull".into(), "Commit + Push".into(), "Push only".into()],
|
||||
cursor: 0,
|
||||
tag: 90,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
st.modal = DetailModal::EditText {
|
||||
title: "Release — Tag Name".into(),
|
||||
prompt: "Tag name (e.g. v1.0.0):".into(),
|
||||
input: String::new(),
|
||||
tag: 92,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('W') => {
|
||||
if let Ok(repo) = Repository::open(&st.summary.path) {
|
||||
if let Ok(remote) = repo.find_remote("origin") {
|
||||
if let Some(url) = remote.url() {
|
||||
let web = to_web_url(url);
|
||||
crate::util::open_path(&web);
|
||||
st.status_msg = Some("Opening in browser...".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('o') => { st.run_action = Some(106); }
|
||||
KeyCode::Char('y') => {
|
||||
let path = st.summary.path.to_string_lossy().to_string();
|
||||
if copy_to_clipboard(&path) { st.status_msg = Some("Path copied!".into()); }
|
||||
}
|
||||
KeyCode::Char('u') => {
|
||||
if let Some(ref url) = st.remote_url_str.clone() {
|
||||
if copy_to_clipboard(url) { st.status_msg = Some("URL copied!".into()); }
|
||||
}
|
||||
}
|
||||
KeyCode::Char('g') => {
|
||||
st.run_action = Some(107);
|
||||
st.status_msg = Some("Opening .gitignore...".into());
|
||||
}
|
||||
KeyCode::Char('S') => {
|
||||
let ok = git_run_ok(&st.summary.path, &["stash"]);
|
||||
st.status_msg = Some(if ok { "Stashed.".into() } else { "Stash failed.".into() });
|
||||
if ok { st.stash_count += 1; }
|
||||
}
|
||||
KeyCode::Char('A') => {
|
||||
let ok = git_run_ok(&st.summary.path, &["stash", "pop"]);
|
||||
st.status_msg = Some(if ok { "Stash applied.".into() } else { "No stash / error.".into() });
|
||||
if ok { st.stash_count = st.stash_count.saturating_sub(1); }
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
+39
-1313
File diff suppressed because it is too large.
Load diff
+32
-1179
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,150 @@
|
||||
//! Modal overlay rendering for the detail view: dispatches on `DetailModal`
|
||||
//! and draws each variant centered over the full view area, on top of
|
||||
//! whatever tab is currently rendered underneath.
|
||||
|
||||
use super::super::{DetailModal, St};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Draws the currently active modal, if any, centered over `area`.
|
||||
pub fn render_modal(f: &mut Frame, st: &St, area: Rect) {
|
||||
match &st.modal {
|
||||
DetailModal::None => {}
|
||||
|
||||
DetailModal::NewBranch { input, error } => {
|
||||
draw_text_modal_with_error(f, area, " New Branch ", input, "branch name", error.as_deref(), Color::Cyan);
|
||||
}
|
||||
|
||||
DetailModal::EditText { title, prompt, input, .. } => {
|
||||
let prompt_owned = prompt.clone();
|
||||
draw_text_modal(f, area, &format!(" {} ", title), input, &prompt_owned, Color::Cyan);
|
||||
}
|
||||
|
||||
DetailModal::SelectOption { title, options, cursor, .. } => {
|
||||
let h = (options.len() as u16 + 4).min(area.height.saturating_sub(4));
|
||||
let w = 44u16.min(area.width.saturating_sub(4));
|
||||
let x = area.x + (area.width.saturating_sub(w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(h)) / 2;
|
||||
let modal_area = Rect::new(x, y, w, h);
|
||||
f.render_widget(Clear, modal_area);
|
||||
let blk = Block::default().borders(Borders::ALL)
|
||||
.title(format!(" {} ", title))
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
let inner = blk.inner(modal_area);
|
||||
f.render_widget(blk, modal_area);
|
||||
|
||||
let mut lines: Vec<Line> = vec![Line::from("")];
|
||||
for (i, opt) in options.iter().enumerate() {
|
||||
let sel = i == *cursor;
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {} {}", if sel { "▶" } else { " " }, opt),
|
||||
Style::default()
|
||||
.fg(if sel { Color::Cyan } else { Color::White })
|
||||
.add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }),
|
||||
),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(" [↑↓] select [Enter] confirm [Esc] cancel", Style::default().fg(Color::DarkGray))));
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
DetailModal::SelectRole { collab_name, cursor } => {
|
||||
const ROLES: &[&str] = &["pull", "push", "maintain", "admin"];
|
||||
let h = (ROLES.len() as u16 + 5).min(area.height.saturating_sub(4));
|
||||
let w = 44u16.min(area.width.saturating_sub(4));
|
||||
let x = area.x + (area.width.saturating_sub(w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(h)) / 2;
|
||||
let modal_area = Rect::new(x, y, w, h);
|
||||
f.render_widget(Clear, modal_area);
|
||||
let blk = Block::default().borders(Borders::ALL)
|
||||
.title(format!(" Role for @{} ", collab_name))
|
||||
.border_style(Style::default().fg(Color::Cyan));
|
||||
let inner = blk.inner(modal_area);
|
||||
f.render_widget(blk, modal_area);
|
||||
|
||||
let mut lines: Vec<Line> = vec![Line::from("")];
|
||||
for (i, role) in ROLES.iter().enumerate() {
|
||||
let sel = i == *cursor;
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {} {}", if sel { "▶" } else { " " }, role),
|
||||
Style::default().fg(if sel { Color::Cyan } else { Color::White })
|
||||
.add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }),
|
||||
)));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(" [↑↓] select [Enter] invite [Esc] cancel", Style::default().fg(Color::DarkGray))));
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
DetailModal::ConfirmAction { message, .. } => {
|
||||
let w = 52u16.min(area.width.saturating_sub(4));
|
||||
let h = 7u16;
|
||||
let x = area.x + (area.width.saturating_sub(w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(h)) / 2;
|
||||
let modal_area = Rect::new(x, y, w, h);
|
||||
f.render_widget(Clear, modal_area);
|
||||
let blk = Block::default().borders(Borders::ALL)
|
||||
.title(" Confirm ")
|
||||
.border_style(Style::default().fg(Color::Red));
|
||||
let inner = blk.inner(modal_area);
|
||||
f.render_widget(blk, modal_area);
|
||||
|
||||
let msg_clone = message.clone();
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(format!(" {}", msg_clone), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" [y / Enter] confirm", Style::default().fg(Color::Red))),
|
||||
Line::from(Span::styled(" [n / Esc] cancel", Style::default().fg(Color::DarkGray))),
|
||||
]),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a single-line text-input modal with no error message.
|
||||
fn draw_text_modal(f: &mut Frame, area: Rect, title: &str, input: &str, hint: &str, border_col: Color) {
|
||||
draw_text_modal_with_error(f, area, title, input, hint, None, border_col);
|
||||
}
|
||||
|
||||
/// Draws a single-line text-input modal, growing by one line and switching
|
||||
/// to a red border when `error` is `Some` (used for the New Branch modal's
|
||||
/// inline validation message).
|
||||
fn draw_text_modal_with_error(f: &mut Frame, area: Rect, title: &str, input: &str, hint: &str, error: Option<&str>, border_col: Color) {
|
||||
let w = 60u16.min(area.width.saturating_sub(4));
|
||||
let h = if error.is_some() { 7u16 } else { 6u16 };
|
||||
let x = area.x + (area.width.saturating_sub(w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(h)) / 2;
|
||||
let modal_area = Rect::new(x, y, w, h);
|
||||
f.render_widget(Clear, modal_area);
|
||||
|
||||
let border_col = if error.is_some() { Color::Red } else { border_col };
|
||||
let blk = Block::default().borders(Borders::ALL)
|
||||
.title(title.to_string())
|
||||
.border_style(Style::default().fg(border_col));
|
||||
let inner = blk.inner(modal_area);
|
||||
f.render_widget(blk, modal_area);
|
||||
|
||||
let mut lines = vec![
|
||||
Line::from(Span::styled(format!(" {}", hint), Style::default().fg(Color::DarkGray))),
|
||||
Line::from(Span::styled(
|
||||
format!(" > {}█", input.chars().take((w as usize).saturating_sub(6)).collect::<String>()),
|
||||
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(" [Enter] confirm [Esc] cancel", Style::default().fg(Color::DarkGray))),
|
||||
];
|
||||
if let Some(err) = error {
|
||||
lines.insert(2, Line::from(Span::styled(format!(" ⚠ {}", err), Style::default().fg(Color::Red))));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Renderers for the detail view's three provider-backed tabs: PRs, Issues,
|
||||
//! and per-repo Todos. Unlike the git-native tabs, PRs/Issues show a
|
||||
//! "fetching"/"not loaded" placeholder until their background fetch
|
||||
//! (`settings_io::spawn_prs_issues_fetch`) completes.
|
||||
|
||||
use super::super::St;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
widgets::{Cell, Paragraph, Row, Table},
|
||||
Frame,
|
||||
};
|
||||
|
||||
// ── PRs tab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Draws the PRs table, or a placeholder while the fetch is in flight or
|
||||
/// hasn't been triggered yet.
|
||||
pub fn render_prs(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let header = Row::new(vec!["#", "TITLE", "AUTHOR", "BRANCH", "AGE", "DRAFT"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
if st.prs_issues_rx.is_some() {
|
||||
f.render_widget(Paragraph::new(" Fetching PRs...").style(dim), area);
|
||||
return;
|
||||
}
|
||||
if st.repo_prs.is_empty() {
|
||||
let msg = if st.prs_loaded { " No open PRs" } else { " PRs not loaded — switch to this tab to load" };
|
||||
f.render_widget(Paragraph::new(msg).style(dim), area);
|
||||
return;
|
||||
}
|
||||
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let pr_off = st.pr_cursor.saturating_sub(avail / 2);
|
||||
let rows: Vec<Row> = st.repo_prs.iter().enumerate()
|
||||
.skip(pr_off).take(avail)
|
||||
.map(|(i, pr)| {
|
||||
let is_cur = i + pr_off == st.pr_cursor;
|
||||
let age = crate::status::format_age_pub(pr.created_ts);
|
||||
let draft_str = if pr.draft { "draft" } else { "" };
|
||||
let branch_str = format!("{} → {}", pr.head_branch.chars().take(10).collect::<String>(), pr.base_branch.chars().take(8).collect::<String>());
|
||||
Row::new(vec![
|
||||
Cell::from(format!("#{}", pr.number)).style(Style::default().fg(Color::Cyan)),
|
||||
Cell::from(pr.title.chars().take(45).collect::<String>()),
|
||||
Cell::from(pr.author.chars().take(14).collect::<String>()).style(dim),
|
||||
Cell::from(branch_str).style(dim),
|
||||
Cell::from(age).style(dim),
|
||||
Cell::from(draft_str).style(Style::default().fg(Color::Yellow)),
|
||||
]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() })
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(7),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(14),
|
||||
Constraint::Length(22),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(6),
|
||||
]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Issues tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Draws the Issues table, or a placeholder while the fetch is in flight or
|
||||
/// hasn't been triggered yet.
|
||||
pub fn render_issues(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let header = Row::new(vec!["#", "TITLE", "AUTHOR", "LABELS", "AGE", "CMT"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
if st.prs_issues_rx.is_some() {
|
||||
f.render_widget(Paragraph::new(" Fetching issues...").style(dim), area);
|
||||
return;
|
||||
}
|
||||
if st.repo_issues.is_empty() {
|
||||
let msg = if st.prs_loaded { " No open issues" } else { " Issues not loaded — switch to this tab to load" };
|
||||
f.render_widget(Paragraph::new(msg).style(dim), area);
|
||||
return;
|
||||
}
|
||||
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let issue_off = st.issue_cursor.saturating_sub(avail / 2);
|
||||
let rows: Vec<Row> = st.repo_issues.iter().enumerate()
|
||||
.skip(issue_off).take(avail)
|
||||
.map(|(i, issue)| {
|
||||
let is_cur = i + issue_off == st.issue_cursor;
|
||||
let age = crate::status::format_age_pub(issue.created_ts);
|
||||
let labels = issue.labels.join(", ").chars().take(18).collect::<String>();
|
||||
Row::new(vec![
|
||||
Cell::from(format!("#{}", issue.number)).style(Style::default().fg(Color::Cyan)),
|
||||
Cell::from(issue.title.chars().take(45).collect::<String>()),
|
||||
Cell::from(issue.author.chars().take(14).collect::<String>()).style(dim),
|
||||
Cell::from(labels).style(Style::default().fg(Color::Yellow)),
|
||||
Cell::from(age).style(dim),
|
||||
Cell::from(issue.comments.to_string()).style(dim),
|
||||
]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() })
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(7),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(14),
|
||||
Constraint::Length(18),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(5),
|
||||
]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Todos tab (per-repo) ──────────────────────────────────────────────────────
|
||||
|
||||
/// Draws the repo's todo list, color-coded by status.
|
||||
pub fn render_repo_todos(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let header = Row::new(vec!["STATUS", "TITLE", "EST", "DIFF", "CREATED"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
if st.repo_todos.is_empty() {
|
||||
f.render_widget(Paragraph::new(" No todos for this repo").style(dim), area);
|
||||
return;
|
||||
}
|
||||
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let todo_off = st.todo_cursor.saturating_sub(avail / 2);
|
||||
let rows: Vec<Row> = st.repo_todos.iter().enumerate()
|
||||
.skip(todo_off).take(avail)
|
||||
.map(|(i, t)| {
|
||||
let is_cur = i + todo_off == st.todo_cursor;
|
||||
let est_str = t.estimate.as_deref().unwrap_or("—").chars().take(10).collect::<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 status_col = t.status.color();
|
||||
let status_str = format!("{} {}", t.status.icon(), t.status.label());
|
||||
let created = crate::status::format_age_pub(t.created_at);
|
||||
Row::new(vec![
|
||||
Cell::from(status_str).style(Style::default().fg(status_col)),
|
||||
Cell::from(t.title.chars().take(50).collect::<String>()),
|
||||
Cell::from(est_str).style(dim),
|
||||
Cell::from(diff_str).style(Style::default().fg(Color::Yellow)),
|
||||
Cell::from(created).style(dim),
|
||||
]).style(if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() })
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(14),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(7),
|
||||
Constraint::Length(10),
|
||||
]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Renderers for the detail view's right-hand panel (27% width column).
|
||||
//! Most tabs show the default info+actions panel; Files/Branches/Stats
|
||||
//! replace it with a tab-specific panel showing the selected item's detail.
|
||||
|
||||
use super::super::{St, Tab};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Picks which right-panel variant to draw based on the active tab.
|
||||
pub fn render_right(f: &mut Frame, st: &St, area: Rect) {
|
||||
match st.tab {
|
||||
Tab::Files => render_right_files(f, st, area),
|
||||
Tab::Branches => render_right_branch(f, st, area),
|
||||
Tab::Stats => render_right_stats(f, st, area),
|
||||
_ => render_right_default(f, st, area),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default right panel: repo info on top, a fixed list of global actions on
|
||||
/// the bottom — shown for every tab without a more specific panel.
|
||||
fn render_right_default(f: &mut Frame, st: &St, area: Rect) {
|
||||
let blk = Block::default().borders(Borders::ALL);
|
||||
let inner = blk.inner(area);
|
||||
f.render_widget(blk, area);
|
||||
|
||||
let actions_h = 16u16;
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Fill(1), Constraint::Length(actions_h)])
|
||||
.split(inner);
|
||||
|
||||
render_info_block(f, st, layout[0]);
|
||||
render_actions_block(f, layout[1]);
|
||||
}
|
||||
|
||||
/// Files-tab right panel: a preview of the selected file's first 200 lines
|
||||
/// (or a directory hint), loaded on demand by `tui_phase_inner`'s `run_action`
|
||||
/// 40 handling rather than eagerly for every node.
|
||||
fn render_right_files(f: &mut Frame, st: &St, area: Rect) {
|
||||
let blk = Block::default().borders(Borders::ALL);
|
||||
let inner = blk.inner(area);
|
||||
f.render_widget(blk, area);
|
||||
|
||||
let selected_node = st.tree_visible.get(st.tree_cursor).map(|&ni| &st.tree_nodes[ni]);
|
||||
|
||||
let title = if let Some(node) = selected_node {
|
||||
if node.is_dir {
|
||||
format!(" {} {} ", if node.expanded { "▼" } else { "▶" }, node.name)
|
||||
} else {
|
||||
format!(" {} ", node.name)
|
||||
}
|
||||
} else { " Preview ".to_string() };
|
||||
|
||||
let title_blk = Block::default().borders(Borders::BOTTOM).title(title.as_str())
|
||||
.border_style(Style::default().fg(Color::DarkGray));
|
||||
let title_h = 2u16;
|
||||
let title_area = Rect::new(inner.x, inner.y, inner.width, title_h);
|
||||
let preview_area = Rect::new(inner.x, inner.y + title_h, inner.width, inner.height.saturating_sub(title_h));
|
||||
f.render_widget(title_blk, title_area);
|
||||
|
||||
if let Some(node) = selected_node {
|
||||
if node.is_dir {
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(Span::styled("(directory)", Style::default().fg(Color::DarkGray))),
|
||||
Line::from(Span::styled("[Enter] expand/collapse", Style::default().fg(Color::DarkGray))),
|
||||
Line::from(Span::styled("[e] open in editor", Style::default().fg(Color::DarkGray))),
|
||||
]),
|
||||
preview_area,
|
||||
);
|
||||
} else if st.file_preview.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new("(navigate to load)").style(Style::default().fg(Color::DarkGray)),
|
||||
preview_area,
|
||||
);
|
||||
} else {
|
||||
let avail = preview_area.height as usize;
|
||||
let skip = st.preview_off.min(st.file_preview.len().saturating_sub(1));
|
||||
let items: Vec<ListItem> = st.file_preview.iter()
|
||||
.skip(skip).take(avail).enumerate()
|
||||
.map(|(i, line)| {
|
||||
let ln = skip + i + 1;
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(format!("{:>4} ", ln), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
line.chars().take(inner.width.saturating_sub(5) as usize).collect::<String>(),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
f.render_widget(List::new(items), preview_area);
|
||||
}
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new("(no file selected)").style(Style::default().fg(Color::DarkGray)),
|
||||
preview_area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Branches-tab right panel: detail (upstream, ahead/behind, last commit)
|
||||
/// for the cursor-selected branch, plus the branch action shortcuts.
|
||||
fn render_right_branch(f: &mut Frame, st: &St, area: Rect) {
|
||||
let blk = Block::default().borders(Borders::ALL);
|
||||
let inner = blk.inner(area);
|
||||
f.render_widget(blk, area);
|
||||
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let kv = |k: &str, v: &str, col: Color| -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:<11}", k), dim),
|
||||
Span::styled(v.to_string(), Style::default().fg(col)),
|
||||
])
|
||||
};
|
||||
let mut lines: Vec<Line> = vec![];
|
||||
|
||||
if let Some(b) = st.branches.get(st.branch_cursor) {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", b.name),
|
||||
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(super::dsep(inner.width));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(kv("Status", if b.is_current { "✓ current" } else { "○ other" }, if b.is_current { Color::Cyan } else { Color::DarkGray }));
|
||||
lines.push(kv("Upstream", b.upstream.as_deref().unwrap_or("(no tracking)"), Color::DarkGray));
|
||||
if b.upstream.is_some() {
|
||||
lines.push(kv("Ahead", &b.ahead.to_string(), if b.ahead > 0 { Color::Green } else { Color::DarkGray }));
|
||||
lines.push(kv("Behind", &b.behind.to_string(), if b.behind > 0 { Color::Red } else { Color::DarkGray }));
|
||||
}
|
||||
lines.push(kv("Age", &b.last_commit_age, Color::DarkGray));
|
||||
lines.push(Line::from(""));
|
||||
let msg: String = b.last_commit_msg.chars().take(inner.width.saturating_sub(3) as usize).collect();
|
||||
lines.push(kv("Last", &msg, Color::DarkGray));
|
||||
} else {
|
||||
lines.push(Line::from(Span::styled(" No branch selected", dim)));
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(super::dsep(inner.width));
|
||||
lines.push(Line::from(Span::styled(" Actions", dim)));
|
||||
lines.push(Line::from(""));
|
||||
for (k, lbl) in [("Enter", "Checkout"), ("n", "New branch (modal)"), ("P", "Push"), ("D", "Delete")] {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" [{}]", k), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(format!(" {}", lbl), Style::default().fg(Color::White)),
|
||||
]));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
/// Stats-tab right panel: top contributors and a code-size summary,
|
||||
/// complementing the activity chart drawn in the left pane.
|
||||
fn render_right_stats(f: &mut Frame, st: &St, area: Rect) {
|
||||
let blk = Block::default().borders(Borders::ALL);
|
||||
let inner = blk.inner(area);
|
||||
f.render_widget(blk, area);
|
||||
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let kv = |k: &str, v: &str, col: Color| -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:<13}", k), dim),
|
||||
Span::styled(v.to_string(), Style::default().fg(col)),
|
||||
])
|
||||
};
|
||||
|
||||
let loc_str = if st.loc_total > 1000 {
|
||||
format!("{}k lines", st.loc_total / 1000)
|
||||
} else {
|
||||
format!("{} lines", st.loc_total)
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(Span::styled(" Top Contributors", yel)),
|
||||
super::dsep(inner.width),
|
||||
Line::from(""),
|
||||
];
|
||||
for (i, c) in st.contributors.iter().take(8).enumerate() {
|
||||
let name: String = c.name.chars().take((inner.width.saturating_sub(8)) as usize).collect();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:>2}. ", i + 1), dim),
|
||||
Span::styled(name, Style::default().fg(Color::White)),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ", dim),
|
||||
Span::styled(format!("{} commits", c.count), dim),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(" Code Stats", yel)));
|
||||
lines.push(super::dsep(inner.width));
|
||||
lines.push(kv("LOC", &loc_str, Color::Cyan));
|
||||
lines.push(kv("Commits", &st.total_commits.to_string(), Color::White));
|
||||
lines.push(kv("Tags", &st.total_tags.to_string(), Color::Yellow));
|
||||
if let Some(lang) = &st.summary.lang {
|
||||
lines.push(kv("Languages", lang, Color::DarkGray));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
// ── info + actions (shared default right panel) ────────────────────────────
|
||||
|
||||
/// Draws the repo metadata block (sync status, owner, visibility, license,
|
||||
/// language, stars/issues/PRs when known, and local counters) used by the
|
||||
/// default right panel.
|
||||
fn render_info_block(f: &mut Frame, st: &St, area: Rect) {
|
||||
let r = &st.summary;
|
||||
let w = area.width.saturating_sub(2) as usize;
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let kv = |k: &str, v: &str, col: Color| -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:<10}", k), dim),
|
||||
Span::styled(v.chars().take(w.saturating_sub(11)).collect::<String>(), Style::default().fg(col)),
|
||||
])
|
||||
};
|
||||
let sync_txt = match (r.ahead, r.behind) {
|
||||
(0, 0) => "in sync".to_string(),
|
||||
(a, 0) => format!("↑{} ahead", a),
|
||||
(0, b) => format!("↓{} behind", b),
|
||||
(a, b) => format!("↑{} ↓{}", a, b),
|
||||
};
|
||||
|
||||
let mut info: Vec<Line> = vec![
|
||||
Line::from(Span::styled(
|
||||
format!(" {}", r.name.chars().take(w).collect::<String>()),
|
||||
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
super::dsep(area.width.saturating_add(2)),
|
||||
Line::from(""),
|
||||
kv("Remote", &sync_txt, if r.behind > 0 { Color::Red } else if r.ahead > 0 { Color::Cyan } else { Color::DarkGray }),
|
||||
kv("Dirty", if r.is_dirty { "⚠ changes" } else { "✓ clean" }, if r.is_dirty { Color::Yellow } else { Color::DarkGray }),
|
||||
Line::from(""),
|
||||
kv("Owner", r.owner.as_deref().unwrap_or("—"), Color::White),
|
||||
kv("Vis.", r.visibility.as_deref().unwrap_or("—"), match r.visibility.as_deref() { Some("public") => Color::Green, Some("private") => Color::Yellow, _ => Color::DarkGray }),
|
||||
kv("License", if r.has_license { "✓" } else { "⚠ missing" }, if r.has_license { Color::DarkGray } else { Color::Yellow }),
|
||||
];
|
||||
if let Some(lang) = &r.lang { info.push(kv("Lang", lang, Color::DarkGray)); }
|
||||
if let Some(s) = r.stars { info.push(kv("Stars", &format!("★ {}", s), Color::Yellow)); }
|
||||
if let Some(i) = r.open_issues { info.push(kv("Issues", &i.to_string(), if i > 0 { Color::Red } else { Color::DarkGray })); }
|
||||
if let Some(p) = r.open_prs { info.push(kv("PRs", &p.to_string(), if p > 0 { Color::Cyan } else { Color::DarkGray })); }
|
||||
info.push(Line::from(""));
|
||||
info.push(kv("Commits", &st.total_commits.to_string(), Color::White));
|
||||
info.push(kv("Files", &st.file_count.to_string(), Color::DarkGray));
|
||||
info.push(kv("Branches", &st.branch_count.to_string(), Color::DarkGray));
|
||||
info.push(kv("Tags", &st.total_tags.to_string(), Color::Yellow));
|
||||
info.push(kv("Stashes", &st.stash_count.to_string(), Color::DarkGray));
|
||||
if let Some(tag) = &r.last_tag { info.push(kv("Last tag", tag, Color::Cyan)); }
|
||||
|
||||
f.render_widget(Paragraph::new(info), area);
|
||||
}
|
||||
|
||||
/// Draws the fixed list of global action shortcuts used by the default
|
||||
/// right panel (sync, release, open-in-editor/browser, clipboard, stash).
|
||||
fn render_actions_block(f: &mut Frame, area: Rect) {
|
||||
let act_blk = Block::default().borders(Borders::TOP).title(" Actions ").border_style(Style::default().fg(Color::DarkGray));
|
||||
let act_inner = act_blk.inner(area);
|
||||
f.render_widget(act_blk, area);
|
||||
|
||||
let act = |k: &'static str, label: &'static str, col: Color| -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" [{}]", k), Style::default().fg(col).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(format!(" {}", label), Style::default().fg(Color::White)),
|
||||
])
|
||||
};
|
||||
let div = || Line::from(Span::styled(" ─────────────────", Style::default().fg(Color::DarkGray)));
|
||||
|
||||
f.render_widget(Paragraph::new(vec![
|
||||
act("s", "Sync", Color::Green),
|
||||
act("r", "Release tag", Color::Green),
|
||||
div(),
|
||||
act("o", "Open in editor", Color::Cyan),
|
||||
act("W", "Open browser", Color::Cyan),
|
||||
act("y", "Copy path", Color::Cyan),
|
||||
act("u", "Copy URL", Color::Cyan),
|
||||
act("g", ".gitignore", Color::Cyan),
|
||||
div(),
|
||||
act("S", "Stash", Color::White),
|
||||
act("A", "Pop stash", Color::White),
|
||||
div(),
|
||||
act("q", "← Back", Color::DarkGray),
|
||||
]), act_inner);
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
//! Renderers for the detail view's git-native tabs (Overview, Commits,
|
||||
//! Branches, Files, Stats, Settings) — everything drawn into the left
|
||||
//! 73%-width pane that doesn't depend on a provider API response.
|
||||
|
||||
use super::super::{HistEntry, St, StatsPeriod};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Cell, List, ListItem, Paragraph, Row, Table},
|
||||
Frame,
|
||||
};
|
||||
|
||||
// ── 1. Overview tab ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Draws a one-screen summary: location/links, repo-level stats, recent
|
||||
/// releases, recent commits, top contributors, and active branches — the
|
||||
/// "at a glance" tab shown by default when opening a repo.
|
||||
pub fn render_overview(f: &mut Frame, st: &St, area: Rect) {
|
||||
let r = &st.summary;
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let wht = Style::default().fg(Color::White);
|
||||
let cyn = Style::default().fg(Color::Cyan);
|
||||
let yel = Style::default().fg(Color::Yellow);
|
||||
let w = area.width.saturating_sub(2) as usize;
|
||||
|
||||
let kv = |k: &str, v: &str, col: Color| -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:<16}", k), dim),
|
||||
Span::styled(v.chars().take(w.saturating_sub(18)).collect::<String>(), Style::default().fg(col)),
|
||||
])
|
||||
};
|
||||
let sep = || super::dsep(area.width);
|
||||
|
||||
let mut lines: Vec<Line> = vec![];
|
||||
|
||||
// ── location & links ──
|
||||
lines.push(Line::from(Span::styled(" Location & Links", yel.add_modifier(Modifier::BOLD))));
|
||||
lines.push(sep());
|
||||
lines.push(kv("Path", &st.summary.path.to_string_lossy(), Color::DarkGray));
|
||||
if let Some(ref url) = st.remote_url_str {
|
||||
lines.push(kv("GitHub URL", url, Color::Cyan));
|
||||
} else {
|
||||
lines.push(kv("GitHub URL", "(no remote)", Color::DarkGray));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// ── repository stats ──
|
||||
lines.push(Line::from(Span::styled(" Repository", yel.add_modifier(Modifier::BOLD))));
|
||||
lines.push(sep());
|
||||
|
||||
let dirty_txt = if r.is_dirty { format!("⚠ {} changed", st.files.len()) } else { "✓ clean".into() };
|
||||
let sync_txt = match (r.ahead, r.behind) {
|
||||
(0, 0) => "in sync".to_string(),
|
||||
(a, 0) => format!("↑{} ahead", a),
|
||||
(0, b) => format!("↓{} behind", b),
|
||||
(a, b) => format!("↑{} ↓{}", a, b),
|
||||
};
|
||||
let age_txt = if st.repo_age_days > 365 {
|
||||
format!("{} years old", st.repo_age_days / 365)
|
||||
} else if st.repo_age_days > 30 {
|
||||
format!("{} months old", st.repo_age_days / 30)
|
||||
} else {
|
||||
format!("{} days old", st.repo_age_days)
|
||||
};
|
||||
|
||||
lines.push(kv("Total commits", &st.total_commits.to_string(), Color::White));
|
||||
lines.push(kv("Total releases", &st.total_tags.to_string(), Color::Yellow));
|
||||
lines.push(kv("Branches", &st.branch_count.to_string(), Color::White));
|
||||
lines.push(kv("Tracked files", &st.file_count.to_string(), Color::DarkGray));
|
||||
lines.push(kv("Working tree", &dirty_txt, if r.is_dirty { Color::Yellow } else { Color::DarkGray }));
|
||||
lines.push(kv("Remote", &sync_txt, if r.behind > 0 { Color::Red } else if r.ahead > 0 { Color::Cyan } else { Color::DarkGray }));
|
||||
lines.push(kv("Age", &age_txt, Color::DarkGray));
|
||||
if let Some(lang) = &r.lang {
|
||||
lines.push(kv("Languages", lang, Color::DarkGray));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// ── latest version + recent releases ──
|
||||
let tags: Vec<_> = st.history.iter().filter_map(|h| {
|
||||
if let HistEntry::Tag { name, age, .. } = h { Some((name.clone(), age.clone())) } else { None }
|
||||
}).collect();
|
||||
|
||||
if !tags.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" Releases", yel.add_modifier(Modifier::BOLD))));
|
||||
lines.push(sep());
|
||||
for (name, age) in tags.iter().take(3) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:<11} ", age), dim),
|
||||
Span::styled(name.clone(), cyn),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// ── recent commits ──
|
||||
if !st.ov_recent.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" Recent Commits", yel.add_modifier(Modifier::BOLD))));
|
||||
lines.push(sep());
|
||||
let skip = st.ov_off.min(st.ov_recent.len().saturating_sub(1));
|
||||
for (age, msg) in st.ov_recent.iter().skip(skip).take(5) {
|
||||
let msg_t: String = msg.chars().take(w.saturating_sub(14)).collect();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:<11} ", age), dim),
|
||||
Span::styled(msg_t, wht),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// ── top contributors ──
|
||||
if !st.contributors.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" Top Contributors", yel.add_modifier(Modifier::BOLD))));
|
||||
lines.push(sep());
|
||||
for c in st.contributors.iter().take(3) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:<25}", c.name.chars().take(25).collect::<String>()), wht),
|
||||
Span::styled(format!("{} commits", c.count), dim),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// ── active branches ──
|
||||
if !st.branches.is_empty() {
|
||||
lines.push(Line::from(Span::styled(" Active Branches", yel.add_modifier(Modifier::BOLD))));
|
||||
lines.push(sep());
|
||||
for b in st.branches.iter().take(4) {
|
||||
let marker = if b.is_current { "▶ " } else { " " };
|
||||
let sync = match (b.ahead, b.behind) {
|
||||
(0, 0) => String::new(),
|
||||
(a, 0) => format!(" ↑{}", a),
|
||||
(0, b) => format!(" ↓{}", b),
|
||||
(a, b) => format!(" ↑{}↓{}", a, b),
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {}", marker), if b.is_current { cyn } else { dim }),
|
||||
Span::styled(b.name.chars().take(25).collect::<String>(), if b.is_current { cyn } else { wht }),
|
||||
Span::styled(sync, dim),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
// ── 2. Commits tab ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Draws the merged commit/tag timeline. Local-only commits (not yet
|
||||
/// pushed) are marked with `↑` and listed first, so the "ahead" count from
|
||||
/// the topbar has a visible explanation here.
|
||||
pub fn render_commits(f: &mut Frame, st: &St, area: Rect) {
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let ahead = st.summary.ahead; // first N commits are local-only
|
||||
let header = Row::new(vec!["", "SHA", "AGE", "AUTHOR", "MESSAGE"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let mut commit_idx = 0usize; // count Commit entries seen so far (for ahead marker)
|
||||
let rows: Vec<Row> = st.history.iter()
|
||||
.skip(st.hist_off)
|
||||
.take(avail)
|
||||
.map(|entry| {
|
||||
match entry {
|
||||
HistEntry::Tag { name, age, msg, .. } => Row::new(vec![
|
||||
Cell::from("T").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
Cell::from(name.clone()).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
Cell::from(age.clone()).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(""),
|
||||
Cell::from(if msg.is_empty() { "(lightweight tag)".into() } else { msg.clone() })
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
HistEntry::Commit { sha, age, author, msg, .. } => {
|
||||
let is_local = commit_idx < ahead;
|
||||
commit_idx += 1;
|
||||
let marker = if is_local { "↑" } else { "●" };
|
||||
let marker_col = if is_local { Color::Cyan } else { Color::DarkGray };
|
||||
let msg_col = if is_local { Color::White } else { Color::Reset };
|
||||
Row::new(vec![
|
||||
Cell::from(marker).style(Style::default().fg(marker_col)),
|
||||
Cell::from(sha.clone()).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(age.clone()).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(format!("@{}", author)).style(Style::default().fg(Color::Cyan)),
|
||||
Cell::from(msg.clone()).style(Style::default().fg(msg_col)),
|
||||
])
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Legend for local-only indicator
|
||||
let legend = if ahead > 0 {
|
||||
Rect::new(area.x, area.y + area.height.saturating_sub(1), area.width, 1)
|
||||
} else {
|
||||
Rect::new(0, 0, 0, 0)
|
||||
};
|
||||
let table_area = if ahead > 0 {
|
||||
Rect::new(area.x, area.y, area.width, area.height.saturating_sub(1))
|
||||
} else {
|
||||
area
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(14),
|
||||
Constraint::Fill(1),
|
||||
]).header(header),
|
||||
table_area,
|
||||
);
|
||||
|
||||
if ahead > 0 {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(" ↑ ", Style::default().fg(Color::Cyan)),
|
||||
Span::styled(format!("{} local-only commit(s) not yet pushed", ahead), Style::default().fg(Color::DarkGray)),
|
||||
])),
|
||||
legend,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Branches tab ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Draws the branch table, scrolled to keep `branch_cursor` in view.
|
||||
pub fn render_branches(f: &mut Frame, st: &St, area: Rect) {
|
||||
if st.branches.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(" No branches found").style(Style::default().fg(Color::DarkGray)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let avail = area.height.saturating_sub(1) as usize;
|
||||
let header = Row::new(vec!["", "BRANCH", "UPSTREAM", "+", "-", "AGE", "LAST COMMIT"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let scroll = st.branch_cursor.saturating_sub(avail.saturating_sub(1));
|
||||
|
||||
let rows: Vec<Row> = st.branches.iter()
|
||||
.enumerate()
|
||||
.skip(scroll)
|
||||
.take(avail)
|
||||
.map(|(i, b)| {
|
||||
let is_sel = i + scroll == st.branch_cursor;
|
||||
let sel_bg = if is_sel { Color::DarkGray } else { Color::Reset };
|
||||
let name_col= if b.is_current { Color::Cyan } else { Color::White };
|
||||
let cur_mark= if b.is_current { "▶" } else { " " };
|
||||
let ahead_col = if b.ahead > 0 { Color::Green } else { Color::DarkGray };
|
||||
let behind_col= if b.behind > 0 { Color::Red } else { Color::DarkGray };
|
||||
|
||||
let msg: String = b.last_commit_msg.chars()
|
||||
.take(area.width.saturating_sub(70).max(10) as usize).collect();
|
||||
|
||||
Row::new(vec![
|
||||
Cell::from(cur_mark).style(Style::default().fg(Color::Cyan).bg(sel_bg)),
|
||||
Cell::from(b.name.clone()).style(Style::default().fg(name_col).bg(sel_bg).add_modifier(if b.is_current { Modifier::BOLD } else { Modifier::empty() })),
|
||||
Cell::from(b.upstream.as_deref().unwrap_or("—")).style(Style::default().fg(Color::DarkGray).bg(sel_bg)),
|
||||
Cell::from(if b.ahead > 0 { b.ahead.to_string() } else { "—".into() }).style(Style::default().fg(ahead_col).bg(sel_bg)),
|
||||
Cell::from(if b.behind > 0 { b.behind.to_string() } else { "—".into() }).style(Style::default().fg(behind_col).bg(sel_bg)),
|
||||
Cell::from(b.last_commit_age.clone()).style(Style::default().fg(Color::DarkGray).bg(sel_bg)),
|
||||
Cell::from(msg).style(Style::default().fg(Color::DarkGray).bg(sel_bg)),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(24),
|
||||
Constraint::Length(18),
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(10),
|
||||
Constraint::Fill(1),
|
||||
]).header(header),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. Files tab (tree) ───────────────────────────────────────────────────────
|
||||
|
||||
/// Draws the flattened file tree, with a one-character dirty-status marker
|
||||
/// (`M`/`D`/`?`) next to any file that also appears in `st.files`.
|
||||
pub fn render_file_tree(f: &mut Frame, st: &St, area: Rect) {
|
||||
if st.tree_visible.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(" No tracked files").style(Style::default().fg(Color::DarkGray)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let avail = area.height as usize;
|
||||
let scroll = st.tree_cursor.saturating_sub(avail.saturating_sub(1));
|
||||
|
||||
let dirty: std::collections::HashSet<&str> = st.files.iter().map(|(_, p)| p.as_str()).collect();
|
||||
|
||||
let items: Vec<ListItem> = st.tree_visible.iter()
|
||||
.skip(scroll)
|
||||
.take(avail)
|
||||
.enumerate()
|
||||
.map(|(row_i, &ni)| {
|
||||
let node = &st.tree_nodes[ni];
|
||||
let is_sel = (scroll + row_i) == st.tree_cursor;
|
||||
let indent: String = " ".repeat(node.depth);
|
||||
let sel_bg = if is_sel { Color::DarkGray } else { Color::Reset };
|
||||
|
||||
let (prefix, name_col) = if node.is_dir {
|
||||
let arrow = if node.expanded { "▼ " } else { "▶ " };
|
||||
(format!("{}{}", indent, arrow), Color::Yellow)
|
||||
} else {
|
||||
(format!("{}· ", indent), Color::White)
|
||||
};
|
||||
|
||||
let name_str = if node.is_dir { format!("{}/", node.name) } else { node.name.clone() };
|
||||
|
||||
let dirty_mark = if !node.is_dir && dirty.contains(node.rel_path.as_str()) {
|
||||
let status = st.files.iter().find(|(_, p)| p == &node.rel_path).map(|(c, _)| *c);
|
||||
match status {
|
||||
Some('M') => Span::styled(" M", Style::default().fg(Color::Yellow)),
|
||||
Some('D') => Span::styled(" D", Style::default().fg(Color::Red)),
|
||||
_ => Span::styled(" ?", Style::default().fg(Color::Green)),
|
||||
}
|
||||
} else { Span::raw("") };
|
||||
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(prefix, Style::default().fg(Color::DarkGray).bg(sel_bg)),
|
||||
Span::styled(name_str, Style::default().fg(name_col).bg(sel_bg)),
|
||||
dirty_mark,
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(List::new(items), area);
|
||||
}
|
||||
|
||||
// ── 5. Stats tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Draws the period selector, activity bar chart, and a key-metrics summary
|
||||
/// (avg commits/month, LOC, largest files) for the selected `stats_period`.
|
||||
pub fn render_stats(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
|
||||
let all_periods = [
|
||||
StatsPeriod::Day1, StatsPeriod::Day7, StatsPeriod::Day30,
|
||||
StatsPeriod::Day90, StatsPeriod::Year1, StatsPeriod::AllTime,
|
||||
];
|
||||
|
||||
// ── Period selector row ──
|
||||
let mut period_spans: Vec<Span> = vec![Span::styled(" Period: ", dim)];
|
||||
for p in all_periods {
|
||||
if p == st.stats_period {
|
||||
period_spans.push(Span::styled(
|
||||
format!("[{}]", p.label()),
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
} else {
|
||||
period_spans.push(Span::styled(format!(" {} ", p.label()), dim));
|
||||
}
|
||||
period_spans.push(Span::raw(" "));
|
||||
}
|
||||
period_spans.push(Span::styled("[p] cycle [</>] prev/next", dim));
|
||||
|
||||
let period_h = 2u16;
|
||||
let period_area = Rect::new(area.x, area.y, area.width, period_h);
|
||||
|
||||
// ── Chart area ──
|
||||
let chart_h = (area.height.saturating_sub(period_h + 2)).clamp(5, 20);
|
||||
let chart_area = Rect::new(area.x, area.y + period_h, area.width, chart_h);
|
||||
|
||||
// ── Bottom metrics ──
|
||||
let metrics_y = area.y + period_h + chart_h + 1;
|
||||
let metrics_h = area.height.saturating_sub(period_h + chart_h + 2);
|
||||
let metrics_area = if metrics_h > 0 {
|
||||
Rect::new(area.x, metrics_y, area.width, metrics_h)
|
||||
} else {
|
||||
Rect::new(0, 0, 0, 0)
|
||||
};
|
||||
|
||||
// Render period selector
|
||||
f.render_widget(Paragraph::new(vec![
|
||||
Line::from(period_spans),
|
||||
super::dsep(area.width),
|
||||
]), period_area);
|
||||
|
||||
// Render vertical bar chart
|
||||
render_bar_chart(f, &st.activity_data, chart_area);
|
||||
|
||||
// Render bottom metrics
|
||||
if metrics_h > 0 {
|
||||
let kv = |k: &str, v: &str, col: Color| -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:<18}", k), dim),
|
||||
Span::styled(v.to_string(), Style::default().fg(col)),
|
||||
])
|
||||
};
|
||||
let avg_per_month = if st.repo_age_days > 0 {
|
||||
st.total_commits * 30 / (st.repo_age_days as usize).max(1)
|
||||
} else { 0 };
|
||||
let loc_str = if st.loc_total > 1000 {
|
||||
format!("{}k", st.loc_total / 1000)
|
||||
} else {
|
||||
st.loc_total.to_string()
|
||||
};
|
||||
|
||||
let mut mlines: Vec<Line> = vec![
|
||||
Line::from(Span::styled(" Key Metrics", yel)),
|
||||
super::dsep(area.width),
|
||||
kv("Total commits", &st.total_commits.to_string(), Color::White),
|
||||
kv("Total releases", &st.total_tags.to_string(), Color::Yellow),
|
||||
kv("Contributors", &st.contributors.len().to_string(), Color::White),
|
||||
kv("Lines of code", &loc_str, Color::Cyan),
|
||||
kv("Avg/month", &format!("{} commits", avg_per_month), Color::DarkGray),
|
||||
];
|
||||
if !st.top_files.is_empty() {
|
||||
mlines.push(Line::from(""));
|
||||
mlines.push(Line::from(Span::styled(" Largest Files (lines)", yel)));
|
||||
mlines.push(super::dsep(area.width));
|
||||
for (name, lines) in st.top_files.iter().take(5) {
|
||||
let short: String = name.chars().rev().take(30).collect::<String>().chars().rev().collect();
|
||||
mlines.push(Line::from(vec![
|
||||
Span::styled(format!(" {:<32}", short), dim),
|
||||
Span::styled(format!("{:>6}", lines), Style::default().fg(Color::Cyan)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(mlines), metrics_area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a vertical bar chart of `data` (label, count) pairs, sized to fit
|
||||
/// `area` exactly. Bar width and label sampling both scale down gracefully
|
||||
/// as the chart gets narrower, rather than overflowing or panicking.
|
||||
fn render_bar_chart(f: &mut Frame, data: &[(String, usize)], area: Rect) {
|
||||
if data.is_empty() || area.height < 3 || area.width < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
let max_v = data.iter().map(|d| d.1).max().unwrap_or(1).max(1);
|
||||
let axis_w = 6usize; // " 123 │"
|
||||
let avail_w = area.width.saturating_sub(axis_w as u16) as usize;
|
||||
let n_cols = data.len();
|
||||
// Each column: bar_chars + 1 space. bar_chars = col_w chars.
|
||||
let col_w = ((avail_w).saturating_sub(n_cols)) / n_cols.max(1);
|
||||
let col_w = col_w.clamp(1, 4);
|
||||
let chart_h = area.height.saturating_sub(2) as usize; // -1 for axis, -1 for labels
|
||||
|
||||
let mut lines: Vec<Line> = vec![];
|
||||
|
||||
// Build chart rows from top to bottom
|
||||
for row in 0..chart_h {
|
||||
// row 0 = top (high values), row chart_h-1 = bottom (0)
|
||||
let mut spans: Vec<Span<'static>> = vec![];
|
||||
|
||||
// Y-axis label
|
||||
let y_val = max_v * (chart_h - row) / chart_h;
|
||||
let y_lbl = if row == 0 {
|
||||
format!("{:>4} │", max_v)
|
||||
} else if row == chart_h / 2 {
|
||||
format!("{:>4} │", y_val)
|
||||
} else if row == chart_h - 1 {
|
||||
" 0 │".to_string()
|
||||
} else {
|
||||
" │".to_string()
|
||||
};
|
||||
spans.push(Span::styled(y_lbl, Style::default().fg(Color::DarkGray)));
|
||||
|
||||
for d in data.iter() {
|
||||
// bar_h = how many rows this column fills from bottom
|
||||
let bar_h = if max_v == 0 { 0 } else { (d.1 * chart_h / max_v).min(chart_h) };
|
||||
// Current row is filled if (row from bottom) < bar_h
|
||||
// row from bottom = chart_h - 1 - row
|
||||
let row_from_bottom = chart_h.saturating_sub(1).saturating_sub(row);
|
||||
let filled = row_from_bottom < bar_h;
|
||||
let bar_str = if filled {
|
||||
"█".repeat(col_w)
|
||||
} else {
|
||||
" ".repeat(col_w)
|
||||
};
|
||||
let bar_col = if filled { Color::Cyan } else { Color::Reset };
|
||||
spans.push(Span::styled(bar_str + " ", Style::default().fg(bar_col)));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
// X-axis
|
||||
let x_axis = format!(" └{}", "─".repeat((col_w + 1) * n_cols + 1));
|
||||
lines.push(Line::from(Span::styled(x_axis, Style::default().fg(Color::DarkGray))));
|
||||
|
||||
// Labels (show every Nth to avoid crowding)
|
||||
let label_every = ((n_cols as u16 * (col_w as u16 + 1)) / area.width.max(1) + 1) as usize;
|
||||
let mut label_spans: Vec<Span<'static>> = vec![Span::raw(" ".to_string())];
|
||||
for (i, d) in data.iter().enumerate() {
|
||||
let lbl = if i % label_every.max(1) == 0 {
|
||||
format!("{:<width$}", d.0.chars().take(col_w + 1).collect::<String>(), width = col_w + 1)
|
||||
} else {
|
||||
" ".repeat(col_w + 1)
|
||||
};
|
||||
label_spans.push(Span::styled(lbl, Style::default().fg(Color::DarkGray)));
|
||||
}
|
||||
lines.push(Line::from(label_spans));
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
// ── 6. Settings tab ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Draws the Settings tab: GitHub API actions, local-file shortcuts
|
||||
/// (.gitignore/LICENSE/README), and git operations, each as a numbered,
|
||||
/// cursor-selectable row sharing layout with `settings_row`.
|
||||
pub fn render_settings(f: &mut Frame, st: &St, area: Rect) {
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let cyn = Style::default().fg(Color::Cyan);
|
||||
let yel = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let red = Style::default().fg(Color::Red);
|
||||
|
||||
let (vis, desc, topics_str, gh_ok) = match &st.settings {
|
||||
None => {
|
||||
let msg = if st.settings_rx.is_some() {
|
||||
" Fetching from GitHub API..."
|
||||
} else {
|
||||
" (loading...)"
|
||||
};
|
||||
f.render_widget(Paragraph::new(msg).style(dim), area);
|
||||
return;
|
||||
}
|
||||
Some(s) => (s.visibility.clone(), s.description.clone(), s.topics.join(", "), s.gh_auth_ok),
|
||||
};
|
||||
|
||||
let busy = st.api_rx.is_some();
|
||||
|
||||
struct Item { label: &'static str, value: String, hint: &'static str, danger: bool }
|
||||
let items: &[Item] = &[
|
||||
Item { label: "Visibility", value: vis.clone(), hint: "toggle public / private", danger: false },
|
||||
Item { label: "Description", value: if desc.is_empty() { "(none)".into() } else { desc.chars().take(35).collect() }, hint: "edit text", danger: false },
|
||||
Item { label: "Topics", value: if topics_str.is_empty() { "(none)".into() } else { topics_str.chars().take(35).collect() }, hint: "edit list", danger: false },
|
||||
Item { label: "Collaborator", value: String::new(), hint: "invite user", danger: false },
|
||||
Item { label: "Archive", value: String::new(), hint: "archive repo on GitHub", danger: true },
|
||||
Item { label: "Rename", value: String::new(), hint: "rename on GitHub", danger: true },
|
||||
Item { label: "Delete", value: String::new(), hint: "delete on GitHub ⚠", danger: true },
|
||||
Item { label: ".gitignore", value: String::new(), hint: "open in $EDITOR", danger: false },
|
||||
Item { label: "LICENSE", value: String::new(), hint: "open in $EDITOR", danger: false },
|
||||
Item { label: "README", value: String::new(), hint: "open in $EDITOR", danger: false },
|
||||
];
|
||||
|
||||
let mut lines: Vec<Line> = vec![];
|
||||
|
||||
if !gh_ok {
|
||||
let msg = st.settings_err.as_deref()
|
||||
.unwrap_or("No GitHub account — run: vulp auth import");
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" ⚠ {}", msg),
|
||||
Style::default().fg(Color::Yellow),
|
||||
)));
|
||||
lines.push(super::dsep(area.width));
|
||||
lines.push(Line::from(""));
|
||||
} else if let Some(err) = &st.settings_err {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" ⚠ {}", err),
|
||||
Style::default().fg(Color::Yellow),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
if busy {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" Updating GitHub...",
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Section: GitHub API
|
||||
lines.push(Line::from(Span::styled(" GitHub API", yel)));
|
||||
lines.push(super::dsep(area.width));
|
||||
for (i, item) in items[..7].iter().enumerate() {
|
||||
lines.push(settings_row(i, item.label, &item.value, item.hint, item.danger, i == st.settings_cursor));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Section: Local Files
|
||||
lines.push(Line::from(Span::styled(" Local Files", yel)));
|
||||
lines.push(super::dsep(area.width));
|
||||
for (i, item) in items[7..].iter().enumerate() {
|
||||
lines.push(settings_row(7 + i, item.label, &item.value, item.hint, false, 7 + i == st.settings_cursor));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Section: Git Operations
|
||||
struct GitItem { label: &'static str, value: String, hint: &'static str }
|
||||
let stash_hint = if st.stash_count > 0 { format!("{} stashed", st.stash_count) } else { String::new() };
|
||||
let git_items: &[GitItem] = &[
|
||||
GitItem { label: "Pull", value: String::new(), hint: "git pull (fetch + merge)" },
|
||||
GitItem { label: "Commit", value: String::new(), hint: "stage all & commit" },
|
||||
GitItem { label: "Push", value: if st.summary.ahead > 0 { format!("{} ahead", st.summary.ahead) } else { String::new() }, hint: "git push" },
|
||||
GitItem { label: "Stash", value: stash_hint.clone(), hint: "git stash" },
|
||||
GitItem { label: "Pop stash", value: stash_hint, hint: "git stash pop" },
|
||||
];
|
||||
lines.push(Line::from(Span::styled(" Git Operations", yel)));
|
||||
lines.push(super::dsep(area.width));
|
||||
for (i, item) in git_items.iter().enumerate() {
|
||||
lines.push(settings_row(10 + i, item.label, &item.value, item.hint, false, 10 + i == st.settings_cursor));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
|
||||
// Suppress unused variable warnings via inline references
|
||||
let _ = (cyn, red);
|
||||
}
|
||||
|
||||
/// Draws one numbered row of the Settings tab, sharing column widths across
|
||||
/// all three sections so the cursor highlight lines up consistently.
|
||||
fn settings_row(
|
||||
idx: usize,
|
||||
label: &str,
|
||||
value: &str,
|
||||
hint: &str,
|
||||
danger: bool,
|
||||
sel: bool,
|
||||
) -> Line<'static> {
|
||||
let sel_bg = if sel { Color::DarkGray } else { Color::Reset };
|
||||
let num_col = if sel { Color::Cyan } else { Color::DarkGray };
|
||||
let lbl_col = if danger && !sel { Color::Red } else { Color::White };
|
||||
let hint_col = if danger { Color::Red } else { Color::DarkGray };
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:>2}. ", idx + 1),
|
||||
Style::default().fg(num_col).bg(sel_bg).add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{:<16}", label),
|
||||
Style::default().fg(lbl_col).bg(sel_bg).add_modifier(if sel { Modifier::BOLD } else { Modifier::empty() }),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{:<30}", value.chars().take(30).collect::<String>()),
|
||||
Style::default().fg(Color::DarkGray).bg(sel_bg),
|
||||
),
|
||||
Span::styled(
|
||||
hint.to_string(),
|
||||
Style::default().fg(hint_col).bg(sel_bg),
|
||||
),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Background I/O for the repo-detail view: fetching activity-chart data,
|
||||
//! loading/mutating Settings-tab data via the hosting provider's API, and
|
||||
//! fetching PRs/issues. All the API calls run on a spawned thread with its
|
||||
//! own short-lived Tokio runtime so the TUI's draw loop never blocks on
|
||||
//! network I/O; results come back over an `mpsc` channel that the relevant
|
||||
//! `poll_*` function drains each frame.
|
||||
|
||||
use super::git::git_run_ok;
|
||||
use super::types::{DetailModal, RepoSettings, SettingOp, St, StatsPeriod};
|
||||
use super::{activity_1d, activity_30d, activity_7d, activity_90d, activity_1y, activity_all};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Returns the Stats tab's activity series for `period`, recomputed from
|
||||
/// scratch each time rather than cached, since switching periods is rare
|
||||
/// enough that the recompute cost doesn't matter.
|
||||
pub fn compute_activity(path: &std::path::Path, period: StatsPeriod) -> Vec<(String, usize)> {
|
||||
match period {
|
||||
StatsPeriod::Day1 => activity_1d(path),
|
||||
StatsPeriod::Day7 => activity_7d(path),
|
||||
StatsPeriod::Day30 => activity_30d(path),
|
||||
StatsPeriod::Day90 => activity_90d(path),
|
||||
StatsPeriod::Year1 => activity_1y(path),
|
||||
StatsPeriod::AllTime => activity_all(path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings fallback shown when there's no provider account configured at
|
||||
/// all — `gh_auth_ok: false` tells the Settings tab to render an "add an
|
||||
/// account" hint instead of treating this as a transient API failure.
|
||||
pub fn settings_fallback(st: &St) -> RepoSettings {
|
||||
RepoSettings {
|
||||
visibility: st.summary.visibility.clone().unwrap_or_else(|| "unknown".into()),
|
||||
description: String::new(),
|
||||
topics: vec![],
|
||||
gh_auth_ok: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings fallback shown when an account exists but a specific API call
|
||||
/// failed — `gh_auth_ok: true` keeps the Settings tab's edit actions enabled
|
||||
/// instead of hiding them behind an auth-missing message.
|
||||
pub fn settings_fallback_auth(st: &St) -> RepoSettings {
|
||||
RepoSettings {
|
||||
visibility: st.summary.visibility.clone().unwrap_or_else(|| "unknown".into()),
|
||||
description: String::new(),
|
||||
topics: vec![],
|
||||
gh_auth_ok: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains the in-flight Settings fetch, if any, and updates `st.settings`/
|
||||
/// `st.settings_err`. Distinguishes an auth failure (no usable account) from
|
||||
/// an API error with an existing account, since only the latter should keep
|
||||
/// any already-loaded settings visible rather than falling back.
|
||||
pub fn poll_settings_rx(st: &mut St) {
|
||||
let done = match &st.settings_rx {
|
||||
None => return,
|
||||
Some(rx) => match rx.try_recv() {
|
||||
Ok(r) => Some(r),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Err("Channel disconnected".into())),
|
||||
},
|
||||
};
|
||||
st.settings_rx = None;
|
||||
match done {
|
||||
Some(Ok(s)) => {
|
||||
st.settings = Some(s);
|
||||
st.settings_err = None;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
let auth_fail = e.contains("No GitHub account") || e.contains("no account")
|
||||
|| e.contains("no github") || e.contains("authentication");
|
||||
if auth_fail {
|
||||
st.settings = Some(settings_fallback(st));
|
||||
st.settings_err = Some(e);
|
||||
} else {
|
||||
// API error, but we are authenticated — keep showing stale data, show error
|
||||
if st.settings.is_none() { st.settings = Some(settings_fallback_auth(st)); }
|
||||
st.settings_err = Some(format!("API error: {}", e));
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kicks off a background fetch of the repo's current settings from the
|
||||
/// hosting provider, unless one is already in flight.
|
||||
pub fn spawn_settings_fetch(st: &mut St) {
|
||||
if st.settings_rx.is_some() { return; } // already in flight
|
||||
let data_dir = st.cfg_data_dir.clone();
|
||||
let repo_name = st.summary.name.clone();
|
||||
let vis = st.summary.visibility.clone().unwrap_or_else(|| "unknown".into());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().expect("tokio rt");
|
||||
let result: Result<RepoSettings, String> = rt.block_on(async move {
|
||||
let accounts = crate::auth::load_accounts_path(&data_dir)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let account = crate::providers::default_account(&accounts, None)
|
||||
.ok_or_else(|| "No GitHub account — run: vulp auth import".to_string())?;
|
||||
let provider = crate::providers::make_provider(account)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let meta = provider.repo_meta(&repo_name).await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(RepoSettings {
|
||||
visibility: vis,
|
||||
description: meta.description.unwrap_or_default(),
|
||||
topics: meta.topics,
|
||||
gh_auth_ok: true,
|
||||
})
|
||||
});
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
st.settings_rx = Some(rx);
|
||||
}
|
||||
|
||||
// ── settings modal opener ─────────────────────────────────────────────────────
|
||||
|
||||
/// Opens the appropriate modal for the Settings-tab row at `st.settings_cursor`.
|
||||
/// Each row maps to a fixed `tag`, later read back by `handle_modal_key` to
|
||||
/// route the submitted value to the right `SettingOp`.
|
||||
pub fn open_settings_modal(st: &mut St) {
|
||||
let cur_vis = st.settings.as_ref().map(|s| s.visibility.clone()).or_else(|| st.summary.visibility.clone()).unwrap_or_default();
|
||||
let cur_desc = st.settings.as_ref().map(|s| s.description.clone()).unwrap_or_default();
|
||||
let cur_top = st.settings.as_ref().map(|s| s.topics.join(", ")).unwrap_or_default();
|
||||
let repo = st.summary.name.clone();
|
||||
match st.settings_cursor {
|
||||
0 => {
|
||||
let cursor = if cur_vis == "private" { 1 } else { 0 };
|
||||
st.modal = DetailModal::SelectOption {
|
||||
title: "Visibility".into(),
|
||||
options: vec!["public".into(), "private".into()],
|
||||
cursor, tag: 60,
|
||||
};
|
||||
}
|
||||
1 => {
|
||||
st.modal = DetailModal::EditText {
|
||||
title: "Description".into(),
|
||||
prompt: "New description (blank to clear):".into(),
|
||||
input: cur_desc, tag: 61,
|
||||
};
|
||||
}
|
||||
2 => {
|
||||
st.modal = DetailModal::EditText {
|
||||
title: "Topics".into(),
|
||||
prompt: "Comma-separated (e.g. rust, cli, tool). No spaces within words.".into(),
|
||||
input: cur_top, tag: 62,
|
||||
};
|
||||
}
|
||||
3 => {
|
||||
st.modal = DetailModal::EditText {
|
||||
title: "Add Collaborator".into(),
|
||||
prompt: "GitHub username:".into(),
|
||||
input: String::new(), tag: 63,
|
||||
};
|
||||
}
|
||||
4 => {
|
||||
st.modal = DetailModal::ConfirmAction {
|
||||
message: format!("Archive '{}' on GitHub?", repo),
|
||||
tag: 64,
|
||||
};
|
||||
}
|
||||
5 => {
|
||||
st.modal = DetailModal::EditText {
|
||||
title: "Rename Repository".into(),
|
||||
prompt: "New name:".into(),
|
||||
input: repo, tag: 65,
|
||||
};
|
||||
}
|
||||
6 => {
|
||||
let name = st.summary.name.clone();
|
||||
st.modal = DetailModal::ConfirmAction {
|
||||
message: format!("Permanently delete '{}' on GitHub AND locally?\nThis cannot be undone.", name),
|
||||
tag: 66,
|
||||
};
|
||||
}
|
||||
7 => { st.run_action = Some(107); }
|
||||
8 => { st.run_action = Some(108); }
|
||||
9 => { st.run_action = Some(109); }
|
||||
10 => {
|
||||
let ok = git_run_ok(&st.summary.path, &["pull"]);
|
||||
st.status_msg = Some(if ok { "Pulled successfully".into() } else { "Pull failed — check remote".into() });
|
||||
if ok { st.summary.behind = 0; }
|
||||
}
|
||||
11 => {
|
||||
st.modal = DetailModal::EditText {
|
||||
title: "Commit".into(),
|
||||
prompt: "Commit message (stages all changes):".into(),
|
||||
input: String::new(),
|
||||
tag: 70,
|
||||
};
|
||||
}
|
||||
12 => {
|
||||
let ok = git_run_ok(&st.summary.path, &["push"]);
|
||||
st.status_msg = Some(if ok { "Pushed successfully".into() } else { "Push failed — check remote".into() });
|
||||
if ok { st.summary.ahead = 0; }
|
||||
}
|
||||
13 => {
|
||||
let ok = git_run_ok(&st.summary.path, &["stash"]);
|
||||
st.status_msg = Some(if ok { "Stash saved".into() } else { "Stash failed".into() });
|
||||
if ok { st.summary.is_dirty = false; st.stash_count += 1; }
|
||||
}
|
||||
14 => {
|
||||
let ok = git_run_ok(&st.summary.path, &["stash", "pop"]);
|
||||
st.status_msg = Some(if ok { "Stash popped".into() } else { "Stash pop failed — no stash?".into() });
|
||||
if ok {
|
||||
if st.stash_count > 0 { st.stash_count -= 1; }
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C").arg(&st.summary.path).args(["status", "--porcelain"])
|
||||
.output();
|
||||
if let Ok(o) = out {
|
||||
st.summary.is_dirty = !o.stdout.is_empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── settings API background operations ───────────────────────────────────────
|
||||
|
||||
/// Kicks off a background mutation (`op`) against the hosting provider's
|
||||
/// API. Refuses to start a second one while one is already in flight rather
|
||||
/// than queuing, since these are all user-triggered one-shot actions.
|
||||
pub fn spawn_settings_op(st: &mut St, op: SettingOp) {
|
||||
if st.api_rx.is_some() {
|
||||
st.status_msg = Some("Another operation in progress...".into());
|
||||
return;
|
||||
}
|
||||
let data_dir = st.cfg_data_dir.clone();
|
||||
let repo_name = st.summary.name.clone();
|
||||
let repo_path = st.summary.path.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
st.quit_after_op = matches!(op, SettingOp::Delete);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().expect("tokio rt");
|
||||
let result: Result<String, String> = rt.block_on(async move {
|
||||
let accounts = crate::auth::load_accounts_path(&data_dir)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let account = crate::providers::default_account(&accounts, Some("github"))
|
||||
.ok_or_else(|| "No GitHub account — run: vulp auth import".to_string())?;
|
||||
let provider = crate::providers::make_provider(account)
|
||||
.map_err(|e| e.to_string())?;
|
||||
match op {
|
||||
SettingOp::Visibility { private } => {
|
||||
provider.update_visibility(&repo_name, private).await
|
||||
.map(|_| format!("Visibility → {}", if private { "private" } else { "public" }))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
SettingOp::Description { text } => {
|
||||
provider.update_description(&repo_name, &text).await
|
||||
.map(|_| "Description updated".to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
SettingOp::Topics { topics } => {
|
||||
provider.set_topics(&repo_name, &topics).await
|
||||
.map(|_| "Topics updated".to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
SettingOp::AddCollaborator { name, role } => {
|
||||
provider.add_collaborator(&repo_name, &name, &role).await
|
||||
.map(|_| format!("Invited @{} as {}", name, role))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
SettingOp::Archive => {
|
||||
provider.archive_repo(&repo_name).await
|
||||
.map(|_| "Repository archived".to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
SettingOp::Rename { new_name } => {
|
||||
provider.rename_repo(&repo_name, &new_name).await
|
||||
.map(|_| format!("Renamed to '{}'", new_name))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
SettingOp::Delete => {
|
||||
provider.delete_repo(&repo_name).await.map_err(|e| e.to_string())?;
|
||||
// If it's a symlink (linked from elsewhere into the workspace),
|
||||
// only remove the link — never delete the real target folder.
|
||||
let is_symlink = repo_path.symlink_metadata()
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false);
|
||||
let local_result = if is_symlink {
|
||||
std::fs::remove_file(&repo_path)
|
||||
} else {
|
||||
std::fs::remove_dir_all(&repo_path)
|
||||
};
|
||||
match local_result {
|
||||
Ok(()) => Ok("Deleted on GitHub and locally".to_string()),
|
||||
Err(e) => Err(format!("Deleted on GitHub, but local delete failed: {}", e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
st.api_rx = Some(rx);
|
||||
st.status_msg = Some("Updating GitHub...".into());
|
||||
}
|
||||
|
||||
/// Drains the in-flight Settings-API mutation, if any, and surfaces its
|
||||
/// result as a status message. A successful `Delete` leaves the repo gone,
|
||||
/// so it also signals the view to quit back to the hub rather than keep
|
||||
/// showing a now-nonexistent repo.
|
||||
pub fn poll_api_rx(st: &mut St) {
|
||||
let done = match &st.api_rx {
|
||||
None => return,
|
||||
Some(rx) => match rx.try_recv() {
|
||||
Ok(r) => Some(r),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Err("Connection lost".into())),
|
||||
},
|
||||
};
|
||||
if let Some(result) = done {
|
||||
let success = result.is_ok();
|
||||
st.status_msg = Some(match result {
|
||||
Ok(msg) => msg,
|
||||
Err(msg) => format!("⚠ {}", msg),
|
||||
});
|
||||
st.api_rx = None;
|
||||
// Delete leaves the repo (and possibly the folder) gone — staying in
|
||||
// detail view would just keep showing stale data for something that
|
||||
// no longer exists, so bounce back to the hub list once it succeeds.
|
||||
if success && st.quit_after_op {
|
||||
st.quit = true;
|
||||
}
|
||||
st.quit_after_op = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains the in-flight PRs/issues fetch, if any, and stores the result.
|
||||
/// `prs_loaded` is set unconditionally (even on a disconnect) so a failed
|
||||
/// fetch doesn't get silently retried forever on every tab switch.
|
||||
pub fn poll_prs_issues_rx(st: &mut St) {
|
||||
let done = match &st.prs_issues_rx {
|
||||
None => return,
|
||||
Some(rx) => match rx.try_recv() {
|
||||
Ok(r) => Some(Ok(r)),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Err(())),
|
||||
},
|
||||
};
|
||||
st.prs_issues_rx = None;
|
||||
st.prs_loaded = true;
|
||||
if let Some(Ok((prs, issues))) = done {
|
||||
st.repo_prs = prs;
|
||||
st.repo_issues = issues;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kicks off a background fetch of the repo's PRs and issues, unless one is
|
||||
/// already in flight or already loaded this session.
|
||||
pub fn spawn_prs_issues_fetch(st: &mut St) {
|
||||
if st.prs_issues_rx.is_some() || st.prs_loaded { return; }
|
||||
let data_dir = st.cfg_data_dir.clone();
|
||||
let repo_name = st.summary.name.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().expect("tokio rt");
|
||||
let result = rt.block_on(async move {
|
||||
let accounts = crate::auth::load_accounts_path(&data_dir).ok()?;
|
||||
let account = crate::providers::default_account(&accounts, None)?;
|
||||
let provider = crate::providers::make_provider(account).ok()?;
|
||||
let prs = provider.list_pull_requests(&repo_name).await.unwrap_or_default();
|
||||
let issues = provider.list_issues(&repo_name).await.unwrap_or_default();
|
||||
Some((prs, issues))
|
||||
});
|
||||
let _ = tx.send(result.unwrap_or_default());
|
||||
});
|
||||
|
||||
st.prs_issues_rx = Some(rx);
|
||||
st.status_msg = Some("Fetching PRs and issues...".into());
|
||||
}
|
||||
|
||||
// ── provider helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Builds a provider client from the default GitHub account, failing with a
|
||||
/// user-facing hint (`vulp auth import`) rather than a raw error when none
|
||||
/// is configured.
|
||||
pub async fn try_make_provider(st: &St) -> Result<Box<dyn crate::providers::Provider>> {
|
||||
let accounts = crate::auth::load_accounts_path(&st.cfg_data_dir)?;
|
||||
let account = crate::providers::default_account(&accounts, Some("github"))
|
||||
.ok_or_else(|| anyhow::anyhow!("no github account — run: vulp auth import"))?;
|
||||
crate::providers::make_provider(account)
|
||||
}
|
||||
|
||||
/// Synchronous (non-channel) settings load used by the legacy CLI-prompt
|
||||
/// action arms in `actions.rs` — unlike `spawn_settings_fetch`, this blocks
|
||||
/// the caller until the API call returns.
|
||||
pub async fn try_load_settings(st: &mut St) -> Result<()> {
|
||||
let provider = try_make_provider(st).await?;
|
||||
let meta = provider.repo_meta(&st.summary.name).await?;
|
||||
st.settings = Some(RepoSettings {
|
||||
visibility: st.summary.visibility.clone().unwrap_or_else(|| "unknown".into()),
|
||||
description: meta.description.unwrap_or_default(),
|
||||
topics: meta.topics,
|
||||
gh_auth_ok: true,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Data structures shared across the repo-detail view: the tab enum, the
|
||||
//! `St` struct holding all per-session state, and the small value types that
|
||||
//! populate it (history entries, branches, the file tree, settings).
|
||||
//!
|
||||
//! Kept separate from the logic that fills these fields (`settings_io.rs`,
|
||||
//! `keys.rs`, `actions.rs`) and the logic that draws them (`render.rs`) so
|
||||
//! that anyone trying to understand "what does the detail view know about a
|
||||
//! repo" can read one file without wading through control flow.
|
||||
|
||||
use crate::status::RepoSummary;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ── history entry ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// One row in the Commits tab: either a real commit or an annotated tag,
|
||||
/// merged into a single timeline sorted by `ts`.
|
||||
#[derive(Clone)]
|
||||
pub enum HistEntry {
|
||||
Commit { sha: String, age: String, ts: i64, author: String, msg: String },
|
||||
Tag { name: String, age: String, ts: i64, msg: String },
|
||||
}
|
||||
|
||||
impl HistEntry {
|
||||
pub fn ts(&self) -> i64 { match self { Self::Commit { ts, .. } | Self::Tag { ts, .. } => *ts } }
|
||||
}
|
||||
|
||||
// ── new data structures ───────────────────────────────────────────────────────
|
||||
|
||||
/// One row in the Branches tab, combining local branch state with its
|
||||
/// upstream tracking info (ahead/behind counts, last commit summary).
|
||||
#[derive(Clone)]
|
||||
pub struct BranchEntry {
|
||||
pub name: String,
|
||||
pub is_current: bool,
|
||||
pub upstream: Option<String>,
|
||||
pub ahead: usize,
|
||||
pub behind: usize,
|
||||
pub last_commit_age: String,
|
||||
pub last_commit_msg: String,
|
||||
}
|
||||
|
||||
/// One row in the Files tab's flattened tree view. `depth` and `expanded`
|
||||
/// drive `rebuild_tree_visible`'s collapse logic rather than a real nested
|
||||
/// tree structure, since a flat `Vec` is simpler to scroll and index into.
|
||||
#[derive(Clone)]
|
||||
pub struct TreeNode {
|
||||
pub name: String,
|
||||
pub rel_path: String,
|
||||
pub is_dir: bool,
|
||||
pub depth: usize,
|
||||
pub expanded: bool,
|
||||
}
|
||||
|
||||
/// One row in the Stats tab's contributor breakdown.
|
||||
#[derive(Clone)]
|
||||
pub struct ContribEntry {
|
||||
pub name: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
|
||||
/// Repo metadata fetched from the hosting provider's API for the Settings
|
||||
/// tab. `gh_auth_ok` distinguishes "we asked the API and it said no" from
|
||||
/// "we never had credentials to ask in the first place", since those need
|
||||
/// different fallback messaging.
|
||||
pub struct RepoSettings {
|
||||
pub visibility: String,
|
||||
pub description: String,
|
||||
pub topics: Vec<String>,
|
||||
pub gh_auth_ok: bool,
|
||||
}
|
||||
|
||||
// ── stats period ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Selectable time window for the Stats tab's activity chart.
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
pub enum StatsPeriod { Day1, Day7, Day30, Day90, Year1, AllTime }
|
||||
|
||||
impl StatsPeriod {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Day1 => "1d",
|
||||
Self::Day7 => "7d",
|
||||
Self::Day30 => "30d",
|
||||
Self::Day90 => "90d",
|
||||
Self::Year1 => "1y",
|
||||
Self::AllTime => "all",
|
||||
}
|
||||
}
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Day1 => Self::Day7,
|
||||
Self::Day7 => Self::Day30,
|
||||
Self::Day30 => Self::Day90,
|
||||
Self::Day90 => Self::Year1,
|
||||
Self::Year1 => Self::AllTime,
|
||||
Self::AllTime => Self::Day1,
|
||||
}
|
||||
}
|
||||
pub fn prev(self) -> Self {
|
||||
match self {
|
||||
Self::Day1 => Self::AllTime,
|
||||
Self::Day7 => Self::Day1,
|
||||
Self::Day30 => Self::Day7,
|
||||
Self::Day90 => Self::Day30,
|
||||
Self::Year1 => Self::Day90,
|
||||
Self::AllTime => Self::Year1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings API operations (sent to background thread) ──────────────────────
|
||||
|
||||
/// One mutating Settings-tab action, captured as data so it can be handed
|
||||
/// off to a background thread (`spawn_settings_op`) that owns the actual
|
||||
/// network call.
|
||||
pub enum SettingOp {
|
||||
Visibility { private: bool },
|
||||
Description { text: String },
|
||||
Topics { topics: Vec<String> },
|
||||
AddCollaborator { name: String, role: String },
|
||||
Archive,
|
||||
Rename { new_name: String },
|
||||
Delete,
|
||||
}
|
||||
|
||||
// ── TUI modals ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// All modal prompts the detail view can show. The `tag: u8` fields on the
|
||||
/// generic variants identify which logical action is in flight, since the
|
||||
/// same `SelectOption`/`EditText` shapes are reused for several different
|
||||
/// settings/sync/release flows.
|
||||
pub enum DetailModal {
|
||||
None,
|
||||
NewBranch { input: String, error: Option<String> },
|
||||
// generic option selector (tag identifies the action)
|
||||
SelectOption { title: String, options: Vec<String>, cursor: usize, tag: u8 },
|
||||
// generic text input (tag identifies the action)
|
||||
EditText { title: String, prompt: String, input: String, tag: u8 },
|
||||
// add-collaborator step 2: name collected, now pick role
|
||||
SelectRole { collab_name: String, cursor: usize },
|
||||
// dangerous confirm (archive, delete)
|
||||
ConfirmAction { message: String, tag: u8 },
|
||||
}
|
||||
|
||||
// ── tabs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The detail view's tab bar, in display order.
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
pub enum Tab { Overview, Commits, Branches, Files, Stats, Settings, PRs, Issues, Todos }
|
||||
|
||||
impl Tab {
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Overview => Self::Commits,
|
||||
Self::Commits => Self::Branches,
|
||||
Self::Branches => Self::Files,
|
||||
Self::Files => Self::Stats,
|
||||
Self::Stats => Self::Settings,
|
||||
Self::Settings => Self::PRs,
|
||||
Self::PRs => Self::Issues,
|
||||
Self::Issues => Self::Todos,
|
||||
Self::Todos => Self::Overview,
|
||||
}
|
||||
}
|
||||
pub fn prev(self) -> Self {
|
||||
match self {
|
||||
Self::Overview => Self::Todos,
|
||||
Self::Commits => Self::Overview,
|
||||
Self::Branches => Self::Commits,
|
||||
Self::Files => Self::Branches,
|
||||
Self::Stats => Self::Files,
|
||||
Self::Settings => Self::Stats,
|
||||
Self::PRs => Self::Settings,
|
||||
Self::Issues => Self::PRs,
|
||||
Self::Todos => Self::Issues,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── state ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// All state for one open repo-detail session: a snapshot of every tab's
|
||||
/// data plus cursors/scroll offsets, loaded eagerly in `run()` (git data) or
|
||||
/// lazily on first tab visit (provider API data behind `*_rx` channels).
|
||||
pub struct St {
|
||||
pub summary: RepoSummary,
|
||||
pub tab: Tab,
|
||||
pub pending_file: Option<PathBuf>,
|
||||
|
||||
// Commits tab
|
||||
pub history: Vec<HistEntry>,
|
||||
pub hist_off: usize,
|
||||
pub total_commits: usize,
|
||||
|
||||
// Files (changed)
|
||||
pub files: Vec<(char, String)>,
|
||||
pub file_count: usize,
|
||||
|
||||
// Branches tab
|
||||
pub branches: Vec<BranchEntry>,
|
||||
pub branch_cursor: usize,
|
||||
|
||||
// Files tab (tree)
|
||||
#[allow(dead_code)] pub all_files: Vec<String>, // kept: files-tab data model
|
||||
pub tree_nodes: Vec<TreeNode>,
|
||||
pub tree_visible: Vec<usize>,
|
||||
pub tree_cursor: usize,
|
||||
pub file_preview: Vec<String>,
|
||||
pub preview_off: usize,
|
||||
|
||||
// Stats tab
|
||||
pub contributors: Vec<ContribEntry>,
|
||||
pub repo_age_days: u64,
|
||||
pub stats_off: usize,
|
||||
pub stats_period: StatsPeriod,
|
||||
pub activity_data: Vec<(String, usize)>, // current period's activity
|
||||
pub loc_total: usize,
|
||||
pub top_files: Vec<(String, usize)>, // largest files by LOC
|
||||
|
||||
// Tags/stashes (shared)
|
||||
pub total_tags: usize,
|
||||
pub stash_count: usize,
|
||||
|
||||
// Overview tab
|
||||
pub ov_recent: Vec<(String, String)>,
|
||||
pub ov_off: usize,
|
||||
pub remote_url_str: Option<String>,
|
||||
|
||||
// Settings tab
|
||||
pub settings: Option<RepoSettings>,
|
||||
pub settings_cursor: usize,
|
||||
pub settings_rx: Option<std::sync::mpsc::Receiver<Result<RepoSettings, String>>>,
|
||||
pub settings_err: Option<String>,
|
||||
pub api_rx: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
|
||||
|
||||
// PRs / Issues / Todos tabs
|
||||
pub repo_prs: Vec<crate::providers::PrDetail>,
|
||||
pub repo_issues: Vec<crate::providers::IssueDetail>,
|
||||
pub repo_todos: Vec<crate::todos::Todo>,
|
||||
pub pr_cursor: usize,
|
||||
pub issue_cursor: usize,
|
||||
pub todo_cursor: usize,
|
||||
pub prs_loaded: bool,
|
||||
pub prs_issues_rx: Option<std::sync::mpsc::Receiver<(Vec<crate::providers::PrDetail>, Vec<crate::providers::IssueDetail>)>>,
|
||||
|
||||
// Shared
|
||||
pub branch_count: usize,
|
||||
pub quit: bool,
|
||||
pub quit_after_op: bool,
|
||||
pub run_action: Option<usize>,
|
||||
pub status_msg: Option<String>,
|
||||
pub cfg_data_dir: PathBuf,
|
||||
pub cfg_editor: String,
|
||||
|
||||
// Pending values collected from in-TUI modals (sync / release)
|
||||
pub pending_sync_mode: Option<String>,
|
||||
pub pending_commit_msg: Option<String>,
|
||||
pub pending_tag_name: Option<String>,
|
||||
pub pending_tag_msg: Option<String>,
|
||||
pub pending_push_tag: bool,
|
||||
|
||||
// Modal
|
||||
pub modal: DetailModal,
|
||||
}
|
||||
|
||||
// ── tree visibility rebuild ───────────────────────────────────────────────────
|
||||
|
||||
/// Flattens `nodes` into the indices that should actually be drawn, skipping
|
||||
/// children of any collapsed directory. Recomputed whenever a directory is
|
||||
/// toggled rather than stored incrementally, since the file tree is small
|
||||
/// enough that a full rebuild is cheap.
|
||||
pub fn rebuild_tree_visible(nodes: &[TreeNode]) -> Vec<usize> {
|
||||
let mut visible = vec![];
|
||||
let mut collapsed_depth: Option<usize> = None;
|
||||
for (i, node) in nodes.iter().enumerate() {
|
||||
if let Some(cd) = collapsed_depth {
|
||||
if node.depth > cd { continue; }
|
||||
else { collapsed_depth = None; }
|
||||
}
|
||||
visible.push(i);
|
||||
if node.is_dir && !node.expanded {
|
||||
collapsed_depth = Some(node.depth);
|
||||
}
|
||||
}
|
||||
visible
|
||||
}
|
||||
Reference in new issue
Block a user