Skip to main content

tui_lipan/widgets/diff_view/
mod.rs

1//! Diff view widget.
2
3mod formatter;
4mod render;
5mod strategy;
6mod types;
7pub(crate) mod wrap_sync;
8
9pub(crate) use formatter::*;
10pub(crate) use render::*;
11pub(crate) use strategy::*;
12pub use types::*;
13pub(crate) use wrap_sync::*;
14
15use std::cell::RefCell;
16use std::collections::HashMap;
17use std::hash::{Hash, Hasher};
18
19use crate::callback::Callback;
20use crate::core::element::Element;
21use crate::style::{Color, DiffPalette, Length, Style};
22use crate::widgets::{Divider, DocumentView, Frame, HStack, ScrollEvent, TextArea};
23use rustc_hash::FxHasher;
24use std::rc::Rc;
25use std::sync::Arc;
26
27// ── DiffData cache ───────────────────────────────────────────────────────────
28
29const PANE_DATA_CACHE_LIMIT: usize = 2048;
30const DIFF_GUTTER_SPANS_CACHE_LIMIT: usize = 4096;
31/// Bound for the content- and pointer-keyed `DiffData` caches. The live working
32/// set is the currently visible diffs (small), so a long session that scrolls
33/// through many distinct files would otherwise grow these unbounded. Clear-on-
34/// overflow (matching `PANE_DATA_CACHE`) caps memory; the only cost on overflow
35/// is re-parsing a diff on next view, which happens off the resize hot path.
36const DIFF_DATA_CACHE_LIMIT: usize = 2048;
37
38// Global cache for `DiffData` results.  Keyed on source contents + config so
39// repeated element rebuilds (e.g. during scroll/viewport metadata updates) skip
40// expensive diff/patch parsing without risking stale collision hits.
41thread_local! {
42    static DIFF_DATA_CACHE: RefCell<HashMap<u64, Arc<DiffData>>> =
43        RefCell::new(HashMap::new());
44    static PATCH_DIFF_DATA_PTR_CACHE: RefCell<HashMap<PatchDiffDataPtrKey, Arc<DiffData>>> =
45        RefCell::new(HashMap::new());
46    static PANE_DATA_CACHE: RefCell<HashMap<(PaneCacheKey, u64), PaneCacheEntry>> =
47        RefCell::new(HashMap::new());
48}
49
50fn diff_data_cache_key(before: &str, after: &str, config: &DiffDataConfig) -> u64 {
51    let mut h = FxHasher::default();
52    0u8.hash(&mut h);
53    before.hash(&mut h);
54    after.hash(&mut h);
55    config.hash(&mut h);
56    h.finish()
57}
58
59fn patch_diff_data_cache_key(patch: &str, config: &DiffDataConfig) -> u64 {
60    let mut h = FxHasher::default();
61    1u8.hash(&mut h);
62    patch.hash(&mut h);
63    config.hash(&mut h);
64    h.finish()
65}
66
67#[derive(Clone)]
68struct PatchDiffDataPtrKey {
69    patch: Arc<str>,
70    config: DiffDataConfig,
71}
72
73impl PartialEq for PatchDiffDataPtrKey {
74    fn eq(&self, other: &Self) -> bool {
75        Arc::ptr_eq(&self.patch, &other.patch) && self.config == other.config
76    }
77}
78
79impl Eq for PatchDiffDataPtrKey {}
80
81impl Hash for PatchDiffDataPtrKey {
82    fn hash<H: Hasher>(&self, state: &mut H) {
83        self.patch.as_ptr().hash(state);
84        self.patch.len().hash(state);
85        self.config.hash(state);
86    }
87}
88
89fn cached_diff_data(before: &str, after: &str, config: DiffDataConfig) -> Arc<DiffData> {
90    let key = diff_data_cache_key(before, after, &config);
91    DIFF_DATA_CACHE.with(|cache| {
92        let mut map = cache.borrow_mut();
93        if let Some(data) = map.get(&key) {
94            return Arc::clone(data);
95        }
96        let data = Arc::new(DiffData::with_config(before, after, config));
97        if map.len() >= DIFF_DATA_CACHE_LIMIT {
98            map.clear();
99        }
100        map.insert(key, Arc::clone(&data));
101        data
102    })
103}
104
105fn cached_patch_diff_data(patch: Arc<str>, config: DiffDataConfig) -> Arc<DiffData> {
106    let ptr_key = PatchDiffDataPtrKey {
107        patch: Arc::clone(&patch),
108        config: config.clone(),
109    };
110    if let Some(data) =
111        PATCH_DIFF_DATA_PTR_CACHE.with(|cache| cache.borrow().get(&ptr_key).cloned())
112    {
113        return data;
114    }
115
116    let content_key = patch_diff_data_cache_key(patch.as_ref(), &config);
117    let data = DIFF_DATA_CACHE.with(|cache| {
118        let mut map = cache.borrow_mut();
119        if let Some(data) = map.get(&content_key) {
120            return Arc::clone(data);
121        }
122        let data = Arc::new(DiffData::from_patch_with_config(patch.as_ref(), config));
123        if map.len() >= DIFF_DATA_CACHE_LIMIT {
124            map.clear();
125        }
126        map.insert(content_key, Arc::clone(&data));
127        data
128    });
129    PATCH_DIFF_DATA_PTR_CACHE.with(|cache| {
130        let mut cache = cache.borrow_mut();
131        if cache.len() >= DIFF_DATA_CACHE_LIMIT && !cache.contains_key(&ptr_key) {
132            cache.clear();
133        }
134        cache.insert(ptr_key, Arc::clone(&data));
135    });
136    data
137}
138
139#[cfg(feature = "syntax-syntect")]
140use crate::widgets::SyntectStrategy;
141use crate::widgets::TextAreaColorStrategy;
142
143/// Cache key for per-pane derived data (numbered render, gutter, excluded lines).
144#[derive(Clone, PartialEq, Eq, Hash)]
145pub(crate) struct PaneCacheKey {
146    diff_hash: u64,
147    mode: DiffViewMode,
148    pane: DiffPane,
149    line_numbers: bool,
150    min_digits: usize,
151}
152
153/// Cached per-pane data to avoid recomputing on every frame.
154#[derive(Clone)]
155pub(crate) struct PaneCacheEntry {
156    key: PaneCacheKey,
157    gutter_style_hash: u64,
158    numbered_render: DiffRender,
159    gutter_spans: DiffGutterSpans,
160    gutter_col_width: u16,
161    excluded_source_lines: Arc<Vec<usize>>,
162    excluded_bytes: Arc<Vec<(usize, usize)>>,
163}
164
165#[derive(Clone, Copy)]
166pub(crate) struct PaneRenderOptions {
167    mode: DiffViewMode,
168    line_numbers: bool,
169    min_digits: usize,
170    style: DiffPalette,
171    gutter_style_hash: u64,
172}
173
174/// A diff view widget with selectable rendering backends.
175#[derive(Clone)]
176pub struct DiffView {
177    before: Arc<str>,
178    after: Arc<str>,
179    patch: Option<Arc<str>>,
180    mode: DiffViewMode,
181    backend: DiffViewBackend,
182    backend_explicit: bool,
183    width_override: Option<Length>,
184    height_override: Option<Length>,
185    editable: bool,
186    wrap_override: Option<bool>,
187    line_numbers_override: Option<bool>,
188    min_line_number_width_override: Option<u8>,
189    gutter_inset_override: Option<u16>,
190    scrollbar_override: Option<bool>,
191    h_scrollbar_override: Option<bool>,
192    focusable_override: Option<bool>,
193    outer_border: bool,
194    pane_border: bool,
195    highlight_full_width: bool,
196    single_scrollbar: bool,
197    join_frame: bool,
198    vertical_separator: bool,
199    vertical_separator_char: char,
200    vertical_separator_style: Style,
201    scroll_offset: Option<usize>,
202    scroll_to_hunk: Option<usize>,
203    on_scroll: Option<Callback<DiffScrollEvent>>,
204    on_context_separator_click: Option<Callback<DiffContextSeparatorEvent>>,
205    context_separator_hover_style: Option<Style>,
206    text_area: TextArea,
207    document_view: DocumentView,
208    diff_style: DiffPalette,
209    prefixes: DiffPrefixes,
210    show_prefixes: bool,
211    word_diff: bool,
212    trim_common_indent: bool,
213    shared_selection_id: Option<Arc<str>>,
214    language: Option<Arc<str>>,
215    theme: Option<Arc<str>>,
216    base_color_strategy: Option<Rc<dyn TextAreaColorStrategy>>,
217    diff_data: Option<Arc<DiffData>>,
218    context_lines: Option<usize>,
219    show_context_separator: bool,
220    context_separator_text: Arc<str>,
221    context_separator_min_lines: usize,
222    context_expand_lines: usize,
223    expanded_contexts: Vec<DiffContextExpansion>,
224    /// Per-pane data cache (avoids recomputing numbered render, gutter, excluded
225    /// lines every frame). Keyed by (diff_hash, mode, pane, line_numbers, min_digits, style).
226    pane_cache: RefCell<Vec<PaneCacheEntry>>,
227}
228
229impl DiffView {
230    /// Create a new, empty diff view.
231    pub fn new() -> Self {
232        Self::new_internal("".into(), "".into(), None)
233    }
234
235    /// Set the "before" content of the diff.
236    pub fn before(mut self, before: impl Into<Arc<str>>) -> Self {
237        self.before = before.into();
238        self
239    }
240
241    /// Set the "after" content of the diff.
242    pub fn after(mut self, after: impl Into<Arc<str>>) -> Self {
243        self.after = after.into();
244        self
245    }
246
247    /// Set the diff content from a raw unified diff (patch) string.
248    pub fn patch(mut self, patch: impl Into<Arc<str>>) -> Self {
249        let patch = patch.into();
250        self.text_area = self.text_area.value("");
251        self.document_view = self.document_view.value("");
252        self.before = "".into();
253        self.after = "".into();
254        self.diff_data = None;
255        self.patch = Some(patch);
256        self
257    }
258
259    /// Create a diff view for the given before/after text.
260    pub fn with_content(before: impl Into<Arc<str>>, after: impl Into<Arc<str>>) -> Self {
261        Self::new_internal(before.into(), after.into(), None)
262    }
263
264    /// Create a diff view from a raw unified diff (patch) string.
265    ///
266    /// This constructor is useful when you have a pre-computed patch (e.g. from
267    /// git or an API) and want to display it with `DiffView` colors and
268    /// formatting.
269    pub fn from_patch(patch: impl Into<Arc<str>>) -> Self {
270        Self::new().patch(patch)
271    }
272
273    fn new_internal(before: Arc<str>, after: Arc<str>, diff_data: Option<DiffData>) -> Self {
274        let text_area = TextArea::new("")
275            .read_only(true)
276            .line_numbers(true)
277            .wrap(false)
278            .scrollbar(true)
279            .h_scrollbar(true);
280        let document_view = DocumentView::new("")
281            .line_numbers(true)
282            .wrap(false)
283            .scrollbar(true)
284            .h_scrollbar(true);
285        Self {
286            before,
287            after,
288            patch: None,
289            mode: DiffViewMode::Split,
290            backend: DiffViewBackend::TextArea,
291            backend_explicit: false,
292            width_override: None,
293            height_override: None,
294            editable: false,
295            wrap_override: None,
296            line_numbers_override: None,
297            min_line_number_width_override: None,
298            gutter_inset_override: None,
299            scrollbar_override: None,
300            h_scrollbar_override: None,
301            focusable_override: None,
302            outer_border: false,
303            pane_border: true,
304            highlight_full_width: false,
305            single_scrollbar: false,
306            join_frame: false,
307            vertical_separator: false,
308            vertical_separator_char: '│',
309            vertical_separator_style: Style::default(),
310            scroll_offset: None,
311            scroll_to_hunk: None,
312            on_scroll: None,
313            on_context_separator_click: None,
314            context_separator_hover_style: None,
315            text_area,
316            document_view,
317            diff_style: DiffPalette::default(),
318            prefixes: DiffPrefixes::default(),
319            show_prefixes: true,
320            word_diff: true,
321            trim_common_indent: true,
322            shared_selection_id: None,
323            language: None,
324            theme: None,
325            base_color_strategy: None,
326            diff_data: diff_data.map(Arc::new),
327            context_lines: None,
328            show_context_separator: true,
329            context_separator_text: default_context_separator_text(),
330            context_separator_min_lines: default_context_separator_min_lines(),
331            context_expand_lines: default_context_expand_lines(),
332            expanded_contexts: Vec::new(),
333            pane_cache: RefCell::new(Vec::new()),
334        }
335    }
336
337    /// Set rendering backend explicitly.
338    ///
339    /// Not required in most cases: calling [`Self::text_area`] or
340    /// [`Self::document_view`] also infers backend when this isn't set.
341    pub fn backend(mut self, backend: DiffViewBackend) -> Self {
342        self.backend = backend;
343        self.backend_explicit = true;
344        self
345    }
346
347    /// Enable/disable editing when using the `TextArea` backend.
348    ///
349    /// This flag is ignored by the `DocumentView` backend.
350    pub fn editable(mut self, editable: bool) -> Self {
351        self.editable = editable;
352        self
353    }
354
355    /// Set controlled vertical scroll offset for rendered pane(s).
356    pub fn scroll_offset(mut self, offset: usize) -> Self {
357        self.scroll_offset = Some(offset);
358        self
359    }
360
361    /// Scroll rendered pane(s) to the first visible row for a parsed patch hunk.
362    ///
363    /// `index` is zero-based patch order. The target is resolved after
364    /// indentation trimming and context collapse, then delegated to the active
365    /// backend so soft wrapping maps to the final visual row during layout.
366    /// A controlled [`Self::scroll_offset`] takes precedence if both are set.
367    pub fn scroll_to_hunk(mut self, index: usize) -> Self {
368        self.scroll_to_hunk = Some(index);
369        self
370    }
371
372    /// Receive pane-aware scroll events from rendered pane(s).
373    pub fn on_scroll(mut self, cb: Callback<DiffScrollEvent>) -> Self {
374        self.on_scroll = Some(cb);
375        self
376    }
377
378    /// Set the callback fired when a visible context separator is clicked.
379    ///
380    /// Use [`Self::expanded_contexts`] with the clicked event's range on the
381    /// next render to expand the hidden lines represented by that separator.
382    pub fn on_context_separator_click(mut self, cb: Callback<DiffContextSeparatorEvent>) -> Self {
383        self.on_context_separator_click = Some(cb);
384        self
385    }
386
387    /// Set the style patched over a context separator while the pointer hovers it.
388    pub fn context_separator_hover_style(mut self, style: Style) -> Self {
389        self.context_separator_hover_style = Some(style);
390        self
391    }
392
393    /// Set the diff presentation mode.
394    pub fn mode(mut self, mode: DiffViewMode) -> Self {
395        self.mode = mode;
396        self
397    }
398
399    /// Override the outer diff view width.
400    pub fn width(mut self, width: impl Into<Length>) -> Self {
401        self.width_override = Some(width.into());
402        self
403    }
404
405    /// Override the outer diff view height.
406    pub fn height(mut self, height: impl Into<Length>) -> Self {
407        self.height_override = Some(height.into());
408        self
409    }
410
411    /// Provide a base `TextArea` configuration.
412    ///
413    /// When backend is not explicitly set, this also selects the `TextArea`
414    /// backend.
415    pub fn text_area(mut self, text_area: TextArea) -> Self {
416        if self.base_color_strategy.is_none() {
417            self.base_color_strategy = text_area.color_strategy.clone();
418        }
419        if self.language.is_none() {
420            self.language = text_area.language.clone();
421        }
422        if self.theme.is_none() {
423            self.theme = text_area.theme.clone();
424        }
425        self.text_area = text_area;
426        if let Some(v) = self.wrap_override {
427            self.text_area = self.text_area.wrap(v);
428        }
429        if let Some(v) = self.gutter_inset_override {
430            self.text_area = self.text_area.gutter_inset(v);
431        }
432        if let Some(v) = self.scrollbar_override {
433            self.text_area = self.text_area.scrollbar(v);
434        }
435        if let Some(v) = self.h_scrollbar_override {
436            self.text_area = self.text_area.h_scrollbar(v);
437        }
438        if let Some(v) = self.focusable_override {
439            self.text_area = self.text_area.focusable(v);
440        }
441        if !self.backend_explicit {
442            self.backend = DiffViewBackend::TextArea;
443        }
444        self
445    }
446
447    /// Provide a base `DocumentView` configuration.
448    ///
449    /// When backend is not explicitly set, this also selects the
450    /// `DocumentView` backend.
451    pub fn document_view(mut self, document_view: DocumentView) -> Self {
452        self.document_view = document_view;
453        if let Some(v) = self.wrap_override {
454            self.document_view = self.document_view.wrap(v);
455        }
456        if let Some(v) = self.gutter_inset_override {
457            self.document_view = self.document_view.gutter_inset(v);
458        }
459        if let Some(v) = self.scrollbar_override {
460            self.document_view = self.document_view.scrollbar(v);
461        }
462        if let Some(v) = self.h_scrollbar_override {
463            self.document_view = self.document_view.h_scrollbar(v);
464        }
465        if let Some(v) = self.focusable_override {
466            self.document_view = self.document_view.focusable(v);
467        }
468        if !self.backend_explicit {
469            self.backend = DiffViewBackend::DocumentView;
470        }
471        self
472    }
473
474    /// Enable/disable wrapping in both backends.
475    pub fn wrap(mut self, wrap: bool) -> Self {
476        self.wrap_override = Some(wrap);
477        self.text_area = self.text_area.wrap(wrap);
478        self.document_view = self.document_view.wrap(wrap);
479        self
480    }
481
482    /// Enable/disable line numbers in both backends.
483    pub fn line_numbers(mut self, show: bool) -> Self {
484        self.line_numbers_override = Some(show);
485        self
486    }
487
488    /// Set minimum line-number digit width in both backends.
489    pub fn min_line_number_width(mut self, width: u8) -> Self {
490        self.min_line_number_width_override = Some(width);
491        self
492    }
493
494    /// Show or hide an outer border around the whole `DiffView`.
495    pub fn border(mut self, border: bool) -> Self {
496        self.outer_border = border;
497        self
498    }
499
500    /// Show or hide per-pane borders in split/unified pane wrappers.
501    pub fn panels_border(mut self, border: bool) -> Self {
502        self.pane_border = border;
503        self
504    }
505
506    /// Highlight changed-line backgrounds to full row width.
507    pub fn highlight_full_width(mut self, enabled: bool) -> Self {
508        self.highlight_full_width = enabled;
509        self
510    }
511
512    /// Show or hide vertical scrollbars in both backends.
513    pub fn scrollbar(mut self, show: bool) -> Self {
514        self.scrollbar_override = Some(show);
515        self.text_area = self.text_area.scrollbar(show);
516        self.document_view = self.document_view.scrollbar(show);
517        self
518    }
519
520    /// Reserve empty cells before the gutter / line numbers in both backends.
521    pub fn gutter_inset(mut self, inset: u16) -> Self {
522        self.gutter_inset_override = Some(inset);
523        self.text_area = self.text_area.gutter_inset(inset);
524        self.document_view = self.document_view.gutter_inset(inset);
525        self
526    }
527
528    /// Show or hide horizontal scrollbars in both backends.
529    pub fn h_scrollbar(mut self, show: bool) -> Self {
530        self.h_scrollbar_override = Some(show);
531        self.text_area = self.text_area.h_scrollbar(show);
532        self.document_view = self.document_view.h_scrollbar(show);
533        self
534    }
535
536    /// Control focusability in both backends.
537    pub fn focusable(mut self, focusable: bool) -> Self {
538        self.focusable_override = Some(focusable);
539        self.text_area = self.text_area.focusable(focusable);
540        self.document_view = self.document_view.focusable(focusable);
541        self
542    }
543
544    /// In split mode, render a single vertical scrollbar on the right pane only.
545    pub fn single_scrollbar(mut self, enabled: bool) -> Self {
546        self.single_scrollbar = enabled;
547        self
548    }
549
550    /// Join pane frames when adjacent (uses `Frame::join_frame`).
551    pub fn join_frame(mut self, join: bool) -> Self {
552        self.join_frame = join;
553        self
554    }
555
556    /// Show a vertical separator between split panes.
557    pub fn vertical_separator(mut self, enabled: bool) -> Self {
558        self.vertical_separator = enabled;
559        self
560    }
561
562    /// Set the split-pane vertical separator character.
563    pub fn vertical_separator_char(mut self, ch: char) -> Self {
564        self.vertical_separator_char = ch;
565        self
566    }
567
568    /// Set style for the split-pane vertical separator.
569    pub fn vertical_separator_style(mut self, style: Style) -> Self {
570        self.vertical_separator_style = style;
571        self
572    }
573
574    /// Configure diff line and word styles.
575    pub fn diff_style(mut self, style: DiffPalette) -> Self {
576        self.diff_style = style;
577        self
578    }
579
580    /// Set background color for unchanged/context lines.
581    ///
582    /// Note: split filler lines (`DiffLineKind::Empty`) are not affected.
583    pub fn neutral_bg(mut self, color: Color) -> Self {
584        self.diff_style.context = self.diff_style.context.bg(color);
585        self
586    }
587
588    /// Enable cross-widget drag selection for this diff view's internal panels.
589    ///
590    /// In **unified** mode the id is used as-is on the single panel.
591    /// In **split** mode the id is suffixed with `:left` and `:right` so that
592    /// only same-side panels share selection across multiple `DiffView`s.
593    ///
594    /// Multiple `DiffView`s (and plain `DocumentView`s) under the same
595    /// `ScrollView` that share the same id participate in unified drag
596    /// selection and copy.
597    pub fn shared_selection_id(mut self, id: impl Into<Arc<str>>) -> Self {
598        self.shared_selection_id = Some(id.into());
599        self
600    }
601
602    /// Configure diff prefixes.
603    pub fn prefixes(mut self, prefixes: DiffPrefixes) -> Self {
604        self.prefixes = prefixes;
605        self
606    }
607
608    /// Toggle prefix rendering.
609    pub fn show_prefixes(mut self, show: bool) -> Self {
610        self.show_prefixes = show;
611        self
612    }
613
614    /// Toggle word-level diff highlighting.
615    pub fn word_diff(mut self, enabled: bool) -> Self {
616        self.word_diff = enabled;
617        self
618    }
619
620    /// Trim shared leading indentation from diff line content.
621    ///
622    /// When enabled (the default), `DiffView` computes the smallest common
623    /// leading indent across visible non-empty diff lines and removes that many
624    /// leading spaces/tabs from line content before rendering. In split mode,
625    /// both panes share the same trim amount so rows stay aligned.
626    pub fn trim_common_indent(mut self, enabled: bool) -> Self {
627        self.trim_common_indent = enabled;
628        self
629    }
630
631    /// Set the number of unchanged context lines to keep around each change.
632    ///
633    /// When set, unchanged regions farther than `n` lines from any change are
634    /// collapsed into a single separator line.  The default (`None`) shows
635    /// all lines.
636    ///
637    /// ```ignore
638    /// DiffView::with_content(before, after).context_lines(4)
639    /// ```
640    pub fn context_lines(mut self, n: usize) -> Self {
641        self.context_lines = Some(n);
642        self
643    }
644
645    /// Show or hide the placeholder separator line when collapsing context.
646    ///
647    /// When `true` (the default) a context separator line is rendered in place
648    /// of each collapsed region. When `false` the hidden lines are simply
649    /// omitted without any visual placeholder. Only meaningful when
650    /// [`Self::context_lines`] is set.
651    pub fn show_context_separator(mut self, show: bool) -> Self {
652        self.show_context_separator = show;
653        self
654    }
655
656    /// Set the template text used for context separators.
657    ///
658    /// Supported placeholders:
659    /// - `{count}`: number of hidden lines
660    /// - `{line_word}`: `line` or `lines`
661    /// - `{direction}`: `above`, `below`, or `between`
662    /// - `{arrow}`: `↑`, `↓`, or `↑↓`
663    pub fn context_separator_text(mut self, text: impl Into<Arc<str>>) -> Self {
664        self.context_separator_text = text.into();
665        self
666    }
667
668    /// Minimum number of hidden lines required before a context separator is shown.
669    ///
670    /// Shorter collapsed runs render as normal context lines instead of a
671    /// separator placeholder (default: `2`).
672    pub fn context_separator_min_lines(mut self, min_lines: usize) -> Self {
673        self.context_separator_min_lines = min_lines.max(1);
674        self
675    }
676
677    /// Default number of hidden lines revealed per context-separator click.
678    ///
679    /// This value is included in [`DiffContextSeparatorEvent`] and used by
680    /// [`DiffContextSeparatorEvent::next_expansion`] (default: `20`).
681    pub fn context_expand_lines(mut self, lines: usize) -> Self {
682        self.context_expand_lines = lines.max(1);
683        self
684    }
685
686    /// Set collapsed context ranges that should render fully expanded.
687    ///
688    /// Pass ranges received from [`DiffContextSeparatorEvent::range`] to turn
689    /// individual separators back into their hidden lines. This is controlled
690    /// by the app so expansion state survives normal component rerenders.
691    pub fn expanded_contexts(mut self, ranges: impl IntoIterator<Item = DiffContextRange>) -> Self {
692        self.expanded_contexts = ranges.into_iter().map(DiffContextExpansion::full).collect();
693        self
694    }
695
696    /// Set controlled partial or full context expansions.
697    pub fn expanded_context_expansions(
698        mut self,
699        expansions: impl IntoIterator<Item = DiffContextExpansion>,
700    ) -> Self {
701        self.expanded_contexts = expansions.into_iter().collect();
702        self
703    }
704
705    /// Expand one collapsed context range fully.
706    pub fn expanded_context(mut self, range: DiffContextRange) -> Self {
707        self.expanded_contexts
708            .push(DiffContextExpansion::full(range));
709        self
710    }
711
712    /// Expand one collapsed context range by a specific number of lines.
713    pub fn expanded_context_lines(
714        mut self,
715        range: DiffContextRange,
716        lines_revealed: usize,
717    ) -> Self {
718        self.expanded_contexts.push(DiffContextExpansion {
719            range,
720            lines_revealed,
721        });
722        self
723    }
724
725    /// Provide a base color strategy (syntax highlighting).
726    ///
727    /// Applied to both backends:
728    /// - `TextArea` backend via `TextArea::color_strategy`
729    /// - `DocumentView` backend via the internal diff formatter
730    pub fn base_color_strategy(mut self, strategy: impl TextAreaColorStrategy + 'static) -> Self {
731        self.base_color_strategy = Some(Rc::new(strategy));
732        self
733    }
734
735    /// Set the language identifier for syntax strategies.
736    pub fn language(mut self, language: impl Into<Arc<str>>) -> Self {
737        let language = language.into();
738        self.language = Some(language.clone());
739        self.text_area = self.text_area.language(language);
740        self
741    }
742
743    /// Set language identifier by resolving from a file path's extension or name.
744    ///
745    /// Uses the default syntect syntax definitions. If no syntax matches the
746    /// path, the language remains unset (plain text fallback). TypeScript/TSX
747    /// paths fall back to JavaScript/JSX-compatible syntaxes when the default
748    /// set does not provide exact grammars.
749    #[cfg(feature = "syntax-syntect")]
750    pub fn language_from_path(self, path: impl AsRef<std::path::Path>) -> Self {
751        if let Some(lang) = crate::widgets::language_from_path(path) {
752            self.language(lang)
753        } else {
754            self
755        }
756    }
757
758    /// Set the theme identifier for syntax strategies.
759    pub fn theme(mut self, theme: impl Into<Arc<str>>) -> Self {
760        let theme = theme.into();
761        self.theme = Some(theme.clone());
762        self.text_area = self.text_area.theme(theme);
763        self
764    }
765
766    /// Enable syntect-based syntax highlighting.
767    #[cfg(feature = "syntax-syntect")]
768    pub fn with_syntax(self, language: impl Into<Arc<str>>, theme: impl Into<Arc<str>>) -> Self {
769        self.base_color_strategy(SyntectStrategy::default())
770            .language(language)
771            .theme(theme)
772    }
773
774    /// Enable syntect-based syntax highlighting with background colors.
775    #[cfg(feature = "syntax-syntect")]
776    pub fn with_syntax_bg(self, language: impl Into<Arc<str>>, theme: impl Into<Arc<str>>) -> Self {
777        self.base_color_strategy(SyntectStrategy::default().use_background(true))
778            .language(language)
779            .theme(theme)
780    }
781
782    /// Use precomputed diff data.
783    pub fn with_diff(mut self, data: DiffData) -> Self {
784        self.patch = None;
785        self.diff_data = Some(Arc::new(data));
786        self
787    }
788
789    /// Use shared precomputed diff data.
790    pub fn with_shared_diff(mut self, data: Arc<DiffData>) -> Self {
791        self.patch = None;
792        self.diff_data = Some(data);
793        self
794    }
795
796    fn resolved_width(&self) -> Length {
797        self.width_override.unwrap_or(match self.backend {
798            DiffViewBackend::TextArea => self.text_area.width,
799            DiffViewBackend::DocumentView => self.document_view.width,
800        })
801    }
802
803    fn effective_wrap(&self) -> bool {
804        self.wrap_override.unwrap_or(match self.backend {
805            DiffViewBackend::TextArea => self.text_area.wrap,
806            DiffViewBackend::DocumentView => self.document_view.wrap,
807        })
808    }
809
810    fn effective_scrollbar(&self) -> bool {
811        self.scrollbar_override.unwrap_or(match self.backend {
812            DiffViewBackend::TextArea => self.text_area.scrollbar,
813            DiffViewBackend::DocumentView => self.document_view.scrollbar,
814        })
815    }
816
817    fn backend_height(&self) -> Length {
818        match self.backend {
819            DiffViewBackend::TextArea => self.text_area.height,
820            DiffViewBackend::DocumentView => self.document_view.height,
821        }
822    }
823
824    fn should_use_implicit_auto_height(&self) -> bool {
825        self.height_override.is_none()
826            && self.effective_wrap()
827            && !self.effective_scrollbar()
828            && matches!(self.backend_height(), Length::Flex(1))
829    }
830
831    fn resolved_height(&self) -> Length {
832        self.height_override.unwrap_or_else(|| {
833            if self.should_use_implicit_auto_height() {
834                Length::Auto
835            } else {
836                self.backend_height()
837            }
838        })
839    }
840}
841
842impl Default for DiffView {
843    fn default() -> Self {
844        Self::new()
845    }
846}
847
848fn separator_click_config(
849    render: &DiffRender,
850    pane: DiffPane,
851    on_click: Option<Callback<DiffContextSeparatorEvent>>,
852    hover_style: Option<Style>,
853    expand_lines: usize,
854) -> Option<DiffContextSeparatorClickConfig> {
855    let has_interaction = on_click.is_some() || hover_style.is_some_and(|style| !style.is_empty());
856    if !has_interaction {
857        return None;
858    }
859    let events = render
860        .lines
861        .iter()
862        .map(|line| {
863            line.context_separator
864                .as_ref()
865                .map(|separator| separator.event(pane, expand_lines))
866        })
867        .collect::<Vec<_>>();
868    events
869        .iter()
870        .any(Option::is_some)
871        .then(|| DiffContextSeparatorClickConfig {
872            events_by_source_line: events.into(),
873            on_click,
874            hover_style,
875        })
876}
877
878impl From<DiffView> for Element {
879    fn from(view: DiffView) -> Self {
880        let outer_width = view.resolved_width();
881        let outer_height = view.resolved_height();
882        let use_auto_pane_height = matches!(outer_height, Length::Auto);
883        let config = DiffDataConfig {
884            prefixes: view.prefixes.clone(),
885            show_prefixes: view.show_prefixes,
886            word_diff: view.word_diff,
887            context_lines: None,
888            ..DiffDataConfig::default()
889        };
890        let diff_data = if let Some(data) = view.diff_data.clone() {
891            data
892        } else if let Some(patch) = view.patch.as_ref() {
893            cached_patch_diff_data(Arc::clone(patch), config)
894        } else {
895            cached_diff_data(&view.before, &view.after, config)
896        };
897        let (left_render, right_render, unified_render) = if view.trim_common_indent {
898            match view.mode {
899                DiffViewMode::Split => {
900                    let trim = common_indent_across_lines(
901                        diff_data
902                            .left
903                            .lines
904                            .iter()
905                            .chain(diff_data.right.lines.iter()),
906                    );
907                    (
908                        trim_render_common_indent(&diff_data.left, trim),
909                        trim_render_common_indent(&diff_data.right, trim),
910                        trim_render_common_indent(
911                            &diff_data.unified,
912                            common_indent_across_lines(diff_data.unified.lines.iter()),
913                        ),
914                    )
915                }
916                DiffViewMode::Unified => {
917                    let trim = common_indent_across_lines(diff_data.unified.lines.iter());
918                    (
919                        trim_render_common_indent(&diff_data.left, trim),
920                        trim_render_common_indent(&diff_data.right, trim),
921                        trim_render_common_indent(&diff_data.unified, trim),
922                    )
923                }
924            }
925        } else {
926            (
927                diff_data.left.clone(),
928                diff_data.right.clone(),
929                diff_data.unified.clone(),
930            )
931        };
932
933        let (left_render, right_render, unified_render) =
934            apply_runtime_context_collapse_to_diff_renders(
935                left_render,
936                right_render,
937                unified_render,
938                view.context_lines,
939                render::context_collapse_options(
940                    view.show_context_separator,
941                    view.context_separator_text.as_ref(),
942                    view.context_separator_min_lines,
943                    &view.expanded_contexts,
944                ),
945            );
946
947        let base_strategy = view
948            .base_color_strategy
949            .clone()
950            .or_else(|| view.text_area.color_strategy.clone());
951
952        let line_numbers_enabled = view.line_numbers_override.unwrap_or(match view.backend {
953            DiffViewBackend::TextArea => view.text_area.line_numbers,
954            DiffViewBackend::DocumentView => view.document_view.line_numbers,
955        });
956        let min_line_digits = view
957            .min_line_number_width_override
958            .map(usize::from)
959            .unwrap_or(match view.backend {
960                DiffViewBackend::TextArea => view.text_area.min_line_number_width as usize,
961                DiffViewBackend::DocumentView => view.document_view.min_line_number_width as usize,
962            });
963
964        let language = view.language.clone();
965        let theme = view.theme.clone();
966        let split_wrap_sync = matches!(view.mode, DiffViewMode::Split) && view.effective_wrap();
967        let split_wrap_state = split_wrap_sync.then(new_split_wrap_sync_state);
968        if let Some(ref sync) = split_wrap_state {
969            wrap_sync::set_split_wrap_scrollbar_cols(
970                sync,
971                split_pane_standalone_scrollbar_cols(&view, DiffPane::Left),
972                split_pane_standalone_scrollbar_cols(&view, DiffPane::Right),
973            );
974        }
975        let split_wrap_padding_gutter_style = split_wrap_sync.then_some(
976            view.diff_style
977                .empty
978                .patch(view.diff_style.context_line_number),
979        );
980        let split_wrap_padding_style = split_wrap_sync.then_some(view.diff_style.empty);
981        let left_peer_source_lines = if split_wrap_sync {
982            Some(Arc::new(
983                right_render
984                    .lines
985                    .iter()
986                    .map(|line| Arc::clone(&line.text))
987                    .collect::<Vec<_>>(),
988            ))
989        } else {
990            None
991        };
992        let right_peer_source_lines = if split_wrap_sync {
993            Some(Arc::new(
994                left_render
995                    .lines
996                    .iter()
997                    .map(|line| Arc::clone(&line.text))
998                    .collect::<Vec<_>>(),
999            ))
1000        } else {
1001            None
1002        };
1003
1004        // Pre-compute per-pane shared selection ids.  Unified uses the id
1005        // as-is; split suffixes `:left` / `:right` so only same-side panels
1006        // share selection across multiple DiffViews.
1007        let pane_shared_selection_id = |pane: DiffPane| -> Option<Arc<str>> {
1008            let base = view.shared_selection_id.as_ref()?;
1009            Some(match (view.mode, pane) {
1010                (DiffViewMode::Unified, _) | (_, DiffPane::Unified) => Arc::clone(base),
1011                (DiffViewMode::Split, DiffPane::Left) => Arc::from(format!("{}:left", base)),
1012                (DiffViewMode::Split, DiffPane::Right) => Arc::from(format!("{}:right", base)),
1013            })
1014        };
1015
1016        let gutter_style_hash = diff_style_hash(&view.diff_style);
1017        let pane_options = PaneRenderOptions {
1018            mode: view.mode,
1019            line_numbers: line_numbers_enabled,
1020            min_digits: min_line_digits,
1021            style: view.diff_style,
1022            gutter_style_hash,
1023        };
1024
1025        let build_text_area = |render: &DiffRender, pane: DiffPane| {
1026            let pane_data = get_pane_data(&view.pane_cache, render, pane, pane_options);
1027            let render = pane_data.numbered_render;
1028            let gutter_spans = pane_data.gutter_spans;
1029            let gutter_col_width = pane_data.gutter_col_width;
1030            let excluded_source_lines = pane_data.excluded_source_lines;
1031            let excluded_bytes = pane_data.excluded_bytes;
1032            let diff_strategy = DiffColorStrategy::new(
1033                render.clone(),
1034                base_strategy.clone(),
1035                view.diff_style,
1036                view.highlight_full_width,
1037                false,
1038            );
1039            let separator_click = separator_click_config(
1040                &render,
1041                pane,
1042                view.on_context_separator_click.clone(),
1043                view.context_separator_hover_style,
1044                view.context_expand_lines,
1045            );
1046            let scroll_to_hunk_line = view
1047                .scroll_to_hunk
1048                .and_then(|hunk_index| hunk_logical_line(&render, hunk_index));
1049            let mut area = view
1050                .text_area
1051                .clone()
1052                .value(render.raw_text.clone())
1053                .read_only(!view.editable)
1054                .color_strategy(diff_strategy)
1055                .gutter_lines(gutter_spans, gutter_col_width)
1056                .copy_excluded_bytes(excluded_bytes)
1057                .selection_excluded_lines(excluded_source_lines);
1058
1059            area = area.line_numbers(false);
1060
1061            area.peer_source_lines = match pane {
1062                DiffPane::Left => left_peer_source_lines.clone(),
1063                DiffPane::Right => right_peer_source_lines.clone(),
1064                DiffPane::Unified => None,
1065            };
1066            area.split_wrap_sync = split_wrap_state.clone();
1067            area.split_wrap_side = match pane {
1068                DiffPane::Left => Some(SplitPaneSide::Left),
1069                DiffPane::Right => Some(SplitPaneSide::Right),
1070                DiffPane::Unified => None,
1071            };
1072            area.split_wrap_padding_gutter_style = split_wrap_padding_gutter_style;
1073            area.split_wrap_padding_style = split_wrap_padding_style;
1074            area.diff_context_separator_click = separator_click;
1075
1076            if let Some(v) = view.wrap_override {
1077                area = area.wrap(v);
1078            }
1079            if let Some(v) = view.scrollbar_override {
1080                area = area.scrollbar(v);
1081            }
1082            if let Some(v) = view.h_scrollbar_override {
1083                area = area.h_scrollbar(v);
1084            }
1085            if let Some(v) = view.focusable_override {
1086                area = area.focusable(v);
1087            }
1088
1089            if view.single_scrollbar && matches!(view.mode, DiffViewMode::Split) {
1090                let is_right = matches!(pane, DiffPane::Right);
1091                area = area.scrollbar(is_right);
1092                area.pin_scrollbar_focus_style = is_right;
1093            }
1094
1095            // Border is handled by pane wrapper frames.
1096            area = area.border(false);
1097
1098            if use_auto_pane_height {
1099                area = area.height(Length::Auto);
1100            }
1101
1102            if let Some(offset) = view.scroll_offset {
1103                area = area.scroll_offset(offset);
1104            }
1105            if let Some(line) = scroll_to_hunk_line {
1106                area = area.scroll_to_line(line);
1107            }
1108
1109            let existing_on_scroll = area.on_scroll.clone();
1110            let view_on_scroll = view.on_scroll.clone();
1111            if existing_on_scroll.is_some() || view_on_scroll.is_some() {
1112                area = area.on_scroll(Callback::new(move |event: ScrollEvent| {
1113                    if let Some(cb) = &existing_on_scroll {
1114                        cb.emit(event);
1115                    }
1116                    if let Some(cb) = &view_on_scroll {
1117                        cb.emit(DiffScrollEvent {
1118                            pane,
1119                            scroll: event,
1120                        });
1121                    }
1122                }));
1123            }
1124
1125            area.into()
1126        };
1127
1128        let build_document_view = |render: &DiffRender, pane: DiffPane| {
1129            let pane_data = get_pane_data(&view.pane_cache, render, pane, pane_options);
1130            let render = pane_data.numbered_render;
1131            let gutter_spans = pane_data.gutter_spans;
1132            let gutter_col_width = pane_data.gutter_col_width;
1133            let excluded_source_lines = pane_data.excluded_source_lines;
1134            let formatter = DiffDocumentFormatter::new(
1135                render.clone(),
1136                base_strategy.clone(),
1137                view.diff_style,
1138                view.highlight_full_width,
1139                false,
1140                language.clone(),
1141                theme.clone(),
1142            );
1143            let separator_click = separator_click_config(
1144                &render,
1145                pane,
1146                view.on_context_separator_click.clone(),
1147                view.context_separator_hover_style,
1148                view.context_expand_lines,
1149            );
1150            let scroll_to_hunk_line = view
1151                .scroll_to_hunk
1152                .and_then(|hunk_index| hunk_logical_line(&render, hunk_index));
1153            let mut doc = view.document_view.clone();
1154            doc.value = render.raw_text.clone();
1155            doc.content_type = Some("diff".into());
1156            doc.formatter = Some(Rc::new(formatter));
1157            doc.highlight_full_width = view.highlight_full_width;
1158            doc.gutter_lines = Some(gutter_spans);
1159            doc.gutter_col_width = gutter_col_width;
1160            doc.copy_excluded_source_lines = Some(excluded_source_lines);
1161            doc.shared_selection_id = pane_shared_selection_id(pane);
1162            doc.peer_source_lines = match pane {
1163                DiffPane::Left => left_peer_source_lines.clone(),
1164                DiffPane::Right => right_peer_source_lines.clone(),
1165                DiffPane::Unified => None,
1166            };
1167            doc.split_wrap_sync = split_wrap_state.clone();
1168            doc.split_wrap_side = match pane {
1169                DiffPane::Left => Some(SplitPaneSide::Left),
1170                DiffPane::Right => Some(SplitPaneSide::Right),
1171                DiffPane::Unified => None,
1172            };
1173            doc.diff_split_pane = if matches!(view.mode, DiffViewMode::Split) {
1174                Some(pane)
1175            } else {
1176                None
1177            };
1178            doc.split_wrap_padding_gutter_style = split_wrap_padding_gutter_style;
1179            doc.split_wrap_padding_style = split_wrap_padding_style;
1180            doc.diff_context_separator_click = separator_click;
1181
1182            doc = doc.line_numbers(false);
1183
1184            if let Some(v) = view.wrap_override {
1185                doc = doc.wrap(v);
1186            }
1187            if let Some(v) = view.scrollbar_override {
1188                doc = doc.scrollbar(v);
1189            }
1190            if let Some(v) = view.h_scrollbar_override {
1191                doc = doc.h_scrollbar(v);
1192            }
1193            if let Some(v) = view.focusable_override {
1194                doc = doc.focusable(v);
1195            }
1196
1197            if view.single_scrollbar && matches!(view.mode, DiffViewMode::Split) {
1198                let is_right = matches!(pane, DiffPane::Right);
1199                doc = doc.scrollbar(is_right);
1200                doc.pin_scrollbar_focus_style = is_right;
1201            }
1202
1203            // Border is handled by pane wrapper frames.
1204            doc = doc.border(false);
1205
1206            if use_auto_pane_height {
1207                doc.height = Length::Auto;
1208            }
1209
1210            if let Some(offset) = view.scroll_offset {
1211                doc.scroll_offset = Some(offset);
1212            }
1213            if let Some(line) = scroll_to_hunk_line {
1214                doc = doc.scroll_to_source_line(line);
1215            }
1216
1217            let existing_on_scroll = doc.on_scroll.clone();
1218            let view_on_scroll = view.on_scroll.clone();
1219            if existing_on_scroll.is_some() || view_on_scroll.is_some() {
1220                doc.on_scroll = Some(Callback::new(move |event: ScrollEvent| {
1221                    if let Some(cb) = &existing_on_scroll {
1222                        cb.emit(event);
1223                    }
1224                    if let Some(cb) = &view_on_scroll {
1225                        cb.emit(DiffScrollEvent {
1226                            pane,
1227                            scroll: event,
1228                        });
1229                    }
1230                }));
1231            }
1232
1233            doc.into()
1234        };
1235
1236        let build_pane = |render: &DiffRender, pane: DiffPane, is_left: bool| -> Element {
1237            let inner: Element = match view.backend {
1238                DiffViewBackend::TextArea => build_text_area(render, pane),
1239                DiffViewBackend::DocumentView => build_document_view(render, pane),
1240            };
1241
1242            let right_pad = if is_left && view.single_scrollbar && view.effective_scrollbar() {
1243                1
1244            } else {
1245                0
1246            };
1247
1248            let mut pane = Frame::new()
1249                .border(view.pane_border)
1250                .join_frame(view.join_frame)
1251                .padding((0, right_pad, 0, 0))
1252                .child(inner);
1253
1254            if use_auto_pane_height {
1255                pane = pane.height(Length::Auto);
1256            }
1257
1258            pane.into()
1259        };
1260
1261        let content = match view.mode {
1262            DiffViewMode::Split => {
1263                let left = build_pane(&left_render, DiffPane::Left, true);
1264                let right = build_pane(&right_render, DiffPane::Right, false);
1265                if view.vertical_separator {
1266                    HStack::new()
1267                        .even_flex(true)
1268                        .child(left)
1269                        .child(
1270                            Divider::vertical()
1271                                .ch(view.vertical_separator_char)
1272                                .style(view.vertical_separator_style)
1273                                .join_frame(view.join_frame),
1274                        )
1275                        .child(right)
1276                        .into()
1277                } else {
1278                    HStack::new()
1279                        .even_flex(true)
1280                        .child(left)
1281                        .child(right)
1282                        .into()
1283                }
1284            }
1285            DiffViewMode::Unified => build_pane(&unified_render, DiffPane::Unified, false),
1286        };
1287
1288        Frame::new()
1289            .border(view.outer_border)
1290            .width(outer_width)
1291            .height(outer_height)
1292            .padding(0)
1293            .child(content)
1294            .into()
1295    }
1296}
1297
1298mod gutter;
1299pub(crate) use gutter::*;
1300
1301#[cfg(test)]
1302mod tests;