Skip to main content

gpui_component/diff/
state.rs

1use std::{
2    cell::RefCell,
3    collections::{HashMap, HashSet},
4    ops::Range,
5    rc::Rc,
6    sync::Arc,
7};
8
9use gpui::{
10    App, AppContext as _, Context, ElementId, EventEmitter, FocusHandle, Focusable, ListAlignment,
11    ListOffset, ListState, Pixels, ScrollHandle, SharedString, Size, Subscription, Task, Window,
12    WindowId, point, px,
13};
14use gpui_base::{TextSelection, TextSelectionContentKey, TextSelectionEvent, TextSelectionHandle};
15
16use super::{
17    DiffAnnotation, DiffConflictResolution, DiffFile, DiffFileStatus, DiffInlineUnit,
18    DiffLinePosition, DiffLineRange, DiffMode, DiffSide,
19    conflict::ConflictPart,
20    presentation::{FilePresentation, PresentationOptions, SyntaxHighlighters},
21    selection::SelectionGeometry,
22};
23
24/// One virtualized row. Every row belongs to exactly one file.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub(crate) enum DisplayRow {
27    /// The file header, with any file-level annotations.
28    File(usize),
29    /// Binary, metadata-only or empty file summary, for a file without source rows.
30    Notice(usize),
31    Hunk {
32        file: usize,
33        hunk: usize,
34    },
35    Fold {
36        file: usize,
37        pairs: Range<usize>,
38    },
39    /// The heading of one part of a merge conflict; for a resolved conflict,
40    /// the [`ConflictPart::Current`] heading stands for the whole conflict.
41    Conflict {
42        file: usize,
43        conflict: usize,
44        part: ConflictPart,
45    },
46    Code {
47        file: usize,
48        original: Option<usize>,
49        modified: Option<usize>,
50        changed: bool,
51    },
52}
53
54impl DisplayRow {
55    pub(crate) fn file(&self) -> usize {
56        match self {
57            Self::File(file) | Self::Notice(file) => *file,
58            Self::Hunk { file, .. }
59            | Self::Fold { file, .. }
60            | Self::Conflict { file, .. }
61            | Self::Code { file, .. } => *file,
62        }
63    }
64}
65
66/// Which part of a collapsed unchanged range a disclosure reveals.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub(crate) enum FoldExpansion {
69    /// The lines just above the following source.
70    Up,
71    /// The lines just below the preceding source.
72    Down,
73    All,
74}
75
76/// Notifications from the readonly comparison surface.
77#[derive(Clone, Debug)]
78#[non_exhaustive]
79pub enum DiffEvent {
80    /// A pointer gesture began selecting source lines from a line number.
81    SelectionStarted(DiffLineRange),
82    /// A pointer, gutter or keyboard operation changed the selected source lines.
83    SelectionChanged(Option<DiffLineRange>),
84    /// A selection gesture finished; the range is ready to act on.
85    SelectionEnded(DiffLineRange),
86    /// The user expanded a collapsed file from its header.
87    FileExpanded(SharedString),
88    /// The user collapsed a file from its header.
89    FileCollapsed(SharedString),
90    /// The user resolved a merge conflict, or undid its resolution; read it
91    /// with [`DiffState::conflict_resolution`].
92    ConflictResolved(SharedString, usize),
93}
94
95/// Inputs that determine row heights and content width.
96#[derive(Clone, Debug, PartialEq)]
97pub(crate) struct LayoutKey {
98    rem: Pixels,
99    font_size: Pixels,
100    font_family: SharedString,
101    mode: DiffMode,
102    line_number: bool,
103    soft_wrap: bool,
104}
105
106impl LayoutKey {
107    pub(crate) fn new(
108        rem: Pixels,
109        font_size: Pixels,
110        font_family: SharedString,
111        mode: DiffMode,
112        line_number: bool,
113        soft_wrap: bool,
114    ) -> Self {
115        Self {
116            rem,
117            font_size,
118            font_family,
119            mode,
120            line_number,
121            soft_wrap,
122        }
123    }
124}
125
126/// The row showing each source line, or the disclosure hiding it.
127#[derive(Default)]
128struct FileRows {
129    original: Vec<Option<usize>>,
130    modified: Vec<Option<usize>>,
131}
132
133/// The selected source lines of one file, resolved for membership tests.
134#[derive(Clone, Debug, PartialEq, Eq)]
135pub(crate) enum SelectedLines {
136    /// Lines numbered `lines` on one side.
137    Side {
138        file: usize,
139        side: DiffSide,
140        lines: Range<usize>,
141    },
142    /// Patch lines in `patch` order, across both sides.
143    Patch { file: usize, patch: Range<usize> },
144}
145
146impl SelectedLines {
147    pub(crate) fn contains(
148        &self,
149        files: &[DiffFile],
150        file: usize,
151        side: DiffSide,
152        ix: usize,
153    ) -> bool {
154        match self {
155            Self::Side {
156                file: selected,
157                side: selected_side,
158                lines,
159            } => {
160                *selected == file
161                    && *selected_side == side
162                    && lines.contains(&files[file].line_number(side, ix))
163            }
164            Self::Patch {
165                file: selected,
166                patch,
167            } => *selected == file && patch.contains(&files[file].patch_ix(side, ix)),
168        }
169    }
170
171    /// The last selected line, where a selection's add-annotation button sits.
172    pub(crate) fn is_last(
173        &self,
174        files: &[DiffFile],
175        file: usize,
176        side: DiffSide,
177        ix: usize,
178    ) -> bool {
179        match self {
180            Self::Side {
181                file: selected,
182                side: selected_side,
183                lines,
184            } => {
185                *selected == file
186                    && *selected_side == side
187                    && files[file].line_number(side, ix) + 1 == lines.end
188            }
189            Self::Patch {
190                file: selected,
191                patch,
192            } => *selected == file && files[file].patch_ix(side, ix) + 1 == patch.end,
193        }
194    }
195}
196
197/// Retained focus, viewport, context expansion and selection for a
198/// [`super::Diff`] showing one or more files of a patch.
199///
200/// Create once in the owning view. Mutations notify observers; the application
201/// owns the patch and chooses when to install new files. Syntax and inline
202/// emphasis are prepared on a background thread and appear once ready.
203pub struct DiffState {
204    files: Vec<DiffFile>,
205    /// Each path's index in `files`; the first file wins a duplicated path.
206    file_ixs: HashMap<SharedString, usize>,
207    mode: DiffMode,
208    context_lines: Option<usize>,
209    expansion_lines: usize,
210    min_collapsed_lines: usize,
211    expanded: Vec<Vec<Range<usize>>>,
212    collapsed: HashSet<SharedString>,
213    resolutions: HashMap<(SharedString, usize), DiffConflictResolution>,
214    file_rows: Vec<FileRows>,
215    header_rows: Vec<usize>,
216    rows: Rc<Vec<DisplayRow>>,
217    change_rows: Vec<usize>,
218    list: ListState,
219    horizontal_scroll: ScrollHandle,
220    focus: FocusHandle,
221    selection: TextSelectionHandle,
222    geometry: Rc<RefCell<SelectionGeometry>>,
223    selected_lines: Option<DiffLineRange>,
224    selection_anchor: Option<DiffLinePosition>,
225    selection_cursor: Option<DiffLinePosition>,
226    selecting: bool,
227    presentation_options: PresentationOptions,
228    presentations: Vec<Option<Arc<FilePresentation>>>,
229    viewport: Size<Pixels>,
230    layout: Option<LayoutKey>,
231    content_width: Pixels,
232    row_height: Option<Pixels>,
233    annotations: HashSet<(Option<DiffLinePosition>, SharedString, ElementId)>,
234    window: Option<WindowId>,
235    /// Bumped when files or presentation options change, so a preparation
236    /// started for earlier ones never installs its results.
237    presentation_generation: usize,
238    preparing: Option<usize>,
239    _presentation: Task<()>,
240    _selection_subscription: Subscription,
241}
242
243impl EventEmitter<DiffEvent> for DiffState {}
244impl Focusable for DiffState {
245    fn focus_handle(&self, _: &App) -> FocusHandle {
246        self.focus.clone()
247    }
248}
249
250impl DiffState {
251    /// Creates a unified viewer with three unchanged lines around each change.
252    pub fn new(files: impl IntoIterator<Item = DiffFile>, cx: &mut Context<Self>) -> Self {
253        let focus = cx.focus_handle().tab_stop(true);
254        let selection = TextSelectionHandle::new("", cx);
255        let geometry = Rc::new(RefCell::new(SelectionGeometry::default()));
256        let weak = cx.entity().downgrade();
257        selection.resolve_content_key_with(
258            {
259                let weak = weak.clone();
260                let geometry = geometry.clone();
261                move |point, cx| {
262                    let state = weak.upgrade()?;
263                    let snapshot = state.read(cx).selection.snapshot(cx);
264                    let entity = Some(state.read(cx).selection.entity_id());
265                    let snapshot =
266                        snapshot.filter(|snapshot| snapshot.anchor().entity_id() == entity);
267                    let side = snapshot
268                        .and_then(|snapshot| snapshot.anchor().content_key())
269                        .map(|key| decode_key(key.value()).1);
270                    geometry
271                        .borrow()
272                        .key_at(point, side)
273                        .map(TextSelectionContentKey::new)
274                        .or_else(|| {
275                            snapshot
276                                .filter(|snapshot| snapshot.cursor().entity_id() == entity)
277                                .and_then(|snapshot| snapshot.cursor().content_key())
278                        })
279                }
280            },
281            cx,
282        );
283        selection.copy_with(
284            {
285                let weak = weak.clone();
286                move |cx| {
287                    weak.upgrade()
288                        .map_or_else(String::new, |state| state.read(cx).selected_text(cx))
289                }
290            },
291            cx,
292        );
293        selection.focus_with(
294            {
295                let focus = focus.clone();
296                move |window, cx| window.focus(&focus, cx)
297            },
298            cx,
299        );
300        let _selection_subscription = selection.subscribe(
301            move |event, cx| {
302                let _ = weak.update(cx, |state, cx| {
303                    if !state.selection.has_local_selection(cx) {
304                        match event {
305                            TextSelectionEvent::SelectionChanged(Some(_)) => {
306                                let changed = state.selected_lines.take().is_some();
307                                // A new text gesture ends the previous gutter anchor.
308                                state.selection_anchor = None;
309                                state.selection_cursor = None;
310                                if changed {
311                                    cx.emit(DiffEvent::SelectionChanged(None));
312                                }
313                            }
314                            TextSelectionEvent::Cleared => {
315                                // A replacement gutter gesture has local selection and
316                                // skips this branch. Keep its anchor for Shift-click,
317                                // but report a clear that leaves no replacement range.
318                                if state.selected_lines.take().is_some() {
319                                    cx.emit(DiffEvent::SelectionChanged(None));
320                                }
321                            }
322                            _ => {}
323                        }
324                    }
325                    cx.notify();
326                });
327            },
328            cx,
329        );
330        let files = files.into_iter().collect::<Vec<_>>();
331        let mut state = Self {
332            expanded: vec![Vec::new(); files.len()],
333            presentations: vec![None; files.len()],
334            file_ixs: file_ixs(&files),
335            files,
336            mode: DiffMode::Unified,
337            context_lines: Some(3),
338            expansion_lines: 20,
339            min_collapsed_lines: 2,
340            collapsed: HashSet::new(),
341            resolutions: HashMap::new(),
342            file_rows: Vec::new(),
343            header_rows: Vec::new(),
344            rows: Rc::default(),
345            change_rows: Vec::new(),
346            list: ListState::new(0, ListAlignment::Top, px(0.)),
347            horizontal_scroll: ScrollHandle::new(),
348            focus,
349            selection,
350            geometry,
351            selected_lines: None,
352            selection_anchor: None,
353            selection_cursor: None,
354            selecting: false,
355            presentation_options: PresentationOptions {
356                inline_unit: Some(DiffInlineUnit::Word),
357                inline_max_line_length: 1000,
358                syntax_max_line_length: 1000,
359            },
360            viewport: Size::default(),
361            layout: None,
362            content_width: px(0.),
363            row_height: None,
364            annotations: HashSet::new(),
365            window: None,
366            presentation_generation: 0,
367            preparing: None,
368            _presentation: Task::ready(()),
369            _selection_subscription,
370        };
371        state.rebuild(true);
372        state
373    }
374
375    /// Sets the initial display mode. Default is [`DiffMode::Unified`].
376    pub fn with_mode(mut self, mode: DiffMode) -> Self {
377        self.mode = mode;
378        self.rebuild(true);
379        self
380    }
381
382    /// Sets the initial unchanged context around each change. Default is
383    /// `Some(3)`; `None` shows every supplied patch line.
384    pub fn with_context_lines(mut self, lines: Option<usize>) -> Self {
385        self.context_lines = lines;
386        self.rebuild(true);
387        self
388    }
389
390    /// Sets how many lines an up or down disclosure reveals. Default is 20.
391    pub fn with_expansion_lines(mut self, lines: usize) -> Self {
392        self.expansion_lines = lines.max(1);
393        self
394    }
395
396    /// Sets the shortest unchanged range that collapses; shorter ranges stay
397    /// visible. Default is 2.
398    pub fn with_min_collapsed_lines(mut self, lines: usize) -> Self {
399        self.min_collapsed_lines = lines.max(1);
400        self.rebuild(true);
401        self
402    }
403
404    /// Sets the unit inline changes between paired lines are emphasized in,
405    /// or `None` to emphasize whole lines only. Default is [`DiffInlineUnit::Word`].
406    pub fn with_inline_unit(mut self, unit: Option<DiffInlineUnit>) -> Self {
407        self.presentation_options.inline_unit = unit;
408        self.reset_presentation();
409        self
410    }
411
412    /// Sets the longest line, in bytes, compared for inline changes. Default is 1,000.
413    pub fn with_inline_max_line_length(mut self, length: usize) -> Self {
414        self.presentation_options.inline_max_line_length = length;
415        self.reset_presentation();
416        self
417    }
418
419    /// Sets the longest line, in bytes, given syntax emphasis. Default is 1,000.
420    pub fn with_syntax_max_line_length(mut self, length: usize) -> Self {
421        self.presentation_options.syntax_max_line_length = length;
422        self.reset_presentation();
423        self
424    }
425
426    /// Changes the number of lines each disclosure reveals, preserving selection and scrolling.
427    pub fn set_expansion_lines(&mut self, lines: usize, cx: &mut Context<Self>) {
428        let lines = lines.max(1);
429        if self.expansion_lines == lines {
430            return;
431        }
432        self.expansion_lines = lines;
433        cx.notify();
434    }
435
436    /// Changes the shortest unchanged range that collapses, preserving selection and scrolling.
437    pub fn set_min_collapsed_lines(&mut self, lines: usize, cx: &mut Context<Self>) {
438        let lines = lines.max(1);
439        if self.min_collapsed_lines == lines {
440            return;
441        }
442        self.min_collapsed_lines = lines;
443        self.rebuild_at_anchor();
444        cx.notify();
445    }
446
447    /// Changes the inline comparison unit without resetting selection or scrolling.
448    pub fn set_inline_unit(&mut self, unit: Option<DiffInlineUnit>, cx: &mut Context<Self>) {
449        if self.presentation_options.inline_unit == unit {
450            return;
451        }
452        self.presentation_options.inline_unit = unit;
453        self.reset_presentation();
454        self.ensure_presentation(cx);
455        cx.notify();
456    }
457
458    /// Changes the inline comparison line limit in bytes without resetting selection or scrolling.
459    pub fn set_inline_max_line_length(&mut self, length: usize, cx: &mut Context<Self>) {
460        if self.presentation_options.inline_max_line_length == length {
461            return;
462        }
463        self.presentation_options.inline_max_line_length = length;
464        self.reset_presentation();
465        self.ensure_presentation(cx);
466        cx.notify();
467    }
468
469    /// Changes the syntax emphasis line limit in bytes without resetting selection or scrolling.
470    pub fn set_syntax_max_line_length(&mut self, length: usize, cx: &mut Context<Self>) {
471        if self.presentation_options.syntax_max_line_length == length {
472            return;
473        }
474        self.presentation_options.syntax_max_line_length = length;
475        self.reset_presentation();
476        self.ensure_presentation(cx);
477        cx.notify();
478    }
479
480    /// The files in patch order.
481    pub fn files(&self) -> &[DiffFile] {
482        &self.files
483    }
484    pub fn mode(&self) -> DiffMode {
485        self.mode
486    }
487    pub fn context_lines(&self) -> Option<usize> {
488        self.context_lines
489    }
490    pub fn expansion_lines(&self) -> usize {
491        self.expansion_lines
492    }
493    pub fn min_collapsed_lines(&self) -> usize {
494        self.min_collapsed_lines
495    }
496    pub fn inline_unit(&self) -> Option<DiffInlineUnit> {
497        self.presentation_options.inline_unit
498    }
499    pub fn inline_max_line_length(&self) -> usize {
500        self.presentation_options.inline_max_line_length
501    }
502    pub fn syntax_max_line_length(&self) -> usize {
503        self.presentation_options.syntax_max_line_length
504    }
505    pub fn selected_lines(&self) -> Option<DiffLineRange> {
506        self.selected_lines.clone()
507    }
508    /// Whether the file at `path` shows only its header.
509    pub fn is_file_collapsed(&self, path: &str) -> bool {
510        self.collapsed.contains(path)
511    }
512
513    pub(crate) fn rows(&self) -> &Rc<Vec<DisplayRow>> {
514        &self.rows
515    }
516    pub(crate) fn list(&self) -> &ListState {
517        &self.list
518    }
519    pub(crate) fn horizontal_scroll(&self) -> &ScrollHandle {
520        &self.horizontal_scroll
521    }
522    pub(crate) fn selection(&self) -> &TextSelectionHandle {
523        &self.selection
524    }
525    pub(crate) fn geometry(&self) -> &Rc<RefCell<SelectionGeometry>> {
526        &self.geometry
527    }
528    pub(crate) fn presentations(&self) -> &[Option<Arc<FilePresentation>>] {
529        &self.presentations
530    }
531    /// The body's size as of the last paint.
532    pub(crate) fn viewport(&self) -> Size<Pixels> {
533        self.viewport
534    }
535    /// The width rows need to show their longest line.
536    pub(crate) fn content_width(&self) -> Pixels {
537        self.content_width
538    }
539    pub(crate) fn set_content_width(&mut self, width: Pixels) {
540        self.content_width = width;
541    }
542    /// Records the painted body size and widest painted row. Returns whether
543    /// either changed, so the frame must be laid out again.
544    pub(crate) fn record_paint(&mut self, viewport: Size<Pixels>, painted_width: Pixels) -> bool {
545        let wider = painted_width > self.content_width;
546        if wider {
547            self.content_width = painted_width;
548        }
549        let resized = self.viewport != viewport;
550        self.viewport = viewport;
551        wider || resized
552    }
553
554    /// Replaces the files and resets expansion, selection and viewport.
555    /// Collapsed files stay collapsed when their path is still present.
556    pub fn set_files(&mut self, files: impl IntoIterator<Item = DiffFile>, cx: &mut Context<Self>) {
557        if let Some(window) = self.window
558            && self.selection_is_local(cx)
559        {
560            TextSelection::clear_for_window(window, cx);
561        }
562        self.files = files.into_iter().collect();
563        self.file_ixs = file_ixs(&self.files);
564        self.collapsed
565            .retain(|path| self.file_ixs.contains_key(path));
566        self.resolutions.clear();
567        self.expanded = vec![Vec::new(); self.files.len()];
568        self.reset_presentation();
569        self.layout = None;
570        self.annotations.clear();
571        self.selection.set_local_selection(false, cx);
572        self.selection.set_fallback_copy_text("", cx);
573        self.selected_lines = None;
574        self.selection_anchor = None;
575        self.selection_cursor = None;
576        self.selecting = false;
577        self.geometry.borrow_mut().clear();
578        self.rebuild(true);
579        self.horizontal_scroll.set_offset(point(px(0.), px(0.)));
580        self.ensure_presentation(cx);
581        cx.notify();
582    }
583
584    /// Changes the display mode while preserving source-line selection and a
585    /// nearby source row.
586    pub fn set_mode(&mut self, mode: DiffMode, cx: &mut Context<Self>) {
587        if self.mode == mode {
588            return;
589        }
590        self.mode = mode;
591        self.rebuild_at_anchor();
592        self.horizontal_scroll.set_offset(point(px(0.), px(0.)));
593        cx.notify();
594    }
595
596    /// Sets surrounding context. `None` shows every supplied patch line.
597    pub fn set_context_lines(&mut self, lines: Option<usize>, cx: &mut Context<Self>) {
598        if self.context_lines == lines {
599            return;
600        }
601        self.context_lines = lines;
602        self.expanded.iter_mut().for_each(Vec::clear);
603        self.rebuild_at_anchor();
604        cx.notify();
605    }
606
607    /// Reveals all unchanged source while preserving the configured context count.
608    pub fn expand_unchanged(&mut self, cx: &mut Context<Self>) {
609        for (expanded, file) in self.expanded.iter_mut().zip(&self.files) {
610            *expanded = vec![0..file.pairs().len()];
611        }
612        self.rebuild_at_anchor();
613        cx.notify();
614    }
615
616    /// Restores the configured context disclosures.
617    pub fn collapse_unchanged(&mut self, cx: &mut Context<Self>) {
618        self.expanded.iter_mut().for_each(Vec::clear);
619        self.rebuild_at_anchor();
620        cx.notify();
621    }
622
623    /// Collapses the file at `path` to its header, or expands it, without
624    /// emitting a user event.
625    pub fn set_file_collapsed(&mut self, path: &str, collapsed: bool, cx: &mut Context<Self>) {
626        let Some(file) = self.file_ix(path) else {
627            return;
628        };
629        let path = self.files[file].path().clone();
630        let changed = if collapsed {
631            self.collapsed.insert(path)
632        } else {
633            self.collapsed.remove(&path)
634        };
635        if changed {
636            self.rebuild_at_anchor();
637            cx.notify();
638        }
639    }
640
641    /// The resolution of conflict `ix` in the file at `path`, if any.
642    /// Use [`DiffFile::conflicts`] to enumerate valid indices; `None` means
643    /// unresolved, or that the file or index does not exist.
644    pub fn conflict_resolution(&self, path: &str, ix: usize) -> Option<DiffConflictResolution> {
645        let file = &self.files[self.file_ix(path)?];
646        self.resolutions.get(&(file.path().clone(), ix)).copied()
647    }
648
649    /// Resolves conflict `ix` (an index in [`DiffFile::conflicts`]) in the file
650    /// at `path`, or clears its resolution
651    /// with `None`, without emitting a user event.
652    pub fn resolve_conflict(
653        &mut self,
654        path: &str,
655        ix: usize,
656        resolution: Option<DiffConflictResolution>,
657        cx: &mut Context<Self>,
658    ) {
659        let Some(file) = self.file_ix(path) else {
660            return;
661        };
662        if ix >= self.files[file].conflicts().len() {
663            return;
664        }
665        let key = (self.files[file].path().clone(), ix);
666        let previous = match resolution {
667            Some(resolution) => self.resolutions.insert(key, resolution),
668            None => self.resolutions.remove(&key),
669        };
670        if previous != resolution {
671            self.rebuild_at_anchor();
672            cx.notify();
673        }
674    }
675
676    /// The working file with every conflict replaced by its resolution, or
677    /// `None` while a conflict is unresolved or `path` names no conflicted file.
678    pub fn resolved_text(&self, path: &str) -> Option<String> {
679        let file = &self.files[self.file_ix(path)?];
680        if file.status() != DiffFileStatus::Conflicted {
681            return None;
682        }
683        let lines = file.lines(DiffSide::Modified);
684        let source = file.source(DiffSide::Modified);
685        let mut text = String::with_capacity(source.len());
686        let mut ix = 0;
687        for (conflict_ix, conflict) in file.conflicts().iter().enumerate() {
688            let resolution = *self.resolutions.get(&(file.path().clone(), conflict_ix))?;
689            for line in &lines[ix..conflict.source_lines().start] {
690                text.push_str(&source[line.source()]);
691            }
692            for range in conflict.kept(resolution) {
693                for line in &lines[range] {
694                    text.push_str(&source[line.source()]);
695                }
696            }
697            ix = conflict.source_lines().end;
698        }
699        for line in &lines[ix..] {
700            text.push_str(&source[line.source()]);
701        }
702        Some(text)
703    }
704
705    /// Synchronizes source-line selection without emitting a user event.
706    /// Clips to supplied patch lines; a range containing none clears selection.
707    pub fn set_selected_lines(&mut self, range: Option<DiffLineRange>, cx: &mut Context<Self>) {
708        let range = range.and_then(|range| self.clip_range(&range));
709        if self.selected_lines == range {
710            return;
711        }
712        self.selection_anchor = range.as_ref().map(DiffLineRange::start_position);
713        self.selection_cursor = range.as_ref().map(DiffLineRange::end_position);
714        self.selection.set_local_selection(range.is_some(), cx);
715        self.selected_lines = range;
716        cx.notify();
717    }
718
719    /// Returns selected source without gutters, diff markers or annotation content.
720    pub fn selected_text(&self, cx: &App) -> String {
721        if self.selection.has_local_selection(cx)
722            && let Some(selected) = self.selected_span()
723        {
724            return match selected {
725                SelectedLines::Side { file, side, lines } => {
726                    self.files[file].text_for_lines(side, lines.start, lines.end - 1)
727                }
728                SelectedLines::Patch { file, patch } => {
729                    let file = &self.files[file];
730                    let mut text = String::new();
731                    for line in &file.patch_lines()[patch] {
732                        // An unchanged line reads the same on both sides.
733                        let (side, ix) = match line.line(DiffSide::Modified) {
734                            Some(ix) => (DiffSide::Modified, ix),
735                            None => (
736                                DiffSide::Original,
737                                line.line(DiffSide::Original).unwrap_or(0),
738                            ),
739                        };
740                        // A side's unterminated final line can precede the
741                        // other side's lines in patch order.
742                        if !text.is_empty() && !text.ends_with('\n') {
743                            text.push('\n');
744                        }
745                        text.push_str(&file.source(side)[file.lines(side)[ix].source()]);
746                    }
747                    text
748                }
749            };
750        }
751        let Some((file, side, range)) =
752            super::selection::selected_source_range(&self.selection, cx)
753        else {
754            return String::new();
755        };
756        self.files
757            .get(file)
758            .and_then(|file| file.source(side).get(range))
759            .unwrap_or("")
760            .to_owned()
761    }
762
763    /// Reveals and scrolls to a source line, expanding its file and hidden
764    /// context if needed.
765    pub fn scroll_to_line(&mut self, position: DiffLinePosition, cx: &mut Context<Self>) {
766        if self.line_index(&position).is_none() {
767            return;
768        }
769        if self.collapsed.remove(position.path().as_str()) {
770            self.rebuild_at_anchor();
771        }
772        if self.expand_line(&position).is_some() {
773            self.reveal_line(&position);
774            cx.notify();
775        }
776    }
777
778    /// Scrolls to the header of the file at `path`, a [`DiffFile::path`].
779    pub fn scroll_to_file(&mut self, path: &str, cx: &mut Context<Self>) {
780        let Some(item_ix) = self
781            .file_ix(path)
782            .and_then(|file| self.header_rows.get(file).copied())
783        else {
784            return;
785        };
786        self.list.scroll_to(ListOffset {
787            item_ix,
788            offset_in_item: px(0.),
789        });
790        cx.notify();
791    }
792
793    /// Moves to the next changed group after the current viewport, across files.
794    pub fn next_change(&mut self, cx: &mut Context<Self>) {
795        self.move_change(true, cx);
796    }
797    /// Moves to the previous changed group before the current viewport, across files.
798    pub fn previous_change(&mut self, cx: &mut Context<Self>) {
799        self.move_change(false, cx);
800    }
801
802    /// Reveals part or all of a collapsed unchanged range.
803    pub(crate) fn expand_fold(
804        &mut self,
805        file: usize,
806        pairs: Range<usize>,
807        expansion: FoldExpansion,
808        cx: &mut Context<Self>,
809    ) {
810        let lines = self.expansion_lines;
811        let revealed = match expansion {
812            FoldExpansion::All => pairs,
813            FoldExpansion::Up => pairs.end.saturating_sub(lines).max(pairs.start)..pairs.end,
814            FoldExpansion::Down => pairs.start..(pairs.start + lines).min(pairs.end),
815        };
816        if let Some(expanded) = self.expanded.get_mut(file) {
817            expanded.push(revealed);
818            self.rebuild_at_anchor();
819            cx.notify();
820        }
821    }
822
823    /// Resolves a conflict from its heading and reports the user's change.
824    pub(crate) fn choose_conflict(
825        &mut self,
826        file: usize,
827        ix: usize,
828        resolution: Option<DiffConflictResolution>,
829        cx: &mut Context<Self>,
830    ) {
831        let path = self.files[file].path().clone();
832        self.resolve_conflict(&path, ix, resolution, cx);
833        cx.emit(DiffEvent::ConflictResolved(path, ix));
834    }
835
836    /// Toggles a file from its header and reports the user's change.
837    pub(crate) fn toggle_file_collapsed(&mut self, file: usize, cx: &mut Context<Self>) {
838        let path = self.files[file].path().clone();
839        let collapsed = !self.collapsed.contains(&path);
840        self.set_file_collapsed(&path, collapsed, cx);
841        cx.emit(if collapsed {
842            DiffEvent::FileCollapsed(path)
843        } else {
844            DiffEvent::FileExpanded(path)
845        });
846    }
847
848    /// Starts a line-number selection gesture, extending the current anchor in
849    /// the same file when `extend` is set.
850    pub(crate) fn begin_line_selection(
851        &mut self,
852        position: DiffLinePosition,
853        extend: bool,
854        window: &mut Window,
855        cx: &mut Context<Self>,
856    ) {
857        if self.line_index(&position).is_none() {
858            return;
859        }
860        let anchor = self
861            .selection_anchor
862            .clone()
863            .filter(|anchor| extend && self.can_extend(anchor, &position))
864            .unwrap_or_else(|| position.clone());
865        TextSelection::clear(window, cx);
866        window.focus(&self.focus, cx);
867        self.selecting = true;
868        cx.emit(DiffEvent::SelectionStarted(range_between(
869            &anchor, &position,
870        )));
871        self.select_user_range(anchor, position, cx);
872    }
873
874    /// Extends an active line-number gesture to `position` in the same file.
875    pub(crate) fn drag_line_selection(
876        &mut self,
877        position: DiffLinePosition,
878        cx: &mut Context<Self>,
879    ) {
880        let Some(anchor) = self.selection_anchor.clone() else {
881            return;
882        };
883        if !self.selecting
884            || !self.can_extend(&anchor, &position)
885            || self.selection_cursor.as_ref() == Some(&position)
886            || self.line_index(&position).is_none()
887        {
888            return;
889        }
890        self.select_user_range(anchor, position, cx);
891    }
892
893    /// Whether a selection anchored at `anchor` may extend to `position`:
894    /// within one file, and across sides only in Unified mode, whose rows
895    /// follow patch order.
896    fn can_extend(&self, anchor: &DiffLinePosition, position: &DiffLinePosition) -> bool {
897        anchor.path() == position.path()
898            && (anchor.side() == position.side() || self.mode == DiffMode::Unified)
899    }
900
901    /// Finishes an active line-number gesture.
902    pub(crate) fn end_line_selection(&mut self, cx: &mut Context<Self>) {
903        if !std::mem::take(&mut self.selecting) {
904            return;
905        }
906        if let Some(range) = self.selected_lines.clone() {
907            cx.emit(DiffEvent::SelectionEnded(range));
908        }
909    }
910
911    /// Selects one line, or extends to it, as a complete gesture.
912    #[cfg(test)]
913    pub(crate) fn click_line(
914        &mut self,
915        position: DiffLinePosition,
916        extend: bool,
917        window: &mut Window,
918        cx: &mut Context<Self>,
919    ) {
920        self.begin_line_selection(position, extend, window, cx);
921        self.end_line_selection(cx);
922    }
923
924    pub(crate) fn keyboard_select(
925        &mut self,
926        direction: isize,
927        extend: bool,
928        window: &mut Window,
929        cx: &mut Context<Self>,
930    ) {
931        let Some((file, side)) = self.active_side(cx) else {
932            return;
933        };
934        let count = self.files[file].lines_count(side);
935        let ix = self
936            .selection_cursor
937            .as_ref()
938            .filter(|position| {
939                position.side() == side && self.file_ix(position.path()) == Some(file)
940            })
941            .and_then(|position| self.line_index(position))
942            .map(|ix| ix.saturating_add_signed(direction).min(count - 1))
943            .unwrap_or(0);
944        let position = self.position(file, side, ix);
945        let anchor = self
946            .selection_anchor
947            .clone()
948            .filter(|anchor| extend && anchor.path() == position.path())
949            .unwrap_or_else(|| position.clone());
950        TextSelection::clear(window, cx);
951        window.focus(&self.focus, cx);
952        let range = self.select_user_range(anchor, position.clone(), cx);
953        cx.emit(DiffEvent::SelectionEnded(range));
954        if let Some(row) = self.expand_line(&position) {
955            self.list.scroll_to_reveal_item(row);
956        }
957    }
958
959    pub(crate) fn select_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
960        let Some((file, side)) = self.active_side(cx) else {
961            return;
962        };
963        let count = self.files[file].lines_count(side);
964        TextSelection::clear(window, cx);
965        window.focus(&self.focus, cx);
966        let range = self.select_user_range(
967            self.position(file, side, 0),
968            self.position(file, side, count - 1),
969            cx,
970        );
971        cx.emit(DiffEvent::SelectionEnded(range));
972    }
973
974    /// The selected lines, resolved for membership tests.
975    pub(crate) fn selected_span(&self) -> Option<SelectedLines> {
976        let range = self.selected_lines.as_ref()?;
977        let file = self.file_ix(range.path())?;
978        if range.is_single_side() {
979            let (start, end) = (
980                range.start().min(range.end()),
981                range.start().max(range.end()),
982            );
983            return Some(SelectedLines::Side {
984                file,
985                side: range.side(),
986                lines: start..end + 1,
987            });
988        }
989        let document = &self.files[file];
990        let start = document.patch_ix(
991            range.side(),
992            document.line_index(range.side(), range.start())?,
993        );
994        let end = document.patch_ix(
995            range.end_side(),
996            document.line_index(range.end_side(), range.end())?,
997        );
998        Some(SelectedLines::Patch {
999            file,
1000            patch: start.min(end)..start.max(end) + 1,
1001        })
1002    }
1003
1004    /// Records the window that renders this state, for clearing its text selection.
1005    pub(crate) fn set_window(&mut self, window: WindowId) {
1006        self.window = Some(window);
1007    }
1008
1009    /// Updates width and row-height measurement when fonts or layout change.
1010    /// Returns whether content width must be measured again.
1011    pub(crate) fn update_layout(&mut self, key: LayoutKey, row_height: Pixels) -> bool {
1012        if self.layout.as_ref() == Some(&key) {
1013            return false;
1014        }
1015        if self.layout.is_some() {
1016            self.list.remeasure();
1017        }
1018        self.layout = Some(key);
1019        self.row_height = Some(row_height);
1020        self.apply_row_height_hint();
1021        true
1022    }
1023
1024    /// Re-measures rows whose annotations were added, removed or replaced.
1025    /// Visible rows are re-measured every frame, so content changes inside an
1026    /// annotation need no notification.
1027    pub(crate) fn sync_annotations(&mut self, annotations: &[DiffAnnotation]) {
1028        let current = annotations
1029            .iter()
1030            .map(|annotation| {
1031                (
1032                    annotation.position().cloned(),
1033                    annotation.path().clone(),
1034                    annotation.id().clone(),
1035                )
1036            })
1037            .collect::<HashSet<_>>();
1038        if current == self.annotations {
1039            return;
1040        }
1041        let rows = current
1042            .symmetric_difference(&self.annotations)
1043            .filter_map(|(position, path, _)| match position {
1044                Some(position) => self.source_row(position),
1045                None => self
1046                    .file_ix(path)
1047                    .and_then(|file| self.header_rows.get(file).copied()),
1048            })
1049            .collect::<Vec<_>>();
1050        for row in rows {
1051            self.list.remeasure_items(row..row + 1);
1052        }
1053        self.annotations = current;
1054    }
1055
1056    pub(crate) fn position(&self, file: usize, side: DiffSide, ix: usize) -> DiffLinePosition {
1057        let document = &self.files[file];
1058        DiffLinePosition::new(
1059            document.path().clone(),
1060            side,
1061            document.line_number(side, ix),
1062        )
1063    }
1064
1065    fn file_ix(&self, path: &str) -> Option<usize> {
1066        self.file_ixs.get(path).copied()
1067    }
1068
1069    fn line_index(&self, position: &DiffLinePosition) -> Option<usize> {
1070        self.files[self.file_ix(position.path())?].line_index(position.side(), position.line())
1071    }
1072
1073    /// Clips a range to supplied lines, normalizing a single-side range.
1074    fn clip_range(&self, range: &DiffLineRange) -> Option<DiffLineRange> {
1075        let file = &self.files[self.file_ix(range.path())?];
1076        let first_at_or_after = |side: DiffSide, line: usize| {
1077            let lines = file.lines(side);
1078            lines
1079                .get(lines.partition_point(|source| source.line_number() < line))
1080                .map(|source| source.line_number())
1081        };
1082        let last_at_or_before = |side: DiffSide, line: usize| {
1083            let lines = file.lines(side);
1084            lines
1085                .partition_point(|source| source.line_number() <= line)
1086                .checked_sub(1)
1087                .map(|ix| lines[ix].line_number())
1088        };
1089        if range.is_single_side() {
1090            let (start, end) = (
1091                range.start().min(range.end()),
1092                range.start().max(range.end()),
1093            );
1094            let start = first_at_or_after(range.side(), start)?;
1095            let end = last_at_or_before(range.side(), end)?;
1096            return (start <= end)
1097                .then(|| DiffLineRange::new(range.path().clone(), range.side(), start, end));
1098        }
1099        let start = first_at_or_after(range.side(), range.start())
1100            .or_else(|| last_at_or_before(range.side(), range.start()))?;
1101        let end = last_at_or_before(range.end_side(), range.end())
1102            .or_else(|| first_at_or_after(range.end_side(), range.end()))?;
1103        Some(
1104            DiffLineRange::new(range.path().clone(), range.side(), start, end)
1105                .with_end_side(range.end_side()),
1106        )
1107    }
1108
1109    fn selection_is_local(&self, cx: &App) -> bool {
1110        let entity = Some(self.selection.entity_id());
1111        self.selection
1112            .snapshot(cx)
1113            .is_some_and(|snapshot| snapshot.anchor().entity_id() == entity)
1114    }
1115
1116    /// The file and side that keyboard selection and Select All act on.
1117    fn active_side(&self, cx: &App) -> Option<(usize, DiffSide)> {
1118        let (file, side) = self
1119            .selection_cursor
1120            .as_ref()
1121            .and_then(|cursor| Some((self.file_ix(cursor.path())?, Some(cursor.side()))))
1122            .filter(|_| self.selected_lines.is_some())
1123            .or_else(|| {
1124                super::selection::selected_source_range(&self.selection, cx)
1125                    .map(|(file, side, _)| (file, Some(side)))
1126            })
1127            .or_else(|| {
1128                let top = self.list.logical_scroll_top().item_ix;
1129                self.rows.get(top).map(|row| (row.file(), None))
1130            })?;
1131        let document = self.files.get(file)?;
1132        let side = side.unwrap_or(if document.lines_count(DiffSide::Modified) > 0 {
1133            DiffSide::Modified
1134        } else {
1135            DiffSide::Original
1136        });
1137        (document.lines_count(side) > 0).then_some((file, side))
1138    }
1139
1140    fn expand_line(&mut self, position: &DiffLinePosition) -> Option<usize> {
1141        let row = self.source_row(position)?;
1142        if let Some(DisplayRow::Fold { file, pairs }) = self.rows.get(row) {
1143            let (file, pairs) = (*file, pairs.clone());
1144            self.expanded[file].push(pairs);
1145            self.rebuild_at_anchor();
1146        }
1147        self.source_row(position)
1148    }
1149
1150    /// Selects from `anchor` to `cursor` in one file and reports a change.
1151    fn select_user_range(
1152        &mut self,
1153        anchor: DiffLinePosition,
1154        cursor: DiffLinePosition,
1155        cx: &mut Context<Self>,
1156    ) -> DiffLineRange {
1157        let range = range_between(&anchor, &cursor);
1158        let changed = self.selected_lines.as_ref() != Some(&range);
1159        self.selected_lines = Some(range.clone());
1160        self.selection_anchor = Some(anchor);
1161        self.selection_cursor = Some(cursor);
1162        self.selection.set_local_selection(true, cx);
1163        if changed {
1164            cx.emit(DiffEvent::SelectionChanged(Some(range.clone())));
1165        }
1166        cx.notify();
1167        range
1168    }
1169
1170    fn move_change(&mut self, forward: bool, cx: &mut Context<Self>) {
1171        let top = self.list.logical_scroll_top().item_ix;
1172        let groups = &self.change_rows;
1173        let target = if forward {
1174            groups
1175                .get(groups.partition_point(|ix| *ix <= top))
1176                .copied()
1177                .or(groups.first().copied())
1178        } else {
1179            groups
1180                .partition_point(|ix| *ix < top)
1181                .checked_sub(1)
1182                .and_then(|ix| groups.get(ix))
1183                .copied()
1184                .or(groups.last().copied())
1185        };
1186        if let Some(ix) = target {
1187            self.list.scroll_to(ListOffset {
1188                item_ix: ix,
1189                offset_in_item: px(0.),
1190            });
1191            cx.notify();
1192        }
1193    }
1194
1195    /// The source line shown by a row, looking past headers within its file.
1196    fn row_position(&self, ix: usize) -> Option<DiffLinePosition> {
1197        let row = self.rows.get(ix)?;
1198        let file = row.file();
1199        let pair_position = |original: Option<usize>, modified: Option<usize>| {
1200            modified
1201                .map(|ix| self.position(file, DiffSide::Modified, ix))
1202                .or_else(|| original.map(|ix| self.position(file, DiffSide::Original, ix)))
1203        };
1204        match row {
1205            DisplayRow::Code {
1206                original, modified, ..
1207            } => pair_position(*original, *modified),
1208            DisplayRow::Fold { pairs, .. } => self.files[file]
1209                .pairs()
1210                .get(pairs.start)
1211                .and_then(|pair| pair_position(pair.original(), pair.modified())),
1212            DisplayRow::File(_)
1213            | DisplayRow::Notice(_)
1214            | DisplayRow::Hunk { .. }
1215            | DisplayRow::Conflict { .. } => self
1216                .rows
1217                .get(ix + 1)
1218                .filter(|next| next.file() == file)
1219                .and_then(|_| self.row_position(ix + 1)),
1220        }
1221    }
1222
1223    pub(crate) fn source_row(&self, position: &DiffLinePosition) -> Option<usize> {
1224        let rows = self.file_rows.get(self.file_ix(position.path())?)?;
1225        let rows = match position.side() {
1226            DiffSide::Original => &rows.original,
1227            DiffSide::Modified => &rows.modified,
1228        };
1229        rows.get(self.line_index(position)?).copied().flatten()
1230    }
1231
1232    fn reveal_line(&self, position: &DiffLinePosition) {
1233        if let Some(item_ix) = self.source_row(position) {
1234            self.list.scroll_to(ListOffset {
1235                item_ix,
1236                offset_in_item: px(0.),
1237            });
1238        }
1239    }
1240
1241    /// Rebuilds rows and restores the source line at the top of the viewport.
1242    fn rebuild_at_anchor(&mut self) {
1243        let anchor = self.row_position(self.list.logical_scroll_top().item_ix);
1244        self.rebuild(false);
1245        if let Some(anchor) = anchor {
1246            self.reveal_line(&anchor);
1247        }
1248    }
1249
1250    /// Projects rows. Without `reset`, only the changed middle is spliced so
1251    /// measured heights of unchanged rows survive disclosure changes.
1252    fn rebuild(&mut self, reset: bool) {
1253        let rows = self.project_rows();
1254        self.file_rows = source_rows(&self.files, &rows);
1255        self.change_rows = changed_groups(&rows);
1256        self.header_rows = rows
1257            .iter()
1258            .enumerate()
1259            .filter_map(|(ix, row)| matches!(row, DisplayRow::File(_)).then_some(ix))
1260            .collect();
1261        if reset {
1262            self.list.reset(rows.len());
1263        } else {
1264            let old = &self.rows;
1265            let prefix = old.iter().zip(&rows).take_while(|(a, b)| a == b).count();
1266            let suffix = old
1267                .iter()
1268                .rev()
1269                .zip(rows.iter().rev())
1270                .take(old.len().min(rows.len()) - prefix)
1271                .take_while(|(a, b)| a == b)
1272                .count();
1273            self.list
1274                .splice(prefix..old.len() - suffix, rows.len() - prefix - suffix);
1275        }
1276        self.rows = Rc::new(rows);
1277        self.apply_row_height_hint();
1278    }
1279
1280    fn project_rows(&self) -> Vec<DisplayRow> {
1281        let mut rows = Vec::new();
1282        for (ix, file) in self.files.iter().enumerate() {
1283            rows.push(DisplayRow::File(ix));
1284            if self.collapsed.contains(file.path()) {
1285                continue;
1286            }
1287            if file.pairs().is_empty() {
1288                rows.push(DisplayRow::Notice(ix));
1289            } else if file.status() == DiffFileStatus::Unchanged {
1290                rows.extend((0..file.pairs().len()).map(|line| DisplayRow::Code {
1291                    file: ix,
1292                    original: None,
1293                    modified: Some(line),
1294                    changed: false,
1295                }));
1296            } else if file.status() == DiffFileStatus::Conflicted {
1297                let resolutions = (0..file.conflicts().len())
1298                    .map(|conflict| {
1299                        self.resolutions
1300                            .get(&(file.path().clone(), conflict))
1301                            .copied()
1302                    })
1303                    .collect::<Vec<_>>();
1304                let visible = visible_pairs(
1305                    file,
1306                    self.context_lines,
1307                    self.min_collapsed_lines,
1308                    &self.expanded[ix],
1309                );
1310                project_conflicts(&mut rows, ix, file, &visible, &resolutions);
1311            } else {
1312                project_file(
1313                    &mut rows,
1314                    ix,
1315                    file,
1316                    self.mode,
1317                    self.context_lines,
1318                    self.min_collapsed_lines,
1319                    &self.expanded[ix],
1320                );
1321            }
1322        }
1323        rows
1324    }
1325
1326    /// Gives unmeasured rows a code-row height so the scrollbar is sized for
1327    /// the whole patch before every row has been rendered.
1328    fn apply_row_height_hint(&self) {
1329        if let Some(height) = self.row_height {
1330            let _ = self.list.clone().with_uniform_item_height(height);
1331        }
1332    }
1333
1334    /// Discards prepared emphasis after files or options change.
1335    fn reset_presentation(&mut self) {
1336        self.presentations = vec![None; self.files.len()];
1337        self.presentation_generation += 1;
1338        self.preparing = None;
1339        self._presentation = Task::ready(());
1340    }
1341
1342    /// Prepares syntax and inline emphasis for files that lack it, one file at
1343    /// a time on the background executor, reusing highlighters per language.
1344    /// Starts on first render, so builders applied after `new` take effect.
1345    pub(crate) fn ensure_presentation(&mut self, cx: &mut Context<Self>) {
1346        let generation = self.presentation_generation;
1347        if self.preparing == Some(generation) {
1348            return;
1349        }
1350        let pending = self
1351            .presentations
1352            .iter()
1353            .enumerate()
1354            .filter(|(_, presentation)| presentation.is_none())
1355            .map(|(ix, _)| (ix, self.files[ix].clone()))
1356            .collect::<Vec<_>>();
1357        if pending.is_empty() {
1358            return;
1359        }
1360        let options = self.presentation_options;
1361        self.preparing = Some(generation);
1362        self._presentation = cx.spawn(async move |this, cx| {
1363            let mut highlighters = Some(SyntaxHighlighters::default());
1364            for (ix, file) in pending {
1365                let mut moved = highlighters.take();
1366                let (presentation, returned) = cx
1367                    .background_spawn(async move {
1368                        let presentation = FilePresentation::prepare(
1369                            &file,
1370                            options,
1371                            moved.get_or_insert_default(),
1372                        );
1373                        (presentation, moved)
1374                    })
1375                    .await;
1376                highlighters = returned;
1377                let current = this.update(cx, |state, cx| {
1378                    if state.presentation_generation != generation {
1379                        return false;
1380                    }
1381                    if let Some(slot) = state.presentations.get_mut(ix) {
1382                        *slot = Some(Arc::new(presentation));
1383                    }
1384                    cx.notify();
1385                    true
1386                });
1387                if !matches!(current, Ok(true)) {
1388                    return;
1389                }
1390            }
1391        });
1392    }
1393}
1394
1395/// The range from `anchor` to `cursor`, normalized when both are on one side.
1396fn range_between(anchor: &DiffLinePosition, cursor: &DiffLinePosition) -> DiffLineRange {
1397    if anchor.side() == cursor.side() {
1398        DiffLineRange::new(
1399            cursor.path().clone(),
1400            cursor.side(),
1401            anchor.line().min(cursor.line()),
1402            anchor.line().max(cursor.line()),
1403        )
1404    } else {
1405        DiffLineRange::new(
1406            cursor.path().clone(),
1407            anchor.side(),
1408            anchor.line(),
1409            cursor.line(),
1410        )
1411        .with_end_side(cursor.side())
1412    }
1413}
1414
1415fn file_ixs(files: &[DiffFile]) -> HashMap<SharedString, usize> {
1416    let mut ixs = HashMap::with_capacity(files.len());
1417    for (ix, file) in files.iter().enumerate() {
1418        ixs.entry(file.path().clone()).or_insert(ix);
1419    }
1420    ixs
1421}
1422
1423/// Every source line maps to its code row or the disclosure hiding it; lines
1424/// of a collapsed file have no row. Disjoint folds visit each pair once, so
1425/// building the maps is linear.
1426fn source_rows(files: &[DiffFile], rows: &[DisplayRow]) -> Vec<FileRows> {
1427    let mut file_rows = files
1428        .iter()
1429        .map(|file| FileRows {
1430            original: vec![None; file.lines_count(DiffSide::Original)],
1431            modified: vec![None; file.lines_count(DiffSide::Modified)],
1432        })
1433        .collect::<Vec<_>>();
1434    for (row_ix, row) in rows.iter().enumerate() {
1435        let mut map = |file: usize, original: Option<usize>, modified: Option<usize>| {
1436            if let Some(line) = original {
1437                file_rows[file].original[line] = Some(row_ix);
1438            }
1439            if let Some(line) = modified {
1440                file_rows[file].modified[line] = Some(row_ix);
1441            }
1442        };
1443        match row {
1444            DisplayRow::Code {
1445                file,
1446                original,
1447                modified,
1448                ..
1449            } => map(*file, *original, *modified),
1450            DisplayRow::Fold { file, pairs } => {
1451                for pair in &files[*file].pairs()[pairs.clone()] {
1452                    map(*file, pair.original(), pair.modified());
1453                }
1454            }
1455            DisplayRow::File(_)
1456            | DisplayRow::Notice(_)
1457            | DisplayRow::Hunk { .. }
1458            | DisplayRow::Conflict { .. } => {}
1459        }
1460    }
1461    file_rows
1462}
1463
1464fn changed_groups(rows: &[DisplayRow]) -> Vec<usize> {
1465    let changed = |row: &DisplayRow| matches!(row, DisplayRow::Code { changed: true, .. });
1466    rows.iter()
1467        .enumerate()
1468        .filter_map(|(ix, row)| {
1469            (changed(row)
1470                && (ix == 0 || !changed(&rows[ix - 1]) || rows[ix - 1].file() != row.file()))
1471            .then_some(ix)
1472        })
1473        .collect()
1474}
1475
1476const SIDE_BIT: u64 = 1 << 40;
1477const OFFSET_MASK: u64 = SIDE_BIT - 1;
1478
1479/// Packs a source offset into a selection key, ordered by file, side and offset.
1480pub(crate) fn encode_key(file: usize, side: DiffSide, offset: usize) -> u64 {
1481    ((file as u64) << 41)
1482        | if side == DiffSide::Modified {
1483            SIDE_BIT
1484        } else {
1485            0
1486        }
1487        | (offset as u64 & OFFSET_MASK)
1488}
1489pub(crate) fn decode_key(key: u64) -> (usize, DiffSide, usize) {
1490    (
1491        (key >> 41) as usize,
1492        if key & SIDE_BIT == 0 {
1493            DiffSide::Original
1494        } else {
1495            DiffSide::Modified
1496        },
1497        (key & OFFSET_MASK) as usize,
1498    )
1499}
1500
1501/// The end of the hunk containing each pair; a fixture without hunks is one.
1502fn hunk_ends(document: &DiffFile) -> Vec<usize> {
1503    let pairs = document.pairs();
1504    let mut hunk_end = vec![pairs.len(); pairs.len()];
1505    for hunk in document.hunks() {
1506        hunk_end[hunk.pairs()].fill(hunk.pairs().end);
1507    }
1508    hunk_end
1509}
1510
1511/// Which pairs show as code: changes with their context, expanded ranges,
1512/// and collapsed ranges shorter than `min_collapsed_lines`.
1513fn visible_pairs(
1514    document: &DiffFile,
1515    context: Option<usize>,
1516    min_collapsed_lines: usize,
1517    expanded: &[Range<usize>],
1518) -> Vec<bool> {
1519    let pairs = document.pairs();
1520    let hunks = document.hunks();
1521    let mut hunk_end = vec![pairs.len(); pairs.len()];
1522    let mut hunk_start = vec![0; pairs.len()];
1523    for hunk in hunks {
1524        hunk_start[hunk.pairs()].fill(hunk.pairs().start);
1525        hunk_end[hunk.pairs()].fill(hunk.pairs().end);
1526    }
1527    // Interval boundaries avoid repeatedly filling overlapping context windows.
1528    // Projection remains linear in source lines plus the number of disclosures.
1529    let mut boundaries = vec![0isize; pairs.len() + 1];
1530    if let Some(context) = context {
1531        let mut ix = 0;
1532        while ix < pairs.len() {
1533            if !pairs[ix].is_changed() {
1534                ix += 1;
1535                continue;
1536            }
1537            let start = ix;
1538            while ix < pairs.len() && ix < hunk_end[start] && pairs[ix].is_changed() {
1539                ix += 1;
1540            }
1541            boundaries[start.saturating_sub(context).max(hunk_start[start])] += 1;
1542            boundaries[ix.saturating_add(context).min(hunk_end[start])] -= 1;
1543        }
1544    } else {
1545        boundaries[0] += 1;
1546        boundaries[pairs.len()] -= 1;
1547    }
1548    for range in expanded {
1549        let start = range.start.min(pairs.len());
1550        let end = range.end.min(pairs.len());
1551        if start < end {
1552            boundaries[start] += 1;
1553            boundaries[end] -= 1;
1554        }
1555    }
1556    let mut active = 0;
1557    let mut visible = boundaries[..pairs.len()]
1558        .iter()
1559        .map(|boundary| {
1560            active += boundary;
1561            active > 0
1562        })
1563        .collect::<Vec<_>>();
1564    // A disclosure shorter than the threshold would hide almost nothing.
1565    let mut ix = 0;
1566    while ix < pairs.len() {
1567        if visible[ix] {
1568            ix += 1;
1569            continue;
1570        }
1571        let start = ix;
1572        while ix < pairs.len() && ix < hunk_end[start] && !visible[ix] {
1573            ix += 1;
1574        }
1575        if ix - start < min_collapsed_lines {
1576            visible[start..ix].fill(true);
1577        }
1578    }
1579    visible
1580}
1581
1582fn project_file(
1583    rows: &mut Vec<DisplayRow>,
1584    file: usize,
1585    document: &DiffFile,
1586    mode: DiffMode,
1587    context: Option<usize>,
1588    min_collapsed_lines: usize,
1589    expanded: &[Range<usize>],
1590) {
1591    let pairs = document.pairs();
1592    let hunks = document.hunks();
1593    let hunk_end = hunk_ends(document);
1594    let visible = visible_pairs(document, context, min_collapsed_lines, expanded);
1595    let mut hunk_ix = 0;
1596    let mut ix = 0;
1597    while ix < pairs.len() {
1598        while let Some(hunk) = hunks.get(hunk_ix) {
1599            if hunk.pairs().start != ix {
1600                break;
1601            }
1602            rows.push(DisplayRow::Hunk {
1603                file,
1604                hunk: hunk_ix,
1605            });
1606            hunk_ix += 1;
1607        }
1608        if !visible[ix] {
1609            let start = ix;
1610            while ix < pairs.len() && ix < hunk_end[start] && !visible[ix] {
1611                ix += 1;
1612            }
1613            rows.push(DisplayRow::Fold {
1614                file,
1615                pairs: start..ix,
1616            });
1617        } else if mode == DiffMode::Unified && pairs[ix].is_changed() {
1618            let start = ix;
1619            while ix < pairs.len() && ix < hunk_end[start] && pairs[ix].is_changed() {
1620                ix += 1;
1621            }
1622            for pair in &pairs[start..ix] {
1623                if let Some(original) = pair.original() {
1624                    rows.push(DisplayRow::Code {
1625                        file,
1626                        original: Some(original),
1627                        modified: None,
1628                        changed: true,
1629                    });
1630                }
1631            }
1632            for pair in &pairs[start..ix] {
1633                if let Some(modified) = pair.modified() {
1634                    rows.push(DisplayRow::Code {
1635                        file,
1636                        original: None,
1637                        modified: Some(modified),
1638                        changed: true,
1639                    });
1640                }
1641            }
1642        } else {
1643            let pair = pairs[ix];
1644            rows.push(DisplayRow::Code {
1645                file,
1646                original: pair.original(),
1647                modified: pair.modified(),
1648                changed: pair.is_changed(),
1649            });
1650            ix += 1;
1651        }
1652    }
1653}
1654
1655/// Projects a conflicted working file: shared lines fold like context, and
1656/// each conflict shows its parts, or only the kept lines once resolved.
1657fn project_conflicts(
1658    rows: &mut Vec<DisplayRow>,
1659    file: usize,
1660    document: &DiffFile,
1661    visible: &[bool],
1662    resolutions: &[Option<DiffConflictResolution>],
1663) {
1664    // Each line of a conflicted file is its own pair.
1665    let code = |line: usize, changed: bool| DisplayRow::Code {
1666        file,
1667        original: None,
1668        modified: Some(line),
1669        changed,
1670    };
1671    let conflicts = document.conflicts();
1672    let mut next = 0;
1673    let mut ix = 0;
1674    while ix < visible.len() {
1675        if let Some(conflict) = conflicts.get(next)
1676            && conflict.source_lines().start == ix
1677        {
1678            match resolutions[next] {
1679                None => {
1680                    for part in [
1681                        ConflictPart::Current,
1682                        ConflictPart::Base,
1683                        ConflictPart::Incoming,
1684                    ] {
1685                        if let Some(lines) = conflict.part(part) {
1686                            rows.push(DisplayRow::Conflict {
1687                                file,
1688                                conflict: next,
1689                                part,
1690                            });
1691                            rows.extend(lines.map(|line| code(line, true)));
1692                        }
1693                    }
1694                }
1695                Some(resolution) => {
1696                    rows.push(DisplayRow::Conflict {
1697                        file,
1698                        conflict: next,
1699                        part: ConflictPart::Current,
1700                    });
1701                    for lines in conflict.kept(resolution) {
1702                        rows.extend(lines.map(|line| code(line, false)));
1703                    }
1704                }
1705            }
1706            ix = conflict.source_lines().end;
1707            next += 1;
1708            continue;
1709        }
1710        if visible[ix] {
1711            rows.push(code(ix, false));
1712            ix += 1;
1713            continue;
1714        }
1715        let start = ix;
1716        while ix < visible.len() && !visible[ix] {
1717            ix += 1;
1718        }
1719        rows.push(DisplayRow::Fold {
1720            file,
1721            pairs: start..ix,
1722        });
1723    }
1724}