review
This commit is contained in:
@@ -16,16 +16,12 @@ use crate::local_repos::LocalReposStore;
|
||||
use crate::repo_list::RepoListStore;
|
||||
|
||||
/// Delay between a refresh request and the actual re-computation.
|
||||
/// Bursts of notifications, settings edits and rescan ticks, collapse into one pass.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// How often the statuses of open repository panels are refreshed.
|
||||
/// A commit or pull in external git surfaces in the banner without reopening the panel.
|
||||
const STATUS_POLL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Background poll interval for the `ready to push` badges of the user's own repositories.
|
||||
/// Used when no repository panel is open.
|
||||
/// Each cycle refreshes the remote view of the checkouts with a git fetch.
|
||||
const PUSH_POLL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Maximum checkouts considered per repository when computing statuses.
|
||||
@@ -36,6 +32,7 @@ struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
|
||||
impl Global for GlobalCheckoutsStore {}
|
||||
|
||||
/// One associated local checkout of a repository.
|
||||
///
|
||||
/// Carries the git facts needed to suggest a pull request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CheckoutStatus {
|
||||
@@ -50,9 +47,11 @@ pub struct CheckoutStatus {
|
||||
/// The fallbacks are `main`, then the first local branch.
|
||||
/// For ready-to-push statuses, the remote-tracking ref.
|
||||
/// Unpushed commits are counted against it.
|
||||
///
|
||||
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
|
||||
pub base: String,
|
||||
/// Commits in `base..branch`.
|
||||
///
|
||||
/// Zero-ahead checkouts are dropped, so this is always above zero.
|
||||
pub ahead: u32,
|
||||
}
|
||||
@@ -67,21 +66,21 @@ struct Remembered {
|
||||
/// Global store of local-checkout associations and per-checkout statuses.
|
||||
pub struct CheckoutsStore {
|
||||
/// Checkout paths per announced repository.
|
||||
/// Remembered records, freshest first, plus scanned repos matched implicitly.
|
||||
/// Deduplicated by path.
|
||||
/// Missing directories are dropped before publishing.
|
||||
by_repo: Arc<HashMap<RepoAddr, Vec<PathBuf>>>,
|
||||
/// Ready-to-contribute statuses of the requested repositories.
|
||||
statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
||||
/// Repositories whose statuses are recomputed on every input change.
|
||||
///
|
||||
/// Those are the repository detail panels currently open.
|
||||
status_requested: HashSet<RepoAddr>,
|
||||
/// Repositories whose `ready to push` statuses are recomputed on the same cycle.
|
||||
///
|
||||
/// The sidebar rows of the user's own repositories and their detail panels.
|
||||
push_requested: HashSet<RepoAddr>,
|
||||
/// Ready-to-push statuses of the requested own repositories.
|
||||
push_statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
||||
/// Last announced head branch per requested repository.
|
||||
///
|
||||
/// A recompute defaults the base the same way.
|
||||
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||
refreshing: bool,
|
||||
@@ -103,9 +102,6 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// Create the store.
|
||||
/// Observe the inputs, settings records, the local scan and the announcement list.
|
||||
/// Signer changes also trigger a refresh.
|
||||
/// Associations are resolved right away.
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
@@ -118,12 +114,15 @@ impl CheckoutsStore {
|
||||
subscriptions.push(cx.observe(&settings, |this, _settings, cx| {
|
||||
this.refresh(cx);
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.observe(&local, |this, _local, cx| {
|
||||
this.refresh(cx);
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
this.refresh(cx);
|
||||
}));
|
||||
|
||||
// Another identity's repositories must not keep the old statuses alive.
|
||||
// Their polls stop too.
|
||||
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
@@ -155,12 +154,11 @@ impl CheckoutsStore {
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
store.refresh(cx);
|
||||
}
|
||||
|
||||
store
|
||||
}
|
||||
|
||||
/// Remember a successful local-checkout use.
|
||||
/// Re-insert the record with a fresh timestamp.
|
||||
/// Freshest-first ordering then follows actual use.
|
||||
pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
@@ -190,6 +188,7 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// The associated checkouts of `addr`, freshest first.
|
||||
///
|
||||
/// Empty when none are known or the resolution has not run yet.
|
||||
pub fn associations_of(&self, addr: &RepoAddr) -> Vec<PathBuf> {
|
||||
self.by_repo.get(addr).cloned().unwrap_or_default()
|
||||
@@ -197,6 +196,7 @@ impl CheckoutsStore {
|
||||
|
||||
/// Ask for the `ready to contribute` statuses of `addr` to stay current.
|
||||
/// Called while the repository's detail panel is open.
|
||||
///
|
||||
/// `announced_head` is the announced HEAD branch, used to default the base.
|
||||
pub fn request_statuses(
|
||||
&mut self,
|
||||
@@ -212,16 +212,13 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// The ready-to-contribute statuses of `addr`.
|
||||
///
|
||||
/// Empty while none are known or nothing is ahead.
|
||||
pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||
self.statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ask for the `ready to push` statuses of `addr` to stay current.
|
||||
/// The sidebar and the detail panels call this for the user's own repositories.
|
||||
/// Recomputed on every input change and on a background poll.
|
||||
/// Each cycle refreshes the remote view first.
|
||||
/// A commit made in external git surfaces within one poll interval.
|
||||
pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context<Self>) {
|
||||
self.push_requested.insert(addr.clone());
|
||||
self.refresh(cx);
|
||||
@@ -229,13 +226,14 @@ impl CheckoutsStore {
|
||||
|
||||
/// The ready-to-push statuses of `addr`.
|
||||
/// Only meaningful for repositories announced by the signed-in user.
|
||||
///
|
||||
/// Empty while none are known or nothing is unpushed.
|
||||
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||
self.push_statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Re-resolve the associations and the requested statuses.
|
||||
/// Debounced, bursts of notifications collapse into one pass.
|
||||
///
|
||||
/// Requests arriving while a pass runs fold into a follow-up.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refreshing {
|
||||
@@ -268,6 +266,7 @@ impl CheckoutsStore {
|
||||
let settings = SettingsStore::global(cx);
|
||||
settings.read(cx).settings().checkouts.records.clone()
|
||||
};
|
||||
|
||||
let remembered: Vec<Remembered> = records
|
||||
.into_iter()
|
||||
.filter_map(|record| {
|
||||
@@ -279,9 +278,11 @@ impl CheckoutsStore {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
||||
let scanned = LocalReposStore::global(cx).read(cx).repos.clone();
|
||||
let cache_root = GitStore::global(cx).cache().root().canonicalize().ok();
|
||||
|
||||
let requested: Vec<(RepoAddr, Option<String>)> = self
|
||||
.status_requested
|
||||
.iter()
|
||||
@@ -292,16 +293,17 @@ impl CheckoutsStore {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let push_requested: Vec<RepoAddr> = self.push_requested.iter().cloned().collect();
|
||||
let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
// Read the git facts of every scanned repository off the main thread.
|
||||
//
|
||||
// The facts are the origin URL and the root commit, both CLI reads.
|
||||
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new();
|
||||
for path in scanned.iter() {
|
||||
// The browser's mirror clones share the announce URLs and EUCs.
|
||||
// They are not user checkouts.
|
||||
// The browser's mirror clones share the announce URLs and EUCs. They are not user checkouts.
|
||||
if cache_root
|
||||
.as_ref()
|
||||
.is_some_and(|root| path.starts_with(root))
|
||||
@@ -314,6 +316,7 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
let associations = resolve_associations(&remembered, &facts, announcements.iter());
|
||||
|
||||
// Missing directories are stale records, drop them.
|
||||
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
|
||||
.into_iter()
|
||||
@@ -384,20 +387,17 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
// Keep the statuses current while any repository panel is open.
|
||||
// The user's own repositories also count when watched for the sidebar badge.
|
||||
// Local commits, pulls and branch switches happen outside the app.
|
||||
// They are not otherwise observable.
|
||||
this.update(cx, |this, cx| {
|
||||
if poll && !this.debouncing && !this.refreshing {
|
||||
this.debouncing = true;
|
||||
// Open panels get the fast cadence.
|
||||
// Sidebar-only badges poll less aggressively.
|
||||
// Each cycle fetches every watched checkout's remote.
|
||||
let delay = if this.status_requested.is_empty() {
|
||||
PUSH_POLL
|
||||
} else {
|
||||
STATUS_POLL
|
||||
};
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(delay).await;
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -405,6 +405,7 @@ impl CheckoutsStore {
|
||||
this.run_refresh(cx);
|
||||
})
|
||||
});
|
||||
|
||||
this.tasks.push(task);
|
||||
}
|
||||
})?;
|
||||
@@ -415,10 +416,6 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// Identity of a repository URL.
|
||||
/// Host, explicit port and path count, with a trailing `.git` and slashes stripped.
|
||||
/// Scheme-insensitive, so `ws`, `wss`, `http`, `https` and `grasp` are one transport.
|
||||
/// `None` for unparseable URLs, e.g. `git@`-style or plain paths.
|
||||
/// Those then compare by raw string.
|
||||
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
||||
let parsed = Url::parse(url).ok()?;
|
||||
let host = parsed.host_str()?.to_ascii_lowercase();
|
||||
@@ -430,7 +427,6 @@ fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
||||
}
|
||||
|
||||
/// Whether two repository URLs point at the same repository.
|
||||
/// Ignores the transport scheme, see [`url_identity`].
|
||||
fn same_repo_url(a: &str, b: &str) -> bool {
|
||||
match (url_identity(a), url_identity(b)) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
@@ -439,9 +435,6 @@ fn same_repo_url(a: &str, b: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Resolve the associations between local checkouts and announced repositories.
|
||||
/// Remembered records come first, freshest first per repository.
|
||||
/// Scanned repositories matched by origin URL or EUC follow.
|
||||
/// Deduplicated by path, remembered entries win.
|
||||
fn resolve_associations<'a>(
|
||||
remembered: &[Remembered],
|
||||
scanned: &[(PathBuf, Option<String>, Option<String>)],
|
||||
@@ -467,9 +460,11 @@ fn resolve_associations<'a>(
|
||||
.iter()
|
||||
.any(|url| same_repo_url(origin, url.as_str()))
|
||||
});
|
||||
|
||||
let euc_match = root
|
||||
.as_deref()
|
||||
.is_some_and(|root| announcement.euc.as_deref() == Some(root));
|
||||
|
||||
if url_match || euc_match {
|
||||
let paths = out.entry(announcement.addr()).or_default();
|
||||
if !paths.contains(path) {
|
||||
@@ -483,8 +478,6 @@ fn resolve_associations<'a>(
|
||||
}
|
||||
|
||||
/// Whether the worktree of `path` has uncommitted changes.
|
||||
/// A dirty checkout is never suggested.
|
||||
/// The proposal should cover committed work.
|
||||
fn worktree_dirty(path: &Path) -> bool {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -499,8 +492,6 @@ fn worktree_dirty(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// Commits in `base..branch` of the checkout at `path`.
|
||||
/// Reads `git rev-list --count`.
|
||||
/// `0` when the range is empty or cannot be computed.
|
||||
fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -518,7 +509,6 @@ fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 {
|
||||
}
|
||||
|
||||
/// The branch checked out at `path`, read via `git branch --show-current`.
|
||||
/// `None` when detached.
|
||||
fn current_branch_of(path: &Path) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -532,10 +522,6 @@ fn current_branch_of(path: &Path) -> Option<String> {
|
||||
}
|
||||
|
||||
/// The ready-to-contribute status of one checkout.
|
||||
/// `None` when idle.
|
||||
/// Idle means detached HEAD, no branches, a dirty worktree or nothing ahead of its base.
|
||||
/// The base defaults like the New PR panel.
|
||||
/// The announced HEAD branch when the checkout has it, else `main`, else the first branch.
|
||||
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
|
||||
let branches = signed_git::worktree_branches(path).ok()?;
|
||||
if branches.is_empty() || worktree_dirty(path) {
|
||||
@@ -562,6 +548,7 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<Checkout
|
||||
}
|
||||
|
||||
/// Whether the reference `name` exists in the checkout at `path`.
|
||||
///
|
||||
/// Example, `refs/remotes/origin/main`.
|
||||
fn ref_exists(path: &Path, name: &str) -> bool {
|
||||
let output = Command::new("git")
|
||||
@@ -574,11 +561,6 @@ fn ref_exists(path: &Path, name: &str) -> bool {
|
||||
}
|
||||
|
||||
/// The `ready to push` status of one checkout of the user's own repository.
|
||||
/// The checked-out branch has commits the grasp servers do not have yet.
|
||||
/// The remote view is refreshed first, best-effort.
|
||||
/// Offline, the last known remote state still counts commits made since.
|
||||
/// Detached checkouts, dirty worktrees and an unknown remote state yield no status.
|
||||
/// Branches the remote does not have yet are counted against the remote HEAD.
|
||||
fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
|
||||
if worktree_dirty(path) {
|
||||
return None;
|
||||
@@ -612,9 +594,6 @@ fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
|
||||
}
|
||||
|
||||
/// Whether the pull request `pr` already proposes the same change as `checkout`.
|
||||
/// `pr` is a kind-1618 root, resolved `open` by the caller.
|
||||
/// Matches when authored by `user` with a matching `branch-name` tag.
|
||||
/// For renamed branches, a `c` tip tag matching the checkout's HEAD commit counts.
|
||||
pub fn pr_proposes_checkout(
|
||||
pr: &Event,
|
||||
open: bool,
|
||||
|
||||
Reference in New Issue
Block a user