Skip to main content

trail/workers/
mod.rs

1//! Async worker pool: `WorkerMsg` enum, spawn/dispatch helpers, mpsc plumbing.
2//!
3//! The terminal panel's session threads report on the same channel
4//! (`TerminalOutput`, `TerminalExited`), though they are OS threads rather than
5//! pool tasks — see `crate::terminal::session`.
6//!
7//! Workers do anything that could be slow (git status, filesystem watching,
8//! syntax highlighting, image decoding) and report results back to the UI
9//! thread over a single `mpsc` channel drained once per UI tick.
10//!
11//! Architecture contract (§4 of `trail_architecture.md`):
12//! - Every task sends its result as a `WorkerMsg` over the shared `Sender`.
13//! - Every `WorkerMsg::Preview` / `WorkerMsg::ImageMeta` carries the
14//!   `generation` it was spawned for; `merge()` compares it against
15//!   `state.preview.generation` and drops the message if they don't match.
16//! - Workers never touch `ratatui`/`crossterm` state directly.
17
18pub mod fswatch;
19pub mod git;
20pub mod highlight;
21pub mod image_decode;
22
23// Re-export the git cache type so callers don't need to reach into the git
24// submodule directly.
25pub use git::GitCache;
26
27use std::path::PathBuf;
28
29use tokio::sync::mpsc;
30
31use crate::app::state::{AppState, GitDirState, GitFileStatus};
32use crate::preview::provider::PreviewContent;
33
34// ── Channel helpers ───────────────────────────────────────────────────────────
35
36/// Creates the bounded `(Sender, Receiver)` pair used between the worker pool
37/// and the UI thread.
38///
39/// The bound of 64 is generous enough to absorb short bursts (e.g. rapid
40/// navigation through a large directory) without blocking worker tasks, while
41/// still providing back-pressure if the UI thread falls behind.
42pub fn channel() -> (mpsc::Sender<WorkerMsg>, mpsc::Receiver<WorkerMsg>) {
43    mpsc::channel(64)
44}
45
46// ── WorkerMsg ─────────────────────────────────────────────────────────────────
47
48/// All messages that worker tasks can send back to the UI thread.
49///
50/// The single-enum design (one channel, one receiver) was decided in the
51/// implementation plan's Decision Log over per-domain channels, to keep the
52/// event loop `select!` simple and avoid priority inversion.
53#[derive(Debug)]
54pub enum WorkerMsg {
55    /// Git repository state for `path` has been computed.
56    ///
57    /// `file_statuses` maps entry filenames to their git status for the
58    /// current directory. Both fields are `None` when the path is not inside
59    /// a git repository.
60    Git {
61        /// The directory this result is for.
62        path: PathBuf,
63        /// Repository-level state (branch, dirty flag); `None` outside a repo.
64        state: Option<GitDirState>,
65        /// Per-file status map: filename → `GitFileStatus`.
66        file_statuses: Vec<(String, GitFileStatus)>,
67    },
68
69    /// The filesystem watcher detected a change in `path` after the debounce
70    /// window elapsed.
71    FsChanged {
72        /// The directory where changes were detected.
73        path: PathBuf,
74    },
75
76    /// An async preview result is ready.
77    ///
78    /// `generation` must match `state.preview.generation` or the message is
79    /// dropped by `merge()` — this is the generation-guard invariant.
80    ///
81    /// The path this preview is for (additional staleness guard).
82    #[allow(dead_code)]
83    Preview {
84        /// The generation counter at the time the preview was requested.
85        generation: u64,
86        /// The path this preview is for (additional staleness guard).
87        path: PathBuf,
88        /// The rendered content.
89        content: PreviewContent,
90        /// Whether the file continues past the loaded content, because the
91        /// worker stopped at `[preview] max_lines`.
92        truncated: bool,
93        /// Whether the worker found the file to be text, or `None` when it never
94        /// got far enough to tell.
95        ///
96        /// Cached on the matching entry by [`merge`] even when the preview
97        /// itself is stale: it is a fact about the file, not about the selection,
98        /// and it is what spares the next preview of that entry a worker round
99        /// trip.
100        is_text: Option<bool>,
101    },
102
103    /// Image metadata (dimensions, format) is ready.
104    ///
105    /// Like `Preview`, guarded by `generation`.
106    #[allow(dead_code)]
107    ImageMeta {
108        /// The generation counter at the time the metadata request was made.
109        generation: u64,
110        /// The path this metadata is for.
111        path: PathBuf,
112        /// The rendered metadata content.
113        content: PreviewContent,
114    },
115
116    /// A terminal panel shell produced output.
117    ///
118    /// Carries no data: the output is already in the session's screen. At most
119    /// one is in flight per session until the next frame is drawn.
120    TerminalOutput {
121        /// The session that produced it.
122        id: u64,
123    },
124
125    /// A terminal panel shell exited.
126    TerminalExited {
127        /// The session that exited.
128        id: u64,
129        /// Its exit code, when the platform reported one.
130        code: Option<u32>,
131    },
132
133    /// A background copy or move finished another entry.
134    TransferProgress {
135        /// The transfer job.
136        id: u64,
137        /// Entries finished so far.
138        done: usize,
139        /// Entries in the job.
140        total: usize,
141    },
142
143    /// A background copy or move finished.
144    TransferDone {
145        /// What became of each entry.
146        report: crate::actions::transfer::TransferReport,
147    },
148}
149
150// ── merge ─────────────────────────────────────────────────────────────────────
151
152/// Applies a `WorkerMsg` to `state`, enforcing the generation-guard for
153/// preview-related messages.
154///
155/// # Generation-guard invariant
156///
157/// Every time the user moves to a new entry, `state.preview.generation` is
158/// incremented. Workers tag their results with the generation they were
159/// spawned for. If `msg.generation != state.preview.generation` by the time
160/// the result arrives, the selection has already moved on and the result is
161/// silently dropped — never rendered.
162///
163/// This prevents stale previews from a slow worker for entry N appearing
164/// while the user is already viewing entry N+k.
165///
166/// Callers should call this once per UI tick for each message drained from the
167/// worker channel.
168pub fn merge(msg: WorkerMsg, state: &mut AppState) {
169    match msg {
170        WorkerMsg::Git {
171            path,
172            state: git_state,
173            file_statuses,
174        } => {
175            // Only apply if the result is for the directory we are currently in.
176            if path != state.cwd {
177                tracing::debug!(
178                    ?path,
179                    cwd = ?state.cwd,
180                    "dropping stale Git result for different directory"
181                );
182                return;
183            }
184
185            state.git = git_state;
186
187            // Apply per-file statuses to the matching entries.
188            for entry in state.entries.iter_mut() {
189                let status = file_statuses
190                    .iter()
191                    .find(|(name, _)| *name == entry.file_name)
192                    .map(|(_, s)| s.clone());
193                entry.git_status = status;
194            }
195
196            state.dirty = true;
197            tracing::debug!(?path, "merged Git worker result");
198        }
199
200        WorkerMsg::FsChanged { path } => {
201            // Handled by the event loop: the loop receives FsChanged, refreshes,
202            // and re-spawns git. This arm is included for completeness but the
203            // event loop treats FsChanged as a special case and calls
204            // state.refresh() directly.
205            tracing::debug!(?path, "FsChanged received in merge (handled by event loop)");
206        }
207
208        WorkerMsg::Preview {
209            generation,
210            path,
211            content,
212            truncated,
213            is_text,
214        } => {
215            // Applied before either guard: what a file *is* does not go stale
216            // when the selection moves, and caching it is what keeps the next
217            // preview of this entry off the worker pool.
218            if let Some(is_text) = is_text {
219                state.classify_entry(&path, is_text);
220            }
221
222            // Generation-guard: drop if the selection has moved on.
223            if generation != state.preview.generation {
224                tracing::debug!(
225                    generation,
226                    current = state.preview.generation,
227                    "dropping stale Preview result (generation mismatch)"
228                );
229                return;
230            }
231            // Secondary path guard.
232            if path != state.preview.for_path {
233                tracing::debug!(
234                    ?path,
235                    current_path = ?state.preview.for_path,
236                    "dropping stale Preview result (path mismatch)"
237                );
238                return;
239            }
240            state.preview.content = content;
241            // Set after both guards, so a stale worker can never mark the
242            // current preview as truncated.
243            state.preview.truncated = truncated;
244            state.dirty = true;
245            tracing::debug!(?path, generation, truncated, "merged Preview worker result");
246        }
247
248        WorkerMsg::ImageMeta {
249            generation,
250            path,
251            content,
252        } => {
253            // Same generation-guard as Preview.
254            if generation != state.preview.generation {
255                tracing::debug!(
256                    generation,
257                    current = state.preview.generation,
258                    "dropping stale ImageMeta result (generation mismatch)"
259                );
260                return;
261            }
262            if path != state.preview.for_path {
263                tracing::debug!(
264                    ?path,
265                    current_path = ?state.preview.for_path,
266                    "dropping stale ImageMeta result (path mismatch)"
267                );
268                return;
269            }
270            state.preview.content = content;
271            // Image metadata is always complete; clear any truncation marker
272            // left by an earlier preview of the same path.
273            state.preview.truncated = false;
274            state.dirty = true;
275            tracing::debug!(?path, generation, "merged ImageMeta worker result");
276        }
277
278        WorkerMsg::TerminalOutput { id } => {
279            tracing::trace!(id, "terminal output");
280            state.terminal.note_output();
281        }
282
283        WorkerMsg::TerminalExited { id, code } => {
284            tracing::debug!(id, ?code, "terminal shell exited");
285            if state.terminal.on_exit(id) {
286                state.dirty = true;
287            }
288        }
289
290        WorkerMsg::TransferProgress { id, done, total } => {
291            if state.transfers.progress(id, done, total) {
292                state.dirty = true;
293            }
294        }
295
296        WorkerMsg::TransferDone { report } => {
297            crate::actions::transfer::finish(state, report);
298        }
299    }
300}