diff --git a/src/app/actions.rs b/src/app/actions.rs index 3ae13a4..2016300 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -11,7 +11,9 @@ pub enum Action { Refresh, OpenInBrowser, ToggleSearch, - ToggleDetail, + ToggleGitLog, + ToggleDiff, + CloseOverlay, SearchInput(char), SearchBackspace, SearchClear, @@ -47,6 +49,15 @@ pub enum DataPayload { key: String, msg: String, }, + PrDiffLoaded { + /// PR url — the key into `AppState::pr_diffs`. + key: String, + diff: String, + }, + PrDiffFailed { + key: String, + msg: String, + }, } #[derive(Debug)] @@ -63,5 +74,12 @@ pub enum SideEffect { /// PR url — echoed back so the result can be stored under the right key. key: String, }, + FetchPrDiff { + owner: String, + name: String, + number: u32, + /// PR url — echoed back so the result can be stored under the right key. + key: String, + }, OpenUrl(String), } diff --git a/src/app/event_loop.rs b/src/app/event_loop.rs index 2759776..50287e7 100644 --- a/src/app/event_loop.rs +++ b/src/app/event_loop.rs @@ -13,7 +13,7 @@ use tokio::sync::{Semaphore, mpsc}; use tracing::{debug, error}; use crate::app::actions::{Action, DataPayload, SideEffect}; -use crate::app::state::AppState; +use crate::app::state::{AppState, DiffEntry, FocusedPane, Overlay, PrDetailEntry}; use crate::app::update::update; use crate::app::view; use crate::cache::CacheStore; @@ -96,8 +96,8 @@ async fn run_loop( // does not spray API calls. Starts far in the future (disarmed). let detail_debounce = tokio::time::sleep(tokio::time::Duration::from_secs(86_400)); tokio::pin!(detail_debounce); - let mut armed_key: Option = None; - let mut pending_detail: Option = None; + let mut armed_key: Option<(String, Overlay)> = None; + let mut pending_fetch: Option<(crate::github::PullRequest, Overlay)> = None; loop { // Render @@ -107,24 +107,28 @@ async fn run_loop( break; } - // (Re)arm the detail debounce whenever the highlighted PR changes while the - // pane is open and we don't already have (or are fetching) its detail. - let desired_pr = if state.detail_open { + // (Re)arm the debounce whenever the highlighted PR or the open overlay + // changes and we don't already have (or are fetching) the data it needs. + let desired_pr = if state.overlay != Overlay::None { state.selected_pr() } else { None }; - let desired_key = desired_pr.as_ref().map(|p| p.url.clone()); + let desired_key = desired_pr.as_ref().map(|p| (p.url.clone(), state.overlay)); if desired_key != armed_key { armed_key = desired_key; - match desired_pr { - Some(pr) if !state.pr_details.contains_key(&pr.url) => { - pending_detail = Some(pr); - detail_debounce.as_mut().reset( - tokio::time::Instant::now() + tokio::time::Duration::from_millis(200), - ); - } - _ => pending_detail = None, + let needs_fetch = match (&desired_pr, state.overlay) { + (Some(pr), Overlay::GitLog) => !state.pr_details.contains_key(&pr.url), + (Some(pr), Overlay::Diff) => !state.pr_diffs.contains_key(&pr.url), + _ => false, + }; + if needs_fetch { + pending_fetch = desired_pr.map(|pr| (pr, state.overlay)); + detail_debounce + .as_mut() + .reset(tokio::time::Instant::now() + tokio::time::Duration::from_millis(200)); + } else { + pending_fetch = None; } } @@ -180,19 +184,32 @@ async fn run_loop( } } } - // Debounced PR detail fetch (only polled while a fetch is pending) - _ = &mut detail_debounce, if pending_detail.is_some() => { - if let Some(pr) = pending_detail.take() { - state - .pr_details - .insert(pr.url.clone(), crate::app::state::PrDetailEntry::Loading); + // Debounced overlay fetch (only polled while a fetch is pending) + _ = &mut detail_debounce, if pending_fetch.is_some() => { + if let Some((pr, overlay)) = pending_fetch.take() { + let effect = match overlay { + Overlay::GitLog => { + state.pr_details.insert(pr.url.clone(), PrDetailEntry::Loading); + SideEffect::FetchPrDetail { + owner: pr.repo_owner.clone(), + name: pr.repo_name.clone(), + number: pr.number, + key: pr.url.clone(), + } + } + Overlay::Diff => { + state.pr_diffs.insert(pr.url.clone(), DiffEntry::Loading); + SideEffect::FetchPrDiff { + owner: pr.repo_owner.clone(), + name: pr.repo_name.clone(), + number: pr.number, + key: pr.url.clone(), + } + } + Overlay::None => continue, + }; spawn_side_effect( - SideEffect::FetchPrDetail { - owner: pr.repo_owner.clone(), - name: pr.repo_name.clone(), - number: pr.number, - key: pr.url.clone(), - }, + effect, &config, &client, &viewer_login, @@ -238,19 +255,42 @@ fn map_event_to_action(event: &Event, state: &AppState) -> Option { }; } + // Handle an open overlay (git log / diff): keys act on the overlay itself, so + // l/d switch between views, j/k scroll (diff), and Esc/h close. + if state.overlay != Overlay::None { + return match code { + KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => Some(Action::CloseOverlay), + KeyCode::Char('l') => Some(Action::ToggleGitLog), + KeyCode::Char('d') => Some(Action::ToggleDiff), + KeyCode::Char('j') | KeyCode::Down => Some(Action::MoveDown), + KeyCode::Char('k') | KeyCode::Up => Some(Action::MoveUp), + KeyCode::Char('o') => Some(Action::OpenInBrowser), + KeyCode::Char('q') => Some(Action::Quit), + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => Some(Action::Quit), + _ => None, + }; + } + + let in_content = state.focused_pane == FocusedPane::Content; + // Normal mode match code { KeyCode::Char('q') => Some(Action::Quit), KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => Some(Action::Quit), KeyCode::Char('j') | KeyCode::Down => Some(Action::MoveDown), KeyCode::Char('k') | KeyCode::Up => Some(Action::MoveUp), - KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => Some(Action::Select), + KeyCode::Enter | KeyCode::Right => Some(Action::Select), + // In the content pane, `l` opens the git-log overlay for the highlighted + // PR; in the nav tree it keeps its vim-style expand/select meaning. + KeyCode::Char('l') if in_content => Some(Action::ToggleGitLog), + KeyCode::Char('l') => Some(Action::Select), + // `d` opens the diff overlay, content pane only. + KeyCode::Char('d') if in_content => Some(Action::ToggleDiff), KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left => Some(Action::Back), KeyCode::Tab => Some(Action::SwitchPane), KeyCode::BackTab => Some(Action::SwitchPane), KeyCode::Char('r') => Some(Action::Refresh), KeyCode::Char('o') => Some(Action::OpenInBrowser), - KeyCode::Char('d') => Some(Action::ToggleDetail), KeyCode::Char('/') => Some(Action::ToggleSearch), _ => None, } @@ -554,6 +594,35 @@ fn spawn_side_effect( } }); } + SideEffect::FetchPrDiff { + owner, + name, + number, + key, + } => { + let client = client.clone(); + let tx = action_tx.clone(); + let sem = semaphore.clone(); + + tokio::spawn(async move { + let _permit = sem.acquire().await; + debug!(owner = %owner, name = %name, number = number, "Fetching PR diff"); + + match client.fetch_pr_diff(&owner, &name, number).await { + Ok(diff) => { + let _ = + tx.send(Action::DataLoaded(DataPayload::PrDiffLoaded { key, diff })); + } + Err(e) => { + error!(error = %e, "Failed to fetch PR diff"); + let _ = tx.send(Action::DataLoaded(DataPayload::PrDiffFailed { + key, + msg: format!("{}", e), + })); + } + } + }); + } SideEffect::OpenUrl(url) => { tokio::task::spawn_blocking(move || { if let Err(e) = crate::util::browser::open_url(&url) { diff --git a/src/app/state.rs b/src/app/state.rs index 4437e2d..46de286 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -10,6 +10,24 @@ pub enum PrDetailEntry { Failed(String), } +/// State of an on-demand PR diff fetch, keyed by PR url in `AppState::pr_diffs`. +#[derive(Debug, Clone)] +pub enum DiffEntry { + Loading, + Loaded(String), + Failed(String), +} + +/// Which full-screen overlay (if any) is shown for the highlighted PR. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Overlay { + None, + /// Recent commits for the PR ("git log"). + GitLog, + /// Full unified diff for the PR. + Diff, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum FocusedPane { Navigation, @@ -66,9 +84,12 @@ pub struct AppState { pub search_active: bool, pub search_query: String, - // PR detail pane (fetched on-highlight) - pub detail_open: bool, + // PR overlays (git log / diff), fetched on-highlight while open + pub overlay: Overlay, pub pr_details: HashMap, + pub pr_diffs: HashMap, + /// Vertical scroll offset (in lines) for the diff overlay. + pub diff_scroll: u16, // UI flags pub loading: bool, @@ -108,8 +129,10 @@ impl AppState { content_cursor: 0, search_active: false, search_query: String::new(), - detail_open: false, + overlay: Overlay::None, pr_details: HashMap::new(), + pr_diffs: HashMap::new(), + diff_scroll: 0, loading: true, loading_orgs: HashSet::new(), error_message: None, diff --git a/src/app/update.rs b/src/app/update.rs index 0cda2db..cecd055 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1,5 +1,7 @@ use crate::app::actions::{Action, DataPayload, SideEffect}; -use crate::app::state::{AppState, ContentView, FocusedPane, NavNode, OrgData, PrDetailEntry}; +use crate::app::state::{ + AppState, ContentView, DiffEntry, FocusedPane, NavNode, OrgData, Overlay, PrDetailEntry, +}; pub fn update(state: &mut AppState, action: Action) -> Vec { match action { @@ -8,6 +10,15 @@ pub fn update(state: &mut AppState, action: Action) -> Vec { vec![] } Action::MoveUp => { + // While the diff overlay is open, j/k scroll the diff instead of moving + // the underlying selection. + if state.overlay == Overlay::Diff { + state.diff_scroll = state.diff_scroll.saturating_sub(1); + return vec![]; + } + if state.overlay == Overlay::GitLog { + return vec![]; + } match state.focused_pane { FocusedPane::Navigation => { if state.nav_cursor > 0 { @@ -23,6 +34,13 @@ pub fn update(state: &mut AppState, action: Action) -> Vec { vec![] } Action::MoveDown => { + if state.overlay == Overlay::Diff { + state.diff_scroll = state.diff_scroll.saturating_add(1); + return vec![]; + } + if state.overlay == Overlay::GitLog { + return vec![]; + } match state.focused_pane { FocusedPane::Navigation => { if state.nav_cursor + 1 < state.nav_nodes.len() { @@ -83,8 +101,8 @@ pub fn update(state: &mut AppState, action: Action) -> Vec { state.search_query.clear(); } else if state.error_message.is_some() { state.error_message = None; - } else if state.detail_open { - state.detail_open = false; + } else if state.overlay != Overlay::None { + state.overlay = Overlay::None; } else if state.focused_pane == FocusedPane::Content { state.focused_pane = FocusedPane::Navigation; } @@ -100,8 +118,9 @@ pub fn update(state: &mut AppState, action: Action) -> Vec { Action::Refresh => { state.loading = true; state.error_message = None; - // Drop cached PR details so merge state / CI is recomputed fresh. + // Drop cached PR details / diffs so they are re-fetched fresh. state.pr_details.clear(); + state.pr_diffs.clear(); vec![SideEffect::RefreshAll] } Action::OpenInBrowser => { @@ -125,10 +144,27 @@ pub fn update(state: &mut AppState, action: Action) -> Vec { } vec![] } - Action::ToggleDetail => { - // Only meaningful in the content pane; the event loop fetches detail - // for the highlighted PR (debounced) while the pane is open. - state.detail_open = !state.detail_open; + Action::ToggleGitLog => { + // Only meaningful in the content pane; the event loop fetches the PR's + // commits (debounced) while the overlay is open. + state.overlay = if state.overlay == Overlay::GitLog { + Overlay::None + } else { + Overlay::GitLog + }; + vec![] + } + Action::ToggleDiff => { + state.diff_scroll = 0; + state.overlay = if state.overlay == Overlay::Diff { + Overlay::None + } else { + Overlay::Diff + }; + vec![] + } + Action::CloseOverlay => { + state.overlay = Overlay::None; vec![] } Action::SearchInput(ch) => { @@ -191,6 +227,14 @@ pub fn update(state: &mut AppState, action: Action) -> Vec { state.pr_details.insert(key, PrDetailEntry::Failed(msg)); return vec![]; } + DataPayload::PrDiffLoaded { key, diff } => { + state.pr_diffs.insert(key, DiffEntry::Loaded(diff)); + return vec![]; + } + DataPayload::PrDiffFailed { key, msg } => { + state.pr_diffs.insert(key, DiffEntry::Failed(msg)); + return vec![]; + } } // Check if all loading complete diff --git a/src/app/view.rs b/src/app/view.rs index 21c1c70..29c298b 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -30,7 +30,7 @@ pub fn render(f: &mut Frame, state: &AppState) { widgets::render_status_bar(f, status_area, state); // Overlays - widgets::render_pr_detail_overlay(f, state); + widgets::render_pr_overlay(f, state); widgets::render_search_overlay(f, state); if state.error_message.is_some() { widgets::render_error_modal(f, f.area(), state); diff --git a/src/github/graphql.rs b/src/github/graphql.rs index aa9eda3..f17d9c3 100644 --- a/src/github/graphql.rs +++ b/src/github/graphql.rs @@ -37,20 +37,37 @@ impl GithubClient { "variables": variables, }); - let resp = self - .client - .post(&self.api_url) - .bearer_auth(&self.token) - .json(&body) - .send() - .await - .context("GitHub API request failed")?; + // GitHub's GraphQL/search endpoint returns transient 5xx (commonly 502) + // under load, especially for broad searches over many repos. Retry a few + // times with backoff before surfacing the error. + const MAX_ATTEMPTS: u32 = 3; + let mut attempt = 0; + let resp = loop { + attempt += 1; + let resp = self + .client + .post(&self.api_url) + .bearer_auth(&self.token) + .json(&body) + .send() + .await + .context("GitHub API request failed")?; + + let status = resp.status(); + if status.is_success() { + break resp; + } + + if status.is_server_error() && attempt < MAX_ATTEMPTS { + let backoff = std::time::Duration::from_millis(500 * u64::from(attempt)); + debug!(%status, attempt, "GitHub API server error, retrying"); + tokio::time::sleep(backoff).await; + continue; + } - let status = resp.status(); - if !status.is_success() { let text = resp.text().await.unwrap_or_default(); bail!("GitHub API returned {}: {}", status, text); - } + }; let data: Value = resp .json() @@ -292,6 +309,48 @@ impl GithubClient { Ok((parse_pr_detail(pr_node), rate_limit)) } + + /// REST v3 base URL, derived from the configured GraphQL `api_url`. + /// `https://api.github.com/graphql` → `https://api.github.com`; + /// Enterprise `https://host/api/graphql` → `https://host/api/v3`. + fn rest_base(&self) -> String { + match self.api_url.strip_suffix("/graphql") { + Some(base) if base.ends_with("/api") => format!("{}/v3", base), + Some(base) => base.to_string(), + None => "https://api.github.com".to_string(), + } + } + + /// Fetch the full unified diff for a single PR via the REST API + /// (`Accept: application/vnd.github.v3.diff`), used by the diff overlay. + pub async fn fetch_pr_diff(&self, owner: &str, name: &str, number: u32) -> Result { + let url = format!( + "{}/repos/{}/{}/pulls/{}", + self.rest_base(), + owner, + name, + number + ); + + let resp = self + .client + .get(&url) + .bearer_auth(&self.token) + .header(reqwest::header::ACCEPT, "application/vnd.github.v3.diff") + .send() + .await + .context("GitHub diff request failed")?; + + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + bail!("GitHub API returned {}: {}", status, text); + } + + let diff = resp.text().await.context("Failed to read PR diff")?; + debug!(owner, name, number, bytes = diff.len(), "Fetched PR diff"); + Ok(diff) + } } fn parse_pr_detail(node: &Value) -> PrDetail { diff --git a/src/github/queries.rs b/src/github/queries.rs index 304e6d6..7cc36eb 100644 --- a/src/github/queries.rs +++ b/src/github/queries.rs @@ -88,7 +88,6 @@ query($owner: String!, $name: String!, $cursor: String) { deletions reviewDecision mergeable - mergeStateStatus commits(last: 1) { nodes { commit { @@ -112,7 +111,7 @@ query($owner: String!, $name: String!, $cursor: String) { pub const SEARCH_PRS_QUERY: &str = r#" query($query: String!, $cursor: String) { - search(query: $query, type: ISSUE, first: 100, after: $cursor) { + search(query: $query, type: ISSUE, first: 50, after: $cursor) { pageInfo { hasNextPage endCursor @@ -134,7 +133,6 @@ query($query: String!, $cursor: String) { deletions reviewDecision mergeable - mergeStateStatus commits(last: 1) { nodes { commit { diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index 6505801..3c8bab7 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -5,7 +5,9 @@ use ratatui::{ widgets::{Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row, Table}, }; -use crate::app::state::{AppState, ContentView, FocusedPane, NavNode, PrDetailEntry}; +use crate::app::state::{ + AppState, ContentView, DiffEntry, FocusedPane, NavNode, Overlay, PrDetailEntry, +}; use crate::github::models::{CiStatus, PrDetail, PullRequest}; use crate::ui::theme; use crate::util::time::relative_time; @@ -328,7 +330,7 @@ pub fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { let key_hints = if state.search_active { "Esc: close search | Enter: filter" } else { - "j/k: nav | Tab: switch pane | Enter: select | d: detail | /: search | r: refresh | o: open | q: quit" + "j/k: nav | Tab: pane | Enter: select | l: log | d: diff | /: search | r: refresh | o: open | q: quit" }; let status = if state.loading { @@ -499,29 +501,36 @@ fn detail_body_lines(detail: &PrDetail, max_commits: usize) -> Vec lines } -/// Detail-on-highlight overlay: fresh merge state, CI rollup, and recent commits -/// for the currently highlighted PR. -pub fn render_pr_detail_overlay(f: &mut Frame, state: &AppState) { - if !state.detail_open { - return; +/// Render the active PR overlay (git log or diff) for the highlighted PR, if any. +pub fn render_pr_overlay(f: &mut Frame, state: &AppState) { + match state.overlay { + Overlay::None => {} + Overlay::GitLog => render_git_log_overlay(f, state), + Overlay::Diff => render_diff_overlay(f, state), } - let Some(pr) = state.selected_pr() else { - return; - }; +} +/// Centered modal rect covering the given fraction of the screen. +fn overlay_area(f: &Frame, width_pct: u16, height_pct: u16) -> Rect { let area = f.area(); - let modal_width = (area.width * 3 / 4).clamp(40, area.width.saturating_sub(4)); - let modal_height = 14u16.min(area.height.saturating_sub(2)); - let x = (area.width.saturating_sub(modal_width)) / 2; - let y = (area.height.saturating_sub(modal_height)) / 2; - let modal_area = Rect { - x, - y, + let modal_width = (area.width * width_pct / 100).clamp(40, area.width.saturating_sub(2)); + let modal_height = (area.height * height_pct / 100).clamp(6, area.height.saturating_sub(2)); + Rect { + x: (area.width.saturating_sub(modal_width)) / 2, + y: (area.height.saturating_sub(modal_height)) / 2, width: modal_width, height: modal_height, + } +} + +/// Git-log overlay: recent commits (plus fresh merge/CI) for the highlighted PR. +fn render_git_log_overlay(f: &mut Frame, state: &AppState) { + let Some(pr) = state.selected_pr() else { + return; }; - let title = format!(" PR #{} — {} ", pr.number, pr.title); + let modal_area = overlay_area(f, 75, 60); + let title = format!(" Git log — PR #{} — {} ", pr.number, pr.title); let block = Block::default() .title(title) .borders(Borders::ALL) @@ -536,12 +545,12 @@ pub fn render_pr_detail_overlay(f: &mut Frame, state: &AppState) { vec![Line::from(Span::styled(msg.clone(), theme::ERROR))] } Some(PrDetailEntry::Loading) | None => { - vec![Line::from(Span::styled("Loading detail…", theme::DIM))] + vec![Line::from(Span::styled("Loading commits…", theme::DIM))] } }; lines.push(Line::from("")); lines.push(Line::from(Span::styled( - "Press d or Esc to close", + "l/Esc: close · d: diff", theme::DIM, ))); @@ -549,3 +558,80 @@ pub fn render_pr_detail_overlay(f: &mut Frame, state: &AppState) { let para = Paragraph::new(lines).block(block); f.render_widget(para, modal_area); } + +/// Style a single unified-diff line by its leading marker. +fn diff_line_style(line: &str) -> ratatui::style::Style { + use ratatui::style::{Color, Style}; + if line.starts_with("diff --git") || line.starts_with("index ") { + theme::NAV_ORG + } else if line.starts_with("@@") { + theme::PR_NUMBER + } else if line.starts_with("+++") || line.starts_with("---") { + theme::HEADER + } else if line.starts_with('+') { + Style::new().fg(Color::Green) + } else if line.starts_with('-') { + Style::new().fg(Color::Red) + } else { + Style::default() + } +} + +/// Diff overlay: full unified diff for the highlighted PR, scrollable with j/k. +fn render_diff_overlay(f: &mut Frame, state: &AppState) { + let Some(pr) = state.selected_pr() else { + return; + }; + + let modal_area = overlay_area(f, 90, 90); + let title = format!(" Diff — PR #{} — {} ", pr.number, pr.title); + + let body_height = modal_area.height.saturating_sub(3) as usize; + + let (lines, scrollable): (Vec, bool) = match state.pr_diffs.get(&pr.url) { + Some(DiffEntry::Loaded(diff)) if !diff.is_empty() => ( + diff.lines() + .map(|l| Line::from(Span::styled(l.to_string(), diff_line_style(l)))) + .collect(), + true, + ), + Some(DiffEntry::Loaded(_)) => ( + vec![Line::from(Span::styled("(empty diff)", theme::DIM))], + false, + ), + Some(DiffEntry::Failed(msg)) => ( + vec![Line::from(Span::styled(msg.clone(), theme::ERROR))], + false, + ), + Some(DiffEntry::Loading) | None => ( + vec![Line::from(Span::styled("Loading diff…", theme::DIM))], + false, + ), + }; + + // Clamp scroll so we can't page past the end. + let max_scroll = lines.len().saturating_sub(body_height) as u16; + let scroll = if scrollable { + state.diff_scroll.min(max_scroll) + } else { + 0 + }; + + let hint = if scrollable { + format!( + " j/k: scroll · d/Esc: close · l: log ({}/{}) ", + scroll, max_scroll + ) + } else { + " d/Esc: close · l: log ".to_string() + }; + let block = Block::default() + .title(title) + .title_bottom(Line::from(Span::styled(hint, theme::DIM))) + .borders(Borders::ALL) + .border_style(theme::BORDER_FOCUSED); + + f.render_widget(Clear, modal_area); + let para = Paragraph::new(lines).block(block).scroll((scroll, 0)); + f.render_widget(para, modal_area); +} diff --git a/tests/state_tests.rs b/tests/state_tests.rs index 298ead1..69c6295 100644 --- a/tests/state_tests.rs +++ b/tests/state_tests.rs @@ -1,5 +1,5 @@ use ghdash::app::actions::{Action, DataPayload, SideEffect}; -use ghdash::app::state::{AppState, ContentView, FocusedPane, NavNode}; +use ghdash::app::state::{AppState, ContentView, FocusedPane, NavNode, Overlay}; use ghdash::app::update::update; use ghdash::github::models::{PullRequest, RateLimit, Repo}; @@ -472,28 +472,47 @@ fn test_archived_repos_excluded_from_nav() { assert_eq!(repo_names, vec!["active-repo"]); } -// --- PR detail pane (task zkk5) --- +// --- PR overlays: git log & diff (task zkk5) --- #[test] -fn test_toggle_detail_flips_flag() { +fn test_toggle_git_log_flips_overlay() { let mut state = make_state(); - assert!(!state.detail_open); - update(&mut state, Action::ToggleDetail); - assert!(state.detail_open); - update(&mut state, Action::ToggleDetail); - assert!(!state.detail_open); + assert_eq!(state.overlay, Overlay::None); + update(&mut state, Action::ToggleGitLog); + assert_eq!(state.overlay, Overlay::GitLog); + update(&mut state, Action::ToggleGitLog); + assert_eq!(state.overlay, Overlay::None); } #[test] -fn test_back_closes_detail_before_switching_pane() { +fn test_toggle_diff_flips_overlay() { + let mut state = make_state(); + update(&mut state, Action::ToggleDiff); + assert_eq!(state.overlay, Overlay::Diff); + update(&mut state, Action::ToggleDiff); + assert_eq!(state.overlay, Overlay::None); +} + +#[test] +fn test_toggle_switches_between_overlays() { + let mut state = make_state(); + update(&mut state, Action::ToggleGitLog); + assert_eq!(state.overlay, Overlay::GitLog); + // Pressing the diff key while the log is open switches to the diff. + update(&mut state, Action::ToggleDiff); + assert_eq!(state.overlay, Overlay::Diff); +} + +#[test] +fn test_back_closes_overlay_before_switching_pane() { let mut state = make_state(); state.focused_pane = FocusedPane::Content; - update(&mut state, Action::ToggleDetail); - assert!(state.detail_open); + update(&mut state, Action::ToggleGitLog); + assert_eq!(state.overlay, Overlay::GitLog); - // Back should close the detail pane first, leaving focus on Content. + // Back should close the overlay first, leaving focus on Content. update(&mut state, Action::Back); - assert!(!state.detail_open); + assert_eq!(state.overlay, Overlay::None); assert_eq!(state.focused_pane, FocusedPane::Content); }