Skip to main content

gwm/tui/state/
async_task.rs

1//! Generic background-task spine for the TUI (issue #231).
2//!
3//! Generalises the off-thread pattern introduced for the GitHub fetch
4//! (#217) so any slow, one-shot operation — worktree list refresh, create,
5//! sync, bootstrap, delete, and GitHub fetches — runs
6//! on a worker thread and posts its result back to the event loop rather
7//! than blocking it. The event loop keeps rendering (the statusbar
8//! spinner animates, `q` / `Esc` stay responsive); a result whose run
9//! was superseded mid-flight is dropped.
10//!
11//! Unlike [`super::github_fetch`] this is **not** a result cache. The
12//! GitHub layer caches `(target, number)` lookups and dedupes them; a
13//! create / refresh / sync / bootstrap / delete-worktree is a one-shot "run
14//! it, give me a fresh result" with nothing worth caching by key. So the spine keeps only
15//! two things from that design — *coalescing* and the *late-result
16//! drop* — and drops the per-key cache:
17//!
18//! - `request(kind)` → `Some(generation)` for a cold slot (the caller
19//!   spawns a worker tagged with that generation), or `None` when a run
20//!   of the same `kind` is already in flight (coalesced — no second
21//!   worker).
22//! - the worker computes owned, `Send` data off-thread and posts it back
23//!   tagged with the `generation` it was handed.
24//! - `complete(kind, generation)` → `true` while the generation is still
25//!   authoritative (apply the result), `false` when a later
26//!   `invalidate` / `request` bumped the generation mid-flight (drop the
27//!   late result — the #138 guard, generalised to non-keyed ops).
28//! - `invalidate(kind)` bumps the generation and frees the slot, so any
29//!   in-flight result is dropped and a fresh run may start.
30//!
31//! The threading itself (the `mpsc` channel + `thread::spawn` + the
32//! "resolve owned `Send` data on the main thread" discipline) lives on
33//! the `App` orchestrator, exactly as the GitHub channel does. This
34//! module is pure state — no I/O, no `App` dependency — so the contract
35//! is pinned by `tests/tui_state_async_task_tests.rs`.
36
37use crate::bootstrap::BootstrapReport;
38use crate::github::{IssueStatus, PrStatus};
39use crate::sync::SyncReport;
40use crate::worktree::WorktreeInfo;
41use std::collections::{HashMap, HashSet};
42use std::path::PathBuf;
43
44/// Identity of a background task — the coalescing key and the source of
45/// the loader label. One variant per migrated op.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum TaskKind {
48  /// Off-thread create flow from the Create modal (issue #276):
49  /// `worktree::add` plus bootstrap can touch disk, refs, copies and hooks, so
50  /// the modal must stay renderable while it runs. A single global op like
51  /// [`Self::Bootstrap`] — one create in flight at a time.
52  CreateWorktree,
53  /// Off-thread worktree list refresh (the `f` / `r` key path). The
54  /// synchronous `App::refresh` stays for internal post-mutation callers
55  /// (create / delete / report-close) that need the list fresh before the
56  /// next render.
57  RefreshWorktrees,
58  /// Off-thread `gh issue view` fetch for the linked issue, keyed by issue
59  /// number (issue #255 — migrated from the separate `github_tx`/`inflight`
60  /// channel). The number is the coalescing key, so `Issue(42)` and
61  /// `Issue(43)` are independent slots with independent generations — the
62  /// per-key identity the late-drop guard needs to discard a stale worker
63  /// without clobbering a newer one's slot (the #138 guarantee, now keyed).
64  GithubIssue(u64),
65  /// PR-side counterpart to [`Self::GithubIssue`] (`gh pr view`). Keyed by
66  /// PR number; never collides with an issue of the same number.
67  GithubPr(u64),
68  /// Off-thread `gwm sync` of the selected worktree (issue #258): fetch +
69  /// rebase/merge its branch onto upstream. A single global op like
70  /// [`Self::RefreshWorktrees`] — one sync in flight at a time, so a second
71  /// `S` press while one runs coalesces instead of racing a second rebase.
72  Sync,
73  /// Off-thread bootstrap of the selected worktree (issue #256 — the `b`
74  /// key): `bootstrap::run` (file copies, guards, command hooks) used to
75  /// block the event loop. A single global op like [`Self::Sync`] — one
76  /// bootstrap in flight at a time, so a second `b` press coalesces instead
77  /// of racing a second run. The TOFU trust gate stays on the main thread
78  /// before the spawn; completion sets `App::report` and flips to
79  /// `View::Report`.
80  Bootstrap,
81  /// Off-thread delete of the selected worktree (issue #257):
82  /// `worktree::remove` can touch git admin files, remove the worktree
83  /// directory, and optionally delete the branch, so it must not block the
84  /// render loop while the confirm modal is open.
85  DeleteWorktree,
86  /// Off-thread `git pull` of the selected worktree's branch (#290). One
87  /// global slot — a second `p` press coalesces while one is in flight.
88  Pull,
89  /// Off-thread `git push` of the selected worktree's branch to its remote
90  /// (#290). One global slot — a second `P` press coalesces.
91  Push,
92  /// Off-thread rename of the selected worktree (`c`, #290): renames the
93  /// local branch (`git branch -m`), the remote branch when it exists
94  /// (`git push origin :<old> <new>:<new>` + re-track), and moves the
95  /// worktree directory on disk (`git worktree move`) so the slug stays in
96  /// sync. One global slot — a second `c` submit coalesces.
97  EditWorktree,
98}
99
100impl TaskKind {
101  /// Human label the loader shows while this task is in flight, mirroring
102  /// the GitHub fetch's "fetching GitHub status…" so every async site
103  /// reads consistently.
104  pub fn loading_label(self) -> &'static str {
105    match self {
106      TaskKind::CreateWorktree => "creating worktree…",
107      TaskKind::RefreshWorktrees => "refreshing worktrees…",
108      TaskKind::GithubIssue(_) | TaskKind::GithubPr(_) => "fetching GitHub status…",
109      TaskKind::Sync => "syncing…",
110      TaskKind::Bootstrap => "bootstrapping…",
111      TaskKind::DeleteWorktree => "deleting worktree…",
112      TaskKind::Pull => "pulling…",
113      TaskKind::Push => "pushing…",
114      TaskKind::EditWorktree => "renaming worktree…",
115    }
116  }
117
118  /// `true` for the GitHub fetch kinds (`GithubIssue` / `GithubPr`). Used
119  /// as the predicate for [`TaskRunner::invalidate_matching`] so the `App`
120  /// can drop every in-flight GitHub fetch on navigation / explicit refresh
121  /// without naming the (now-stale) issue/PR numbers it no longer holds
122  /// (issue #255).
123  pub fn is_github(self) -> bool {
124    matches!(self, TaskKind::GithubIssue(_) | TaskKind::GithubPr(_))
125  }
126
127  /// `true` for workers that can leave repository / worktree state
128  /// partially changed if the process exits before their result is drained.
129  pub fn is_mutating(self) -> bool {
130    matches!(
131      self,
132      TaskKind::CreateWorktree
133        | TaskKind::Sync
134        | TaskKind::Bootstrap
135        | TaskKind::DeleteWorktree
136        | TaskKind::Pull
137        | TaskKind::Push
138        | TaskKind::EditWorktree
139    )
140  }
141}
142
143/// Successful result of a Create-modal worker (issue #276).
144pub struct CreateWorktreeResult {
145  pub branch: String,
146  pub created: PathBuf,
147  pub report: BootstrapReport,
148}
149
150/// Successful result of an Edit-modal worker (`c`, #290). Carries the new
151/// branch name, the new on-disk path (after `git worktree move`), and the
152/// new worktree display name so the drain can refresh the list and report
153/// the rename in the status bar.
154pub struct EditWorktreeResult {
155  pub new_branch: String,
156  pub new_path: PathBuf,
157  pub new_name: String,
158  /// `true` when the remote branch was also renamed (it existed on origin).
159  pub remote_renamed: bool,
160}
161
162/// Result of an off-thread task, posted from a worker thread back to the
163/// event loop over `App`'s task channel (issue #231). Carries owned,
164/// `Send` data only (no `git2::Repository` crosses the thread boundary)
165/// plus the `generation` the worker was spawned with, so
166/// [`TaskRunner::complete`] can drop a superseded late result.
167pub enum TaskMsg {
168  /// A create-worktree result (issue #276): the worker's `generation` and the
169  /// created worktree + bootstrap report, or a stringified failure from naming,
170  /// libgit2 worktree creation, or bootstrap.
171  CreateWorktree(u64, std::result::Result<CreateWorktreeResult, String>),
172  /// A worktree list refresh result: the freshly-listed worktrees, or a
173  /// stringified error from the off-thread `discover_repo` + `list`.
174  RefreshWorktrees(u64, std::result::Result<Vec<WorktreeInfo>, String>),
175  /// A `gh issue view` result (issue #255): the worker's `generation`, the
176  /// issue `number` it fetched, and the parsed [`IssueStatus`] (or a
177  /// stringified error). The generation lets [`TaskRunner::complete`] drop a
178  /// stale worker's result; the number keys it back into the GitHub cache.
179  GithubIssue(u64, u64, std::result::Result<IssueStatus, String>),
180  /// PR-side counterpart to [`Self::GithubIssue`] (`gh pr view`).
181  GithubPr(u64, u64, std::result::Result<PrStatus, String>),
182  /// A `gwm sync` result (issue #258): the worker's `generation`, the synced
183  /// worktree's display `name` (for the status line), and the [`SyncReport`]
184  /// (or a stringified error — dirty tree, no upstream, conflicts).
185  Sync(u64, String, std::result::Result<SyncReport, String>),
186  /// A bootstrap result (issue #256): the worker's `generation` and the
187  /// [`BootstrapReport`] (or a stringified error). On a live generation the
188  /// drain sets `App::report` and flips to `View::Report`; a superseded
189  /// late result is dropped by [`TaskRunner::complete`].
190  Bootstrap(u64, std::result::Result<BootstrapReport, String>),
191  /// A delete-worktree result (issue #257): the worker's generation, the
192  /// deleted worktree's display name + path label for the status line, and
193  /// the deletion outcome.
194  DeleteWorktree(u64, String, String, std::result::Result<(), String>),
195  /// A `git pull` result (#290): the worker's generation, the worktree's
196  /// display name, and the outcome (a one-line status string on success or
197  /// a stringified error).
198  Pull(u64, String, std::result::Result<String, String>),
199  /// A `git push` result (#290): same shape as [`Self::Pull`].
200  Push(u64, String, std::result::Result<String, String>),
201  /// An edit-worktree result (`c`, #290): the worker's generation and the
202  /// rename outcome (new branch/path/name on success, or a stringified error
203  /// from `git branch -m` / `git push` / `git worktree move`).
204  EditWorktree(u64, std::result::Result<EditWorktreeResult, String>),
205}
206
207/// Coalescing + late-drop spine for background tasks (issue #231).
208///
209/// See the module docs for the full contract. The short version: the
210/// `App` calls [`Self::request`] before spawning a worker, branches on
211/// the returned generation, and reports the worker's result back via
212/// [`Self::complete`] so a superseded late result is dropped.
213#[derive(Debug, Default)]
214pub struct TaskRunner {
215  /// Current authoritative generation per kind. Bumped on each `request`
216  /// (a cold spawn) and on each `invalidate`; a result whose generation
217  /// no longer matches is dropped. Absent keys are generation 0.
218  generation: HashMap<TaskKind, u64>,
219  /// Kinds with a worker currently in flight. A `request` for a kind
220  /// already here is coalesced (returns `None`); drives the loader's
221  /// "is anything loading" signal.
222  running: HashSet<TaskKind>,
223}
224
225impl TaskRunner {
226  /// Construct an empty runner — no generations claimed, nothing in
227  /// flight. The `App` constructor calls this once.
228  pub fn new() -> Self {
229    Self::default()
230  }
231
232  /// Claim a run of `kind`. Returns `Some(generation)` for a cold slot —
233  /// the caller owns the off-thread spawn and must tag the worker's
234  /// result with that generation — or `None` when a run of the same
235  /// `kind` is already in flight (coalesced; no second worker).
236  pub fn request(&mut self, kind: TaskKind) -> Option<u64> {
237    // Coalesce: a run of this kind is already in flight, so a second
238    // request rides on it instead of spawning a redundant worker.
239    if self.running.contains(&kind) {
240      return None;
241    }
242    let generation = self.generation.entry(kind).or_insert(0);
243    *generation += 1;
244    let claimed = *generation;
245    self.running.insert(kind);
246    Some(claimed)
247  }
248
249  /// Drop any in-flight run of `kind` and bump the generation so a late
250  /// result is discarded, freeing the slot for a fresh run.
251  pub fn invalidate(&mut self, kind: TaskKind) {
252    *self.generation.entry(kind).or_insert(0) += 1;
253    self.running.remove(&kind);
254  }
255
256  /// [`Self::invalidate`] every in-flight kind matching `pred` (issue #255).
257  /// The GitHub fetch needs this because, on navigation, the `App` clears
258  /// the per-key cache for *all* GitHub fetches but no longer holds the
259  /// (stale) issue/PR numbers to invalidate them by key — a
260  /// `|k| k.is_github()` predicate drops every running GitHub worker's slot
261  /// so a fresh fetch starts at a new generation and the stale worker's late
262  /// result is discarded by [`Self::complete`]. Only running kinds are
263  /// touched: an idle kind has no in-flight worker to drop.
264  pub fn invalidate_matching<F: Fn(TaskKind) -> bool>(&mut self, pred: F) {
265    let hits: Vec<TaskKind> = self.running.iter().copied().filter(|&k| pred(k)).collect();
266    for kind in hits {
267      self.invalidate(kind);
268    }
269  }
270
271  /// Decide whether a result tagged `generation` for `kind` is still
272  /// authoritative. Returns `true` (apply it) only when the generation
273  /// matches the current one and a run was in flight, clearing the slot;
274  /// returns `false` for a late result whose generation was bumped by an
275  /// intervening `invalidate` / `request` (the #138 guard).
276  pub fn complete(&mut self, kind: TaskKind, generation: u64) -> bool {
277    let current = self.generation.get(&kind).copied().unwrap_or(0);
278    // Short-circuit on a stale generation so the still-authoritative slot
279    // of a *newer* run is never cleared by an older worker's late report.
280    if generation != current {
281      return false;
282    }
283    self.running.remove(&kind)
284  }
285
286  /// `true` while a run of `kind` is in flight.
287  pub fn is_loading(&self, kind: TaskKind) -> bool {
288    self.running.contains(&kind)
289  }
290
291  /// `true` while any task is in flight — drives the statusbar spinner
292  /// alongside the GitHub fetch's own loading signal.
293  pub fn is_any_loading(&self) -> bool {
294    !self.running.is_empty()
295  }
296
297  /// `true` while a mutating worker is still in flight. Quit handling uses
298  /// this to keep `sync` / `bootstrap` / delete-worktree from being abandoned mid-operation.
299  pub fn has_mutating_task_in_flight(&self) -> bool {
300    self.running.iter().any(|kind| kind.is_mutating())
301  }
302
303  /// Loader label for a mutating in-flight task, if any.
304  pub fn mutating_loading_label(&self) -> Option<&'static str> {
305    if self.running.contains(&TaskKind::CreateWorktree) {
306      Some(TaskKind::CreateWorktree.loading_label())
307    } else if self.running.contains(&TaskKind::Sync) {
308      Some(TaskKind::Sync.loading_label())
309    } else if self.running.contains(&TaskKind::Bootstrap) {
310      Some(TaskKind::Bootstrap.loading_label())
311    } else if self.running.contains(&TaskKind::DeleteWorktree) {
312      Some(TaskKind::DeleteWorktree.loading_label())
313    } else if self.running.contains(&TaskKind::Pull) {
314      Some(TaskKind::Pull.loading_label())
315    } else if self.running.contains(&TaskKind::Push) {
316      Some(TaskKind::Push.loading_label())
317    } else if self.running.contains(&TaskKind::EditWorktree) {
318      Some(TaskKind::EditWorktree.loading_label())
319    } else {
320      None
321    }
322  }
323
324  /// The loader label for an in-flight task, if any. `None` when nothing
325  /// is loading.
326  pub fn loading_label(&self) -> Option<&'static str> {
327    self.running.iter().next().map(|kind| kind.loading_label())
328  }
329}