use std::collections::HashMap;
use tracing::{debug, warn};
use crate::github;
use super::actions::Action;
use super::state::App;
use super::types::{DetailKind, DetailRef, FirstRunSuggestion, Focus, PerTabState};
impl App {
pub(super) fn spawn_fetch(&mut self, tx: tokio::sync::mpsc::UnboundedSender<Action>) {
if self.fetching {
debug!("fetch already in progress; skipping");
return;
}
let Some(client) = self.client.clone() else {
debug!("no GitHub client; skipping fetch");
return;
};
self.fetching = true;
send_or_warn(&tx, Action::InboxFetchStarted);
let repos = self.config.repos.clone();
let show_all = self.config.show_all_prs;
tokio::spawn(async move {
let inner = tokio::spawn(async move {
if show_all {
client.fetch_inbox_all(&repos).await
} else {
client.fetch_inbox().await
}
});
let action = match inner.await {
Ok(Ok(inbox)) => Action::InboxLoaded(Box::new(inbox)),
Ok(Err(e)) => Action::FetchFailed(e.to_string()),
Err(join_err) if join_err.is_panic() => {
Action::FetchFailed(format!("fetch task panicked: {join_err}"))
}
Err(join_err) => Action::FetchFailed(format!("fetch task aborted: {join_err}")),
};
send_or_warn(&tx, action);
});
}
pub(super) fn on_inbox_loaded(&mut self, inbox: github::Inbox) {
let viewer_login = inbox.viewer_login.clone();
for tab in &mut self.tabs.tabs {
let count = inbox
.prs
.iter()
.filter(|pr| pr.repo == tab.repo)
.filter(|pr| {
let flag = pr.primary_flag(&viewer_login);
flag != crate::github::flags::ActionFlag::Clean
&& flag != crate::github::flags::ActionFlag::Draft
})
.count()
+ inbox.issues.iter().filter(|i| i.repo == tab.repo).count();
tab.needs_action_count = Some(count);
}
for (repo, idx) in &mut self.selection {
let max_pr = inbox.prs.iter().filter(|pr| pr.repo == *repo).count();
let max_issue = inbox.issues.iter().filter(|i| i.repo == *repo).count();
let max = max_pr.max(max_issue);
if max == 0 {
*idx = 0;
} else if *idx >= max {
*idx = max - 1;
}
}
if self.config.repos.is_empty() && self.focus == Focus::Dashboard {
let mut counts: HashMap<String, usize> = HashMap::new();
for pr in &inbox.prs {
*counts.entry(pr.repo.clone()).or_insert(0) += 1;
}
for issue in &inbox.issues {
*counts.entry(issue.repo.clone()).or_insert(0) += 1;
}
if !counts.is_empty() {
let mut suggestions: Vec<FirstRunSuggestion> = counts
.into_iter()
.map(|(repo, count)| FirstRunSuggestion { repo, count, selected: false })
.collect();
suggestions.sort_unstable_by(|a, b| {
b.count.cmp(&a.count).then_with(|| a.repo.cmp(&b.repo))
});
self.first_run_suggestions = suggestions;
self.first_run_cursor = 0;
self.focus = Focus::FirstRun;
}
}
self.inbox = Some(inbox);
self.inbox_loaded_at = Some(chrono::Utc::now());
self.fetching = false;
self.last_fetch_error = None;
}
pub(super) fn on_fetch_failed(&mut self, err: String) {
self.fetching = false;
warn!("GitHub inbox fetch failed: {err}");
self.last_fetch_error = Some(err);
}
pub fn spawn_detail_fetch(
&mut self,
kind: DetailKind,
repo: String,
number: u32,
tx: tokio::sync::mpsc::UnboundedSender<Action>,
) {
if self.detail_fetching {
debug!("detail fetch already in progress; skipping");
return;
}
let Some(client) = self.client.clone() else {
debug!("no GitHub client; skipping detail fetch");
send_or_warn(&tx, Action::DetailFetchFailed("no GitHub client configured".to_owned()));
return;
};
self.detail_fetching = true;
spawn_supervised_detail_fetch(client, kind, repo, number, tx, "detail fetch");
}
pub fn spawn_detail_fetch_background(
&self,
kind: DetailKind,
repo: String,
number: u32,
tx: tokio::sync::mpsc::UnboundedSender<Action>,
) -> bool {
let Some(client) = self.client.clone() else {
debug!("no GitHub client; skipping background detail fetch");
return false;
};
spawn_supervised_detail_fetch(client, kind, repo, number, tx, "bg detail fetch");
true
}
pub(super) fn active_list_len(&self) -> usize {
let Some(repo) = self.tabs.active_tab().map(|t| t.repo.clone()) else {
return 0;
};
let Some(inbox) = &self.inbox else {
return 0;
};
let mode = self.session.view_mode(&repo);
match mode {
crate::state::ViewMode::Prs => inbox.prs.iter().filter(|p| p.repo == repo).count(),
crate::state::ViewMode::Issues => {
inbox.issues.iter().filter(|i| i.repo == repo).count()
}
}
}
pub(super) fn current_detail_lines(&self) -> Vec<ratatui::text::Line<'static>> {
if let Some(detail) = &self.pr_detail {
let scoped_patches: Option<&std::collections::HashMap<String, Option<String>>> =
self.selected_commit.and_then(|idx| {
detail.commits.get(idx).and_then(|c| {
self.detail_cache
.get_commit_patches(&detail.repo, &c.sha)
.map(|cached| &cached.data)
})
});
let comments_scope_sha: Option<&str> = self
.selected_commit
.and_then(|idx| detail.commits.get(idx).map(|c| c.sha.as_str()));
let (lines, _) = crate::ui::pr_detail::build_section(
self.pr_detail_selected_section,
detail,
self.pr_detail_files_cursor,
self.pr_detail_files_show_diff,
self.detail_comments_expanded,
self.detail_show_outdated,
self.thread_index.as_ref(),
&self.pr_detail_expanded_threads,
&self.pr_detail_diff_cursor,
scoped_patches,
self.commits_cursor,
comments_scope_sha,
&self.palette,
self.config.show_ascii_glyphs,
);
return lines;
}
if let Some(detail) = &self.issue_detail {
let (lines, _) = crate::ui::issue_detail::build_content(
detail,
self.detail_comments_expanded,
&self.palette,
self.config.show_ascii_glyphs,
);
return lines;
}
Vec::new()
}
pub(super) fn clamp_pr_detail_scroll(&mut self) {
if !matches!(self.focus, Focus::Detail) {
return;
}
let area = self.pr_detail_right_viewport.get();
if area.height == 0 || area.width == 0 {
return;
}
let lines = self.current_detail_lines();
let wraps = self.pr_detail_selected_section != crate::ui::pr_detail::DetailSection::Files;
let rendered_rows = if wraps {
let probe = ratatui::widgets::Paragraph::new(lines)
.wrap(ratatui::widgets::Wrap { trim: false });
u16::try_from(probe.line_count(area.width)).unwrap_or(u16::MAX)
} else {
u16::try_from(lines.len()).unwrap_or(u16::MAX)
};
let max_scroll = rendered_rows.saturating_sub(area.height);
let scroll = self.right_pane_scroll_mut();
if *scroll > max_scroll {
*scroll = max_scroll;
}
}
pub(super) fn ensure_cursor_visible(&mut self, lines: &[ratatui::text::Line<'static>]) {
let area = self.pr_detail_right_viewport.get();
let (vw, vh) = (area.width, area.height);
if vh == 0 {
return;
}
let cursor_row = u16::try_from(self.copy_mode.cursor.row).unwrap_or(u16::MAX);
let section = self.pr_detail_selected_section;
{
let scroll = self.scroll_mut(section);
if cursor_row < *scroll {
*scroll = cursor_row;
} else if cursor_row >= scroll.saturating_add(vh) {
*scroll = cursor_row.saturating_sub(vh).saturating_add(1);
}
}
if vw == 0 {
return;
}
let line = lines.get(self.copy_mode.cursor.row);
let cursor_col = line
.map_or(0, |l| crate::ui::copy_mode::cursor_display_col(l, self.copy_mode.cursor.col));
if cursor_col < self.copy_mode.h_scroll {
self.copy_mode.h_scroll = cursor_col;
} else if cursor_col >= self.copy_mode.h_scroll.saturating_add(vw) {
self.copy_mode.h_scroll = cursor_col.saturating_sub(vw).saturating_add(1);
}
}
pub(super) fn save_current_tab_state(&mut self) {
let Some(repo) = self.tabs.active_tab().map(|t| t.repo.clone()) else {
return;
};
let detail_ref = if self.focus == Focus::Detail {
if let Some(d) = &self.pr_detail {
Some(DetailRef { repo: d.repo.clone(), number: d.number, kind: DetailKind::Pr })
} else {
self.issue_detail.as_ref().map(|d| DetailRef {
repo: d.repo.clone(),
number: d.number,
kind: DetailKind::Issue,
})
}
} else {
None
};
self.per_tab_state.insert(repo, PerTabState { detail_ref });
}
pub(super) fn clear_detail_loading_markers(&mut self, repo: &str, number: u32) {
if self.detail_refreshing.as_ref().is_some_and(|(r, n)| r == repo && *n == number) {
self.detail_refreshing = None;
}
self.detail_fetching = false;
self.detail_error = None;
}
pub(super) fn restore_detail_kind(&mut self, kind: DetailKind, repo: String, number: u32) {
let is_fresh: Option<bool> = match kind {
DetailKind::Pr => self.detail_cache.get_pr(&repo, number).map(|c| {
let fresh = c.is_fresh();
let data = c.data.clone();
self.pr_detail = Some(data);
fresh
}),
DetailKind::Issue => self.detail_cache.get_issue(&repo, number).map(|c| {
let fresh = c.is_fresh();
let data = c.data.clone();
self.issue_detail = Some(data);
fresh
}),
};
match is_fresh {
None => {
if let Some(tx) = self.action_tx.clone() {
self.spawn_detail_fetch(kind, repo, number, tx);
}
}
Some(true) => {} Some(false) => {
let already_refreshing = self
.detail_refreshing
.as_ref()
.is_some_and(|(r, n)| r == &repo && *n == number);
if !already_refreshing {
self.detail_refreshing = Some((repo.clone(), number));
if let Some(tx) = self.action_tx.clone() {
self.spawn_detail_fetch_background(kind, repo, number, tx);
}
}
}
}
}
pub(super) fn restore_active_tab_state(&mut self) {
self.pr_detail = None;
self.issue_detail = None;
self.detail_error = None;
self.detail_fetching = false;
let Some(repo) = self.tabs.active_tab().map(|t| t.repo.clone()) else {
self.focus = Focus::Dashboard;
return;
};
let saved = self.per_tab_state.get(&repo).cloned().unwrap_or_default();
let Some(detail_ref) = saved.detail_ref else {
self.focus = Focus::Dashboard;
return;
};
self.focus = Focus::Detail;
let dref_repo = detail_ref.repo.clone();
let dref_number = detail_ref.number;
self.restore_detail_kind(detail_ref.kind, dref_repo, dref_number);
}
pub(super) fn yank_and_flash(
flash: &mut Option<crate::ui::status_bar::FlashMessage>,
text: &str,
) {
match crate::actions_util::copy_to_clipboard(text) {
Ok(()) => {
let len = text.chars().count();
*flash = Some(crate::ui::status_bar::FlashMessage::new(
format!("Copied {len} chars"),
std::time::Duration::from_secs(2),
));
}
Err(e) => {
*flash = Some(crate::ui::status_bar::FlashMessage::new(
format!("Copy failed: {e}"),
std::time::Duration::from_secs(3),
));
}
}
}
}
pub fn spawn_commit_diff_fetch(
client: std::sync::Arc<github::Client>,
repo: String,
sha: String,
tx: tokio::sync::mpsc::UnboundedSender<Action>,
) {
tokio::spawn(async move {
let inner: tokio::task::JoinHandle<anyhow::Result<Action>> = {
let client = client.clone();
let repo2 = repo.clone();
let sha2 = sha.clone();
tokio::spawn(async move {
let patches = client.fetch_commit_diff(&repo2, &sha2).await?;
Ok(Action::CommitDiffLoaded(repo2, sha2, patches))
})
};
let action = match inner.await {
Ok(Ok(action)) => action,
Ok(Err(e)) => Action::CommitDiffFailed(repo, sha, e.to_string()),
Err(join_err) if join_err.is_panic() => Action::CommitDiffFailed(
repo,
sha,
format!("commit diff task panicked: {join_err}"),
),
Err(join_err) => {
Action::CommitDiffFailed(repo, sha, format!("commit diff task aborted: {join_err}"))
}
};
send_or_warn(&tx, action);
});
}
fn send_or_warn(tx: &tokio::sync::mpsc::UnboundedSender<Action>, action: Action) {
if let Err(err) = tx.send(action) {
warn!("action channel closed; dropping fetch result: {err}");
}
}
fn spawn_supervised_detail_fetch(
client: std::sync::Arc<github::Client>,
kind: DetailKind,
repo: String,
number: u32,
tx: tokio::sync::mpsc::UnboundedSender<Action>,
label: &'static str,
) {
tokio::spawn(async move {
let inner: tokio::task::JoinHandle<anyhow::Result<Action>> = tokio::spawn(async move {
match kind {
DetailKind::Pr => {
let detail = client.fetch_pr_detail(&repo, number).await?;
Ok(Action::PrDetailLoaded(Box::new(detail)))
}
DetailKind::Issue => {
let detail = client.fetch_issue_detail(&repo, number).await?;
Ok(Action::IssueDetailLoaded(Box::new(detail)))
}
}
});
let action = match inner.await {
Ok(Ok(action)) => action,
Ok(Err(e)) => Action::DetailFetchFailed(e.to_string()),
Err(join_err) if join_err.is_panic() => {
Action::DetailFetchFailed(format!("{label} task panicked: {join_err}"))
}
Err(join_err) => Action::DetailFetchFailed(format!("{label} task aborted: {join_err}")),
};
send_or_warn(&tx, action);
});
}