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

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

No files matched your search

+241
View File
@@ -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(&current).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(&current).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
View File
@@ -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])
}
+522
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+32 -1179
View File
File diff suppressed because it is too large. Load diff
+150
View File
@@ -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);
}
+164
View File
@@ -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,
);
}
+290
View File
@@ -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);
}
+647
View File
@@ -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),
),
])
}
+404
View File
@@ -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(())
}
+287
View File
@@ -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
}