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
This commit is contained in:
dualfroz committed 2026-07-07 17:58:02 +02:00
1 parent 14ef7fb06c
commit a3658d5e97
4 files changed
+64 -22

No files matched your search

+10 -5
View File
@@ -38,12 +38,16 @@ use settings_io::{compute_activity, poll_api_rx, poll_prs_issues_rx, poll_settin
/// 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.
/// 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<()> {
) -> Result<crate::tui::Tty> {
let path = summary.path.clone();
let name = summary.name.clone();
@@ -147,9 +151,10 @@ pub async fn run(
};
// Main loop — terminal stays alive the whole time.
let result = run_detail_loop(&mut st, &mut terminal).await;
crate::tui::teardown(terminal)?;
result
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<()> {
+10 -2
View File
@@ -148,8 +148,16 @@ pub fn maybe_load_activity(state: &mut HubState) {
/// event, and routes it to whichever input mode is currently active. Modal/
/// confirm/filter input short-circuit here (they're loop control flow, not
/// part of the global keymap); everything else goes to `keys_dispatch`.
pub fn tui_phase(state: &mut HubState) -> Result<(PhaseResult, Option<crate::tui::Tty>)> {
let mut terminal = crate::tui::setup()?;
///
/// `existing_term` lets the caller hand back a terminal a sub-view (repo/
/// project detail) just returned instead of tearing down and setting up a
/// fresh one — the previous "leave alt screen, blink the real terminal,
/// re-enter" was visible for a frame every time detail handed control back.
pub fn tui_phase(state: &mut HubState, existing_term: Option<crate::tui::Tty>) -> Result<(PhaseResult, Option<crate::tui::Tty>)> {
let mut terminal = match existing_term {
Some(t) => t,
None => crate::tui::setup()?,
};
let result = (|| -> Result<PhaseResult> {
loop {
state.poll_incoming();
+10 -3
View File
@@ -174,8 +174,13 @@ pub async fn run_overview_tui(
log
};
// Carried across loop iterations so a terminal handed back by a sub-view
// (repo/project detail) can be reused by the next `tui_phase` call
// instead of tearing down and setting a fresh one up — see `tui_phase`'s
// doc comment for the flash this avoids.
let mut carried_term: Option<crate::tui::Tty> = None;
loop {
let (phase_result, handed_term) = tui_phase(&mut state)?;
let (phase_result, handed_term) = tui_phase(&mut state, carried_term.take())?;
if state.quit {
session::save(state.tab, state.cur_repo_idx().map(|ri| state.repos[ri].path.clone()));
@@ -186,12 +191,14 @@ pub async fn run_overview_tui(
PhaseResult::OpenDetail => {
state.open_detail = false;
if let Some(ri) = state.cur_repo_idx() {
let _ = crate::status::detail::run(state.repos[ri].clone(), cfg, handed_term).await;
if let Ok(term) = crate::status::detail::run(state.repos[ri].clone(), cfg, handed_term).await {
carried_term = Some(term);
}
} else if let Some(pi) = state.cur_proj_idx() {
let repo_indices = state.project_repos(pi);
let proj_repos: Vec<RepoSummary> =
repo_indices.iter().filter_map(|&ri| state.repos.get(ri)).cloned().collect();
project_detail::run(state.projects[pi].clone(), proj_repos, cfg, handed_term).await?;
carried_term = Some(project_detail::run(state.projects[pi].clone(), proj_repos, cfg, handed_term).await?);
}
// Detail view can rename/delete/archive repos or change branches —
// rescan so the hub's list reflects whatever just happened, instead
+34 -12
View File
@@ -109,28 +109,41 @@ impl St {
/// Runs the project detail sub-view until the user backs out. Reuses the
/// caller's terminal handle when given one (avoids an alternate-screen
/// flash when entering from the hub), otherwise sets up its own.
pub async fn run(project: Project, repos: Vec<RepoSummary>, cfg: &AppConfig, maybe_term: Option<crate::tui::Tty>) -> Result<()> {
let mut terminal = match maybe_term {
/// flash when entering from the hub), otherwise sets up its own. On a
/// normal exit, hands the still-live terminal back to the caller instead of
/// tearing it down, so the hub can reuse it for its own next frame without
/// the alternate screen toggling off in between; only torn down here if
/// `run_loop` itself errors.
pub async fn run(project: Project, repos: Vec<RepoSummary>, cfg: &AppConfig, maybe_term: Option<crate::tui::Tty>) -> Result<crate::tui::Tty> {
// `Option` (rather than a bare `Tty`) so the Enter-on-Repos branch below
// can `take()` it out for the nested `crate::detail::run` call (which
// needs to own the terminal to hand it further down) and put the
// (possibly-recreated) terminal back afterwards, without ever tearing
// down and setting up a fresh one in between.
let mut terminal = Some(match maybe_term {
Some(t) => t,
None => crate::tui::setup()?,
};
});
let mut st = St::new(project, repos, cfg.clone());
// Spawn commits aggregation immediately
spawn_commits(&mut st);
let result = run_loop(&mut st, &mut terminal).await;
crate::tui::teardown(terminal)?;
result
match run_loop(&mut st, &mut terminal).await {
Ok(()) => Ok(terminal.expect("terminal always present after a clean run_loop exit")),
Err(e) => {
if let Some(t) = terminal.take() { crate::tui::teardown(t)?; }
Err(e)
}
}
}
async fn run_loop(st: &mut St, terminal: &mut crate::tui::Tty) -> Result<()> {
async fn run_loop(st: &mut St, terminal: &mut Option<crate::tui::Tty>) -> Result<()> {
loop {
poll_commits(st);
poll_activity(st);
terminal.draw(|f| super::project_detail_render::draw(f, st))?;
terminal.as_mut().expect("terminal present during run_loop").draw(|f| super::project_detail_render::draw(f, st))?;
let event = crate::tui::next_event(250)?;
let key = match event { Some(Event::Key(k)) => k, _ => continue };
@@ -182,13 +195,13 @@ async fn run_loop(st: &mut St, terminal: &mut crate::tui::Tty) -> Result<()> {
continue;
}
handle_key(key.code, st).await;
handle_key(key.code, st, terminal).await;
if st.quit { break; }
}
Ok(())
}
async fn handle_key(code: KeyCode, st: &mut St) {
async fn handle_key(code: KeyCode, st: &mut St, terminal: &mut Option<crate::tui::Tty>) {
st.status_msg = None;
match code {
KeyCode::Char('q') | KeyCode::Esc => { st.quit = true; }
@@ -233,7 +246,16 @@ async fn handle_key(code: KeyCode, st: &mut St) {
KeyCode::Enter => match st.tab {
ProjectTab::Repos => {
if let Some(r) = st.repos.get(st.repo_cursor).cloned() {
let _ = crate::detail::run(r, &st.cfg, None).await;
// Hand our live terminal down (no flash), then take back
// whatever it returns instead of tearing it down here.
let handed = terminal.take();
match crate::detail::run(r, &st.cfg, handed).await {
Ok(t) => *terminal = Some(t),
// detail::run already tore its (our) terminal down on
// error — nothing left to draw to, so back out clean
// instead of panicking on the next frame's `.expect`.
Err(_) => st.quit = true,
}
}
}
ProjectTab::Todos => {