Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/app/actions.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::github::models::{PullRequest, RateLimit, Repo};
use crate::github::models::{PrDetail, PullRequest, RateLimit, Repo};

#[derive(Debug)]
#[allow(dead_code)]
Expand All @@ -11,6 +11,7 @@ pub enum Action {
Refresh,
OpenInBrowser,
ToggleSearch,
ToggleDetail,
SearchInput(char),
SearchBackspace,
SearchClear,
Expand All @@ -36,6 +37,16 @@ pub enum DataPayload {
prs: Vec<PullRequest>,
rate_limit: RateLimit,
},
PrDetailLoaded {
/// PR url — the key into `AppState::pr_details`.
key: String,
detail: PrDetail,
rate_limit: RateLimit,
},
PrDetailFailed {
key: String,
msg: String,
},
}

#[derive(Debug)]
Expand All @@ -45,5 +56,12 @@ pub enum SideEffect {
FetchUserRepos(String),
FetchInbox,
FetchAllOpenPrs,
FetchPrDetail {
owner: String,
name: String,
number: u32,
/// PR url — echoed back so the result can be stored under the right key.
key: String,
},
OpenUrl(String),
}
84 changes: 84 additions & 0 deletions src/app/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ async fn run_loop(
// First tick fires immediately (already handled by initial fetch above)
refresh_timer.tick().await;

// PR detail debounce: when the highlighted PR changes while the detail pane is
// open, wait for ~200ms of stable selection before fetching, so holding j/k
// 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<String> = None;
let mut pending_detail: Option<crate::github::PullRequest> = None;

loop {
// Render
terminal.draw(|f| view::render(f, &state))?;
Expand All @@ -99,6 +107,27 @@ 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 {
state.selected_pr()
} else {
None
};
let desired_key = desired_pr.as_ref().map(|p| p.url.clone());
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,
}
}

// Wait for events
tokio::select! {
// Terminal events
Expand Down Expand Up @@ -151,6 +180,28 @@ 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);
spawn_side_effect(
SideEffect::FetchPrDetail {
owner: pr.repo_owner.clone(),
name: pr.repo_name.clone(),
number: pr.number,
key: pr.url.clone(),
},
&config,
&client,
&viewer_login,
&cache_store,
&action_tx,
&semaphore,
);
}
}
}
}

Expand Down Expand Up @@ -199,6 +250,7 @@ fn map_event_to_action(event: &Event, state: &AppState) -> Option<Action> {
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,
}
Expand Down Expand Up @@ -470,6 +522,38 @@ fn spawn_side_effect(
}
});
}
SideEffect::FetchPrDetail {
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 detail");

match client.fetch_pr_detail(&owner, &name, number).await {
Ok((detail, rate_limit)) => {
let _ = tx.send(Action::DataLoaded(DataPayload::PrDetailLoaded {
key,
detail,
rate_limit,
}));
}
Err(e) => {
error!(error = %e, "Failed to fetch PR detail");
let _ = tx.send(Action::DataLoaded(DataPayload::PrDetailFailed {
key,
msg: format!("{}", e),
}));
}
}
});
}
SideEffect::OpenUrl(url) => {
tokio::task::spawn_blocking(move || {
if let Err(e) = crate::util::browser::open_url(&url) {
Expand Down
37 changes: 36 additions & 1 deletion src/app/state.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
use std::collections::{HashMap, HashSet};

use crate::github::models::{PullRequest, RateLimit, Repo};
use crate::github::models::{PrDetail, PullRequest, RateLimit, Repo};

/// State of an on-demand PR detail fetch, keyed by PR url in `AppState::pr_details`.
#[derive(Debug, Clone)]
pub enum PrDetailEntry {
Loading,
Loaded(PrDetail),
Failed(String),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FocusedPane {
Expand Down Expand Up @@ -58,6 +66,10 @@ pub struct AppState {
pub search_active: bool,
pub search_query: String,

// PR detail pane (fetched on-highlight)
pub detail_open: bool,
pub pr_details: HashMap<String, PrDetailEntry>,

// UI flags
pub loading: bool,
pub loading_orgs: HashSet<String>,
Expand Down Expand Up @@ -96,6 +108,8 @@ impl AppState {
content_cursor: 0,
search_active: false,
search_query: String::new(),
detail_open: false,
pr_details: HashMap::new(),
loading: true,
loading_orgs: HashSet::new(),
error_message: None,
Expand Down Expand Up @@ -188,6 +202,27 @@ impl AppState {
prs.get(self.content_cursor).map(|pr| pr.url.clone())
}

/// The currently highlighted PR (in the content pane), cloned.
pub fn selected_pr(&self) -> Option<PullRequest> {
self.current_pr_list().into_iter().nth(self.content_cursor)
}

/// Apply a freshly fetched merge state to the matching PR in every list, so
/// the list column reflects the authoritative value once detail resolves.
pub fn apply_fresh_merge_state(
&mut self,
url: &str,
mergeable: Option<String>,
merge_state_status: Option<String>,
) {
for pr in self.all_open_prs.iter_mut().chain(self.inbox.iter_mut()) {
if pr.url == url {
pr.mergeable = mergeable.clone();
pr.merge_state_status = merge_state_status.clone();
}
}
}

pub fn selected_nav_url(&self) -> Option<String> {
self.nav_nodes
.get(self.nav_cursor)
Expand Down
33 changes: 32 additions & 1 deletion src/app/update.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::app::actions::{Action, DataPayload, SideEffect};
use crate::app::state::{AppState, ContentView, FocusedPane, NavNode, OrgData};
use crate::app::state::{AppState, ContentView, FocusedPane, NavNode, OrgData, PrDetailEntry};

pub fn update(state: &mut AppState, action: Action) -> Vec<SideEffect> {
match action {
Expand Down Expand Up @@ -83,6 +83,8 @@ pub fn update(state: &mut AppState, action: Action) -> Vec<SideEffect> {
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.focused_pane == FocusedPane::Content {
state.focused_pane = FocusedPane::Navigation;
}
Expand All @@ -98,6 +100,8 @@ pub fn update(state: &mut AppState, action: Action) -> Vec<SideEffect> {
Action::Refresh => {
state.loading = true;
state.error_message = None;
// Drop cached PR details so merge state / CI is recomputed fresh.
state.pr_details.clear();
vec![SideEffect::RefreshAll]
}
Action::OpenInBrowser => {
Expand All @@ -121,6 +125,12 @@ pub fn update(state: &mut AppState, action: Action) -> Vec<SideEffect> {
}
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;
vec![]
}
Action::SearchInput(ch) => {
if state.search_active {
state.search_query.push(ch);
Expand Down Expand Up @@ -160,6 +170,27 @@ pub fn update(state: &mut AppState, action: Action) -> Vec<SideEffect> {
state.rate_limit = rate_limit;
state.all_open_prs = prs;
}
DataPayload::PrDetailLoaded {
key,
detail,
rate_limit,
} => {
state.rate_limit = rate_limit;
// Upgrade the list column to the freshly computed merge state.
state.apply_fresh_merge_state(
&key,
detail.mergeable.clone(),
detail.merge_state_status.clone(),
);
state.pr_details.insert(key, PrDetailEntry::Loaded(detail));
// A detail fetch is not part of the initial load; leave the
// global loading flag untouched by returning early.
return vec![];
}
DataPayload::PrDetailFailed { key, msg } => {
state.pr_details.insert(key, PrDetailEntry::Failed(msg));
return vec![];
}
}

// Check if all loading complete
Expand Down
1 change: 1 addition & 0 deletions src/app/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +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_search_overlay(f, state);
if state.error_message.is_some() {
widgets::render_error_modal(f, f.area(), state);
Expand Down
65 changes: 65 additions & 0 deletions src/github/graphql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,69 @@ impl GithubClient {
let query_string = format!("is:open is:pr archived:false {}", filter);
self.search_prs(&query_string).await
}

/// Fetch on-demand detail for a single PR (fresh merge state, recent commits,
/// CI rollup). Used by the detail-on-highlight pane.
pub async fn fetch_pr_detail(
&self,
owner: &str,
name: &str,
number: u32,
) -> Result<(PrDetail, RateLimit)> {
let variables = json!({
"owner": owner,
"name": name,
"number": number,
});

let data = self.query(queries::PR_DETAIL_QUERY, variables).await?;
let rate_limit = Self::extract_rate_limit(&data);

let pr_node = &data["data"]["repository"]["pullRequest"];
if pr_node.is_null() {
bail!("Pull request {}/{}#{} not found", owner, name, number);
}

Ok((parse_pr_detail(pr_node), rate_limit))
}
}

fn parse_pr_detail(node: &Value) -> PrDetail {
let commit_nodes = node["commits"]["nodes"].as_array();

let commits: Vec<CommitInfo> = commit_nodes
.map(|arr| {
arr.iter()
.filter_map(|n| {
let commit = &n["commit"];
let oid = commit["oid"].as_str()?.to_string();
Some(CommitInfo {
oid,
headline: commit["messageHeadline"].as_str().unwrap_or("").to_string(),
committed_date: commit["committedDate"]
.as_str()
.and_then(|s| s.parse().ok())
.unwrap_or_default(),
author: commit["author"]["name"].as_str().unwrap_or("").to_string(),
})
})
.collect()
})
.unwrap_or_default();

// GitHub returns commits oldest-first; the CI rollup on the newest (last) commit
// reflects the PR's current check status.
let checks_status = commit_nodes
.and_then(|arr| arr.last())
.and_then(|n| n["commit"]["statusCheckRollup"]["state"].as_str())
.map(|s| s.to_string());

PrDetail {
mergeable: node["mergeable"].as_str().map(|s| s.to_string()),
merge_state_status: node["mergeStateStatus"].as_str().map(|s| s.to_string()),
checks_status,
commits,
}
}

fn parse_search_pr(node: &Value) -> PullRequest {
Expand Down Expand Up @@ -307,6 +370,8 @@ fn parse_search_pr(node: &Value) -> PullRequest {
additions: node["additions"].as_u64().unwrap_or(0) as u32,
deletions: node["deletions"].as_u64().unwrap_or(0) as u32,
review_decision: node["reviewDecision"].as_str().map(|s| s.to_string()),
mergeable: node["mergeable"].as_str().map(|s| s.to_string()),
merge_state_status: node["mergeStateStatus"].as_str().map(|s| s.to_string()),
labels,
}
}
Loading
Loading