Skip to main content

diffs_rs/
engine.rs

1//! A review owns its workers and view state, but never the host terminal.
2
3mod reading;
4mod selection;
5
6use std::{
7    collections::VecDeque,
8    fs::File,
9    path::PathBuf,
10    sync::{
11        Arc,
12        atomic::{AtomicBool, AtomicU64, Ordering},
13        mpsc::{self, SyncSender},
14    },
15    time::{Duration, Instant},
16};
17
18use anyhow::{Context, Result, bail, ensure};
19use crossterm::event::Event;
20use ratatui::{Frame, layout::Rect, widgets::Paragraph};
21
22pub use crate::app::{Command, InputEffect, Layout, SidebarPreference};
23use crate::{
24    app::App,
25    input::{self, LocalInput, PatchInput, SourceState},
26    model::RowKind,
27    render, storage,
28    text::{safe_label, safe_text},
29    theme::Appearance,
30};
31
32pub enum Input {
33    Patch(PathBuf),
34    /// Takes exclusive ownership of the shared read cursor and status flags until close.
35    Stream {
36        file: File,
37        label: String,
38    },
39    Local(LocalInput),
40}
41
42#[derive(Clone, Copy)]
43pub struct Options {
44    pub layout: Layout,
45    pub appearance: Appearance,
46    pub sidebar: SidebarPreference,
47    pub wrap: bool,
48    pub line_numbers: bool,
49}
50
51impl Default for Options {
52    fn default() -> Self {
53        Self {
54            layout: Layout::Auto,
55            appearance: Appearance::Dark,
56            sidebar: SidebarPreference::Auto,
57            wrap: false,
58            line_numbers: true,
59        }
60    }
61}
62
63/// One open occurrence within this process, not a persistent revision identifier.
64#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
65pub struct ReviewId(u64);
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum Side {
69    Old,
70    New,
71}
72
73/// Original one-based source lines of the selected record, never display-row or byte offsets.
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub struct SourceLocation {
76    pub review: ReviewId,
77    pub file: usize,
78    pub side: Side,
79    pub old_line: Option<u32>,
80    pub new_line: Option<u32>,
81}
82
83pub struct Status<'a> {
84    pub closed: bool,
85    pub loading: bool,
86    pub files: usize,
87    pub coloring: bool,
88    pub preparing_source: bool,
89    pub source_failed: bool,
90    pub error: Option<&'a str>,
91}
92
93/// Caller-owned controls to display alongside the engine's existing hints and help.
94pub struct ReadingControls<'a> {
95    pub hint: &'a str,
96    pub help: &'a str,
97    pub status: Option<&'a str>,
98}
99
100pub struct Engine {
101    id: ReviewId,
102    options: Options,
103    label: String,
104    app: Option<App>,
105    input: Option<input::Worker>,
106    full_sources: bool,
107    wake: SyncSender<()>,
108    loading: bool,
109    error: Option<String>,
110    source_failed: bool,
111    source_reads: Arc<AtomicBool>,
112    dirty: bool,
113    request: bool,
114    progress: bool,
115    last_draw: Instant,
116    draw_pending: bool,
117    area: Rect,
118}
119
120impl Engine {
121    pub fn open(input: Input, options: Options, wake: SyncSender<()>) -> Result<Self> {
122        static NEXT: AtomicU64 = AtomicU64::new(1);
123        let id = ReviewId(
124            NEXT.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1))
125                .map_err(|_| anyhow::anyhow!("Review identities exhausted"))?,
126        );
127        let full_sources = matches!(&input, Input::Local(_));
128        let (label, worker) = match input {
129            Input::Patch(path) => {
130                let label = safe_label(path.to_string_lossy().as_bytes());
131                (
132                    label,
133                    input::Worker::start_patch(PatchInput::Path(path), wake.clone()),
134                )
135            }
136            Input::Stream { file, label } => (
137                safe_text(label.as_bytes()),
138                input::Worker::start_patch(PatchInput::Stream(file), wake.clone()),
139            ),
140            Input::Local(input) => (input.label(), input::Worker::start(input, wake.clone())),
141        };
142        Ok(Self {
143            id,
144            options,
145            label,
146            app: None,
147            input: Some(worker),
148            full_sources,
149            wake,
150            loading: true,
151            error: None,
152            source_failed: false,
153            source_reads: Arc::new(AtomicBool::new(true)),
154            dirty: true,
155            request: true,
156            progress: false,
157            last_draw: Instant::now(),
158            draw_pending: false,
159            area: Rect::default(),
160        })
161    }
162
163    pub fn review_id(&self) -> ReviewId {
164        self.id
165    }
166
167    pub fn options(&self) -> Options {
168        self.app.as_ref().map_or(self.options, |app| Options {
169            layout: app.layout,
170            appearance: app.appearance,
171            sidebar: app.sidebar.preference,
172            wrap: app.wrap,
173            line_numbers: app.numbers,
174        })
175    }
176
177    pub fn file(&self, index: usize) -> Option<&storage::FileMetadata> {
178        self.app
179            .as_ref()?
180            .doc
181            .files
182            .get(index)
183            .map(|file| &file.meta)
184    }
185
186    /// Prepares an owned source job without reading source bodies on the input thread.
187    /// Streaming rejects missing lines unless checked full source covers the range.
188    pub fn source_read(
189        &mut self,
190        range: crate::reading::SourceRange,
191    ) -> Result<crate::reading::SourceRead> {
192        ensure!(self.input.is_some(), "The review is closed");
193        ensure!(
194            range.review == self.id,
195            "Source range belongs to another review"
196        );
197        let app = self
198            .app
199            .as_mut()
200            .context("Review source is not available")?;
201        let file = app
202            .doc
203            .files
204            .get(range.file)
205            .context("Source file is not available")?;
206        let pair = match app
207            .sources
208            .as_ref()
209            .and_then(|sources| sources.get(range.file))
210        {
211            Some(SourceState::Checked(pair)) => Some(pair),
212            _ => None,
213        };
214        let pair = if let Some(pair) = pair {
215            Some((pair, app.source_reads.lease(range.file)))
216        } else {
217            None
218        };
219        crate::reading::SourceRead::new(range, file.clone(), pair, self.source_reads.clone())
220    }
221
222    pub(crate) fn search_domain(&mut self) -> Result<crate::reading::domain::Domain> {
223        ensure!(self.input.is_some(), "The review is closed");
224        ensure!(
225            !self.loading,
226            "Wait for the review to finish loading before searching"
227        );
228        ensure!(self.error.is_none(), "Cannot search an incomplete review");
229        let app = self
230            .app
231            .as_mut()
232            .context("Review source is not available")?;
233        crate::reading::domain::Domain::capture(
234            self.id,
235            self.source_reads.clone(),
236            &app.doc,
237            &mut app.view,
238            app.sources.as_deref(),
239        )
240    }
241
242    pub fn status(&self) -> Status<'_> {
243        let mut coloring = false;
244        let mut preparing_source = false;
245        if let Some(app) = &self.app {
246            for row in app
247                .visible
248                .iter()
249                .filter_map(|(position, _)| app.row(position.row))
250            {
251                preparing_source |= matches!(
252                    app.sources
253                        .as_ref()
254                        .and_then(|sources| sources.get(row.file)),
255                    Some(SourceState::Pending)
256                );
257                if let RowKind::Code { left, right } = row.kind {
258                    coloring |= left
259                        .into_iter()
260                        .chain(right)
261                        .any(|id| app.cache.get(id).is_none());
262                }
263            }
264        }
265        Status {
266            closed: self.input.is_none(),
267            loading: self.loading,
268            files: self.app.as_ref().map_or(0, |app| app.doc.files.len()),
269            coloring,
270            preparing_source,
271            source_failed: self.source_failed,
272            error: self.error.as_deref(),
273        }
274    }
275
276    pub fn selected_source(&self) -> Result<Option<SourceLocation>> {
277        let Some(app) = &self.app else {
278            return Ok(None);
279        };
280        let Some(row) = app.row(app.cursor.row) else {
281            return Ok(None);
282        };
283        let Some((id, old)) = app.source_at_cursor(None)? else {
284            return Ok(None);
285        };
286        let info = app.line_info(id)?;
287        Ok(Some(SourceLocation {
288            review: self.id,
289            file: row.file,
290            side: if old { Side::Old } else { Side::New },
291            old_line: info.old,
292            new_line: info.new,
293        }))
294    }
295
296    fn app(&mut self) -> Result<&mut App> {
297        if self.app.is_none() {
298            let mut app = App::new(
299                storage::Document::default(),
300                self.label.clone(),
301                self.options.layout,
302                self.options.appearance,
303                self.wake.clone(),
304            )?;
305            app.set_sidebar_preference(self.options.sidebar);
306            app.wrap = self.options.wrap;
307            app.numbers = self.options.line_numbers;
308            if self.full_sources {
309                app.sources = Some(Vec::new());
310            }
311            return Ok(self.app.insert(app));
312        }
313        self.app.as_mut().context("The review has no viewer state")
314    }
315
316    /// Clear the host wake token before calling this. Each drain is bounded so input can run:
317    /// the input worker queues a bounded number of files, and this takes all of them at once.
318    pub fn poll(&mut self) -> Result<()> {
319        if self.input.is_none() {
320            return Ok(());
321        }
322        if (self.loading || self.full_sources) && self.error.is_none() {
323            let replies = match self.input.as_ref().map(input::Worker::try_replies) {
324                Some(Ok(replies)) => replies,
325                Some(Err(mpsc::TryRecvError::Disconnected)) => {
326                    self.fail_input("The input worker stopped before completing its work.".into());
327                    VecDeque::new()
328                }
329                Some(Err(mpsc::TryRecvError::Empty)) | None => VecDeque::new(),
330            };
331            for reply in replies {
332                match reply {
333                    Ok(input::Message::File(file)) => {
334                        let app = self.app()?;
335                        let exposes_more = app
336                            .visible
337                            .last()
338                            .is_none_or(|(p, _)| p.row + 1 >= app.row_len());
339                        app.doc.append(file)?;
340                        if let Some(sources) = &mut app.sources {
341                            sources.push(SourceState::Pending);
342                        }
343                        self.request |= exposes_more;
344                        self.dirty |= exposes_more;
345                        self.progress = true;
346                    }
347                    Ok(input::Message::Complete { files }) => {
348                        crate::delivery_event!("indexed", 0, 0);
349                        ensure!(
350                            self.app()?.doc.files.len() == files,
351                            "Input completion does not match the published files"
352                        );
353                        self.loading = false;
354                        self.dirty = true;
355                    }
356                    Ok(input::Message::Source { file, state }) => {
357                        self.source_failed |= matches!(state, SourceState::Unavailable(_));
358                        self.app
359                            .as_mut()
360                            .context("Source reply arrived before the patch")?
361                            .set_source(file, state)?;
362                        self.request = true;
363                        self.dirty = true;
364                    }
365                    Err(error) => {
366                        self.fail_input(format!("{error:#}"));
367                        break;
368                    }
369                }
370            }
371        }
372        if let Some(app) = &mut self.app {
373            app.loading = self.loading;
374            for _ in 0..64 {
375                let reply = match app.worker.try_reply() {
376                    Ok(reply) => reply?,
377                    Err(mpsc::TryRecvError::Empty) => break,
378                    Err(mpsc::TryRecvError::Disconnected) => {
379                        bail!("The syntax worker stopped before the viewer closed")
380                    }
381                };
382                #[cfg(feature = "delivery-trace")]
383                crate::delivery_trace::source_event("drain", reply.generation, Some(reply.id));
384                let id = reply.id;
385                let failed = reply.source_error.is_some();
386                if app.accept_highlight(reply)? {
387                    self.source_failed |= failed;
388                    self.request |= failed;
389                    self.dirty |= failed || app.visible.iter().any(|(p, _)| {
390                        matches!(app.row(p.row).map(|r| r.kind), Some(RowKind::Code { left, right }) if left == Some(id) || right == Some(id))
391                    });
392                }
393            }
394        }
395        Ok(())
396    }
397
398    fn fail_input(&mut self, error: String) {
399        self.error = Some(safe_text(error.as_bytes()));
400        self.loading = false;
401        self.dirty = true;
402    }
403
404    pub fn needs_draw(&self) -> bool {
405        self.dirty || (self.progress && self.last_draw.elapsed() >= Duration::from_millis(100))
406    }
407
408    /// Draw only into the intersection with the frame. The supplied pane controls reflow.
409    pub fn draw(&mut self, frame: &mut Frame<'_>, area: Rect) -> Result<()> {
410        self.draw_with_controls(frame, area, None)
411    }
412
413    pub fn draw_with_controls(
414        &mut self,
415        frame: &mut Frame<'_>,
416        area: Rect,
417        controls: Option<&ReadingControls<'_>>,
418    ) -> Result<()> {
419        self.draw_pending = false;
420        let area = area.intersection(frame.area());
421        self.request |= (self.area.width, self.area.height) != (area.width, area.height);
422        self.area = area;
423        if let Some(app) = &mut self.app {
424            let previous_size = (app.width, app.height);
425            render::draw_with_controls(frame, app, area, self.error.as_deref(), controls)?;
426            self.request |= previous_size != (app.width, app.height);
427        } else {
428            let text = self
429                .error
430                .as_ref()
431                .map(|error| format!("Cannot open patch\n\n{error}\n\nq quit"))
432                .unwrap_or_else(|| {
433                    if self.input.is_some() {
434                        "Loading patch…\n\nq quit · Ctrl-C cancel".into()
435                    } else {
436                        "Review closed".into()
437                    }
438                });
439            frame.render_widget(Paragraph::new(text), area);
440        }
441        self.draw_pending = true;
442        Ok(())
443    }
444
445    /// Call after a successful host terminal draw, before handling more events.
446    /// Visible work starts after the text frame has been written, not while it is being composed.
447    pub fn after_draw(&mut self) {
448        if !std::mem::take(&mut self.draw_pending) {
449            return;
450        }
451        if let Some(app) = &self.app {
452            if self.full_sources && !self.loading {
453                let file = app
454                    .row(app.cursor.row)
455                    .map(|row| row.file)
456                    .into_iter()
457                    .chain(
458                        app.visible
459                            .iter()
460                            .filter_map(|(position, _)| app.row(position.row).map(|row| row.file)),
461                    )
462                    .find(|id| {
463                        matches!(
464                            app.sources.as_ref().and_then(|sources| sources.get(*id)),
465                            Some(SourceState::Pending)
466                        )
467                    });
468                if let Some(worker) = &self.input {
469                    worker.request(file.unwrap_or(usize::MAX));
470                }
471            }
472            crate::delivery_event!("frame", app.epoch, 0);
473            if self.request {
474                app.request_highlights();
475            }
476        } else {
477            crate::delivery_event!("frame", 0, 0);
478        }
479        self.request = false;
480        self.dirty = false;
481        self.progress = false;
482        self.last_draw = Instant::now();
483    }
484
485    /// The host routes keyboard focus; mouse coordinates remain absolute terminal cells.
486    pub fn handle_event(&mut self, event: Event, focused: bool) -> Result<InputEffect> {
487        if self.input.is_none() {
488            return Ok(InputEffect::Ignored);
489        }
490        if matches!(event, Event::Resize(..)) {
491            self.dirty = true;
492            self.request = true;
493            return Ok(InputEffect::Redraw);
494        }
495        if matches!(event, Event::Key(_)) && !focused {
496            return Ok(InputEffect::Ignored);
497        }
498        if let Event::Mouse(mouse) = &event
499            && !self.area.contains((mouse.column, mouse.row).into())
500        {
501            return Ok(InputEffect::Ignored);
502        }
503        let shift_click = matches!(&event, Event::Mouse(mouse)
504            if mouse.kind == crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left)
505            && mouse.modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
506            && self.app.as_ref().is_some_and(|app| !app.help && app.sidebar.diff.contains((mouse.column, mouse.row).into())));
507        if shift_click {
508            if self.selection().is_none() {
509                self.selection_command(Command::SelectLines)?;
510            }
511            if let Some(selection) = self.app.as_mut().and_then(|app| app.selection.as_mut()) {
512                selection.extending = true;
513            }
514        }
515        let extend = selection::extends(&event)
516            && match &event {
517                Event::Mouse(mouse)
518                    if mouse.kind
519                        == crossterm::event::MouseEventKind::Down(
520                            crossterm::event::MouseButton::Left,
521                        ) =>
522                {
523                    self.app.as_ref().is_some_and(|app| {
524                        app.sidebar.diff.contains((mouse.column, mouse.row).into())
525                            && app
526                                .visible
527                                .iter()
528                                .find(|(_, y)| *y == mouse.row - app.sidebar.diff.y + 1)
529                                .and_then(|(position, _)| app.row(position.row))
530                                .is_some_and(|row| matches!(row.kind, RowKind::Code { .. }))
531                    })
532                }
533                _ => true,
534            };
535        let Some(app) = &mut self.app else {
536            return Ok(InputEffect::Ignored);
537        };
538        let effect = app.handle_event(event)?;
539        self.changed(effect);
540        self.track_selection(extend && effect != InputEffect::Ignored)?;
541        if shift_click
542            && let Some(selection) = self.app.as_mut().and_then(|app| app.selection.as_mut())
543        {
544            selection.extending = false;
545        }
546        Ok(effect)
547    }
548
549    pub fn execute(&mut self, command: Command) -> Result<InputEffect> {
550        if matches!(command, Command::SelectLines | Command::ClearSelection) {
551            return self.selection_command(command);
552        }
553        let Some(app) = &mut self.app else {
554            return Ok(InputEffect::Ignored);
555        };
556        let effect = app.execute(command)?;
557        self.changed(effect);
558        self.track_selection(selection::moves(command) && effect != InputEffect::Ignored)?;
559        Ok(effect)
560    }
561
562    fn changed(&mut self, effect: InputEffect) {
563        self.dirty |= effect != InputEffect::Ignored;
564        self.request |= effect == InputEffect::Highlight;
565    }
566
567    /// Close the previous review before creating fresh channels, identity and workers.
568    pub fn replace(&mut self, input: Input) -> Result<()> {
569        let options = self.options();
570        self.close()?;
571        *self = Self::open(input, options, self.wake.clone())?;
572        Ok(())
573    }
574
575    /// Joins engine workers and releases its view/source ownership.
576    /// Caller-owned source jobs are revoked but must be joined and dropped by their owner.
577    /// Individual parser calls are not interruptible.
578    pub fn close(&mut self) -> Result<()> {
579        self.source_reads.store(false, Ordering::Release);
580        self.options = self.options();
581        if let Some(app) = &self.app {
582            app.worker.cancel();
583        }
584        let input = self
585            .input
586            .take()
587            .map(|mut worker| worker.close())
588            .transpose();
589        let colors = self.app.as_mut().map(|app| app.worker.close()).transpose();
590        self.app = None;
591        self.loading = false;
592        self.error = None;
593        self.source_failed = false;
594        self.progress = false;
595        self.request = false;
596        self.draw_pending = false;
597        self.dirty = true;
598        crate::cleanup::finish(input.map(|_| ()), colors.map(|_| ()))
599    }
600
601    #[cfg(feature = "owner-census")]
602    pub fn poll_census(&self, capture: &mut crate::owner_census::Capture) -> Result<()> {
603        self.app.as_ref().map_or(Ok(()), |app| capture.poll(app))
604    }
605
606    #[cfg(feature = "owner-census")]
607    pub fn request_census(&self, capture: &mut crate::owner_census::Capture) -> Result<()> {
608        self.app.as_ref().map_or(Ok(()), |app| capture.request(app))
609    }
610}
611
612impl Drop for Engine {
613    fn drop(&mut self) {
614        let _ = self.close();
615    }
616}
617
618#[cfg(test)]
619mod tests;