use crate::app::App;
use crate::picker::{Picker, PickerItem, PickerKind};
use crate::scm::{self, IntegrationPr};
use std::sync::mpsc;
use std::thread;
const US: char = '\x1F';
impl App {
pub fn open_pr_picker(&mut self) {
let fresh = self
.scm_pr_cache
.as_ref()
.map(|c| !c.is_stale())
.unwrap_or(false);
if !fresh {
self.toast("loading PRs from forge integrations…");
self.scm_pr_cache = Some(scm::aggregate_all());
}
let Some(cache) = self.scm_pr_cache.as_ref() else {
unreachable!("aggregate_all always returns a cache");
};
if cache.prs.is_empty() {
if cache.errors.is_empty() {
self.toast(
"no open PRs across installed forge integrations (mnml-forge-bitbucket / github / gitlab / azdevops)",
);
} else {
let summary = cache
.errors
.iter()
.map(|(bin, _)| bin.as_str())
.collect::<Vec<_>>()
.join(", ");
self.toast(format!("0 PRs (errors: {summary})"));
}
return;
}
let items: Vec<PickerItem> = cache.prs.iter().map(pr_to_item).collect();
let mut title = format!("Open PRs ({})", cache.prs.len());
if !cache.errors.is_empty() {
title.push_str(&format!(
" · {} integration errors (Esc to dismiss)",
cache.errors.len()
));
}
self.open_picker(Picker::new(PickerKind::OpenPullRequests, title, items));
}
pub fn refresh_scm_prs(&mut self) {
if self.scm_pr_pending.is_some() {
self.toast("PR refresh already in flight");
return;
}
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let cache = scm::aggregate_all();
let _ = tx.send(cache);
});
self.scm_pr_pending = Some(rx);
self.toast("refreshing PRs across forge integrations…");
}
pub fn drain_scm_pr_pending(&mut self) -> bool {
let Some(rx) = self.scm_pr_pending.as_ref() else {
return false;
};
match rx.try_recv() {
Ok(cache) => {
let n = cache.prs.len();
self.scm_pr_cache = Some(cache);
self.scm_pr_pending = None;
self.refresh_rail_pulls();
self.toast(format!("PRs refreshed: {n} across forge integrations"));
true
}
Err(mpsc::TryRecvError::Empty) => false,
Err(mpsc::TryRecvError::Disconnected) => {
self.scm_pr_pending = None;
false
}
}
}
pub fn accept_pr_picker_primary(&mut self, item_id: &str) {
let url = item_id.split(US).next().unwrap_or(item_id);
crate::app::open_url_external(url);
self.toast(format!("opened {url}"));
}
pub fn accept_pr_picker_secondary(&mut self, item_id: &str) {
let parts: Vec<&str> = item_id.split(US).collect();
if parts.len() < 5 {
self.toast("this PR row has no branch — Tab cross-nav unavailable");
return;
}
let (host, owner, repo, branch) = (parts[1], parts[2], parts[3], parts[4]);
if branch.is_empty() {
self.toast(format!(
"no source branch on this PR (host: {host}) — Tab cross-nav unavailable"
));
return;
}
match scm::find_pipeline_url(host, owner, repo, branch) {
Some(url) => {
crate::app::open_url_external(&url);
self.toast(format!("opened pipeline {url}"));
}
None => self.toast(format!("no pipeline found for {owner}/{repo}@{branch}")),
}
}
}
fn pr_to_item(pr: &IntegrationPr) -> PickerItem {
let id = format!(
"{}{US}{}{US}{}{US}{}{US}{}",
pr.url,
pr.host,
pr.owner,
pr.repo,
pr.source_branch.as_deref().unwrap_or(""),
);
let host_chip = host_chip(&pr.host);
let label = format!(
"{host_chip} {}/{}#{} {}",
pr.owner, pr.repo, pr.id, pr.title
);
let detail = format!("by {} · {}", pr.author, short_date(&pr.updated_at));
PickerItem::new(id, label, detail)
}
fn host_chip(host: &str) -> &'static str {
match host {
"bitbucket" => "BB",
"github" => "GH",
"gitlab" => "GL",
"azdevops" => "AZ",
_ => "??",
}
}
fn short_date(s: &str) -> String {
s.chars().take(10).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pr_item_id_packs_payload() {
let pr = IntegrationPr {
id: "1".into(),
url: "https://example.com/pr/1".into(),
owner: "foo".into(),
repo: "bar".into(),
title: "t".into(),
author: "a".into(),
source_branch: Some("feat/x".into()),
dest_branch: Some("main".into()),
state: "open".into(),
updated_at: "2026-06-06T15:43:00Z".into(),
remote_url_https: "https://example.com/foo/bar.git".into(),
remote_url_ssh: "git@example.com:foo/bar.git".into(),
host: "github".into(),
};
let item = pr_to_item(&pr);
let parts: Vec<&str> = item.id.split('\x1F').collect();
assert_eq!(parts.len(), 5);
assert_eq!(parts[0], "https://example.com/pr/1");
assert_eq!(parts[1], "github");
assert_eq!(parts[2], "foo");
assert_eq!(parts[3], "bar");
assert_eq!(parts[4], "feat/x");
}
}