Skip to main content

clankerdiff_core/
session.rs

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                // Code rows reveal the nearest hunk edge when no gap row is selected.
267                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    /// Replaces the complete immutable document while preserving review and view
311    /// state, including gap expansions for files whose content did not change.
312    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}