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