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