Files
vulp/src/detail/mod.rs
T
dualfroz a3658d5e97 fix: eliminate alt-screen flash when returning from repo/project detail
The hub hands its live terminal to detail/project_detail on open (already
flash-free going in), but both sub-views unconditionally tore the terminal
down on exit; the hub's next loop iteration then always called tui::setup()
again, producing a real LeaveAlternateScreen -> (plain terminal visible for
one frame) -> EnterAlternateScreen blink on every trip back.

- detail::run / project_detail::run now return the live terminal on a clean
  exit instead of tearing it down (only torn down if the inner loop itself
  errors); hub::tui_phase accepts an already-live terminal and skips its own
  setup() when given one, threading it across loop iterations
- project_detail's own nested repo-detail call (Enter on its Repos tab) had
  the same issue one level down, fixed the same way via Option<Tty> so the
  terminal can be taken out for the nested call and put back
- verified via raw pty byte counts: two full hub<->detail round trips now
  show zero EnterAlternateScreen/LeaveAlternateScreen pairs in between,
  vs. one pair per trip before
2026-07-07 17:58:02 +02:00

265 lines
9.9 KiB
Rust

//! Repo-detail view: the full-screen TUI shown after selecting a single
//! repo from the hub. Owns the loop that polls background fetches, draws
//! the current tab, and routes key events — the actual state shape lives in
//! `types.rs`, key handling in `keys.rs`, background I/O in `settings_io.rs`,
//! out-of-TUI actions in `actions.rs`, and rendering in `render.rs`.
mod actions;
mod git;
mod keys;
mod render;
mod settings_io;
mod types;
pub use git::{activity_1d, activity_7d, activity_30d, activity_90d, activity_1y, activity_all};
pub use types::*;
/// Builds the full commit/tag history for `path`, exposed at the crate root
/// so the project-detail view can reuse the same history-building logic
/// without depending on `detail`'s private `git` submodule directly.
pub fn build_history_pub(path: &std::path::Path, limit: usize) -> Vec<HistEntry> {
git::build_history(path, limit)
}
use crate::config::AppConfig;
use crate::status::{count_tracked_files, repo_detail, repo_recent_commits, RepoSummary};
use actions::exec_action;
use anyhow::Result;
use crossterm::event::Event;
use git::{
build_history, build_tree, count_lines_of_code, count_stashes, count_tags, largest_files,
list_all_files, list_branches, list_contributors, read_file_preview, remote_url,
repo_first_commit_ts, total_commits,
};
use keys::{handle_key, handle_modal_key};
use settings_io::{compute_activity, poll_api_rx, poll_prs_issues_rx, poll_settings_rx, spawn_prs_issues_fetch, spawn_settings_fetch};
// ── entry point ───────────────────────────────────────────────────────────────
/// Opens the detail view for `summary`. Reuses an already-live terminal
/// handed over by the hub when given one (avoids a flash of the alternate
/// screen toggling off and back on), otherwise sets up a fresh one. On a
/// normal exit (back to the hub), hands the still-live terminal back to the
/// caller instead of tearing it down — the hub reuses it for its own next
/// frame, so the alternate screen never toggles off in between. Only torn
/// down here if `run_detail_loop` itself errors.
pub async fn run(
summary: RepoSummary,
cfg: &AppConfig,
maybe_term: Option<crate::tui::Tty>,
) -> Result<crate::tui::Tty> {
let path = summary.path.clone();
let name = summary.name.clone();
// Reuse the handed terminal (no LeaveAlternateScreen flash) or create a
// new one. Either way, TUI is live before any blocking git calls.
let mut terminal = match maybe_term {
Some(t) => t,
None => crate::tui::setup()?,
};
// Show "Loading…" while we run the blocking git subprocesses.
terminal.draw(|f| {
use ratatui::widgets::Paragraph;
f.render_widget(
Paragraph::new(format!(" Loading {}…", name))
.style(ratatui::style::Style::default().fg(ratatui::style::Color::DarkGray)),
f.area(),
);
})?;
let detail = repo_detail(&path).unwrap_or_else(|_| crate::status::RepoDetail {
summary: summary.clone(),
modified_files: vec![],
recent_commits: vec![],
meta_issues: None, meta_prs: None, meta_stars: None,
});
let all_files = list_all_files(&path);
let tree_nodes = build_tree(&all_files);
let tree_visible = rebuild_tree_visible(&tree_nodes);
let contributors = list_contributors(&path);
let branches = list_branches(&path);
let branch_count = branches.len().max(1);
let first_ts = repo_first_commit_ts(&path).unwrap_or(0);
let repo_age_days = ((chrono::Utc::now().timestamp() - first_ts) / 86400).max(0) as u64;
let total = total_commits(&path);
let url_str = remote_url(&path);
let loc = count_lines_of_code(&path);
let top_f = largest_files(&path, 5);
let activity = activity_1y(&path);
let mut st = St {
tab: Tab::Overview,
files: detail.modified_files,
file_count: count_tracked_files(&path),
total_tags: count_tags(&path),
stash_count: count_stashes(&path),
branch_count,
history: build_history(&path, 200),
hist_off: 0,
total_commits: total,
branches,
branch_cursor: 0,
all_files,
tree_nodes,
tree_visible,
tree_cursor: 0,
file_preview: vec![],
preview_off: 0,
contributors,
repo_age_days,
stats_off: 0,
stats_period: StatsPeriod::Year1,
activity_data: activity,
loc_total: loc,
top_files: top_f,
ov_recent: repo_recent_commits(&path, 6),
ov_off: 0,
remote_url_str: url_str,
settings: None,
settings_cursor: 0,
settings_rx: None,
settings_err: None,
api_rx: None,
repo_prs: vec![],
repo_issues: vec![],
repo_todos: {
let rname = name.clone();
crate::todos::load_manual().into_iter()
.filter(|t| t.repo.as_deref() == Some(&rname))
.collect()
},
pr_cursor: 0,
issue_cursor: 0,
todo_cursor: 0,
prs_loaded: false,
prs_issues_rx: None,
quit: false,
quit_after_op: false,
run_action: None,
status_msg: None,
cfg_data_dir: cfg.data_dir.clone(),
cfg_editor: cfg.editor.clone(),
pending_sync_mode: None,
pending_commit_msg: None,
pending_tag_name: None,
pending_tag_msg: None,
pending_push_tag: false,
modal: DetailModal::None,
pending_file: None,
summary,
};
// Main loop — terminal stays alive the whole time.
match run_detail_loop(&mut st, &mut terminal).await {
Ok(()) => Ok(terminal),
Err(e) => { crate::tui::teardown(terminal)?; Err(e) }
}
}
async fn run_detail_loop(st: &mut St, terminal: &mut crate::tui::Tty) -> Result<()> {
loop {
let action = tui_phase_inner(st, terminal)?;
if st.quit { break; }
if let Some(a) = action {
// Leave alternate screen for interactive prompts, then return.
crate::tui::leave_tui(terminal)?;
let r = exec_action(st, a).await;
crate::tui::enter_tui(terminal)?;
r?;
}
}
Ok(())
}
/// Runs draw/poll/input frames until a key handler requests an out-of-TUI
/// action (returned as `Some(idx)`) or the view quits (returned as `None`).
/// `idx` values 40/41/50/80 are handled here rather than handed up to
/// `exec_action`, since they're cheap, non-interactive follow-ups (refresh a
/// file preview, recompute a stats chart, kick off a background fetch) that
/// don't need to leave the alternate screen.
fn tui_phase_inner(st: &mut St, terminal: &mut crate::tui::Tty) -> Result<Option<usize>> {
let path = st.summary.path.clone();
loop {
poll_settings_rx(st);
poll_api_rx(st);
poll_prs_issues_rx(st);
terminal.draw(|f| render::draw(f, st))?;
let event = crate::tui::next_event(250)?;
let key = match event { Some(Event::Key(k)) => k, _ => continue };
if handle_modal_key(key.code, st) {
if st.quit { return Ok(None); }
if let Some(a) = st.run_action.take() {
if a == 40 {
if let Some(&ni) = st.tree_visible.get(st.tree_cursor) {
let node = &st.tree_nodes[ni];
if !node.is_dir {
st.file_preview = read_file_preview(&path, &node.rel_path, 200);
} else {
st.file_preview = vec![];
}
st.preview_off = 0;
}
} else if a == 41 {
st.activity_data = compute_activity(&path, st.stats_period);
} else if a == 50 {
spawn_settings_fetch(st);
} else if a == 80 {
spawn_prs_issues_fetch(st);
} else {
return Ok(Some(a));
}
}
continue;
}
handle_key(key.code, st);
if st.quit { return Ok(None); }
if let Some(a) = st.run_action.take() {
if a == 40 {
if let Some(&ni) = st.tree_visible.get(st.tree_cursor) {
let node = &st.tree_nodes[ni];
if !node.is_dir {
st.file_preview = read_file_preview(&path, &node.rel_path, 200);
} else {
st.file_preview = vec![];
}
st.preview_off = 0;
}
continue;
}
if a == 41 {
st.activity_data = compute_activity(&path, st.stats_period);
continue;
}
if a == 50 {
spawn_settings_fetch(st);
continue;
}
if a == 80 {
spawn_prs_issues_fetch(st);
continue;
}
return Ok(Some(a));
}
}
}
/// Triggers a Settings-tab fetch the first time that tab is visited this
/// session, so switching tabs repeatedly doesn't refetch on every visit.
fn maybe_load_settings(st: &mut St) {
if st.tab == Tab::Settings && st.settings.is_none() {
st.run_action = Some(50);
}
}
/// Triggers a PRs/Issues fetch the first time either of those tabs is
/// visited this session, mirroring `maybe_load_settings`.
fn maybe_load_prs_issues(st: &mut St) {
if (st.tab == Tab::PRs || st.tab == Tab::Issues) && !st.prs_loaded && st.prs_issues_rx.is_none() {
st.run_action = Some(80);
}
}