Test message 123!

This commit is contained in:
dualfroz committed 2026-06-14 00:22:54 +02:00
1 parent 4cf726709f
commit d4fdc9890a
52 files changed
+5537 -277

No files matched your search

+96
View File
@@ -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()
}
+244
View File
@@ -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(())
}
+365
View File
@@ -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()),
])
}