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