use std::collections::HashSet;
use std::path::Path;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use tui_pane::PERF_LOG_TARGET;
use tui_pane::TrackedItem;
use super::constants::PR_CHECK_POLL_SECS;
use crate::ci;
use crate::ci::OwnerRepo;
use crate::http::HttpClient;
use crate::http::PullRequestFetch;
use crate::http::ServiceSignal;
use crate::project::AbsolutePath;
use crate::project::CheckoutInfo;
use crate::project::GitStatus;
use crate::project::LocalGitState;
use crate::project::ProjectPrData;
use crate::project::ProjectPrInfo;
use crate::project::ProjectPrUnavailable;
use crate::project::PullRequestGoneReason;
use crate::project::PullRequestInfo;
use crate::project::PullRequestState;
use crate::project::PullRequestUnavailableReason;
use crate::project::RepoInfo;
use crate::project::RootItem;
use crate::scan;
use crate::scan::BackgroundMsg;
use crate::scan::CachedRepoData;
use crate::scan::CiFetchResult;
use crate::scan::RepoCache;
use crate::tui::app::App;
use crate::tui::project_list::SyncResolution;
use crate::tui::state;
impl App {
pub(super) fn spawn_repo_fetch_for_git_info(&mut self, path: &Path, repo_url: &str) {
let Some(owner_repo) = ci::parse_owner_repo(repo_url) else {
return;
};
if !self
.net
.github
.repo_fetch_in_flight_mut()
.insert(owner_repo.clone())
{
return;
}
let sender = self.background.background_sender();
let client = self.net.http_client();
let repo_cache = self.net.github.fetch_cache.clone();
let path: AbsolutePath = AbsolutePath::from(path);
let repo_url = repo_url.to_string();
let ci_run_count = self.config.ci_run_count();
thread::spawn(move || {
let mut data =
scan::load_cached_repo_data(&repo_cache, &owner_repo).unwrap_or_else(|| {
let _ = sender.send(BackgroundMsg::RepoFetchQueued {
repo: owner_repo.clone(),
});
let (result, meta, signal) = scan::fetch_ci_runs_cached(
&client,
&repo_url,
owner_repo.owner(),
owner_repo.repo(),
ci_run_count,
);
scan::emit_service_signal(&sender, signal);
let (runs, github_total) = match result {
CiFetchResult::Loaded { runs, github_total } => (runs, github_total),
CiFetchResult::CacheOnly(runs) => (runs, 0),
};
let data = CachedRepoData {
runs,
meta,
github_total,
pr_data: ProjectPrData::Unfetched,
};
scan::store_cached_repo_data(&repo_cache, &owner_repo, data.clone());
data
});
if data.pr_data.needs_fetch() {
let _ = sender.send(BackgroundMsg::RepoFetchQueued {
repo: owner_repo.clone(),
});
let stale = data.pr_data.info().cloned();
let _ = sender.send(BackgroundMsg::PullRequests {
repo: owner_repo.clone(),
data: ProjectPrData::Loading(stale.clone()),
});
let (pr_fetch, signal) = client.fetch_open_pull_requests(owner_repo.clone());
scan::emit_service_signal(&sender, signal);
data.pr_data = match pr_fetch {
Some(PullRequestFetch::Loaded(info)) => ProjectPrData::Loaded(info),
Some(PullRequestFetch::Unavailable(reason)) => {
ProjectPrData::Unavailable(ProjectPrUnavailable {
reason,
stale,
fetched_at: None,
})
},
None => ProjectPrData::Unavailable(ProjectPrUnavailable {
reason: PullRequestUnavailableReason::Network,
stale,
fetched_at: None,
}),
};
scan::store_cached_repo_data(&repo_cache, &owner_repo, data.clone());
}
let _ = sender.send(BackgroundMsg::CiRuns {
path: path.clone(),
runs: data.runs,
github_total: data.github_total,
});
let _ = sender.send(BackgroundMsg::PullRequests {
repo: owner_repo.clone(),
data: data.pr_data,
});
if let Some(meta) = data.meta {
let _ = sender.send(BackgroundMsg::RepoMeta {
path,
stars: meta.stars,
description: meta.description,
});
}
let _ = sender.send(BackgroundMsg::RepoFetchComplete { repo: owner_repo });
});
}
pub fn handle_checkout_info(&mut self, path: &Path, info: CheckoutInfo) {
tracing::trace!(
target: PERF_LOG_TARGET,
path = %path.display(),
git_status = %info.status.label(),
"checkout_info_applied"
);
let status = info.status;
let stores_own_git_state = !self.project_list.is_vendored_path(path)
&& !self.project_list.is_workspace_member_path(path);
if stores_own_git_state && let Some(project) = self.project_list.at_path_mut(path) {
project.local_git_state = LocalGitState::Detected(Box::new(info));
}
self.panes.clear_worktree_summary_cache();
self.project_list.ensure_git_repo_for(path);
if self.scan.is_complete() {
let git_dir = self
.startup_git_directory_for_path(path)
.unwrap_or_else(|| AbsolutePath::from(path));
self.startup.git.seen.insert(git_dir);
self.maybe_log_startup_phase_completions();
}
self.record_git_status_observation(path, status);
self.maybe_trigger_repo_fetch(path);
}
fn record_git_status_observation(&mut self, path: &Path, status: GitStatus) {
let Some(transition) = self
.git_status_tracker
.observe(AbsolutePath::from(path), status)
else {
return;
};
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_string();
let label = state::format_git_status_transition(&name, &transition);
let seq = self.git_status_tracker.next_item_seq();
let key = format!("{}#{seq}", path.display());
let item = TrackedItem {
label,
key: key.into(),
started_at: None,
completed_at: Some(Instant::now()),
};
let reuse = self
.git_status_tracker
.current_toast()
.filter(|id| self.framework.toasts.tracked_item_count(*id) > 0);
let toast_id = reuse.unwrap_or_else(|| {
let id = self.framework.toasts.push_task("Git status changes", "", 1);
self.git_status_tracker.set_current_toast(Some(id));
id
});
self.framework
.toasts
.add_new_tracked_items(toast_id, &[item]);
}
pub fn handle_repo_info(&mut self, path: &Path, mut info: RepoInfo) {
let preserved_first_commit = self
.project_list
.repo_info_for(path)
.and_then(|existing| existing.first_commit.clone());
if info.first_commit.is_none() {
info.first_commit = preserved_first_commit
.or_else(|| self.scan.pending_git_first_commit_mut().remove(path));
}
let previous_last_fetched = self
.project_list
.repo_info_for(path)
.and_then(|existing| existing.last_fetched.clone());
let fetch_head_advanced =
info.last_fetched.is_some() && info.last_fetched != previous_last_fetched;
let is_submodule_target = self.project_list.is_submodule_path(path);
if is_submodule_target {
if let Some(git_repo) = self.project_list.ensure_git_repo_for(path) {
git_repo.repo_info = Some(info);
}
} else if let Some(entry) = self.project_list.entry_containing_mut(path) {
if entry.root_item.path().as_path() != path {
return;
}
let git_repo = entry.git_repo.get_or_insert_with(Default::default);
git_repo.repo_info = Some(info);
}
if fetch_head_advanced
&& self.scan.is_complete()
&& let Some(url) = self.project_list.fetch_url_for(path)
&& let Some(owner_repo) = ci::parse_owner_repo(&url)
&& !self.net.github.contains_in_flight(&owner_repo)
{
scan::invalidate_cached_repo_data(&self.net.github.fetch_cache, &owner_repo);
}
self.record_sync_observation(path);
self.maybe_trigger_repo_fetch(path);
}
fn record_sync_observation(&mut self, path: &Path) {
let SyncResolution::Resolved(current) = self.project_list.primary_sync_resolution(path)
else {
return;
};
let Some(transition) = self.sync_tracker.observe(AbsolutePath::from(path), current) else {
return;
};
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?")
.to_string();
let label = state::format_sync_transition(&name, &transition);
let seq = self.sync_tracker.next_item_seq();
let key = format!("{}#{seq}", path.display());
let item = TrackedItem {
label,
key: key.into(),
started_at: None,
completed_at: Some(Instant::now()),
};
let reuse = self
.sync_tracker
.current_toast()
.filter(|id| self.framework.toasts.tracked_item_count(*id) > 0);
let toast_id = reuse.unwrap_or_else(|| {
let id = self.framework.toasts.push_task("Sync changes", "", 1);
self.sync_tracker.set_current_toast(Some(id));
id
});
self.framework
.toasts
.add_new_tracked_items(toast_id, &[item]);
}
pub(super) fn maybe_trigger_repo_fetch(&mut self, path: &Path) {
let Some(url) = self.project_list.fetch_url_for(path) else {
return;
};
self.spawn_repo_fetch_for_git_info(path, &url);
}
pub(super) fn handle_git_first_commit(&mut self, path: &Path, first_commit: Option<&str>) {
let first_commit = first_commit.map(String::from);
let applied = self
.project_list
.entry_containing_mut(path)
.and_then(|entry| entry.git_repo.as_mut()?.repo_info.as_mut())
.map(|repo| repo.first_commit.clone_from(&first_commit))
.is_some();
if applied {
self.scan.pending_git_first_commit_mut().remove(path);
} else if let Some(first_commit) = first_commit {
self.scan
.pending_git_first_commit_mut()
.insert(AbsolutePath::from(path), first_commit);
} else {
self.scan.pending_git_first_commit_mut().remove(path);
}
}
pub(super) fn handle_repo_fetch_queued(&mut self, repo: OwnerRepo) {
if self.startup.is_collecting() {
self.startup.repo.expected.insert(repo.clone());
self.startup.repo.seen.remove(&repo);
self.startup.repo.complete_at = None;
}
self.net.github_running_mut().insert(repo, Instant::now());
self.sync_running_repo_fetch_toast();
}
pub(super) fn handle_repo_fetch_complete(&mut self, repo: OwnerRepo) {
self.net.github.repo_fetch_in_flight_mut().remove(&repo);
self.net.github_running_mut().remove(&repo);
self.mark_sync_eligible_for(&repo);
self.startup.repo.seen.insert(repo);
self.maybe_log_startup_phase_completions();
self.sync_running_repo_fetch_toast();
}
pub(super) fn handle_pull_requests(&mut self, repo: &OwnerRepo, data: &ProjectPrData) {
let prior = self.project_list.pr_info_for_repo(repo).cloned();
let data = preserve_live_pr_snapshot_while_loading(data, prior.as_ref());
let selected_matches = self.selected_repo_matches(repo);
self.maybe_toast_deleted_pull_requests(repo, prior.as_ref(), &data);
self.project_list.replace_pr_data_for_repo(repo, &data);
self.sync_pull_request_check_polls(repo, &data);
if selected_matches {
self.scan.bump_generation();
}
}
pub(super) fn handle_pull_request_check_poll_stopped(&mut self, repo: &OwnerRepo, number: u32) {
let removed = self.net.github.remove_pr_check_poll(repo, number);
if removed && self.selected_repo_matches(repo) {
self.scan.bump_generation();
}
}
fn selected_repo_matches(&self, repo: &OwnerRepo) -> bool {
self.project_list
.selected_project_path()
.and_then(|path| self.project_list.fetch_url_for(path))
.and_then(|url| ci::parse_owner_repo(&url))
.as_ref()
== Some(repo)
}
fn sync_pull_request_check_polls(&mut self, repo: &OwnerRepo, data: &ProjectPrData) {
let ProjectPrData::Loaded(info) = data else {
return;
};
let active = self.net.github.pr_check_poll_numbers(repo);
let checking: HashSet<u32> = info
.open
.iter()
.filter(|pull_request| pull_request.state == PullRequestState::ChecksFailing)
.map(|pull_request| pull_request.number)
.collect();
for pull_request in info.open.iter().filter(|pull_request| {
active.contains(&pull_request.number)
&& pull_request.state != PullRequestState::ChecksFailing
}) {
self.toast_pull_request_checks_finished(repo, pull_request);
}
self.net
.github
.retain_pr_check_polls_for_repo(repo, &checking);
for number in checking {
self.start_pull_request_check_poll(repo, number);
}
}
fn toast_pull_request_checks_finished(
&mut self,
repo: &OwnerRepo,
pull_request: &PullRequestInfo,
) {
self.framework.toasts.push_status(
"Pull request checks finished",
format!(
"{repo}: #{} {} is {}",
pull_request.number,
pull_request.title,
pull_request.state.label()
),
);
}
fn start_pull_request_check_poll(&mut self, repo: &OwnerRepo, number: u32) {
if !self.net.github.insert_pr_check_poll(repo.clone(), number) {
return;
}
let sender = self.background.background_sender();
let client = self.net.http_client();
let repo_cache = self.net.github.fetch_cache.clone();
let repo = repo.clone();
thread::spawn(move || {
loop {
thread::sleep(Duration::from_secs(PR_CHECK_POLL_SECS));
let (data, signal) =
fetch_pull_requests_for_check_poll(&client, &repo_cache, &repo);
scan::emit_service_signal(&sender, signal);
let Some(data) = data else {
continue;
};
let still_checking = pull_request_still_checking(&data, number);
let _ = sender.send(BackgroundMsg::PullRequests {
repo: repo.clone(),
data,
});
if !still_checking {
break;
}
}
let _ = sender.send(BackgroundMsg::PullRequestCheckPollStopped { repo, number });
});
}
fn maybe_toast_deleted_pull_requests(
&self,
repo: &OwnerRepo,
prior: Option<&ProjectPrInfo>,
data: &ProjectPrData,
) {
let deleted = deleted_pull_requests(prior, data);
if deleted.is_empty() {
return;
}
self.spawn_pull_request_disappearance_classification(repo.clone(), deleted);
}
fn spawn_pull_request_disappearance_classification(
&self,
repo: OwnerRepo,
pull_requests: Vec<PullRequestInfo>,
) {
let sender = self.background.background_sender();
let client = self.net.http_client();
thread::spawn(move || {
for pull_request in pull_requests {
let (reason, signal) =
client.fetch_pull_request_gone_reason(repo.clone(), pull_request.number);
scan::emit_service_signal(&sender, signal);
let _ = sender.send(BackgroundMsg::PullRequestDisappeared {
repo: repo.clone(),
pull_request,
reason: reason.unwrap_or(PullRequestGoneReason::Unknown),
});
}
});
}
pub(super) fn handle_pull_request_disappeared(
&mut self,
repo: &OwnerRepo,
pull_request: &PullRequestInfo,
reason: &PullRequestGoneReason,
) {
let (title, body) = pull_request_disappeared_toast(repo, pull_request, reason);
self.framework.toasts.push_status(title, body);
}
fn mark_sync_eligible_for(&mut self, repo: &OwnerRepo) {
let mut targets: Vec<(AbsolutePath, SyncResolution)> = Vec::new();
self.project_list.for_each_leaf_path(|path, _| {
let Some(url) = self.project_list.fetch_url_for(path) else {
return;
};
if ci::parse_owner_repo(&url).as_ref() != Some(repo) {
return;
}
targets.push((
AbsolutePath::from(path),
self.project_list.primary_sync_resolution(path),
));
});
for (path, resolution) in targets {
if let SyncResolution::Resolved(current) = resolution {
self.sync_tracker.seed_baseline(path.clone(), current);
}
self.sync_tracker.mark_eligible(path);
}
}
pub fn handle_project_discovered(&mut self, item: RootItem) -> bool {
let legacy_expansions = self.project_list.capture_legacy_root_expansions();
let discovered_path = item.path().to_path_buf();
let mut already_exists = false;
self.project_list.for_each_leaf_path(|path, _| {
if path == discovered_path {
already_exists = true;
}
});
if already_exists {
return false;
}
let service_item = item.clone();
let discovered_path = item.path().to_path_buf();
let inline_dirs = self.config.current().tui.inline_dirs.clone();
let dispatch = self.metadata_dispatch();
{
let mut tree = self.mutate_tree();
tree.insert_into_hierarchy(item, &dispatch);
tree.regroup_members(&inline_dirs);
}
self.register_discovery_shimmer(discovered_path.as_path());
if !legacy_expansions.is_empty() {
self.project_list
.migrate_legacy_root_expansions(&legacy_expansions);
self.rebuild_visible_rows_now();
}
self.reload_lint_history(&discovered_path);
self.background
.register_item_background_services(&service_item);
self.register_lint_project_if_eligible(&service_item);
true
}
pub fn handle_project_refreshed(&mut self, item: RootItem) -> bool {
let legacy_expansions = self.project_list.capture_legacy_root_expansions();
let path = item.path().to_path_buf();
let service_item = item.clone();
let inline_dirs = self.config.current().tui.inline_dirs.clone();
let dispatch = self.metadata_dispatch();
{
let mut tree = self.mutate_tree();
let Some(old) = tree.replace_leaf_by_path(&path, item.clone(), &dispatch) else {
return false;
};
let mut item = item;
for (project_path, info) in old.collect_project_info() {
if let Some(project) = item.at_path_mut(&project_path) {
let fresh_worktree_health = project.worktree_health;
*project = info;
project.worktree_health = fresh_worktree_health;
}
transfer_lint_runs(&old, &mut item, &project_path);
transfer_crates_io(&old, &mut item, &project_path);
}
tree.replace_leaf_by_path(&path, item, &dispatch);
tree.regroup_members(&inline_dirs);
tree.regroup_top_level_worktrees();
}
self.reload_lint_history(&path);
if !legacy_expansions.is_empty() {
self.project_list
.migrate_legacy_root_expansions(&legacy_expansions);
self.rebuild_visible_rows_now();
}
self.ci.clear_content();
self.lint.clear_content();
self.panes.clear_detail_data(None);
self.background
.register_item_background_services(&service_item);
self.register_lint_project_if_eligible(&service_item);
true
}
fn startup_git_directory_for_path(&self, path: &Path) -> Option<AbsolutePath> {
self.project_list
.iter()
.find(|entry| entry.root_item.at_path(path).is_some())
.and_then(|entry| entry.root_item.git_directory())
}
}
fn transfer_lint_runs(old: &RootItem, item: &mut RootItem, path: &AbsolutePath) {
if let Some(source) = old.lint_at_path(path.as_path()).cloned()
&& let Some(target) = item.lint_at_path_mut(path.as_path())
{
*target = source;
}
}
fn transfer_crates_io(old: &RootItem, item: &mut RootItem, path: &AbsolutePath) {
if let Some(source) = old.rust_info_at_path(path)
&& let (Some(version), Some(downloads)) =
(source.crates_version(), source.crates_downloads())
&& let Some(target) = item.rust_info_at_path_mut(path)
{
let prerelease = source.crates_prerelease().map(str::to_string);
target.set_crates_io(version.to_string(), prerelease, downloads);
}
if let Some(source) = old.vendored_at_path(path)
&& let (Some(version), Some(downloads)) =
(source.crates_version(), source.crates_downloads())
&& let Some(target) = item.vendored_at_path_mut(path)
{
let prerelease = source.crates_prerelease().map(str::to_string);
target.set_crates_io(version.to_string(), prerelease, downloads);
}
}
fn preserve_live_pr_snapshot_while_loading(
data: &ProjectPrData,
prior: Option<&ProjectPrInfo>,
) -> ProjectPrData {
match (data, prior) {
(ProjectPrData::Loading(None), Some(prior)) => ProjectPrData::Loading(Some(prior.clone())),
_ => data.clone(),
}
}
fn pull_request_disappeared_toast(
repo: &OwnerRepo,
pull_request: &PullRequestInfo,
reason: &PullRequestGoneReason,
) -> (String, String) {
let prefix = format!(
"#{number} {title}",
number = pull_request.number,
title = pull_request.title
);
match reason {
PullRequestGoneReason::Merged { base } => (
"Pull request merged".to_string(),
format!("{repo}: {prefix} merged into {base}"),
),
PullRequestGoneReason::Closed => (
"Pull request closed".to_string(),
format!("{repo}: {prefix} closed"),
),
PullRequestGoneReason::Missing | PullRequestGoneReason::Unknown => (
"Pull request no longer open".to_string(),
format!("{repo}: {prefix} is no longer open"),
),
}
}
fn deleted_pull_requests(
prior: Option<&ProjectPrInfo>,
data: &ProjectPrData,
) -> Vec<PullRequestInfo> {
let Some(previous) = prior else {
return Vec::new();
};
let ProjectPrData::Loaded(current) = data else {
return Vec::new();
};
if previous.viewer_login != current.viewer_login {
return Vec::new();
}
let current_numbers: HashSet<u32> = current
.open
.iter()
.map(|pull_request| pull_request.number)
.collect();
previous
.open
.iter()
.filter(|pull_request| !current_numbers.contains(&pull_request.number))
.cloned()
.collect()
}
fn fetch_pull_requests_for_check_poll(
client: &HttpClient,
repo_cache: &RepoCache,
repo: &OwnerRepo,
) -> (Option<ProjectPrData>, Option<ServiceSignal>) {
let (pr_fetch, signal) = client.fetch_open_pull_requests(repo.clone());
let data = match pr_fetch {
Some(PullRequestFetch::Loaded(info)) => ProjectPrData::Loaded(info),
Some(PullRequestFetch::Unavailable(_)) | None => return (None, signal),
};
store_polled_pull_request_data(repo_cache, repo, &data);
(Some(data), signal)
}
fn store_polled_pull_request_data(repo_cache: &RepoCache, repo: &OwnerRepo, data: &ProjectPrData) {
let Some(mut cached) = scan::load_cached_repo_data(repo_cache, repo) else {
return;
};
cached.pr_data = data.clone();
scan::store_cached_repo_data(repo_cache, repo, cached);
}
fn pull_request_still_checking(data: &ProjectPrData, number: u32) -> bool {
data.info().is_some_and(|info| {
info.open.iter().any(|pull_request| {
pull_request.number == number && pull_request.state == PullRequestState::ChecksFailing
})
})
}