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
Expand Up @@ -11,7 +11,9 @@ pub enum Action {
Refresh,
OpenInBrowser,
ToggleSearch,
ToggleDetail,
ToggleGitLog,
ToggleDiff,
CloseOverlay,
SearchInput(char),
SearchBackspace,
SearchClear,
Expand Down Expand Up @@ -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)]
Expand All @@ -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),
}
127 changes: 98 additions & 29 deletions src/app/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> = None;
let mut pending_detail: Option<crate::github::PullRequest> = None;
let mut armed_key: Option<(String, Overlay)> = None;
let mut pending_fetch: Option<(crate::github::PullRequest, Overlay)> = None;

loop {
// Render
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -238,19 +255,42 @@ fn map_event_to_action(event: &Event, state: &AppState) -> Option<Action> {
};
}

// 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,
}
Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 26 additions & 3 deletions src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String, PrDetailEntry>,
pub pr_diffs: HashMap<String, DiffEntry>,
/// Vertical scroll offset (in lines) for the diff overlay.
pub diff_scroll: u16,

// UI flags
pub loading: bool,
Expand Down Expand Up @@ -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,
Expand Down
60 changes: 52 additions & 8 deletions src/app/update.rs
Original file line number Diff line number Diff line change
@@ -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<SideEffect> {
match action {
Expand All @@ -8,6 +10,15 @@ pub fn update(state: &mut AppState, action: Action) -> Vec<SideEffect> {
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 {
Expand All @@ -23,6 +34,13 @@ pub fn update(state: &mut AppState, action: Action) -> Vec<SideEffect> {
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() {
Expand Down Expand Up @@ -83,8 +101,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.overlay != Overlay::None {
state.overlay = Overlay::None;
} else if state.focused_pane == FocusedPane::Content {
state.focused_pane = FocusedPane::Navigation;
}
Expand All @@ -100,8 +118,9 @@ 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.
// Drop cached PR details / diffs so they are re-fetched fresh.
state.pr_details.clear();
state.pr_diffs.clear();
vec![SideEffect::RefreshAll]
}
Action::OpenInBrowser => {
Expand All @@ -125,10 +144,27 @@ 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;
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) => {
Expand Down Expand Up @@ -191,6 +227,14 @@ pub fn update(state: &mut AppState, action: Action) -> Vec<SideEffect> {
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
Expand Down
2 changes: 1 addition & 1 deletion src/app/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading