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}