1use crate::{
2 ContentProjection, DiffDocument, DiffPresentation, DiffSide, GapId, Layout, LineAnchor,
3 PresentationOptions, PresentedCell, PresentedRow, RepoPath, Review, ReviewSubmission, RowId,
4 SourceLocation, ViewMode, presentation::retained_expansions,
5};
6use serde::{Deserialize, Serialize};
7use std::{ops::Range, sync::Arc};
8
9const EXPAND_STEP: usize = 20;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum RevealAmount {
14 Step,
15 All,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct SessionOptions {
20 pub include_file_headers: bool,
21}
22
23impl Default for SessionOptions {
24 fn default() -> Self {
25 Self {
26 include_file_headers: true,
27 }
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct CommentDraft {
33 anchor: LineAnchor,
34 line_text: String,
35 body: String,
36 cursor: usize,
37 editing: Option<u64>,
38}
39
40impl CommentDraft {
41 fn new(anchor: LineAnchor, line_text: String, body: String, editing: Option<u64>) -> Self {
42 Self {
43 anchor,
44 line_text,
45 cursor: body.len(),
46 body,
47 editing,
48 }
49 }
50
51 #[must_use]
52 pub const fn anchor(&self) -> &LineAnchor {
53 &self.anchor
54 }
55
56 #[must_use]
57 pub fn line_text(&self) -> &str {
58 &self.line_text
59 }
60
61 #[must_use]
62 pub fn body(&self) -> &str {
63 &self.body
64 }
65
66 #[must_use]
67 pub const fn cursor(&self) -> usize {
68 self.cursor
69 }
70
71 #[must_use]
72 pub fn body_before_cursor(&self) -> &str {
73 &self.body[..self.cursor]
74 }
75
76 #[must_use]
77 pub const fn editing(&self) -> Option<u64> {
78 self.editing
79 }
80
81 pub fn set_body(&mut self, body: impl Into<String>) {
82 self.body = body.into();
83 self.cursor = self.body.len();
84 }
85
86 pub fn insert(&mut self, text: &str) {
87 self.body.insert_str(self.cursor, text);
88 self.cursor += text.len();
89 }
90
91 pub fn delete_before_cursor(&mut self) {
92 let previous = self.previous_boundary();
93 self.body.replace_range(previous..self.cursor, "");
94 self.cursor = previous;
95 }
96
97 pub fn delete_at_cursor(&mut self) {
98 let next = self.next_boundary();
99 self.body.replace_range(self.cursor..next, "");
100 }
101
102 pub fn move_cursor_left(&mut self) {
103 self.cursor = self.previous_boundary();
104 }
105
106 pub fn move_cursor_right(&mut self) {
107 self.cursor = self.next_boundary();
108 }
109
110 pub const fn move_cursor_to_start(&mut self) {
111 self.cursor = 0;
112 }
113
114 pub fn move_cursor_to_end(&mut self) {
115 self.cursor = self.body.len();
116 }
117
118 fn previous_boundary(&self) -> usize {
119 self.body[..self.cursor]
120 .char_indices()
121 .next_back()
122 .map_or(0, |(offset, _)| offset)
123 }
124
125 fn next_boundary(&self) -> usize {
126 self.body[self.cursor..]
127 .char_indices()
128 .nth(1)
129 .map_or(self.body.len(), |(offset, _)| self.cursor + offset)
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134enum Restore {
135 Source,
136 Returning,
137 Refresh,
138}
139
140#[derive(Debug, Clone)]
141struct SelectionBookmark {
142 path: RepoPath,
143 row: Option<RowId>,
144 source: Option<SourceLocation>,
145 anchor: Option<LineAnchor>,
146 side: DiffSide,
147}
148
149#[derive(Debug, Clone)]
150pub struct ReviewSession {
151 document: Arc<DiffDocument>,
152 presentation: DiffPresentation,
153 review: Review,
154 options: SessionOptions,
155 view_mode: ViewMode,
156 split_when_auto: bool,
157 selected_file: usize,
158 selected_row: usize,
159 selected_side: DiffSide,
160 draft: Option<CommentDraft>,
161 projection: ContentProjection,
162 projection_revision: u64,
163 pending_source_restore: Option<SourceLocation>,
164 diff_return: Option<SelectionBookmark>,
165}
166
167impl ReviewSession {
168 #[must_use]
169 pub fn new(document: Arc<DiffDocument>) -> Self {
170 Self::with_options(document, SessionOptions::default())
171 }
172
173 #[must_use]
174 pub fn with_options(document: Arc<DiffDocument>, options: SessionOptions) -> Self {
175 let mut session = Self {
176 presentation: DiffPresentation::new(
177 document.clone(),
178 PresentationOptions {
179 view_mode: ViewMode::Auto,
180 split_when_auto: false,
181 include_file_headers: options.include_file_headers,
182 },
183 ),
184 document,
185 review: Review::default(),
186 options,
187 view_mode: ViewMode::Auto,
188 split_when_auto: false,
189 selected_file: 0,
190 selected_row: 0,
191 selected_side: DiffSide::New,
192 draft: None,
193 projection: ContentProjection::default(),
194 projection_revision: 0,
195 pending_source_restore: None,
196 diff_return: None,
197 };
198 session.select_first_row();
199 session
200 }
201
202 #[must_use]
203 pub const fn document(&self) -> &Arc<DiffDocument> {
204 &self.document
205 }
206
207 #[must_use]
208 pub const fn presentation(&self) -> &DiffPresentation {
209 &self.presentation
210 }
211
212 #[must_use]
213 pub const fn review(&self) -> &Review {
214 &self.review
215 }
216
217 pub const fn review_mut(&mut self) -> &mut Review {
218 &mut self.review
219 }
220
221 #[must_use]
222 pub const fn view_mode(&self) -> ViewMode {
223 self.view_mode
224 }
225
226 #[must_use]
227 pub const fn layout(&self) -> Layout {
228 self.projection.layout(self.presentation_options())
229 }
230
231 const fn presentation_options(&self) -> PresentationOptions {
232 PresentationOptions {
233 view_mode: self.view_mode,
234 split_when_auto: self.split_when_auto,
235 include_file_headers: self.options.include_file_headers,
236 }
237 }
238
239 #[must_use]
240 pub fn selected_file(&self) -> Option<usize> {
241 (!self.document.files.is_empty()).then_some(self.selected_file)
242 }
243
244 #[must_use]
245 pub fn selected_row(&self) -> Option<usize> {
246 (self.presentation.row_count() != 0).then_some(self.selected_row)
247 }
248
249 #[must_use]
250 pub const fn selected_side(&self) -> DiffSide {
251 self.selected_side
252 }
253
254 #[must_use]
255 pub fn selected_file_range(&self) -> Option<Range<usize>> {
256 self.presentation.file_range(self.selected_file)
257 }
258
259 #[must_use]
260 pub const fn draft(&self) -> Option<&CommentDraft> {
261 self.draft.as_ref()
262 }
263
264 pub const fn draft_mut(&mut self) -> Option<&mut CommentDraft> {
265 self.draft.as_mut()
266 }
267
268 #[must_use]
269 pub const fn projection_revision(&self) -> u64 {
270 self.projection_revision
271 }
272
273 #[must_use]
274 pub fn selected_source_line(&self) -> Option<SourceLocation> {
275 let row = self.selected_presented_row()?;
276 self.presentation
277 .source_location(row, row.preferred_cell(self.selected_side)?)
278 }
279
280 #[must_use]
281 pub fn source_view(&self) -> Option<&RepoPath> {
282 self.projection.source_view()
283 }
284
285 #[must_use]
286 pub fn source_view_label(&self) -> Option<&'static str> {
287 Some(match self.source_view_side()? {
288 DiffSide::New => "Source (new) · o back to diff",
289 DiffSide::Old => "Source (old — deleted) · o back to diff",
290 })
291 }
292
293 fn source_view_side(&self) -> Option<DiffSide> {
294 let path = self.source_view()?;
295 Some(self.document.files[self.document.file_index(path)?].source_side())
296 }
297
298 pub fn toggle_source_view(&mut self) -> bool {
299 if self.draft.is_some() {
300 return false;
301 }
302 if self.source_view().is_some() {
303 self.projection.set_source_view(None);
304 let bookmark = self.diff_return.take();
305 self.rebuild_restoring(bookmark);
306 return true;
307 }
308 let Some(file) = self.document.files.get(self.selected_file) else {
309 return false;
310 };
311 let (path, side) = (file.path.clone(), file.source_side());
312 self.diff_return = self.capture_bookmark();
313 let range = self.selected_file_range().unwrap_or(0..0);
314 let line_number = self
315 .presentation
316 .rows(self.selected_row..range.end)
317 .iter()
318 .chain(
319 self.presentation
320 .rows(range.start..self.selected_row)
321 .iter()
322 .rev(),
323 )
324 .find_map(|row| row.cell(side)?.line_number())
325 .unwrap_or(1);
326 self.pending_source_restore = Some(SourceLocation {
327 path: path.clone(),
328 side,
329 line_number,
330 });
331 self.projection.set_source_view(Some(path));
332 self.rebuild();
333 true
334 }
335
336 fn capture_bookmark(&self) -> Option<SelectionBookmark> {
337 Some(SelectionBookmark {
338 path: self.document.files.get(self.selected_file)?.path.clone(),
339 row: self.selected_presented_row().map(|row| row.id),
340 source: self.selected_source_line(),
341 anchor: self.selected_anchor(),
342 side: self.selected_side,
343 })
344 }
345
346 pub fn reveal_selected_gap(&mut self, amount: RevealAmount) -> bool {
347 let id = self
348 .presentation
349 .gap_info(self.selected_row)
350 .map(|info| info.id)
351 .or_else(|| {
352 let row = self.presentation.row(self.selected_row)?;
353 let source = row.preferred_cell(self.selected_side)?.patch_source?;
354 let hunk = self
355 .document
356 .files
357 .get(row.file_index)?
358 .hunks
359 .get(source.hunk_index)?;
360 let after = source.line_index.saturating_mul(2) >= hunk.lines.len();
362 Some(GapId {
363 file_index: row.file_index,
364 gap_index: source.hunk_index + usize::from(after),
365 })
366 });
367 let Some(id) = id else {
368 return false;
369 };
370 let Some(file) = self.document.files.get(id.file_index) else {
371 return false;
372 };
373 let hunk_count = file.hunks.len();
374 let expansion = self.projection.expansions.entry(id).or_default();
375 let amount = match amount {
376 RevealAmount::Step => EXPAND_STEP,
377 RevealAmount::All => usize::MAX,
378 };
379 if id.gap_index != 0 || hunk_count == 0 {
380 expansion.revealed_prefix = expansion.revealed_prefix.saturating_add(amount);
381 }
382 if id.gap_index != hunk_count || hunk_count == 0 {
383 expansion.revealed_suffix = expansion.revealed_suffix.saturating_add(amount);
384 }
385 self.rebuild();
386 true
387 }
388
389 pub fn toggle_full_file(&mut self) -> bool {
390 let Some(file) = self.document.files.get(self.selected_file) else {
391 return false;
392 };
393 if file.binary || file.omitted_bytes.is_some() {
394 return false;
395 }
396 let path = file.path.clone();
397 if !self.projection.full_files.remove(&path) {
398 self.projection.full_files.insert(path);
399 }
400 self.rebuild();
401 true
402 }
403
404 pub fn set_document(&mut self, document: Arc<DiffDocument>) {
407 self.review.reconcile(&document);
408 if Arc::ptr_eq(&document, &self.document) || document == self.document {
409 return;
410 }
411 if self
412 .source_view()
413 .is_some_and(|path| document.file_index(path).is_none())
414 {
415 self.projection.set_source_view(None);
416 self.diff_return = None;
417 }
418 let expansions =
419 retained_expansions(&self.document, &self.projection.expansions, &document);
420 let selected_path = self
421 .document
422 .files
423 .get(self.selected_file)
424 .map(|file| file.path.clone());
425 self.selected_file = selected_path
426 .and_then(|path| document.file_index(&path))
427 .unwrap_or(0)
428 .min(document.files.len().saturating_sub(1));
429 self.projection.expansions = expansions;
430 self.projection
431 .full_files
432 .retain(|path| document.file_index(path).is_some());
433 if self
434 .pending_source_restore
435 .as_ref()
436 .is_some_and(|location| document.file_index(&location.path).is_none())
437 {
438 self.pending_source_restore = None;
439 }
440 self.document = document;
441 self.rebuild();
442 }
443
444 pub fn set_view_mode(&mut self, mode: ViewMode) -> bool {
445 if self.view_mode == mode {
446 return false;
447 }
448 let previous = self.layout();
449 self.view_mode = mode;
450 if self.layout() != previous {
451 self.rebuild();
452 }
453 true
454 }
455
456 pub fn cycle_view_mode(&mut self) -> bool {
457 self.set_view_mode(self.view_mode.next())
458 }
459
460 pub fn set_split_when_auto(&mut self, split: bool) -> bool {
461 if self.split_when_auto == split {
462 return false;
463 }
464 let previous = self.layout();
465 self.split_when_auto = split;
466 if self.layout() == previous {
467 return false;
468 }
469 self.rebuild();
470 true
471 }
472
473 pub fn clear_review(&mut self) {
474 self.review.clear();
475 self.draft = None;
476 }
477
478 pub fn select_file(&mut self, index: usize) -> bool {
479 if index >= self.document.files.len() {
480 return false;
481 }
482 if self.source_view().is_some() {
483 if self.selected_file == index {
484 return true;
485 }
486 self.toggle_source_view();
487 }
488 self.pending_source_restore = None;
489 self.selected_file = index;
490 self.draft = None;
491 self.select_first_row();
492 true
493 }
494
495 pub fn move_file(&mut self, delta: isize) -> Option<usize> {
496 if self.document.files.is_empty() {
497 return None;
498 }
499 let index = offset(self.selected_file, delta, self.document.files.len() - 1);
500 self.select_file(index).then_some(index)
501 }
502
503 pub fn move_row(&mut self, delta: isize) {
504 self.pending_source_restore = None;
505 let Some(range) = self.selected_file_range() else {
506 return;
507 };
508 let mut current = self.selected_row;
509 if !self.presentation.is_navigable(current) {
510 let Some(first) = self.presentation.first_navigable(range.clone()) else {
511 return;
512 };
513 current = first;
514 if delta <= 0 {
515 self.selected_row = current;
516 return;
517 }
518 }
519 for _ in 0..delta.unsigned_abs() {
520 let Some(next) = self
521 .presentation
522 .step_navigable(current, delta.is_negative(), &range)
523 else {
524 break;
525 };
526 current = next;
527 }
528 self.selected_row = current;
529 self.normalize_selected_side();
530 }
531
532 pub fn select_boundary(&mut self, end: bool) {
533 self.pending_source_restore = None;
534 let Some(range) = self.selected_file_range() else {
535 return;
536 };
537 let selected = if end {
538 self.presentation.last_navigable(range)
539 } else {
540 self.presentation.first_navigable(range)
541 };
542 if let Some(index) = selected {
543 self.selected_row = index;
544 self.normalize_selected_side();
545 }
546 }
547
548 pub fn select_row(&mut self, index: usize) -> bool {
549 if self.source_view().is_some()
550 && !self
551 .selected_file_range()
552 .is_some_and(|range| range.contains(&index))
553 {
554 return false;
555 }
556 if !self.presentation.is_navigable(index) {
557 return false;
558 }
559 self.pending_source_restore = None;
560 self.selected_row = index;
561 self.normalize_selected_side();
562 true
563 }
564
565 #[must_use]
566 pub fn hunk_target(&self, delta: isize) -> Option<usize> {
567 let file = self.document.files.get(self.selected_file)?;
568 if file.hunks.is_empty() {
569 return None;
570 }
571 let current = self
572 .presentation
573 .row(self.selected_row)
574 .and_then(|row| row.hunk_index)
575 .unwrap_or(0);
576 let target = offset(current, delta, file.hunks.len() - 1);
577 let range = self.presentation.hunk_range(self.selected_file, target)?;
578 Some(
579 self.presentation
580 .first_navigable(range)
581 .unwrap_or(self.selected_row),
582 )
583 }
584
585 pub fn move_hunk(&mut self, delta: isize) -> bool {
586 self.pending_source_restore = None;
587 let Some(target) = self.hunk_target(delta) else {
588 return false;
589 };
590 self.selected_row = target;
591 self.normalize_selected_side();
592 true
593 }
594
595 pub fn set_selected_side(&mut self, side: DiffSide) -> bool {
596 self.pending_source_restore = None;
597 let available = self
598 .selected_row()
599 .and_then(|index| self.presentation.row(index))
600 .is_some_and(|row| row.cell(side).is_some());
601 if available {
602 self.selected_side = side;
603 }
604 available
605 }
606
607 #[must_use]
608 pub fn selected_presented_row(&self) -> Option<&PresentedRow> {
609 self.presentation.row(self.selected_row)
610 }
611
612 #[must_use]
613 pub fn selected_cell(&self) -> Option<&PresentedCell> {
614 self.selected_presented_row()?
615 .preferred_cell(self.selected_side)
616 }
617
618 #[must_use]
619 pub fn selected_anchor(&self) -> Option<LineAnchor> {
620 let row = self.selected_presented_row()?;
621 self.presentation
622 .cell_anchor(row, row.preferred_cell(self.selected_side)?)
623 }
624
625 #[must_use]
626 pub fn comment_id_at_selection(&self) -> Option<u64> {
627 let anchor = self.selected_anchor()?;
628 self.review
629 .comments_for_anchor(&anchor)
630 .next_back()
631 .map(|comment| comment.id)
632 }
633
634 #[must_use]
635 pub fn last_comment_id(&self) -> Option<u64> {
636 self.review.comments().last().map(|comment| comment.id)
637 }
638
639 pub fn begin_draft(&mut self, editing: Option<u64>) -> bool {
640 if editing.is_some_and(|id| self.review.comment(id).is_none()) {
641 return false;
642 }
643 let Some(anchor) = self.selected_anchor() else {
644 return false;
645 };
646 let line_text = self
647 .selected_cell()
648 .map_or_else(String::new, |cell| cell.text.to_string());
649 let body = editing
650 .and_then(|id| self.review.comment(id))
651 .map_or_else(String::new, |comment| comment.body.clone());
652 self.draft = Some(CommentDraft::new(anchor, line_text, body, editing));
653 true
654 }
655
656 pub fn edit_comment_at_selection(&mut self) -> bool {
657 self.comment_id_at_selection()
658 .is_some_and(|id| self.begin_draft(Some(id)))
659 }
660
661 pub fn undo_last_comment(&mut self) -> bool {
662 self.last_comment_id()
663 .is_some_and(|id| self.review.remove_comment(id).is_some())
664 }
665
666 pub fn cancel_draft(&mut self) {
667 self.draft = None;
668 }
669
670 pub fn submit_draft(&mut self) -> Option<u64> {
671 let draft = self.draft.take()?;
672 if draft.body.trim().is_empty() {
673 return None;
674 }
675 Some(match draft.editing {
676 Some(id) => {
677 if !self.review.edit_comment(id, draft.body) {
678 return None;
679 }
680 id
681 }
682 None => self
683 .review
684 .add_comment_with_context(draft.anchor, draft.line_text, draft.body),
685 })
686 }
687
688 pub fn delete_comment_at_selection(&mut self) -> bool {
689 let Some(id) = self.comment_id_at_selection() else {
690 return false;
691 };
692 self.review.remove_comment(id).is_some()
693 }
694
695 #[must_use]
696 pub fn submission(&self) -> ReviewSubmission {
697 self.review.submission()
698 }
699
700 fn rebuild(&mut self) {
701 self.rebuild_restoring(None);
702 }
703
704 fn rebuild_restoring(&mut self, bookmark: Option<SelectionBookmark>) {
705 let mode = match (bookmark.is_some(), self.source_view().is_some()) {
706 (true, _) => Restore::Returning,
707 (false, true) => Restore::Source,
708 (false, false) => Restore::Refresh,
709 };
710 let pending = self.pending_source_restore.take();
711 let bookmark = bookmark.or_else(|| self.capture_bookmark());
712 let mut source = bookmark
713 .as_ref()
714 .and_then(|bookmark| bookmark.source.clone());
715 if mode != Restore::Returning {
716 source = pending.or(source);
717 }
718 if let Some(bookmark) = &bookmark {
719 self.selected_file = self
720 .document
721 .file_index(&bookmark.path)
722 .unwrap_or(self.selected_file);
723 self.selected_side = bookmark.side;
724 }
725 if mode == Restore::Source
726 && let Some(location) = &mut source
727 && let Some(index) = self
728 .source_view()
729 .and_then(|path| self.document.file_index(path))
730 {
731 let file = &self.document.files[index];
732 location.path = file.path.clone();
733 location.side = file.source_side();
734 let count = file
735 .source_document(location.side)
736 .map_or(0, |source| source.line_count());
737 location.line_number = location.line_number.clamp(1, count.max(1));
738 }
739 let draft_anchor = self.draft.as_ref().map(|draft| draft.anchor.clone());
740 self.presentation = DiffPresentation::with_projection(
741 self.document.clone(),
742 self.presentation_options(),
743 &self.projection,
744 );
745 self.projection_revision = self.projection_revision.wrapping_add(1);
746 let source_row = source
747 .as_ref()
748 .and_then(|location| self.presentation.row_showing_source(location));
749 if source_row.is_none() && mode == Restore::Refresh {
750 self.pending_source_restore = source;
751 }
752 let patch_row = || {
753 let bookmark = bookmark.as_ref()?;
754 bookmark
755 .row
756 .and_then(|id| self.presentation.row_with_id(id))
757 .or_else(|| {
758 bookmark
759 .anchor
760 .as_ref()
761 .and_then(|anchor| self.presentation.row_showing_anchor(anchor))
762 })
763 };
764 let restored = match mode {
765 Restore::Source => source_row,
766 Restore::Returning => patch_row().or(source_row),
767 Restore::Refresh => source_row.or_else(patch_row),
768 };
769 if let Some(row) = restored {
770 self.selected_row = row;
771 self.selected_file = self
772 .presentation
773 .row(row)
774 .map_or(self.selected_file, |row| row.file_index);
775 } else {
776 self.select_first_row();
777 }
778 let draft_is_current = draft_anchor.as_ref().is_none_or(|anchor| {
779 self.presentation
780 .row_showing_anchor(anchor)
781 .and_then(|row_index| self.presentation.row(row_index))
782 .and_then(|row| {
783 row.cells()
784 .find(|cell| {
785 cell.patch_source
786 .is_some_and(|source| source.side == anchor.side)
787 && cell.line_number() == anchor.line_number()
788 })
789 .and_then(|cell| self.presentation.cell_anchor(row, cell))
790 })
791 .is_some_and(|current| current == *anchor)
792 });
793 if !draft_is_current {
794 self.draft = None;
795 }
796 match self.source_view_side() {
797 Some(side) => self.selected_side = side,
798 None => self.normalize_selected_side(),
799 }
800 }
801
802 fn select_first_row(&mut self) {
803 match self.selected_file_range() {
804 Some(range) => {
805 self.selected_row = self
806 .presentation
807 .first_navigable(range.clone())
808 .unwrap_or(range.start);
809 }
810 None => self.selected_row = 0,
811 }
812 self.normalize_selected_side();
813 }
814
815 fn normalize_selected_side(&mut self) {
816 let Some(row) = self.presentation.row(self.selected_row) else {
817 return;
818 };
819 if row.cell(self.selected_side).is_none()
820 && row.cell(self.selected_side.opposite()).is_some()
821 {
822 self.selected_side = self.selected_side.opposite();
823 }
824 }
825}
826
827fn offset(value: usize, delta: isize, maximum: usize) -> usize {
828 if delta.is_negative() {
829 value.saturating_sub(delta.unsigned_abs())
830 } else {
831 value.saturating_add(delta.unsigned_abs()).min(maximum)
832 }
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838 use crate::{DiffDocument, FileDiff, testing::DocumentBuilder};
839
840 fn session() -> ReviewSession {
841 ReviewSession::new(Arc::new(DiffDocument {
842 repo_root: "/repo".into(),
843 files: vec![
844 FileDiff::from_texts("a.rs", "one\ntwo\n", "ONE\nTWO\n").unwrap(),
845 FileDiff::from_texts("b.rs", "keep\n", "kept\n").unwrap(),
846 ],
847 }))
848 }
849
850 #[test]
851 fn selection_starts_on_a_commentable_row() {
852 let session = session();
853 assert_eq!(session.selected_file(), Some(0));
854 assert!(session.selected_anchor().is_some());
855 }
856
857 #[test]
858 fn navigation_clamps_at_both_ends() {
859 let mut session = session();
860 let first = session.selected_row().unwrap();
861 session.move_row(-5);
862 assert_eq!(session.selected_row(), Some(first));
863 session.move_row(1_000);
864 let last = session.selected_row().unwrap();
865 session.move_row(1_000);
866 assert_eq!(session.selected_row(), Some(last));
867 assert!(session.selected_anchor().is_some());
868 }
869
870 #[test]
871 fn file_movement_resets_the_row_and_draft() {
872 let mut session = session();
873 assert!(session.begin_draft(None));
874 assert_eq!(session.move_file(1), Some(1));
875 assert!(session.draft().is_none());
876 assert_eq!(session.move_file(9), Some(1));
877 assert_eq!(session.move_file(-9), Some(0));
878 }
879
880 #[test]
881 fn split_selection_can_target_the_removed_side() {
882 let mut session = session();
883 session.set_view_mode(ViewMode::Split);
884 assert_eq!(session.layout(), Layout::Split);
885 assert!(session.set_selected_side(DiffSide::Old));
886 assert_eq!(
887 session.selected_anchor().map(|anchor| anchor.side),
888 Some(DiffSide::Old)
889 );
890 assert!(session.set_selected_side(DiffSide::New));
891 assert_eq!(
892 session.selected_anchor().map(|anchor| anchor.side),
893 Some(DiffSide::New)
894 );
895 }
896
897 #[test]
898 fn selection_side_follows_cells_available_on_each_row() {
899 let document = Arc::new(DiffDocument {
900 repo_root: "/repo".into(),
901 files: vec![FileDiff::from_texts("a.rs", "old\n", "new\nextra\n").unwrap()],
902 });
903 let mut session = ReviewSession::new(document);
904 session.set_view_mode(ViewMode::Split);
905 assert!(session.set_selected_side(DiffSide::Old));
906 session.move_row(1);
907 assert_eq!(session.selected_side(), DiffSide::New);
908 assert!(!session.set_selected_side(DiffSide::Old));
909 assert_eq!(session.selected_side(), DiffSide::New);
910 }
911
912 #[test]
913 fn auto_mode_only_rebuilds_when_the_layout_changes() {
914 let mut session = session();
915 assert!(session.set_split_when_auto(true));
916 assert_eq!(session.layout(), Layout::Split);
917 assert!(!session.set_split_when_auto(true));
918 assert!(session.set_view_mode(ViewMode::Split));
919 assert!(!session.set_split_when_auto(false));
920 assert_eq!(session.layout(), Layout::Split);
921 }
922
923 #[test]
924 fn drafts_round_trip_through_the_review() {
925 let mut session = session();
926 assert!(session.begin_draft(None));
927 session.draft_mut().unwrap().insert("please fix");
928 let id = session.submit_draft().unwrap();
929 assert_eq!(session.review().len(), 1);
930 assert_eq!(session.comment_id_at_selection(), Some(id));
931
932 assert!(session.begin_draft(Some(id)));
933 assert_eq!(session.draft().unwrap().body(), "please fix");
934 session.draft_mut().unwrap().insert(" now");
935 assert_eq!(session.submit_draft(), Some(id));
936 assert_eq!(session.review().comment(id).unwrap().body, "please fix now");
937 assert_eq!(session.review().len(), 1);
938
939 assert!(session.delete_comment_at_selection());
940 assert!(session.review().is_empty());
941 }
942
943 #[test]
944 fn a_blank_draft_adds_nothing() {
945 let mut session = session();
946 assert!(session.begin_draft(None));
947 session.draft_mut().unwrap().insert(" ");
948 assert_eq!(session.submit_draft(), None);
949 assert!(session.review().is_empty());
950 }
951
952 #[test]
953 fn draft_editing_is_utf8_safe() {
954 let mut session = session();
955 session.begin_draft(None);
956 let draft = session.draft_mut().unwrap();
957 draft.insert("a界b");
958 draft.move_cursor_left();
959 draft.delete_before_cursor();
960 assert_eq!(draft.body(), "ab");
961 draft.move_cursor_to_start();
962 draft.delete_at_cursor();
963 assert_eq!(draft.body(), "b");
964 draft.move_cursor_to_end();
965 assert_eq!(draft.cursor(), 1);
966 }
967
968 #[allow(clippy::format_collect)]
969 fn full_file_session() -> ReviewSession {
970 let old = (1..=60)
971 .map(|line| format!("old {line}\n"))
972 .collect::<String>();
973 let new = old.replace("old 31\n", "new 31\n");
974 ReviewSession::new(
975 DocumentBuilder::new()
976 .changed_with_hunk_window("a.rs", &old, &new, 28..=34)
977 .build(),
978 )
979 }
980
981 #[test]
982 fn eager_full_file_projection_keeps_expanded_lines_non_commentable() {
983 let mut session = full_file_session();
984 let selected = session.selected_source_line().unwrap();
985 assert!(session.toggle_full_file());
986 assert_eq!(session.selected_source_line(), Some(selected));
987 let expanded = session
988 .presentation()
989 .rows(0..session.presentation().row_count())
990 .iter()
991 .position(|row| row.kind == crate::RowKind::ExpandedContext)
992 .unwrap();
993 assert!(session.select_row(expanded));
994 let expanded_location = session.selected_source_line().unwrap();
995 assert!(session.selected_anchor().is_none());
996 assert!(!session.begin_draft(None));
997 assert!(session.toggle_full_file());
998 assert!(session.toggle_full_file());
999 assert_eq!(session.selected_source_line(), Some(expanded_location));
1000 }
1001
1002 #[test]
1003 fn document_replacement_preserves_an_existing_patch_selection_and_draft() {
1004 let mut session = session();
1005 session.move_row(2);
1006 let anchor = session.selected_anchor().unwrap();
1007 assert!(session.begin_draft(None));
1008 session.draft_mut().unwrap().insert("still editing");
1009 let document = session.document().clone();
1010 session.set_document(document);
1011 assert_eq!(session.selected_anchor(), Some(anchor));
1012 assert_eq!(
1013 session.draft().map(CommentDraft::body),
1014 Some("still editing")
1015 );
1016 }
1017
1018 #[test]
1019 fn document_replacement_drops_a_draft_when_content_changes_at_the_same_line() {
1020 let mut session = session();
1021 assert!(session.begin_draft(None));
1022 session.draft_mut().unwrap().insert("stale draft");
1023 let replacement = Arc::new(DiffDocument {
1024 repo_root: "/repo".into(),
1025 files: vec![
1026 FileDiff::from_texts("a.rs", "different\ntwo\n", "CHANGED\nTWO\n").unwrap(),
1027 ],
1028 });
1029 session.set_document(replacement);
1030 assert!(session.draft().is_none());
1031 }
1032
1033 #[allow(clippy::format_collect)]
1034 fn windowed_source(changed_lines: &[usize]) -> (String, String) {
1035 let old = (1..=60)
1036 .map(|line| format!("old {line}\n"))
1037 .collect::<String>();
1038 let new = changed_lines.iter().fold(old.clone(), |text, line| {
1039 text.replace(&format!("old {line}\n"), &format!("new {line}\n"))
1040 });
1041 (old, new)
1042 }
1043
1044 fn windowed_fixture(new_text: &str) -> Arc<DiffDocument> {
1045 let (old, _) = windowed_source(&[]);
1046 DocumentBuilder::new()
1047 .changed_with_hunk_window("a.rs", &old, new_text, 28..=34)
1048 .build()
1049 }
1050
1051 fn expanded_row_count(session: &ReviewSession) -> usize {
1052 let presentation = session.presentation();
1053 presentation
1054 .rows(0..presentation.row_count())
1055 .iter()
1056 .filter(|row| row.kind == crate::RowKind::ExpandedContext)
1057 .count()
1058 }
1059
1060 fn reveal_from_first_code_row(session: &mut ReviewSession) -> usize {
1061 let code_row = (0..session.presentation().row_count())
1062 .find(|index| {
1063 session
1064 .presentation()
1065 .row(*index)
1066 .is_some_and(|row| row.kind == crate::RowKind::Code)
1067 })
1068 .expect("a code row");
1069 assert!(session.select_row(code_row));
1070 assert!(session.reveal_selected_gap(RevealAmount::Step));
1071 let expanded = expanded_row_count(session);
1072 assert!(expanded > 0, "revealing must expand context");
1073 expanded
1074 }
1075
1076 #[test]
1077 fn from_texts_documents_capture_both_sides_and_reveal_unchanged_context() {
1078 let old = (1..=80).fold(String::new(), |mut text, line| {
1079 use std::fmt::Write;
1080 let _ = writeln!(text, "line {line}");
1081 text
1082 });
1083 let new = old.replace("line 40\n", "changed 40\n");
1084 let document = DiffDocument::from_texts([
1085 ("src/changed.rs", old.as_str(), new.as_str()),
1086 ("src/added.rs", "", "fn main() {}\n"),
1087 ])
1088 .unwrap();
1089 let changed = &document.files[0];
1090 assert_eq!(changed.hunks.len(), 1);
1091 assert!(changed.hunks[0].lines.len() <= 8);
1092 assert_eq!(
1093 changed.source_document(DiffSide::Old).unwrap().line_count(),
1094 80
1095 );
1096 assert_eq!(
1097 document.files[1].source_unavailable(DiffSide::Old),
1098 Some(&crate::SourceUnavailable::Absent)
1099 );
1100
1101 let mut session = ReviewSession::new(Arc::new(document));
1102 reveal_from_first_code_row(&mut session);
1103 let presentation = session.presentation();
1104 let gap = (0..presentation.row_count())
1105 .find_map(|index| presentation.gap_info(index))
1106 .expect("a remaining collapsed gap");
1107 assert_eq!(gap.unavailable, None);
1108 }
1109
1110 #[test]
1111 fn snapshot_replacement_retains_gap_expansions_through_an_index_shift() {
1112 let (_, new) = windowed_source(&[31]);
1113 let fixture = windowed_fixture(&new);
1114 let mut session = ReviewSession::new(fixture.clone());
1115 let expanded = reveal_from_first_code_row(&mut session);
1116
1117 let (old, _) = windowed_source(&[]);
1118 let replacement = DocumentBuilder::new()
1119 .changed("inserted.rs", "one\n", "two\n")
1120 .changed_with_hunk_window("a.rs", &old, &new, 28..=34)
1121 .build();
1122 session.set_document(replacement);
1123
1124 assert_eq!(session.selected_file(), Some(1));
1125 assert_eq!(expanded_row_count(&session), expanded);
1126 }
1127
1128 #[test]
1129 fn snapshot_replacement_keeps_selection_in_expanded_context() {
1130 let (_, new) = windowed_source(&[31]);
1131 let fixture = windowed_fixture(&new);
1132 let mut session = ReviewSession::new(fixture.clone());
1133 reveal_from_first_code_row(&mut session);
1134 let expanded = session
1135 .presentation()
1136 .rows(0..session.presentation().row_count())
1137 .iter()
1138 .position(|row| row.kind == crate::RowKind::ExpandedContext)
1139 .expect("expanded context exists");
1140 assert!(session.select_row(expanded));
1141 let source = session
1142 .selected_source_line()
1143 .expect("expanded row has source location");
1144 session.set_document(windowed_fixture(&new));
1145 assert_eq!(session.selected_source_line(), Some(source));
1146 assert_eq!(
1147 session
1148 .presentation()
1149 .row(session.selected_row().unwrap())
1150 .unwrap()
1151 .kind,
1152 crate::RowKind::ExpandedContext
1153 );
1154 }
1155
1156 #[test]
1157 fn snapshot_replacement_drops_gap_expansions_when_a_side_changes() {
1158 let (_, new) = windowed_source(&[31]);
1159 let fixture = windowed_fixture(&new);
1160 let mut session = ReviewSession::new(fixture.clone());
1161 reveal_from_first_code_row(&mut session);
1162
1163 let (_, edited) = windowed_source(&[31, 55]);
1164 let replacement = windowed_fixture(&edited);
1165 assert_eq!(
1166 replacement.files[0].hunks, fixture.files[0].hunks,
1167 "the retained window must stay equal so only source content differs"
1168 );
1169 session.set_document(replacement);
1170
1171 assert_eq!(expanded_row_count(&session), 0);
1172 }
1173
1174 #[test]
1175 fn snapshot_replacement_keeps_a_draft_when_another_file_changes() {
1176 let fixture = DocumentBuilder::new()
1177 .changed("a.rs", "one\ntwo\n", "ONE\nTWO\n")
1178 .changed("b.rs", "keep\n", "kept\n")
1179 .build();
1180 let mut session = ReviewSession::new(fixture.clone());
1181 assert!(session.begin_draft(None));
1182 session.draft_mut().unwrap().insert("still editing");
1183
1184 let replacement = DocumentBuilder::new()
1185 .changed("a.rs", "one\ntwo\n", "ONE\nTWO\n")
1186 .changed("b.rs", "keep\n", "changed\n")
1187 .build();
1188 session.set_document(replacement);
1189
1190 assert_eq!(
1191 session.draft().map(CommentDraft::body),
1192 Some("still editing")
1193 );
1194 }
1195
1196 #[test]
1197 fn replacing_the_document_keeps_the_selected_path() {
1198 let mut session = session();
1199 session.move_file(1);
1200 let document = session.document().clone();
1201 session.set_document(document);
1202 assert_eq!(session.selected_file(), Some(1));
1203 }
1204}