From c2c2839ac15c9997763499accdc8b6e192b1cede Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 6 Sep 2026 14:42:33 +0700 Subject: [PATCH] update --- crates/signed_state/src/checkouts.rs | 39 ++++++++- crates/signed_state/src/refresh.rs | 121 +++++++++++++++++++++++++-- crates/signed_state/src/repo.rs | 5 +- 3 files changed, 154 insertions(+), 11 deletions(-) diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs index 00b3a21..d9b6547 100644 --- a/crates/signed_state/src/checkouts.rs +++ b/crates/signed_state/src/checkouts.rs @@ -235,6 +235,35 @@ impl CheckoutsStore { self.refresh(cx); } + /// The checkout at `path` was just pushed to the remote. + /// + /// Its ready-to-push status is obsolete. Drop it from the cached statuses + /// and notify observers right away, so the sidebar badge and the push + /// banner update immediately instead of waiting for the next background + /// pass, which re-scans and re-fetches the remote. The debounced refresh + /// reconciles the remaining checkouts of the repository afterwards. + pub fn checkout_pushed(&mut self, addr: &RepoAddr, path: &Path, cx: &mut Context) { + let mut removed = false; + + if let Some(statuses) = self.push_statuses.get_mut(addr) { + let before = statuses.len(); + statuses.retain(|status| status.path.as_path() != path); + removed = statuses.len() != before; + + if removed && statuses.is_empty() { + self.push_statuses.remove(addr); + } + } + + if removed { + cx.notify(); + } + + // The other checkouts of this repository still need re-deriving + // against the remote, now that the pushed refs landed there. + self.request_push_statuses(addr, cx); + } + /// The ready-to-push statuses of `addr`. /// /// Empty while none are known or nothing is unpushed. @@ -402,7 +431,7 @@ impl CheckoutsStore { // Keep the statuses current while any repository panel is open. this.update(cx, |this, cx| { if poll && this.refresh.idle() { - this.refresh.debounce(); + this.refresh.poll(); // Open panels get the fast cadence. // Each cycle fetches every watched checkout's remote. @@ -414,7 +443,13 @@ impl CheckoutsStore { let task = cx.spawn(async move |this, cx| { cx.background_executor().timer(delay).await; - this.update(cx, |this, cx| this.run_refresh(cx)) + this.update(cx, |this, cx| { + // A request that arrived while the poll was pending + // superseded it with its own debounce; skip the stale poll. + if this.refresh.take_poll() { + this.run_refresh(cx); + } + }) }); this.push_task(task); diff --git a/crates/signed_state/src/refresh.rs b/crates/signed_state/src/refresh.rs index c1cae3e..3e9532e 100644 --- a/crates/signed_state/src/refresh.rs +++ b/crates/signed_state/src/refresh.rs @@ -3,7 +3,13 @@ /// [`crate::RepoStore`], [`crate::RepoListStore`] and [`crate::CheckoutsStore`] /// re-query their inputs on a debounce timer with the same policy: /// a request arriving while a run is in flight is folded into a follow-up run, -/// a request arriving while the debounce timer is pending is dropped by it. +/// a request arriving while a request debounce is pending is dropped by it. +/// +/// A slow poll cycle (`poll`) is different: it keeps a store's derived data +/// fresh while nothing is happening, but it must never delay a real request. +/// A request arriving while a poll is pending supersedes the poll with its own +/// short debounce, so external events (a push landing, a settings change) +/// propagate promptly instead of waiting out the poll interval. #[derive(Debug, Default)] pub struct RefreshGate { /// A run is in flight. @@ -12,6 +18,10 @@ pub struct RefreshGate { dirty: bool, /// The debounce timer is pending. debouncing: bool, + /// The pending debounce is a poll cycle, not a request. + /// + /// Polls wait for a quiet moment; requests supersede them. + poll: bool, } /// What a refresh request decided. @@ -41,28 +51,47 @@ impl RefreshGate { /// A new refresh request arrived. /// - /// Folded into a follow-up run while one is in flight, dropped while the - /// debounce timer is pending, otherwise starts the timer. + /// Folded into a follow-up run while one is in flight or a request debounce + /// is pending, superseding a slow poll with the request's own debounce, + /// otherwise starts the debounce timer. pub fn request(&mut self) -> RefreshRequest { if self.running { self.dirty = true; RefreshRequest::Fold - } else if self.debouncing { + } else if self.debouncing && !self.poll { RefreshRequest::Fold } else { + // A request supersedes a pending poll: start the short debounce. self.debouncing = true; + self.poll = false; RefreshRequest::Schedule } } - /// A timer was started without a request, e.g. a poll cycle. - pub fn debounce(&mut self) { + /// A slow poll timer was started without a request. + pub fn poll(&mut self) { self.debouncing = true; + self.poll = true; + } + + /// A poll timer fired. Whether it is still the scheduled pass and may run. + /// + /// A request that arrived while the poll was pending superseded it with its + /// own debounce, so the stale poll timer is skipped. + pub fn take_poll(&mut self) -> bool { + if self.debouncing && self.poll { + self.debouncing = false; + self.poll = false; + true + } else { + false + } } /// The debounce timer fired and the run starts now. pub fn begin(&mut self) { self.debouncing = false; + self.poll = false; self.running = true; } @@ -77,3 +106,83 @@ impl RefreshGate { self.running = false; } } + +#[cfg(test)] +mod tests { + use super::{RefreshGate, RefreshRequest}; + + #[test] + fn request_starts_the_debounce_when_idle() { + let mut gate = RefreshGate::default(); + assert_eq!(gate.request(), RefreshRequest::Schedule); + assert!(gate.debouncing()); + assert!(!gate.idle()); + } + + #[test] + fn request_folds_into_a_request_debounce() { + let mut gate = RefreshGate::default(); + gate.request(); + assert_eq!(gate.request(), RefreshRequest::Fold); + } + + #[test] + fn request_folds_into_a_running_run_and_runs_again() { + let mut gate = RefreshGate::default(); + gate.request(); + gate.begin(); + assert!(gate.running()); + assert_eq!(gate.request(), RefreshRequest::Fold); + assert!(gate.finish()); + assert_eq!(gate.request(), RefreshRequest::Schedule); + } + + #[test] + fn request_supersedes_a_pending_poll() { + let mut gate = RefreshGate::default(); + gate.poll(); + assert!(gate.debouncing()); + + // The request starts its own short debounce instead of waiting out the poll. + assert_eq!(gate.request(), RefreshRequest::Schedule); + assert!(gate.debouncing()); + assert!( + !gate.take_poll(), + "the superseded poll timer must be skipped" + ); + + // The request's own debounce still fires. + gate.begin(); + assert!(gate.running()); + } + + #[test] + fn poll_timer_runs_when_not_superseded() { + let mut gate = RefreshGate::default(); + gate.poll(); + assert!(gate.take_poll()); + assert!(gate.idle()); + gate.begin(); + assert!(gate.running()); + } + + #[test] + fn poll_keeps_scheduling_until_a_request_preempts() { + let mut gate = RefreshGate::default(); + gate.poll(); + assert!(gate.take_poll()); + gate.begin(); + gate.finish(); + gate.poll(); + gate.request(); + assert!(!gate.take_poll(), "preempted by the request"); + } + + #[test] + fn request_after_a_superseded_poll_is_folded_into_the_new_debounce() { + let mut gate = RefreshGate::default(); + gate.poll(); + gate.request(); + assert_eq!(gate.request(), RefreshRequest::Fold); + } +} diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index bb8071f..eb12437 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -1133,6 +1133,7 @@ impl RepoStore { self.last_error = None; cx.notify(); + let checkouts = CheckoutsStore::global(cx); let backend = Backend::global(cx); let push = backend.update(cx, |backend, cx| { backend.push_checkout(announcement, path.clone(), head, cx) @@ -1147,10 +1148,8 @@ impl RepoStore { match &result { Ok(()) => { // The remote moved, so recompute the ready-to-push statuses. - let checkouts = CheckoutsStore::global(cx); checkouts.update(cx, |store, cx| { - store.request_push_statuses(&addr, cx); - cx.notify(); + store.checkout_pushed(&addr, &path, cx); }); } Err(e) => {