1mod 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 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#[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#[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
93pub 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 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 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 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 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 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 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 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;