diff --git a/src/detail/mod.rs b/src/detail/mod.rs index d4ed514..15eea9d 100644 --- a/src/detail/mod.rs +++ b/src/detail/mod.rs @@ -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, -) -> Result<()> { +) -> Result { 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<()> { diff --git a/src/hub/keys.rs b/src/hub/keys.rs index 2e1befc..31b6dfa 100644 --- a/src/hub/keys.rs +++ b/src/hub/keys.rs @@ -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)> { - 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) -> Result<(PhaseResult, Option)> { + let mut terminal = match existing_term { + Some(t) => t, + None => crate::tui::setup()?, + }; let result = (|| -> Result { loop { state.poll_incoming(); diff --git a/src/hub/mod.rs b/src/hub/mod.rs index 3204347..e731ab8 100644 --- a/src/hub/mod.rs +++ b/src/hub/mod.rs @@ -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 = 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 = 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 diff --git a/src/hub/project_detail.rs b/src/hub/project_detail.rs index 26963b6..5d22a87 100644 --- a/src/hub/project_detail.rs +++ b/src/hub/project_detail.rs @@ -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, cfg: &AppConfig, maybe_term: Option) -> 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, cfg: &AppConfig, maybe_term: Option) -> Result { + // `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) -> 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) { 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 => {