use super::*;
impl App {
pub fn enter_pr_diff_mode(
&mut self,
backend: Box<dyn ForgeBackend>,
opened: crate::forge::pr_open::OpenedPullRequest,
) -> Result<()> {
let crate::forge::pr_open::OpenedPullRequest {
details,
diff_files,
session,
key,
commits,
review_metadata,
pr_info,
} = opened;
self.save_current_session_merging_external()?;
let pr_source = PullRequestDiffSource::from_details(&details);
let read_only_reason = pr_source.read_only_reason();
let virtual_root = session.repo_path.clone();
self.vcs_info = VcsInfo {
root_path: virtual_root.clone(),
head_commit: details.head_sha.clone(),
branch_name: Some(details.head_ref_name.clone()),
vcs_type: VcsType::File,
};
self.vcs = Box::new(PrNoopVcs::new(self.vcs_info.clone()));
self.session = session;
self.diff_files = diff_files;
self.reset_persisted_session_tracking();
self.diff_source = DiffSource::PullRequest(Box::new(pr_source));
self.forge_backend = Some(backend);
self.forge_repository = Some(key.repository.clone());
self.forge_review_threads = Vec::new();
self.forge_review_summaries = Vec::new();
self.forge_review_threads_loading = false;
self.pr_threads_rx = None;
self.current_pr_head = Some(details.head_sha.clone());
self.input_mode = InputMode::Normal;
self.focused_panel = FocusedPanel::Diff;
self.clear_expanded_gaps();
self.commit_list.clear();
self.commit_selection_range = None;
self.review_commits.clear();
self.pr_commits.clear();
self.pr_last_reviewed_commit_index = None;
self.show_commit_selector = false;
self.range_diff_files = None;
self.saved_inline_selection = None;
self.diff_state = DiffState::default();
self.pr_info = Some(pr_info);
let since_last_review_message = self.apply_pr_commit_selector(commits, review_metadata);
let preserve_hunks =
Self::is_strict_commit_selection(self.commit_selection_range, self.pr_commits.len());
Self::register_diff_files(&mut self.session, &self.diff_files, preserve_hunks);
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
if let Some(reason) = read_only_reason {
self.set_warning(format!("This PR is {reason} — review is read-only"));
} else if let Some(message) = since_last_review_message {
self.set_message(message);
}
if matches!(&self.diff_source, DiffSource::PullRequest(_))
&& let Some(range) = self.commit_selection_range
&& !self.pr_commits.is_empty()
&& (range.0 > 0 || range.1 + 1 < self.pr_commits.len())
{
self.spawn_pr_range_reload();
}
Ok(())
}
fn capture_pr_cursor_anchor(&self) -> Option<PrCursorAnchor> {
let annotation = self.line_annotations.get(self.diff_state.cursor_line)?;
let (file_idx, old_lineno, new_lineno) = match annotation {
AnnotatedLine::DiffLine {
file_idx,
old_lineno,
new_lineno,
..
} => (*file_idx, *old_lineno, *new_lineno),
AnnotatedLine::SideBySideLine {
file_idx,
old_lineno,
new_lineno,
..
} => (*file_idx, *old_lineno, *new_lineno),
AnnotatedLine::ExpandedContext { gap_id, .. } => {
let file_idx = gap_id.file_idx;
(file_idx, None, None)
}
_ => {
let file_idx = annotation_file_idx(annotation)?;
(file_idx, None, None)
}
};
let path = self.diff_files.get(file_idx)?.display_path().clone();
Some(PrCursorAnchor {
path,
new_lineno,
old_lineno,
})
}
fn restore_pr_cursor_to_anchor(&mut self, anchor: &PrCursorAnchor) {
let mut best: Option<usize> = None;
let mut file_first: Option<usize> = None;
for (idx, ann) in self.line_annotations.iter().enumerate() {
let file_idx = match ann {
AnnotatedLine::DiffLine { file_idx, .. }
| AnnotatedLine::SideBySideLine { file_idx, .. }
| AnnotatedLine::HunkHeader { file_idx, .. }
| AnnotatedLine::FileHeader { file_idx, .. } => *file_idx,
_ => continue,
};
let Some(file) = self.diff_files.get(file_idx) else {
continue;
};
if file.display_path() != &anchor.path {
continue;
}
file_first.get_or_insert(idx);
let (line_new, line_old) = match ann {
AnnotatedLine::DiffLine {
old_lineno,
new_lineno,
..
}
| AnnotatedLine::SideBySideLine {
old_lineno,
new_lineno,
..
} => (*new_lineno, *old_lineno),
_ => (None, None),
};
if anchor.new_lineno.is_some() && line_new == anchor.new_lineno {
best = Some(idx);
break;
}
if anchor.old_lineno.is_some() && line_old == anchor.old_lineno {
best = Some(idx);
}
}
let target = best.or(file_first).unwrap_or(0);
self.move_cursor_to_annotation(target);
}
pub fn persist_pr_commit_selection_range(&mut self) {
if !matches!(self.diff_source, DiffSource::PullRequest(_)) {
return;
}
let total = self.pr_commits.len();
let value = match self.commit_selection_range {
Some((s, e)) if total > 0 && (s > 0 || e + 1 < total) => Some((s, e)),
_ => None,
};
self.session.commit_selection_range = value;
self.session.updated_at = chrono::Utc::now();
let _ = self.save_current_session_merging_external();
}
pub fn pr_range_sha_pair(&self) -> Option<(String, String)> {
let DiffSource::PullRequest(ref pr) = self.diff_source else {
return None;
};
let (start_idx, end_idx) = self.commit_selection_range?;
if self.pr_commits.is_empty() || start_idx > end_idx || end_idx >= self.pr_commits.len() {
return None;
}
let newest = self.pr_commits.get(start_idx)?;
let parent_sha = if end_idx + 1 < self.pr_commits.len() {
self.pr_commits[end_idx + 1].oid.clone()
} else {
pr.base_sha.clone()
};
Some((parent_sha, newest.oid.clone()))
}
pub fn reload_pr_inline_selection(&mut self) {
if !matches!(self.diff_source, DiffSource::PullRequest(_)) {
return;
}
let Some(range) = self.commit_selection_range else {
return;
};
let total = self.pr_commits.len();
if total == 0 {
return;
}
if range.0 == 0 && range.1 + 1 == total {
self.apply_cached_full_pr_diff();
return;
}
self.spawn_pr_range_reload();
}
fn apply_cached_full_pr_diff(&mut self) {
let Some(files) = self.range_diff_files.clone() else {
return;
};
let anchor = self.capture_pr_cursor_anchor();
self.diff_files = files;
self.clear_expanded_gaps();
for file in &self.diff_files {
self.session.add_diff_file(file);
}
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
if let Some(anchor) = anchor {
self.restore_pr_cursor_to_anchor(&anchor);
}
}
pub fn spawn_pr_range_reload(&mut self) {
let DiffSource::PullRequest(current) = self.diff_source.clone() else {
return;
};
let Some((start_sha, end_sha)) = self.pr_range_sha_pair() else {
return;
};
let Some(range) = self.commit_selection_range else {
return;
};
let anchor = self.capture_pr_cursor_anchor();
let request = PrRangeReloadRequest {
repository: current.key.repository.clone(),
pr_number: current.key.number,
head_sha: current.key.head_sha.clone(),
start_sha: start_sha.clone(),
end_sha: end_sha.clone(),
range,
started_at: Instant::now(),
anchor,
};
self.pr_range_reload_state = Some(request.clone());
let local_checkout = self
.forge_backend
.as_deref()
.and_then(|backend| backend.local_checkout_path());
let (tx, rx) = std::sync::mpsc::channel();
self.pr_range_reload_rx = Some(rx);
let repository = current.key.repository.clone();
let pr_number = current.key.number;
let head_sha = current.key.head_sha.clone();
let base_sha = current.base_sha.clone();
std::thread::spawn(move || {
let backend = create_forge_backend(&repository, local_checkout);
let details = crate::forge::traits::PullRequestDetails {
repository: repository.clone(),
number: pr_number,
title: String::new(),
url: String::new(),
state: "OPEN".to_string(),
is_draft: false,
author: None,
head_ref_name: String::new(),
base_ref_name: String::new(),
head_sha,
base_sha,
body: String::new(),
updated_at: None,
closed: false,
merged_at: None,
diff_start_sha: None,
};
let outcome = backend
.get_pull_request_commit_range_diff(&details, &start_sha, &end_sha)
.map_err(|e| e.to_string());
let _ = tx.send(PrRangeReloadEvent::Done {
request,
result: outcome,
});
});
}
pub fn poll_pr_range_reload_events(&mut self) {
let Some(rx) = self.pr_range_reload_rx.as_ref() else {
return;
};
let event = match rx.try_recv() {
Ok(e) => e,
Err(_) => return,
};
self.pr_range_reload_rx = None;
let PrRangeReloadEvent::Done { request, result } = event;
let in_flight = self.pr_range_reload_state.clone();
let still_active = in_flight.as_ref().is_some_and(|s| {
s.start_sha == request.start_sha
&& s.end_sha == request.end_sha
&& s.repository == request.repository
&& s.pr_number == request.pr_number
&& s.head_sha == request.head_sha
&& s.range == request.range
});
if !still_active {
return;
}
self.pr_range_reload_state = None;
match result {
Ok(patch) => {
if let Err(e) = self.finish_pr_range_reload(&request, &patch) {
self.set_error(format!("Range diff failed: {e}"));
}
}
Err(e) => {
self.set_error(format!("Range diff failed: {e}"));
}
}
}
pub(in crate::app) fn finish_pr_range_reload(
&mut self,
request: &PrRangeReloadRequest,
patch: &str,
) -> Result<()> {
use crate::vcs::diff_parser::{DiffFormat, parse_unified_diff};
let highlighter = self.theme.syntax_highlighter();
let parsed = match parse_unified_diff(patch, DiffFormat::GitStyle, highlighter) {
Ok(files) => files,
Err(TuicrError::NoChanges) => Vec::new(),
Err(e) => return Err(e),
};
let local_checkout = self
.forge_backend
.as_deref()
.and_then(|b| b.local_checkout_path());
let files = match local_checkout.as_deref() {
Some(root) => crate::tuicrignore::filter_diff_files(root, parsed),
None => parsed,
};
self.diff_files = files;
self.clear_expanded_gaps();
Self::register_diff_files(&mut self.session, &self.diff_files, true);
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
if let Some(anchor) = &request.anchor {
self.restore_pr_cursor_to_anchor(anchor);
}
Ok(())
}
pub fn spawn_pr_reload(&mut self) -> Result<()> {
use crate::forge::pr_open::fetch_pr_data;
use crate::forge::traits::PullRequestTarget;
let DiffSource::PullRequest(current) = self.diff_source.clone() else {
return Err(TuicrError::UnsupportedOperation(
"Not in PR mode".to_string(),
));
};
if self.pr_reload_state.is_some() {
return Ok(()); }
let restore_overview_cursor = (self.diff_state.cursor_line
< self.review_comments_render_height())
.then_some(self.diff_state.cursor_line);
let anchor = if restore_overview_cursor.is_some() {
None
} else {
self.capture_pr_cursor_anchor()
};
let request = PrReloadRequest {
repository: current.key.repository.clone(),
pr_number: current.key.number,
head_sha: current.key.head_sha.clone(),
started_at: Instant::now(),
anchor,
restore_overview_cursor,
};
self.pr_reload_state = Some(request.clone());
let local_checkout = self
.forge_backend
.as_deref()
.and_then(|backend| backend.local_checkout_path());
let (tx, rx) = std::sync::mpsc::channel();
self.pr_reload_rx = Some(rx);
let repository = current.key.repository.clone();
let pr_number = current.key.number;
std::thread::spawn(move || {
let backend = create_forge_backend(&repository, local_checkout);
let target =
PullRequestTarget::with_repository(repository, pr_number, pr_number.to_string());
let outcome = fetch_pr_data(backend.as_ref(), target).map_err(|e| e.to_string());
let _ = tx.send(PrReloadEvent::Done {
request,
result: outcome,
});
});
Ok(())
}
pub fn poll_pr_reload_events(&mut self) {
let Some(rx) = self.pr_reload_rx.as_ref() else {
return;
};
let event = match rx.try_recv() {
Ok(e) => e,
Err(_) => return,
};
self.pr_reload_rx = None;
let in_flight = self.pr_reload_state.clone();
self.pr_reload_state = None;
let PrReloadEvent::Done { request, result } = event;
if !in_flight
.as_ref()
.is_some_and(|s| s.pr_number == request.pr_number && s.repository == request.repository)
{
return;
}
match result {
Ok((details, patch, commits, review_metadata, pr_info)) => {
if let Err(e) = self.finish_pr_reload(
details,
patch,
commits,
review_metadata,
pr_info,
&request,
) {
self.set_error(format!("Reload failed: {e}"));
}
}
Err(e) => {
self.set_error(format!("Reload failed: {e}"));
}
}
}
pub(in crate::app) fn finish_pr_reload(
&mut self,
details: crate::forge::traits::PullRequestDetails,
patch: String,
commits: Vec<crate::forge::traits::PullRequestCommit>,
review_metadata: crate::forge::traits::PullRequestReviewMetadata,
pr_info: crate::forge::traits::PullRequestInfo,
request: &PrReloadRequest,
) -> Result<()> {
use crate::forge::pr_open::prepare_open_pr;
let local_checkout = self
.forge_backend
.as_deref()
.and_then(|backend| backend.local_checkout_path());
let highlighter = self.theme.syntax_highlighter();
let opened = prepare_open_pr(
details,
&patch,
commits,
review_metadata,
pr_info,
local_checkout.as_deref(),
highlighter,
)?;
let head_changed = opened.details.head_sha != request.head_sha;
if head_changed {
let details_for_threads = opened.details.clone();
let opened = self.opened_pr_with_new_head_session(opened)?;
let backend = create_forge_backend(&request.repository, local_checkout.clone());
let previous_message = self.message.clone();
self.enter_pr_diff_mode(backend, opened)?;
self.spawn_pr_threads_fetch(&details_for_threads, local_checkout);
if self.message == previous_message {
self.set_message("Reloaded PR at new head".to_string());
}
} else {
self.set_pr_last_reviewed_commit_from_metadata(
&opened.commits,
&opened.review_metadata,
);
self.diff_files = opened.diff_files;
self.pr_info = Some(opened.pr_info);
self.clear_expanded_gaps();
for file in &self.diff_files {
self.session.add_diff_file(file);
}
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
self.refetch_pr_threads();
self.set_message("Reloaded PR (no new commits)".to_string());
}
if let Some(line) = request.restore_overview_cursor {
self.diff_state.cursor_line = line;
self.ensure_cursor_visible();
} else if let Some(anchor) = &request.anchor {
self.restore_pr_cursor_to_anchor(anchor);
}
Ok(())
}
#[allow(dead_code)]
pub fn reload_pull_request(&mut self) -> Result<bool> {
let DiffSource::PullRequest(current) = self.diff_source.clone() else {
return Err(TuicrError::UnsupportedOperation(
"Not in PR mode".to_string(),
));
};
let local_checkout = self
.forge_backend
.as_deref()
.and_then(|backend| backend.local_checkout_path());
let backend = create_forge_backend(¤t.key.repository, local_checkout.clone());
self.reload_pull_request_with_backend(backend, local_checkout)
}
#[allow(dead_code)]
pub fn reload_pull_request_with_backend(
&mut self,
backend: Box<dyn ForgeBackend>,
local_checkout: Option<std::path::PathBuf>,
) -> Result<bool> {
use crate::forge::pr_open::open_pull_request;
let DiffSource::PullRequest(current) = self.diff_source.clone() else {
return Err(TuicrError::UnsupportedOperation(
"Not in PR mode".to_string(),
));
};
let target = crate::forge::traits::PullRequestTarget::with_repository(
current.key.repository.clone(),
current.key.number,
current.key.number.to_string(),
);
let highlighter = self.theme.syntax_highlighter();
let opened = open_pull_request(
backend.as_ref(),
target,
local_checkout.as_deref(),
highlighter,
)?;
let head_changed = opened.details.head_sha != current.key.head_sha;
if head_changed {
let details_for_threads = opened.details.clone();
let opened = self.opened_pr_with_new_head_session(opened)?;
self.enter_pr_diff_mode(backend, opened)?;
self.spawn_pr_threads_fetch(&details_for_threads, local_checkout.clone());
} else {
self.set_pr_last_reviewed_commit_from_metadata(
&opened.commits,
&opened.review_metadata,
);
self.diff_files = opened.diff_files;
self.pr_info = Some(opened.pr_info);
self.clear_expanded_gaps();
for file in &self.diff_files {
self.session.add_diff_file(file);
}
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
}
Ok(head_changed)
}
pub(in crate::app) fn spawn_pr_initial_load(
&mut self,
origin: ForgeRepository,
override_repo: Option<ForgeRepository>,
skip_resolution: bool,
scope: crate::forge::traits::PullRequestListScope,
) {
use crate::forge::selector::PR_PAGE_SIZE;
use crate::forge::traits::{ForgeKind, PullRequestListQuery};
let (tx, rx) = std::sync::mpsc::channel();
self.pr_load_rx = Some(rx);
std::thread::spawn(move || {
let canonical = if skip_resolution || origin.kind != ForgeKind::GitHub {
override_repo.unwrap_or(origin)
} else {
use crate::forge::canonical::resolve_canonical_repository;
use crate::forge::github::gh::SystemGhRunner;
let runner = SystemGhRunner;
resolve_canonical_repository(&origin, override_repo.as_ref(), &runner)
};
let backend = create_forge_backend(&canonical, None);
let query =
PullRequestListQuery::first_page_with_scope(canonical.clone(), PR_PAGE_SIZE, scope);
let result = backend
.list_pull_requests(query)
.map(|page| (page.pull_requests, page.has_more))
.map_err(|err| err.to_string());
let _ = tx.send(PrLoadEvent::Initial { canonical, result });
});
}
fn spawn_pr_load_more(
&mut self,
repository: ForgeRepository,
scope: crate::forge::traits::PullRequestListScope,
already_loaded: usize,
) {
use crate::forge::selector::PR_PAGE_SIZE;
use crate::forge::traits::PullRequestListQuery;
let (tx, rx) = std::sync::mpsc::channel();
self.pr_load_rx = Some(rx);
std::thread::spawn(move || {
let backend = create_forge_backend(&repository, None);
let query = PullRequestListQuery {
repository,
already_loaded,
page_size: PR_PAGE_SIZE,
scope,
};
let result = backend
.list_pull_requests(query)
.map(|page| (page.pull_requests, page.has_more))
.map_err(|err| err.to_string());
let _ = tx.send(PrLoadEvent::LoadMore(result));
});
}
pub fn poll_pr_load_events(&mut self) {
let Some(rx) = self.pr_load_rx.as_ref() else {
return;
};
let mut events = Vec::new();
while let Ok(event) = rx.try_recv() {
events.push(event);
}
if events.is_empty() {
return;
}
self.pr_load_rx = None;
for event in events {
match event {
PrLoadEvent::Initial { canonical, result } => {
self.pr_tab.apply_canonical(canonical.clone());
self.forge_repository = Some(canonical);
self.canonical_resolved = true;
self.pr_tab.apply_initial_load(result);
}
PrLoadEvent::LoadMore(result) => self.pr_tab.apply_load_more(result),
}
}
self.pr_tab.clamp_cursor();
}
pub fn pr_tab_cursor_up(&mut self) {
self.pr_tab.cursor_up();
self.pr_tab
.ensure_cursor_visible(self.pr_list_viewport_height);
}
pub fn pr_tab_cursor_down(&mut self) {
self.pr_tab.cursor_down();
self.pr_tab
.ensure_cursor_visible(self.pr_list_viewport_height);
}
pub fn toggle_pr_review_requested_filter(&mut self) {
let Some((repo, scope)) = self.pr_tab.toggle_scope_and_start_reload() else {
return;
};
self.pr_filter_draft = None;
let override_repo = self.repo_url_override.clone();
let skip_resolution = self.canonical_resolved;
self.spawn_pr_initial_load(repo, override_repo, skip_resolution, scope);
self.set_message(format!("PR list: {}", scope.label()));
}
pub fn pr_tab_select(&mut self) -> bool {
if self.pr_open_state.is_some() {
return true;
}
if self.pr_tab.cursor_on_load_more() {
if let Some((repo, scope, already)) = self.pr_tab.start_load_more() {
self.spawn_pr_load_more(repo, scope, already);
}
return true;
}
let Some(summary) = self.pr_tab.cursor_pr().cloned() else {
return false;
};
self.spawn_pr_open(&summary);
true
}
fn spawn_pr_open(&mut self, summary: &crate::forge::traits::PullRequestSummary) {
use crate::forge::pr_open::fetch_pr_data;
use crate::forge::traits::PullRequestTarget;
let request = PrOpenRequest {
repository: summary.repository.clone(),
pr_number: summary.number,
started_at: Instant::now(),
};
self.pr_open_state = Some(request.clone());
let (tx, rx) = std::sync::mpsc::channel();
self.pr_open_rx = Some(rx);
let summary_repo = summary.repository.clone();
let pr_number = summary.number;
std::thread::spawn(move || {
let backend = create_forge_backend(&summary_repo, None);
let target =
PullRequestTarget::with_repository(summary_repo, pr_number, pr_number.to_string());
let outcome = fetch_pr_data(backend.as_ref(), target).map_err(|e| e.to_string());
let _ = tx.send(PrOpenEvent::Done {
request,
result: outcome,
});
});
}
pub fn poll_pr_open_events(&mut self) {
let Some(rx) = self.pr_open_rx.as_ref() else {
return;
};
let event = match rx.try_recv() {
Ok(e) => e,
Err(_) => return,
};
self.pr_open_rx = None;
let in_flight = self.pr_open_state.clone();
self.pr_open_state = None;
match event {
PrOpenEvent::Done { request, result } => {
if !in_flight
.as_ref()
.map(|s| s.matches(&request.repository, request.pr_number))
.unwrap_or(false)
{
return;
}
match result {
Ok((details, patch, commits, review_metadata, pr_info)) => {
if let Err(e) = self.finish_pr_open(
details,
patch,
commits,
review_metadata,
pr_info,
&request,
) {
self.set_error(format!(
"Failed to open PR #{}: {}",
request.pr_number, e
));
}
}
Err(e) => {
self.set_error(format!("Failed to open PR #{}: {}", request.pr_number, e));
}
}
}
}
}
fn finish_pr_open(
&mut self,
details: crate::forge::traits::PullRequestDetails,
patch: String,
commits: Vec<crate::forge::traits::PullRequestCommit>,
review_metadata: crate::forge::traits::PullRequestReviewMetadata,
pr_info: crate::forge::traits::PullRequestInfo,
request: &PrOpenRequest,
) -> Result<()> {
use crate::forge::pr_open::prepare_open_pr;
let local_checkout =
crate::forge::local_checkout_for_repo(&self.vcs_info.root_path, &request.repository);
let highlighter = self.theme.syntax_highlighter();
let opened = prepare_open_pr(
details.clone(),
&patch,
commits,
review_metadata,
pr_info,
local_checkout.as_deref(),
highlighter,
)?;
let opened = Self::opened_pr_with_persisted_session(opened)?;
let backend = create_forge_backend(&request.repository, local_checkout.clone());
let previous_message = self.message.clone();
self.enter_pr_diff_mode(backend, opened)?;
self.spawn_pr_threads_fetch(&details, local_checkout);
if self.message == previous_message {
self.set_message(format!(
"Opened PR {}#{}",
request.repository.display_name(),
request.pr_number,
));
}
Ok(())
}
pub(in crate::app) fn spawn_pr_threads_fetch(
&mut self,
details: &crate::forge::traits::PullRequestDetails,
local_checkout: Option<std::path::PathBuf>,
) {
self.forge_review_threads.clear();
self.forge_review_threads_loading = true;
let (tx, rx) = std::sync::mpsc::channel();
self.pr_threads_rx = Some(rx);
let details_clone = details.clone();
let repository = details.repository.clone();
let pr_number = details.number;
let head_sha = details.head_sha.clone();
std::thread::spawn(move || {
let backend = create_forge_backend(&repository, local_checkout);
let threads = backend
.list_review_threads(&details_clone)
.map_err(|e| e.to_string());
let summaries = backend
.list_review_summaries(&details_clone)
.map_err(|e| e.to_string());
let _ = tx.send(PrThreadsEvent::Done {
repository,
pr_number,
head_sha,
threads,
summaries,
});
});
}
pub fn poll_pr_threads_events(&mut self) {
let Some(rx) = self.pr_threads_rx.as_ref() else {
return;
};
let event = match rx.try_recv() {
Ok(e) => e,
Err(_) => return,
};
self.pr_threads_rx = None;
self.forge_review_threads_loading = false;
match event {
PrThreadsEvent::Done {
repository,
pr_number,
head_sha,
threads,
summaries,
} => {
let current = match &self.diff_source {
DiffSource::PullRequest(pr) => Some((
pr.key.repository.clone(),
pr.key.number,
pr.key.head_sha.clone(),
)),
_ => None,
};
let still_relevant = current
.as_ref()
.map(|(r, n, sha)| *r == repository && *n == pr_number && *sha == head_sha)
.unwrap_or(false);
if !still_relevant {
return;
}
let mut had_error = false;
let mut threads_loaded = false;
match threads {
Ok(t) => {
self.forge_review_threads = t;
threads_loaded = true;
}
Err(e) => {
self.forge_review_threads = Vec::new();
self.set_warning(format!("Failed to load remote comments: {e}"));
had_error = true;
}
}
match summaries {
Ok(s) => {
self.forge_review_summaries = s;
}
Err(e) => {
self.forge_review_summaries = Vec::new();
had_error = true;
if threads_loaded {
self.set_warning(format!("Failed to load remote reviews: {e}"));
}
}
}
if !had_error {
self.prune_locked_comments();
let _ = self.save_current_session_merging_external();
}
self.rebuild_annotations();
}
}
}
pub fn set_remote_comments_visibility(
&mut self,
visibility: crate::forge::remote_comments::PrCommentsVisibility,
) -> bool {
if self.session.remote_comments_visibility == visibility {
return false;
}
self.session.remote_comments_visibility = visibility;
self.rebuild_annotations();
true
}
pub fn cancel_pr_open(&mut self) -> bool {
if self.pr_open_state.is_none() {
return false;
}
self.pr_open_state = None;
self.pr_open_rx = None;
self.set_message("PR open cancelled".to_string());
true
}
pub fn refetch_pr_threads(&mut self) {
let local_checkout = self
.forge_backend
.as_deref()
.and_then(|b| b.local_checkout_path());
let details = match &self.diff_source {
DiffSource::PullRequest(pr) => crate::forge::traits::PullRequestDetails {
repository: pr.key.repository.clone(),
number: pr.key.number,
title: pr.title.clone(),
url: pr.url.clone(),
state: pr.state.clone(),
is_draft: false,
author: None,
head_ref_name: pr.head_ref_name.clone(),
base_ref_name: pr.base_ref_name.clone(),
head_sha: pr.key.head_sha.clone(),
base_sha: pr.base_sha.clone(),
body: String::new(),
updated_at: None,
closed: pr.closed,
merged_at: None,
diff_start_sha: None,
},
_ => return,
};
self.spawn_pr_threads_fetch(&details, local_checkout);
}
#[allow(dead_code)]
pub fn open_pr_with_backend(
&mut self,
summary: &crate::forge::traits::PullRequestSummary,
backend: Box<dyn ForgeBackend>,
local_checkout: Option<std::path::PathBuf>,
) -> Result<()> {
use crate::forge::pr_open::open_pull_request;
use crate::forge::traits::PullRequestTarget;
let target = PullRequestTarget::with_repository(
summary.repository.clone(),
summary.number,
summary.number.to_string(),
);
let highlighter = self.theme.syntax_highlighter();
let opened = open_pull_request(
backend.as_ref(),
target,
local_checkout.as_deref(),
highlighter,
)?;
let opened = Self::opened_pr_with_persisted_session(opened)?;
let threads = backend
.list_review_threads(&opened.details)
.unwrap_or_default();
let summaries = backend
.list_review_summaries(&opened.details)
.unwrap_or_default();
self.enter_pr_diff_mode(backend, opened)?;
self.forge_review_threads = threads;
self.forge_review_summaries = summaries;
self.prune_locked_comments();
self.rebuild_annotations();
Ok(())
}
pub fn begin_pr_filter(&mut self) {
if !self.pr_tab.is_loaded() {
return;
}
let current = match &self.pr_tab {
PullRequestsTab::Loaded { filter, .. } => filter.clone(),
_ => String::new(),
};
self.pr_filter_draft = Some(current);
}
pub fn commit_pr_filter(&mut self) {
if let Some(draft) = self.pr_filter_draft.take() {
self.pr_tab.set_filter(draft);
}
}
pub fn cancel_pr_filter(&mut self) {
self.pr_filter_draft = None;
}
pub fn pr_filter_insert_char(&mut self, ch: char) {
if let Some(draft) = self.pr_filter_draft.as_mut() {
draft.push(ch);
}
}
pub fn pr_filter_delete_char(&mut self) {
if let Some(draft) = self.pr_filter_draft.as_mut() {
draft.pop();
}
}
pub fn pr_filter_clear(&mut self) {
if let Some(draft) = self.pr_filter_draft.as_mut() {
draft.clear();
}
}
pub fn pr_filter_editing(&self) -> bool {
self.pr_filter_draft.is_some()
}
}