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}