Skip to main content

clankerdiff_core/
session.rs

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