feat: push checkout #14

Merged
reya merged 14 commits from feat/fork into master 2026-09-06 13:14:13 +00:00
3 changed files with 154 additions and 11 deletions
Showing only changes of commit c2c2839ac1 - Show all commits
+37 -2
View File
@@ -235,6 +235,35 @@ impl CheckoutsStore {
self.refresh(cx); 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<Self>) {
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`. /// The ready-to-push statuses of `addr`.
/// ///
/// Empty while none are known or nothing is unpushed. /// 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. // Keep the statuses current while any repository panel is open.
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
if poll && this.refresh.idle() { if poll && this.refresh.idle() {
this.refresh.debounce(); this.refresh.poll();
// Open panels get the fast cadence. // Open panels get the fast cadence.
// Each cycle fetches every watched checkout's remote. // Each cycle fetches every watched checkout's remote.
@@ -414,7 +443,13 @@ impl CheckoutsStore {
let task = cx.spawn(async move |this, cx| { let task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(delay).await; 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); this.push_task(task);
+115 -6
View File
@@ -3,7 +3,13 @@
/// [`crate::RepoStore`], [`crate::RepoListStore`] and [`crate::CheckoutsStore`] /// [`crate::RepoStore`], [`crate::RepoListStore`] and [`crate::CheckoutsStore`]
/// re-query their inputs on a debounce timer with the same policy: /// 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 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)] #[derive(Debug, Default)]
pub struct RefreshGate { pub struct RefreshGate {
/// A run is in flight. /// A run is in flight.
@@ -12,6 +18,10 @@ pub struct RefreshGate {
dirty: bool, dirty: bool,
/// The debounce timer is pending. /// The debounce timer is pending.
debouncing: bool, 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. /// What a refresh request decided.
@@ -41,28 +51,47 @@ impl RefreshGate {
/// A new refresh request arrived. /// A new refresh request arrived.
/// ///
/// Folded into a follow-up run while one is in flight, dropped while the /// Folded into a follow-up run while one is in flight or a request debounce
/// debounce timer is pending, otherwise starts the timer. /// is pending, superseding a slow poll with the request's own debounce,
/// otherwise starts the debounce timer.
pub fn request(&mut self) -> RefreshRequest { pub fn request(&mut self) -> RefreshRequest {
if self.running { if self.running {
self.dirty = true; self.dirty = true;
RefreshRequest::Fold RefreshRequest::Fold
} else if self.debouncing { } else if self.debouncing && !self.poll {
RefreshRequest::Fold RefreshRequest::Fold
} else { } else {
// A request supersedes a pending poll: start the short debounce.
self.debouncing = true; self.debouncing = true;
self.poll = false;
RefreshRequest::Schedule RefreshRequest::Schedule
} }
} }
/// A timer was started without a request, e.g. a poll cycle. /// A slow poll timer was started without a request.
pub fn debounce(&mut self) { pub fn poll(&mut self) {
self.debouncing = true; 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. /// The debounce timer fired and the run starts now.
pub fn begin(&mut self) { pub fn begin(&mut self) {
self.debouncing = false; self.debouncing = false;
self.poll = false;
self.running = true; self.running = true;
} }
@@ -77,3 +106,83 @@ impl RefreshGate {
self.running = false; 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);
}
}
+2 -3
View File
@@ -1133,6 +1133,7 @@ impl RepoStore {
self.last_error = None; self.last_error = None;
cx.notify(); cx.notify();
let checkouts = CheckoutsStore::global(cx);
let backend = Backend::global(cx); let backend = Backend::global(cx);
let push = backend.update(cx, |backend, cx| { let push = backend.update(cx, |backend, cx| {
backend.push_checkout(announcement, path.clone(), head, cx) backend.push_checkout(announcement, path.clone(), head, cx)
@@ -1147,10 +1148,8 @@ impl RepoStore {
match &result { match &result {
Ok(()) => { Ok(()) => {
// The remote moved, so recompute the ready-to-push statuses. // The remote moved, so recompute the ready-to-push statuses.
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |store, cx| { checkouts.update(cx, |store, cx| {
store.request_push_statuses(&addr, cx); store.checkout_pushed(&addr, &path, cx);
cx.notify();
}); });
} }
Err(e) => { Err(e) => {