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