Test message 123!
This commit is contained in:
1 parent
4cf726709f
commit
d4fdc9890a
52 files changed
+5537
-277
No files matched your search
@@ -0,0 +1,96 @@
|
||||
use super::HistEntry;
|
||||
use crate::status::format_age_pub;
|
||||
use git2::Repository;
|
||||
|
||||
pub use crate::util::git::{git_run, git_run_ok};
|
||||
pub use crate::util::url::to_web_url;
|
||||
pub use crate::util::clipboard::copy_auto as copy_to_clipboard;
|
||||
|
||||
pub fn build_history(path: &std::path::Path, limit: usize) -> Vec<HistEntry> {
|
||||
let repo = match Repository::open(path) { Ok(r) => r, Err(_) => return vec![] };
|
||||
|
||||
let mut commits: Vec<HistEntry> = {
|
||||
let mut rw = match repo.revwalk() { Ok(r) => r, Err(_) => return vec![] };
|
||||
let _ = rw.push_head();
|
||||
rw.take(limit).filter_map(|id| {
|
||||
let id = id.ok()?;
|
||||
let c = repo.find_commit(id).ok()?;
|
||||
let sha = id.to_string()[..7].to_string();
|
||||
let ts = c.time().seconds();
|
||||
let age = format_age_pub(ts);
|
||||
let author = c.author().name().unwrap_or("").to_string();
|
||||
let msg = c.summary().unwrap_or("").chars().take(72).collect();
|
||||
Some(HistEntry::Commit { sha, age, ts, author, msg })
|
||||
}).collect()
|
||||
};
|
||||
|
||||
let mut tags: Vec<HistEntry> = vec![];
|
||||
if let Ok(tag_names) = repo.tag_names(None) {
|
||||
for name in tag_names.iter().flatten() {
|
||||
let refname = format!("refs/tags/{}", name);
|
||||
if let Ok(obj) = repo.revparse_single(&refname) {
|
||||
let (ts, tag_msg) = if let Ok(tag) = obj.clone().into_tag() {
|
||||
let ts = tag.tagger().map(|s| s.when().seconds()).unwrap_or(0);
|
||||
let msg = tag.message().unwrap_or("").chars().take(72).collect();
|
||||
(ts, msg)
|
||||
} else if let Ok(c) = obj.peel_to_commit() {
|
||||
(c.time().seconds(), String::new())
|
||||
} else { continue };
|
||||
tags.push(HistEntry::Tag {
|
||||
name: name.to_string(),
|
||||
age: format_age_pub(ts),
|
||||
ts,
|
||||
msg: tag_msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
commits.extend(tags);
|
||||
commits.sort_by(|a, b| b.ts().cmp(&a.ts()));
|
||||
commits.truncate(limit + 20);
|
||||
commits
|
||||
}
|
||||
|
||||
pub fn count_contributors(path: &std::path::Path) -> usize {
|
||||
std::process::Command::new("git")
|
||||
.arg("-C").arg(path).args(["shortlog", "-sn", "--all"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).lines().count())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn count_tags(path: &std::path::Path) -> usize {
|
||||
std::process::Command::new("git")
|
||||
.arg("-C").arg(path).args(["tag", "-l"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).lines().filter(|l| !l.is_empty()).count())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn count_stashes(path: &std::path::Path) -> usize {
|
||||
std::process::Command::new("git")
|
||||
.arg("-C").arg(path).args(["stash", "list"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).lines().count())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn count_local_branches(path: &std::path::Path) -> usize {
|
||||
std::process::Command::new("git")
|
||||
.arg("-C").arg(path).args(["branch"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).lines().count())
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
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"])
|
||||
.output()
|
||||
.unwrap_or_else(|_| std::process::Output {
|
||||
status: std::process::ExitStatus::default(), stdout: vec![], stderr: vec![]
|
||||
});
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines().map(|l| l.to_string()).collect()
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
mod git;
|
||||
mod render;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::status::{count_tracked_files, repo_detail, repo_recent_commits, RepoSummary};
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use git2::Repository;
|
||||
use git::{
|
||||
build_history, copy_to_clipboard, count_contributors, count_local_branches,
|
||||
count_stashes, count_tags, git_run, git_run_ok, list_all_files, to_web_url,
|
||||
};
|
||||
use render::draw;
|
||||
|
||||
// ── history entry (commits + release tags interleaved) ────────────────────────
|
||||
|
||||
#[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 } }
|
||||
}
|
||||
|
||||
// ── tabs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
pub enum Tab { Overview, History, Files }
|
||||
|
||||
// ── state ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct St {
|
||||
pub summary: RepoSummary,
|
||||
pub tab: Tab,
|
||||
pub files: Vec<(char, String)>,
|
||||
pub file_count: usize,
|
||||
pub contributors: usize,
|
||||
pub total_tags: usize,
|
||||
pub recent_commits: Vec<(String, String)>,
|
||||
pub stash_count: usize,
|
||||
pub branch_count: usize,
|
||||
pub history: Vec<HistEntry>,
|
||||
pub hist_off: usize,
|
||||
pub file_off: usize,
|
||||
pub show_all_files: bool,
|
||||
pub all_files: Vec<String>,
|
||||
pub ov_scroll: usize,
|
||||
pub quit: bool,
|
||||
pub run_action: Option<usize>,
|
||||
pub status_msg: Option<String>,
|
||||
}
|
||||
|
||||
// ── entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn run(summary: RepoSummary, cfg: &AppConfig) -> Result<()> {
|
||||
let detail = repo_detail(&summary.path)?;
|
||||
let path = summary.path.clone();
|
||||
|
||||
let mut st = St {
|
||||
files: detail.modified_files,
|
||||
file_count: count_tracked_files(&path),
|
||||
contributors: count_contributors(&path),
|
||||
total_tags: count_tags(&path),
|
||||
recent_commits: repo_recent_commits(&path, 8),
|
||||
stash_count: count_stashes(&path),
|
||||
branch_count: count_local_branches(&path),
|
||||
history: build_history(&path, 60),
|
||||
hist_off: 0,
|
||||
file_off: 0,
|
||||
show_all_files: false,
|
||||
all_files: list_all_files(&path),
|
||||
ov_scroll: 0,
|
||||
quit: false,
|
||||
run_action: None,
|
||||
status_msg: None,
|
||||
tab: Tab::Overview,
|
||||
summary,
|
||||
};
|
||||
|
||||
loop {
|
||||
let action = tui_phase(&mut st)?;
|
||||
if st.quit { break; }
|
||||
if let Some(a) = action {
|
||||
exec_action(&mut st, a).await?;
|
||||
}
|
||||
}
|
||||
let _ = cfg;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tui_phase(st: &mut St) -> Result<Option<usize>> {
|
||||
let mut terminal = crate::tui::setup()?;
|
||||
let result = (|| -> Result<Option<usize>> {
|
||||
loop {
|
||||
terminal.draw(|f| draw(f, st))?;
|
||||
let event = crate::tui::next_event(250)?;
|
||||
let key = match event { Some(Event::Key(k)) => k, _ => continue };
|
||||
handle_key(key.code, st);
|
||||
if st.quit { return Ok(None); }
|
||||
if let Some(a) = st.run_action.take() { return Ok(Some(a)); }
|
||||
}
|
||||
})();
|
||||
crate::tui::teardown(terminal)?;
|
||||
result
|
||||
}
|
||||
|
||||
fn handle_key(code: KeyCode, st: &mut St) {
|
||||
st.status_msg = None;
|
||||
match code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => st.quit = true,
|
||||
|
||||
KeyCode::Tab => {
|
||||
st.tab = match st.tab { Tab::Overview => Tab::History, Tab::History => Tab::Files, Tab::Files => Tab::Overview };
|
||||
}
|
||||
KeyCode::Char('1') => st.tab = Tab::Overview,
|
||||
KeyCode::Char('2') => st.tab = Tab::History,
|
||||
KeyCode::Char('3') => st.tab = Tab::Files,
|
||||
|
||||
KeyCode::Down | KeyCode::Char('j') => match st.tab {
|
||||
Tab::History => st.hist_off += 1,
|
||||
Tab::Files => st.file_off += 1,
|
||||
Tab::Overview => st.ov_scroll += 1,
|
||||
},
|
||||
KeyCode::Up | KeyCode::Char('k') => match st.tab {
|
||||
Tab::History => st.hist_off = st.hist_off.saturating_sub(1),
|
||||
Tab::Files => st.file_off = st.file_off.saturating_sub(1),
|
||||
Tab::Overview => st.ov_scroll = st.ov_scroll.saturating_sub(1),
|
||||
},
|
||||
KeyCode::PageDown => match st.tab {
|
||||
Tab::History => st.hist_off = (st.hist_off + 10).min(st.history.len().saturating_sub(1)),
|
||||
Tab::Files => st.file_off += 10,
|
||||
Tab::Overview => st.ov_scroll += 5,
|
||||
},
|
||||
KeyCode::PageUp => match st.tab {
|
||||
Tab::History => st.hist_off = st.hist_off.saturating_sub(10),
|
||||
Tab::Files => st.file_off = st.file_off.saturating_sub(10),
|
||||
Tab::Overview => st.ov_scroll = st.ov_scroll.saturating_sub(5),
|
||||
},
|
||||
|
||||
KeyCode::Char('s') => { st.run_action = Some(0); }
|
||||
KeyCode::Char('r') => { st.run_action = Some(1); }
|
||||
KeyCode::Char('W') => {
|
||||
if let Ok(repo) = Repository::open(&st.summary.path) {
|
||||
if let Ok(remote) = repo.find_remote("origin") {
|
||||
if let Some(url) = remote.url() {
|
||||
let web = to_web_url(url);
|
||||
let _ = std::process::Command::new("xdg-open").arg(&web).spawn();
|
||||
st.status_msg = Some("Opening in browser...".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('o') => {
|
||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".into());
|
||||
let _ = std::process::Command::new(&editor).arg(&st.summary.path).spawn();
|
||||
st.status_msg = Some(format!("Opened in {}", editor));
|
||||
}
|
||||
KeyCode::Char('y') => {
|
||||
let path = st.summary.path.to_string_lossy().to_string();
|
||||
if copy_to_clipboard(&path) { st.status_msg = Some("Path copied!".into()); }
|
||||
}
|
||||
KeyCode::Char('u') => {
|
||||
if let Ok(repo) = Repository::open(&st.summary.path) {
|
||||
if let Ok(remote) = repo.find_remote("origin") {
|
||||
if let Some(url) = remote.url() {
|
||||
if copy_to_clipboard(url) { st.status_msg = Some("URL copied!".into()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('g') => {
|
||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".into());
|
||||
let gi = st.summary.path.join(".gitignore");
|
||||
let _ = std::process::Command::new(&editor).arg(&gi).spawn();
|
||||
st.status_msg = Some("Opening .gitignore...".into());
|
||||
}
|
||||
KeyCode::Char('c') => { st.run_action = Some(10); }
|
||||
KeyCode::Char('S') => {
|
||||
let ok = git_run_ok(&st.summary.path, &["stash"]);
|
||||
st.status_msg = Some(if ok { "Stashed.".into() } else { "Stash failed.".into() });
|
||||
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); }
|
||||
}
|
||||
KeyCode::Char('a') if st.tab == Tab::Files => {
|
||||
st.show_all_files = !st.show_all_files;
|
||||
st.file_off = 0;
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn exec_action(st: &mut St, idx: usize) -> Result<()> {
|
||||
let path = &st.summary.path;
|
||||
match idx {
|
||||
0 => {
|
||||
println!("\n── Sync: {} ──\n", st.summary.name);
|
||||
let mode = inquire::Select::new("Action:", vec!["pull", "commit + push", "push only"])
|
||||
.with_page_size(3).prompt_skippable()?;
|
||||
if let Some(m) = mode {
|
||||
match m {
|
||||
"pull" => git_run(path, &["pull"]),
|
||||
"commit + push" => {
|
||||
let msg = inquire::Text::new("Commit message:").with_default("chore: update").prompt()?;
|
||||
git_run(path, &["add", "-A"]);
|
||||
git_run(path, &["commit", "-m", &msg]);
|
||||
git_run(path, &["push"]);
|
||||
}
|
||||
_ => git_run(path, &["push"]),
|
||||
}
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
println!("\n── Release: {} ──\n", st.summary.name);
|
||||
let tag = inquire::Text::new("Tag name (e.g. v1.0.0):").prompt()?;
|
||||
let msg = inquire::Text::new("Tag message:").with_default(&tag).prompt()?;
|
||||
git_run(path, &["tag", "-a", &tag, "-m", &msg]);
|
||||
if inquire::Confirm::new("Push tag?").with_default(true).prompt()? {
|
||||
git_run(path, &["push", "origin", &tag]);
|
||||
}
|
||||
st.history = build_history(path, 60);
|
||||
st.total_tags += 1;
|
||||
}
|
||||
10 => {
|
||||
println!("\n── New branch in: {} ──\n", st.summary.name);
|
||||
let name = inquire::Text::new("Branch name:").prompt()?;
|
||||
if !name.trim().is_empty() {
|
||||
git_run(path, &["checkout", "-b", &name]);
|
||||
st.branch_count += 1;
|
||||
st.summary.branch = name;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
println!("\n Press Enter to return...");
|
||||
let _ = std::io::stdin().lines().next();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
use super::{HistEntry, St, Tab};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table},
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub fn draw(f: &mut Frame, st: &St) {
|
||||
let area = f.area();
|
||||
|
||||
let vert = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(2), Constraint::Fill(1), Constraint::Length(1)])
|
||||
.split(area);
|
||||
|
||||
render_header(f, st, vert[0]);
|
||||
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Fill(1), Constraint::Length(24)])
|
||||
.split(vert[1]);
|
||||
|
||||
match st.tab {
|
||||
Tab::Overview => render_overview(f, st, body[0]),
|
||||
Tab::History => render_history(f, st, body[0]),
|
||||
Tab::Files => render_files(f, st, body[0]),
|
||||
}
|
||||
render_sidebar(f, st, body[1]);
|
||||
|
||||
let hint = if let Some(ref msg) = st.status_msg {
|
||||
format!(" {} — press any key to continue", msg)
|
||||
} else {
|
||||
match st.tab {
|
||||
Tab::Overview => " [↑↓/jk] scroll [Tab/1-3] tab [q] back to hub".into(),
|
||||
Tab::History => " [↑↓/jk] scroll [PgUp/Dn] fast [Tab/1-3] tab [q] back".into(),
|
||||
Tab::Files => " [a] toggle all/changed [↑↓] scroll [g] edit .gitignore [q] back".into(),
|
||||
}
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(hint.as_str()).style(Style::default().fg(Color::DarkGray)),
|
||||
vert[2],
|
||||
);
|
||||
}
|
||||
|
||||
fn render_header(f: &mut Frame, st: &St, area: Rect) {
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Length(1)])
|
||||
.split(area);
|
||||
|
||||
let r = &st.summary;
|
||||
let owner_s = r.owner.as_deref().map(|o| format!(" @{}", o)).unwrap_or_default();
|
||||
let stars_s = r.stars.map(|s| format!(" ★ {}", s)).unwrap_or_default();
|
||||
let issues_s = r.open_issues.map(|i| if i > 0 { format!(" ⚠ {} issues", i) } else { String::new() }).unwrap_or_default();
|
||||
let prs_s = r.open_prs.map(|p| if p > 0 { format!(" ↔ {} PRs", p) } else { String::new() }).unwrap_or_default();
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {}", r.name), Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(owner_s, Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(format!(" ⎇ {}", r.branch), Style::default().fg(Color::Cyan)),
|
||||
if r.is_dirty { Span::styled(" ⚠ dirty", Style::default().fg(Color::Yellow)) }
|
||||
else { Span::styled(" ✓ clean", Style::default().fg(Color::DarkGray)) },
|
||||
Span::styled(stars_s, Style::default().fg(Color::Yellow)),
|
||||
Span::styled(issues_s, Style::default().fg(Color::Red)),
|
||||
Span::styled(prs_s, Style::default().fg(Color::Cyan)),
|
||||
])),
|
||||
rows[0],
|
||||
);
|
||||
|
||||
let tab_s = |label: &str, on: bool| {
|
||||
if on { Span::styled(format!(" {} ", label), Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)) }
|
||||
else { Span::styled(format!(" {} ", label), Style::default().fg(Color::DarkGray)) }
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
tab_s("[1] Overview", st.tab == Tab::Overview),
|
||||
Span::raw(" "),
|
||||
tab_s("[2] History", st.tab == Tab::History),
|
||||
Span::raw(" "),
|
||||
tab_s("[3] Files", st.tab == Tab::Files),
|
||||
])),
|
||||
rows[1],
|
||||
);
|
||||
}
|
||||
|
||||
fn render_sidebar(f: &mut Frame, st: &St, area: Rect) {
|
||||
let blk = Block::default().title(" Actions ").borders(Borders::LEFT)
|
||||
.border_style(Style::default().fg(Color::DarkGray));
|
||||
let inner = blk.inner(area);
|
||||
f.render_widget(blk, area);
|
||||
|
||||
let act = |key: &'static str, label: &'static str, col: Color| -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" [{}]", key), Style::default().fg(col).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(format!(" {}", label), Style::default().fg(Color::White)),
|
||||
])
|
||||
};
|
||||
let sep = || Line::from(Span::styled(" ─────────────────", Style::default().fg(Color::DarkGray)));
|
||||
let stat_line = |label: &str, val: String, col: Color| -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:<10}", label), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(val, Style::default().fg(col)),
|
||||
])
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line> = vec![
|
||||
stat_line("Branch", st.summary.branch.chars().take(11).collect(), Color::Cyan),
|
||||
stat_line("Tags", st.total_tags.to_string(), Color::Yellow),
|
||||
stat_line("Stashes", st.stash_count.to_string(), Color::DarkGray),
|
||||
stat_line("Branches", st.branch_count.to_string(), Color::DarkGray),
|
||||
sep(),
|
||||
act("s", "Sync", Color::Green),
|
||||
act("r", "Release", Color::Green),
|
||||
sep(),
|
||||
act("o", "Editor", Color::Cyan),
|
||||
act("W", "Browser", Color::Cyan),
|
||||
act("y", "Copy path", Color::Cyan),
|
||||
act("u", "Copy URL", Color::Cyan),
|
||||
act("g", ".gitignore", Color::Cyan),
|
||||
sep(),
|
||||
act("c", "New branch", Color::White),
|
||||
act("S", "Stash", Color::White),
|
||||
act("A", "Pop stash", Color::White),
|
||||
sep(),
|
||||
act("q", "← Back", Color::DarkGray),
|
||||
];
|
||||
|
||||
if let Some(ref msg) = st.status_msg {
|
||||
lines.push(sep());
|
||||
let trunc: String = msg.chars().take(20).collect();
|
||||
lines.push(Line::from(Span::styled(format!(" {}", trunc), Style::default().fg(Color::Green))));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
fn render_overview(f: &mut Frame, st: &St, area: Rect) {
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(52), Constraint::Percentage(48)])
|
||||
.split(area);
|
||||
|
||||
let r = &st.summary;
|
||||
|
||||
let mut all_lines: Vec<Line> = vec![section("Identity"), Line::from("")];
|
||||
all_lines.push(kv("Path", &r.path.to_string_lossy()));
|
||||
all_lines.push(kv("Owner", r.owner.as_deref().unwrap_or("—")));
|
||||
all_lines.push(kv("Visibility", r.visibility.as_deref().unwrap_or("unknown")));
|
||||
all_lines.push(kv("Language", r.lang.as_deref().unwrap_or("—")));
|
||||
all_lines.push(kv("License", if r.license { "✓ present" } else { "⚠ not found" }));
|
||||
if let Some(s) = r.stars { all_lines.push(kv("Stars", &format!("★ {}", s))); }
|
||||
if let Some(i) = r.open_issues { all_lines.push(kv("Issues", &format!("{} open", i))); }
|
||||
if let Some(p) = r.open_prs { all_lines.push(kv("Open PRs", &format!("{}", p))); }
|
||||
all_lines.push(Line::from(""));
|
||||
|
||||
all_lines.push(section("Status"));
|
||||
all_lines.push(Line::from(""));
|
||||
let branch_detail = match (r.ahead, r.behind) {
|
||||
(0, 0) => " (in sync)".to_string(),
|
||||
(a, 0) => format!(" ↑{}", a),
|
||||
(0, b) => format!(" ↓{}", b),
|
||||
(a, b) => format!(" ↑{} ↓{}", a, b),
|
||||
};
|
||||
all_lines.push(Line::from(vec![
|
||||
Span::styled(" Branch ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(r.branch.clone(), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(branch_detail, Style::default().fg(Color::DarkGray)),
|
||||
]));
|
||||
let (dirty_txt, dirty_col) = if r.is_dirty {
|
||||
(format!("⚠ {} file(s)", st.files.len()), Color::Yellow)
|
||||
} else { ("✓ clean".into(), Color::DarkGray) };
|
||||
all_lines.push(Line::from(vec![
|
||||
Span::styled(" Changes ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(dirty_txt, Style::default().fg(dirty_col)),
|
||||
]));
|
||||
all_lines.push(Line::from(""));
|
||||
|
||||
all_lines.push(section("Repository"));
|
||||
all_lines.push(Line::from(""));
|
||||
all_lines.push(kv("Files", &st.file_count.to_string()));
|
||||
all_lines.push(kv("Branches", &st.branch_count.to_string()));
|
||||
all_lines.push(kv("Contributors", &st.contributors.to_string()));
|
||||
all_lines.push(kv("Stashes", &st.stash_count.to_string()));
|
||||
all_lines.push(kv("Tags", &st.total_tags.to_string()));
|
||||
all_lines.push(Line::from(""));
|
||||
|
||||
all_lines.push(section("Last commit"));
|
||||
all_lines.push(Line::from(""));
|
||||
all_lines.push(kv("When", &r.last_commit_age));
|
||||
let msg_trunc: String = r.last_commit_msg.chars().take(46).collect();
|
||||
all_lines.push(Line::from(vec![
|
||||
Span::styled(" Message ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(format!("\"{}\"", msg_trunc), Style::default().fg(Color::White)),
|
||||
]));
|
||||
|
||||
let skip = st.ov_scroll.min(all_lines.len().saturating_sub(1));
|
||||
let visible: Vec<Line> = all_lines.into_iter().skip(skip).collect();
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(visible).block(Block::default().borders(Borders::RIGHT)),
|
||||
cols[0],
|
||||
);
|
||||
|
||||
let mut right: Vec<Line> = vec![section("Recent commits"), Line::from("")];
|
||||
for (age, msg) in &st.recent_commits {
|
||||
right.push(Line::from(vec![
|
||||
Span::styled(format!(" {:>8} ", age), Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(msg.chars().take(cols[1].width.saturating_sub(14) as usize).collect::<String>()),
|
||||
]));
|
||||
}
|
||||
if !st.files.is_empty() {
|
||||
right.push(Line::from(""));
|
||||
right.push(section("Changed files"));
|
||||
right.push(Line::from(""));
|
||||
if st.file_count > 500 {
|
||||
right.push(Line::from(Span::styled(
|
||||
format!(" ⚠ {} tracked — check .gitignore!", st.file_count),
|
||||
Style::default().fg(Color::Yellow),
|
||||
)));
|
||||
}
|
||||
for (ch, path) in st.files.iter().take(10) {
|
||||
let col = match ch { 'M' => Color::Yellow, 'D' => Color::Red, _ => Color::Green };
|
||||
right.push(Line::from(vec![
|
||||
Span::styled(format!(" {} ", ch), Style::default().fg(col)),
|
||||
Span::styled(
|
||||
path.chars().take(cols[1].width.saturating_sub(6) as usize).collect::<String>(),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]));
|
||||
}
|
||||
if st.files.len() > 10 {
|
||||
right.push(Line::from(Span::styled(
|
||||
format!(" … {} more", st.files.len() - 10),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(right), cols[1]);
|
||||
}
|
||||
|
||||
fn render_history(f: &mut Frame, st: &St, area: Rect) {
|
||||
let blk = Block::default()
|
||||
.title(format!(" History ({} entries) ", st.history.len()))
|
||||
.borders(Borders::ALL);
|
||||
let inner = blk.inner(area);
|
||||
f.render_widget(blk, area);
|
||||
|
||||
let avail = inner.height.saturating_sub(1) as usize;
|
||||
let header = Row::new(vec!["TYPE", "REF", "AGE", "AUTHOR / TAG MSG", "MESSAGE"])
|
||||
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
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("🔖 tag").style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
Cell::from(name.clone()).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||
Cell::from(age.clone()).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(""),
|
||||
Cell::from(if msg.is_empty() { "(lightweight tag)".to_string() } else { msg.clone() })
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
]).style(Style::default().bg(Color::Reset))
|
||||
}
|
||||
HistEntry::Commit { sha, age, author, msg, .. } => {
|
||||
Row::new(vec![
|
||||
Cell::from("●").style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(sha.clone()).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(age.clone()).style(Style::default().fg(Color::DarkGray)),
|
||||
Cell::from(format!("@{}", author)).style(Style::default().fg(Color::Cyan)),
|
||||
Cell::from(msg.clone()),
|
||||
])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
f.render_widget(
|
||||
Table::new(rows, [
|
||||
Constraint::Length(7),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(18),
|
||||
Constraint::Fill(1),
|
||||
]).header(header),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_files(f: &mut Frame, st: &St, area: Rect) {
|
||||
let title: &str = if st.show_all_files {
|
||||
" All tracked files — [a] show changed "
|
||||
} else {
|
||||
" Changed files — [a] show all tracked "
|
||||
};
|
||||
|
||||
let blk = Block::default().title(title).borders(Borders::ALL);
|
||||
let inner = blk.inner(area);
|
||||
f.render_widget(blk, area);
|
||||
|
||||
let mut warn_rows = 0usize;
|
||||
if st.file_count > 500 && !st.show_all_files {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
format!(" ⚠ {} tracked files — large repo, consider .gitignore [g] edit it", st.file_count),
|
||||
Style::default().fg(Color::Yellow),
|
||||
))),
|
||||
Rect::new(inner.x, inner.y, inner.width, 1),
|
||||
);
|
||||
warn_rows = 1;
|
||||
}
|
||||
|
||||
let usable = Rect::new(inner.x, inner.y + warn_rows as u16, inner.width, inner.height.saturating_sub(warn_rows as u16));
|
||||
|
||||
if st.show_all_files {
|
||||
let avail = usable.height as usize;
|
||||
let items: Vec<ListItem> = st.all_files.iter()
|
||||
.skip(st.file_off).take(avail)
|
||||
.map(|p| ListItem::new(Line::from(Span::styled(format!(" {}", p), Style::default().fg(Color::DarkGray)))))
|
||||
.collect();
|
||||
let count = Line::from(Span::styled(
|
||||
format!(" {} files total (skip: {})", st.all_files.len(), st.file_off),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
let mut all_items = vec![ListItem::new(count)];
|
||||
all_items.extend(items);
|
||||
f.render_widget(List::new(all_items), usable);
|
||||
} else {
|
||||
if st.files.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(" (working tree clean)").style(Style::default().fg(Color::DarkGray)),
|
||||
usable,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let avail = usable.height.saturating_sub(1) as usize;
|
||||
let rows: Vec<Row> = st.files.iter()
|
||||
.skip(st.file_off).take(avail)
|
||||
.map(|(ch, p)| {
|
||||
let col = match ch { 'M' => Color::Yellow, 'D' => Color::Red, _ => Color::Green };
|
||||
Row::new(vec![
|
||||
Cell::from(ch.to_string()).style(Style::default().fg(col)),
|
||||
Cell::from(p.clone()),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
f.render_widget(Table::new(rows, [Constraint::Length(3), Constraint::Fill(1)]), usable);
|
||||
}
|
||||
}
|
||||
|
||||
fn section(title: &str) -> Line<'static> {
|
||||
Line::from(Span::styled(
|
||||
format!(" {}", title),
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
}
|
||||
|
||||
fn kv(key: &str, val: &str) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {:<15}", key), Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(val.to_string()),
|
||||
])
|
||||
}
|
||||
Reference in new issue
Block a user