Skip to main content

gpui_rhai/
document_view.rs

1//! Retained native `CodeViewer` and `DiffViewer` surfaces.
2
3use std::cell::RefCell;
4use std::collections::{BTreeMap, BTreeSet};
5use std::ops::Range;
6use std::rc::Rc;
7use std::sync::Arc;
8
9use gpui::prelude::FluentBuilder as _;
10use gpui::{
11    AnyElement, App, AppContext as _, Bounds, Context, CursorStyle, Element, ElementId, Entity,
12    FocusHandle, Focusable, FontWeight, GlobalElementId, HighlightStyle, InspectorElementId,
13    InteractiveElement, IntoElement, KeyBinding, ListHorizontalSizingBehavior, MouseButton,
14    MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Render, ScrollHandle,
15    ScrollStrategy, SharedString, StatefulInteractiveElement, Styled, StyledText, Task, Window,
16    actions, div, px, relative, rgba, uniform_list,
17};
18use regex::RegexBuilder;
19use similar::TextDiff;
20use unicode_segmentation::UnicodeSegmentation as _;
21
22use crate::text_input::{
23    NativeTypography, TextInputCallbacks, TextInputConfig, TextInputEntity, native_typography,
24};
25use crate::{
26    DiffDisplayRow, DiffRowKind, DiffViewMode, DiffWhitespace, DocumentDescriptor,
27    DocumentRuntimeConfig, DocumentSource, DocumentWrap, EventSchema, ObjectField, PreparedDiff,
28    PreparedDocument, PrimitiveContext, PrimitiveDescriptor, PrimitiveHandler, PrimitiveId,
29    PrimitiveInstance, PrimitiveInstanceId, PrimitiveProps, PrimitiveTheme, PrimitiveValue, Style,
30    SyntaxRegistry, UiValue, ValueSchema, prepare_diff_with_limits, prepare_document_with_limits,
31};
32
33const CODE_CONTEXT: &str = "GPUIRhaiCodeViewer";
34const DIFF_CONTEXT: &str = "GPUIRhaiDiffViewer";
35
36actions!(
37    gpui_rhai_document,
38    [
39        Find,
40        FindNext,
41        FindPrevious,
42        CloseFind,
43        CopyDocumentSelection,
44        NextChange,
45        PreviousChange,
46        ExpandAll,
47        CollapseAll,
48        CopyUnifiedDiff,
49        ActivateDocumentLocation,
50    ]
51);
52
53#[derive(Clone, Debug, PartialEq, gpui::Action)]
54#[action(namespace = gpui_rhai_document, no_json)]
55pub struct RevealDocumentLine {
56    pub side: Option<DiffSide>,
57    /// One-based logical source line.
58    pub line: usize,
59}
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62/// One pane of a neutral two-way comparison.
63pub enum DiffSide {
64    /// The left document descriptor.
65    Left,
66    /// The right document descriptor.
67    Right,
68}
69
70pub fn init_document_view(cx: &mut App) {
71    cx.bind_keys([
72        KeyBinding::new("cmd-f", Find, Some(CODE_CONTEXT)),
73        KeyBinding::new("cmd-g", FindNext, Some(CODE_CONTEXT)),
74        KeyBinding::new("shift-cmd-g", FindPrevious, Some(CODE_CONTEXT)),
75        KeyBinding::new("escape", CloseFind, Some(CODE_CONTEXT)),
76        KeyBinding::new("cmd-c", CopyDocumentSelection, Some(CODE_CONTEXT)),
77        KeyBinding::new("enter", ActivateDocumentLocation, Some(CODE_CONTEXT)),
78        KeyBinding::new("cmd-f", Find, Some(DIFF_CONTEXT)),
79        KeyBinding::new("cmd-g", FindNext, Some(DIFF_CONTEXT)),
80        KeyBinding::new("shift-cmd-g", FindPrevious, Some(DIFF_CONTEXT)),
81        KeyBinding::new("escape", CloseFind, Some(DIFF_CONTEXT)),
82        KeyBinding::new("cmd-c", CopyDocumentSelection, Some(DIFF_CONTEXT)),
83        KeyBinding::new("enter", ActivateDocumentLocation, Some(DIFF_CONTEXT)),
84        KeyBinding::new("alt-down", NextChange, Some(DIFF_CONTEXT)),
85        KeyBinding::new("alt-up", PreviousChange, Some(DIFF_CONTEXT)),
86        KeyBinding::new("shift-cmd-e", ExpandAll, Some(DIFF_CONTEXT)),
87        KeyBinding::new("shift-cmd-c", CollapseAll, Some(DIFF_CONTEXT)),
88        KeyBinding::new("alt-cmd-c", CopyUnifiedDiff, Some(DIFF_CONTEXT)),
89    ]);
90}
91
92#[derive(Clone, Copy, Debug, Eq, PartialEq)]
93enum DocumentSide {
94    Single,
95    Left,
96    Right,
97}
98
99impl DocumentSide {
100    const fn as_str(self) -> &'static str {
101        match self {
102            Self::Single => "single",
103            Self::Left => "left",
104            Self::Right => "right",
105        }
106    }
107}
108
109#[derive(Clone)]
110struct ActiveDocumentSelection {
111    side: DocumentSide,
112    source: Arc<str>,
113    anchor: usize,
114    head: usize,
115}
116
117#[derive(Clone, Default)]
118struct DocumentSelection {
119    active: Rc<RefCell<Option<ActiveDocumentSelection>>>,
120}
121
122impl DocumentSelection {
123    fn begin(&self, side: DocumentSide, source: Arc<str>, offset: usize) {
124        self.active.borrow_mut().replace(ActiveDocumentSelection {
125            side,
126            source,
127            anchor: offset,
128            head: offset,
129        });
130    }
131
132    fn update(&self, side: DocumentSide, offset: usize) {
133        if let Some(selection) = self
134            .active
135            .borrow_mut()
136            .as_mut()
137            .filter(|selection| selection.side == side)
138        {
139            selection.head = offset.min(selection.source.len());
140        }
141    }
142
143    fn range_for(
144        &self,
145        side: DocumentSide,
146        source: &Arc<str>,
147        range: Range<usize>,
148    ) -> Option<Range<usize>> {
149        let selection = self.active.borrow();
150        let selection = selection
151            .as_ref()
152            .filter(|selection| selection.side == side && Arc::ptr_eq(&selection.source, source))?;
153        let start = selection.anchor.min(selection.head);
154        let end = selection.anchor.max(selection.head);
155        let clipped_start = start.max(range.start);
156        let clipped_end = end.min(range.end);
157        (clipped_start < clipped_end).then_some(
158            clipped_start.saturating_sub(range.start)..clipped_end.saturating_sub(range.start),
159        )
160    }
161
162    fn selected_text(&self) -> Option<String> {
163        let selection = self.active.borrow();
164        let selection = selection.as_ref()?;
165        let start = selection.anchor.min(selection.head);
166        let end = selection.anchor.max(selection.head);
167        (start < end)
168            .then(|| selection.source.get(start..end).map(ToOwned::to_owned))
169            .flatten()
170    }
171
172    fn position(&self) -> Option<(DocumentSide, Arc<str>, usize)> {
173        let selection = self.active.borrow();
174        let selection = selection.as_ref()?;
175        Some((
176            selection.side,
177            Arc::clone(&selection.source),
178            selection.head,
179        ))
180    }
181
182    fn clear(&self) {
183        self.active.borrow_mut().take();
184    }
185
186    fn rebase(&self, side: DocumentSide, previous: &Arc<str>, next: Arc<str>) {
187        let mut active = self.active.borrow_mut();
188        let Some(selection) = active
189            .as_mut()
190            .filter(|selection| selection.side == side && Arc::ptr_eq(&selection.source, previous))
191        else {
192            return;
193        };
194        selection.anchor = clamp_char_boundary(&next, selection.anchor);
195        selection.head = clamp_char_boundary(&next, selection.head);
196        selection.source = next;
197    }
198}
199
200#[derive(Clone, Debug, PartialEq)]
201struct CodeViewerConfig {
202    descriptor: DocumentDescriptor,
203    show_line_numbers: bool,
204    wrap: DocumentWrap,
205    tab_size: usize,
206    monospace_family: SharedString,
207    gutter_style: Style,
208    line_style: Style,
209    text_style: Style,
210    loading_style: Style,
211    error_style: Style,
212    search_style: Style,
213}
214
215impl CodeViewerConfig {
216    fn preparation_changed(&self, next: &Self) -> bool {
217        self.descriptor.source != next.descriptor.source
218            || self.descriptor.file_name != next.descriptor.file_name
219            || self.descriptor.language != next.descriptor.language
220    }
221}
222
223#[cfg(target_os = "macos")]
224const DOCUMENT_MONOSPACE_CANDIDATES: &[&str] = &["Lilex", "SF Mono", "Menlo", "Monaco"];
225#[cfg(target_os = "macos")]
226const DOCUMENT_MONOSPACE_FALLBACK: &str = "Menlo";
227
228#[cfg(target_os = "windows")]
229const DOCUMENT_MONOSPACE_CANDIDATES: &[&str] = &[
230    "Lilex",
231    "Cascadia Mono",
232    "Cascadia Code",
233    "Consolas",
234    "Courier New",
235];
236#[cfg(target_os = "windows")]
237const DOCUMENT_MONOSPACE_FALLBACK: &str = "Consolas";
238
239#[cfg(any(target_os = "linux", target_os = "freebsd"))]
240const DOCUMENT_MONOSPACE_CANDIDATES: &[&str] = &[
241    "Lilex",
242    "DejaVu Sans Mono",
243    "Liberation Mono",
244    "Noto Sans Mono",
245    "Ubuntu Mono",
246];
247#[cfg(any(target_os = "linux", target_os = "freebsd"))]
248const DOCUMENT_MONOSPACE_FALLBACK: &str = "DejaVu Sans Mono";
249
250#[cfg(not(any(
251    target_os = "macos",
252    target_os = "windows",
253    target_os = "linux",
254    target_os = "freebsd"
255)))]
256const DOCUMENT_MONOSPACE_CANDIDATES: &[&str] = &["Lilex", "DejaVu Sans Mono", "Courier New"];
257#[cfg(not(any(
258    target_os = "macos",
259    target_os = "windows",
260    target_os = "linux",
261    target_os = "freebsd"
262)))]
263const DOCUMENT_MONOSPACE_FALLBACK: &str = "DejaVu Sans Mono";
264
265fn select_document_monospace_family(
266    available: &[String],
267    candidates: &[&str],
268    fallback: &str,
269) -> SharedString {
270    candidates
271        .iter()
272        .find_map(|candidate| {
273            available
274                .iter()
275                .find(|family| family.eq_ignore_ascii_case(candidate))
276        })
277        .cloned()
278        .unwrap_or_else(|| fallback.to_owned())
279        .into()
280}
281
282fn resolve_document_monospace_family(cx: &App) -> SharedString {
283    select_document_monospace_family(
284        &cx.text_system().all_font_names(),
285        DOCUMENT_MONOSPACE_CANDIDATES,
286        DOCUMENT_MONOSPACE_FALLBACK,
287    )
288}
289
290#[derive(Clone, Debug)]
291struct DisplaySegment {
292    logical_line: usize,
293    range: Range<usize>,
294    first: bool,
295    column_offset: usize,
296}
297
298#[derive(Clone, Copy, Debug, Eq, PartialEq)]
299enum PendingDisplayColumns {
300    Unwrapped,
301    Wrapped(usize),
302}
303
304impl From<Option<usize>> for PendingDisplayColumns {
305    fn from(columns: Option<usize>) -> Self {
306        columns.map_or(Self::Unwrapped, Self::Wrapped)
307    }
308}
309
310fn build_code_display(
311    document: &PreparedDocument,
312    columns: Option<usize>,
313    tab_size: usize,
314) -> Arc<[DisplaySegment]> {
315    let mut display = Vec::with_capacity(document.lines().len());
316    for (line_index, line) in document.lines().iter().enumerate() {
317        let text = document.line_text(line_index).unwrap_or_default();
318        for (range, first, column_offset) in wrap_source_line(&line.range, text, columns, tab_size)
319        {
320            display.push(DisplaySegment {
321                logical_line: line_index,
322                range,
323                first,
324                column_offset,
325            });
326        }
327    }
328    display.into()
329}
330
331#[derive(Clone, Copy, Debug, Default)]
332struct SearchOptions {
333    case_sensitive: bool,
334    whole_word: bool,
335    regex: bool,
336}
337
338fn search_ranges(
339    text: &str,
340    query: &str,
341    options: SearchOptions,
342    limit: usize,
343) -> Result<Vec<Range<usize>>, String> {
344    let pattern = if options.regex {
345        query.to_owned()
346    } else {
347        regex::escape(query)
348    };
349    let pattern = if options.whole_word {
350        format!(r"\b(?:{pattern})\b")
351    } else {
352        pattern
353    };
354    RegexBuilder::new(&pattern)
355        .case_insensitive(!options.case_sensitive)
356        .build()
357        .map(|regex| {
358            regex
359                .find_iter(text)
360                .map(|found| found.start()..found.end())
361                .take(limit)
362                .collect()
363        })
364        .map_err(|error| error.to_string())
365}
366
367#[derive(Clone, Debug, Default)]
368struct SearchState {
369    open: bool,
370    query: String,
371    options: SearchOptions,
372    matches: Vec<Range<usize>>,
373    current: Option<usize>,
374    error: Option<String>,
375}
376
377struct CodeViewerEntity {
378    config: CodeViewerConfig,
379    events: PrimitiveContext,
380    theme: PrimitiveTheme,
381    syntaxes: SyntaxRegistry,
382    document_runtime: DocumentRuntimeConfig,
383    focus: FocusHandle,
384    scroll: gpui::UniformListScrollHandle,
385    prepared: Option<Arc<PreparedDocument>>,
386    display: Arc<[DisplaySegment]>,
387    display_columns: Option<usize>,
388    display_job: u64,
389    display_task: Option<Task<()>>,
390    pending_display_columns: Option<PendingDisplayColumns>,
391    viewport: Option<Bounds<Pixels>>,
392    selection: DocumentSelection,
393    search: SearchState,
394    search_input: Entity<TextInputEntity>,
395    search_job: u64,
396    search_task: Option<Task<()>>,
397    job: u64,
398    loading: bool,
399    error: Option<String>,
400    prepare_task: Option<Task<()>>,
401}
402
403impl CodeViewerEntity {
404    fn new(
405        config: CodeViewerConfig,
406        events: PrimitiveContext,
407        theme: PrimitiveTheme,
408        syntaxes: SyntaxRegistry,
409        document_runtime: DocumentRuntimeConfig,
410        search_typography: NativeTypography,
411        cx: &mut Context<Self>,
412    ) -> Self {
413        let weak = cx.entity().downgrade();
414        let callbacks = TextInputCallbacks {
415            change: Some(Rc::new({
416                let weak = weak.clone();
417                move |value, _, cx| {
418                    let _ = weak.update(cx, |viewer, cx| {
419                        viewer.search.query = value;
420                        viewer.start_search(cx);
421                        cx.notify();
422                    });
423                }
424            })),
425            submit: Some(Rc::new(move |_, _, cx| {
426                let _ = weak.update(cx, |viewer, cx| viewer.advance_match(false, cx));
427            })),
428            ..TextInputCallbacks::default()
429        };
430        let selection_color = theme_color(&theme, "selection", 0x3347_6fff);
431        let caret_color = theme_color(&theme, "accent", 0x7aa2_f7ff);
432        let search_input = cx.new(|cx| {
433            TextInputEntity::new(
434                TextInputConfig {
435                    value: String::new(),
436                    placeholder: "Find in document".to_owned(),
437                    disabled: false,
438                    read_only: false,
439                    autofocus: false,
440                    selection_color,
441                    caret_color,
442                    typography: search_typography,
443                },
444                callbacks,
445                cx,
446            )
447        });
448        Self {
449            config,
450            events,
451            theme,
452            syntaxes,
453            document_runtime,
454            focus: cx.focus_handle(),
455            scroll: gpui::UniformListScrollHandle::new(),
456            prepared: None,
457            display: Arc::from([]),
458            display_columns: None,
459            display_job: 0,
460            display_task: None,
461            pending_display_columns: None,
462            viewport: None,
463            selection: DocumentSelection::default(),
464            search: SearchState::default(),
465            search_input,
466            search_job: 0,
467            search_task: None,
468            job: 0,
469            loading: false,
470            error: None,
471            prepare_task: None,
472        }
473    }
474
475    fn update(
476        &mut self,
477        config: CodeViewerConfig,
478        events: PrimitiveContext,
479        theme: &PrimitiveTheme,
480        search_typography: NativeTypography,
481        cx: &mut Context<Self>,
482    ) {
483        let needs_prepare = self.config.preparation_changed(&config);
484        let presentation_changed =
485            self.config.wrap != config.wrap || self.config.tab_size != config.tab_size;
486        self.config = config;
487        self.events = events;
488        self.theme = theme.clone();
489        let callbacks = self.search_input.read(cx).callbacks();
490        self.search_input.update(cx, |input, cx| {
491            input.update_props(
492                &TextInputConfig {
493                    value: self.search.query.clone(),
494                    placeholder: "Find in document".to_owned(),
495                    disabled: false,
496                    read_only: false,
497                    autofocus: false,
498                    selection_color: theme_color(theme, "selection", 0x3347_6fff),
499                    caret_color: theme_color(theme, "accent", 0x7aa2_f7ff),
500                    typography: search_typography,
501                },
502                callbacks,
503                cx,
504            );
505        });
506        if needs_prepare || (self.loading && presentation_changed) {
507            self.start_prepare(cx);
508        } else if presentation_changed {
509            self.start_display(cx, true);
510        } else {
511            cx.notify();
512        }
513    }
514
515    fn start_prepare(&mut self, cx: &mut Context<Self>) {
516        self.job = self.job.saturating_add(1);
517        self.display_job = self.display_job.saturating_add(1);
518        self.display_task = None;
519        self.pending_display_columns = None;
520        let job = self.job;
521        let descriptor = self.config.descriptor.clone();
522        let syntaxes = self.syntaxes.clone();
523        let limits = self.document_runtime.limits();
524        let columns = self.wrap_columns();
525        let tab_size = self.config.tab_size;
526        self.loading = true;
527        self.error = None;
528        self.prepare_task = Some(cx.spawn(async move |this, cx| {
529            let result = cx
530                .background_spawn(async move {
531                    prepare_document_with_limits(&descriptor, &syntaxes, limits).map(|document| {
532                        let display = build_code_display(&document, columns, tab_size);
533                        (document, display)
534                    })
535                })
536                .await;
537            let _ = this.update(cx, |viewer, cx| {
538                if viewer.job != job {
539                    return;
540                }
541                viewer.loading = false;
542                match result {
543                    Ok((document, display)) => {
544                        let document = Arc::new(document);
545                        viewer.display_job = viewer.display_job.saturating_add(1);
546                        viewer.display_task = None;
547                        viewer.pending_display_columns = None;
548                        if let Some(previous) = viewer.prepared.as_ref() {
549                            if previous.identity() == document.identity() {
550                                viewer.selection.rebase(
551                                    DocumentSide::Single,
552                                    &previous.text_arc(),
553                                    document.text_arc(),
554                                );
555                            } else {
556                                viewer.selection.clear();
557                                viewer.scroll.scroll_to_item_strict(0, ScrollStrategy::Top);
558                            }
559                        }
560                        viewer.error = None;
561                        viewer.prepared = Some(document);
562                        viewer.display = display;
563                        viewer.display_columns = columns;
564                        viewer.start_search(cx);
565                    }
566                    Err(error) => viewer.error = Some(error.to_string()),
567                }
568                cx.notify();
569            });
570        }));
571    }
572
573    fn wrap_columns(&self) -> Option<usize> {
574        match self.config.wrap {
575            DocumentWrap::None => None,
576            DocumentWrap::Column(columns) => Some(columns.max(1)),
577            DocumentWrap::Viewport => self.viewport.map(|bounds| {
578                let gutter = if self.config.show_line_numbers {
579                    72.0
580                } else {
581                    16.0
582                };
583                let available = (f64::from(bounds.size.width) - gutter).max(20.0);
584                let (text_size, _) = document_text_metrics(&self.config.text_style, &self.theme);
585                let character_width = (f64::from(text_size) * 0.604).max(1.0);
586                (available / character_width)
587                    .floor()
588                    .to_string()
589                    .parse()
590                    .unwrap_or(80)
591                    .max(1)
592            }),
593        }
594    }
595
596    fn start_display(&mut self, cx: &mut Context<Self>, force: bool) {
597        let Some(document) = self.prepared.clone() else {
598            return;
599        };
600        let columns = self.wrap_columns();
601        if !force && self.display_columns == columns && !self.display.is_empty() {
602            return;
603        }
604        if !force && self.pending_display_columns == Some(columns.into()) {
605            return;
606        }
607        self.display_job = self.display_job.saturating_add(1);
608        let job = self.display_job;
609        let tab_size = self.config.tab_size;
610        self.pending_display_columns = Some(columns.into());
611        self.display_task = Some(cx.spawn(async move |this, cx| {
612            let display = cx
613                .background_spawn(async move { build_code_display(&document, columns, tab_size) })
614                .await;
615            let _ = this.update(cx, |viewer, cx| {
616                if viewer.display_job == job {
617                    viewer.display = display;
618                    viewer.display_columns = columns;
619                    viewer.pending_display_columns = None;
620                    cx.notify();
621                }
622            });
623        }));
624    }
625
626    fn start_search(&mut self, cx: &mut Context<Self>) {
627        self.search_job = self.search_job.saturating_add(1);
628        let job = self.search_job;
629        self.search.matches.clear();
630        self.search.error = None;
631        self.search.current = None;
632        let Some(document) = self.prepared.as_ref() else {
633            return;
634        };
635        if self.search.query.is_empty() {
636            self.search_task = None;
637            return;
638        }
639        let text = document.text_arc();
640        let query = self.search.query.clone();
641        let options = self.search.options;
642        self.search_task = Some(cx.spawn(async move |this, cx| {
643            let result = cx
644                .background_spawn(async move { search_ranges(&text, &query, options, 100_000) })
645                .await;
646            let _ = this.update(cx, |viewer, cx| {
647                if viewer.search_job != job {
648                    return;
649                }
650                match result {
651                    Ok(matches) => {
652                        viewer.search.matches = matches;
653                        viewer.search.current = (!viewer.search.matches.is_empty()).then_some(0);
654                    }
655                    Err(error) => viewer.search.error = Some(error),
656                }
657                cx.notify();
658            });
659        }));
660    }
661
662    fn advance_match(&mut self, backwards: bool, cx: &mut Context<Self>) {
663        if self.loading || self.search.matches.is_empty() {
664            return;
665        }
666        let current = self.search.current.unwrap_or(0);
667        let next = if backwards {
668            (current + self.search.matches.len() - 1) % self.search.matches.len()
669        } else {
670            (current + 1) % self.search.matches.len()
671        };
672        self.search.current = Some(next);
673        self.reveal_match(next);
674        cx.notify();
675    }
676
677    fn reveal_match(&self, index: usize) {
678        let Some(target) = self.search.matches.get(index) else {
679            return;
680        };
681        if let Some(display_index) = self.display.iter().position(|segment| {
682            target.start >= segment.range.start && target.start <= segment.range.end
683        }) {
684            self.scroll
685                .scroll_to_item(display_index, ScrollStrategy::Center);
686        }
687    }
688
689    fn open_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
690        self.search.open = true;
691        self.search_input.focus_handle(cx).focus(window, cx);
692        cx.notify();
693    }
694
695    fn find(&mut self, _: &Find, window: &mut Window, cx: &mut Context<Self>) {
696        self.open_find(window, cx);
697    }
698
699    fn find_next(&mut self, _: &FindNext, _: &mut Window, cx: &mut Context<Self>) {
700        self.advance_match(false, cx);
701    }
702
703    fn find_previous(&mut self, _: &FindPrevious, _: &mut Window, cx: &mut Context<Self>) {
704        self.advance_match(true, cx);
705    }
706
707    fn close_find(&mut self, _: &CloseFind, window: &mut Window, cx: &mut Context<Self>) {
708        if self.search.open {
709            self.search.open = false;
710            self.focus.focus(window, cx);
711            cx.notify();
712        }
713    }
714
715    fn copy_selection(
716        &mut self,
717        _: &CopyDocumentSelection,
718        _: &mut Window,
719        cx: &mut Context<Self>,
720    ) {
721        if self.loading {
722            return;
723        }
724        if let Some(text) = self.selection.selected_text() {
725            cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
726            cx.stop_propagation();
727        }
728    }
729
730    fn reveal_line(&mut self, action: &RevealDocumentLine, _: &mut Window, cx: &mut Context<Self>) {
731        if action.side.is_some() || action.line == 0 {
732            return;
733        }
734        let line = action.line - 1;
735        if let Some(index) = self
736            .display
737            .iter()
738            .position(|segment| segment.logical_line == line)
739        {
740            self.scroll.scroll_to_item(index, ScrollStrategy::Center);
741            cx.notify();
742        }
743    }
744
745    fn activate_location(
746        &mut self,
747        _: &ActivateDocumentLocation,
748        window: &mut Window,
749        cx: &mut Context<Self>,
750    ) {
751        if self.loading {
752            return;
753        }
754        let Some(document) = self.prepared.as_ref() else {
755            return;
756        };
757        let Some((DocumentSide::Single, source, offset)) = self.selection.position() else {
758            return;
759        };
760        if !Arc::ptr_eq(&source, &document.text_arc()) {
761            return;
762        }
763        if let Some(payload) = source_location_payload(document, offset, None) {
764            let events = self.events.clone();
765            window.defer(cx, move |window, cx| {
766                let _ = events.emit("location_activate", payload, window, cx);
767            });
768        }
769    }
770
771    fn toggle_search_option(&mut self, option: &'static str, cx: &mut Context<Self>) {
772        match option {
773            "case" => self.search.options.case_sensitive = !self.search.options.case_sensitive,
774            "word" => self.search.options.whole_word = !self.search.options.whole_word,
775            "regex" => self.search.options.regex = !self.search.options.regex,
776            _ => return,
777        }
778        self.start_search(cx);
779        cx.notify();
780    }
781
782    fn search_bar(&self, cx: &mut Context<Self>) -> AnyElement {
783        let weak = cx.entity().downgrade();
784        let button = |label: &'static str, option: &'static str, active: bool| {
785            let weak = weak.clone();
786            div()
787                .id(SharedString::from(format!(
788                    "document-search-option-{option}"
789                )))
790                .w(px(28.0))
791                .h(px(24.0))
792                .flex()
793                .items_center()
794                .justify_center()
795                .text_size(px(11.0))
796                .font_weight(FontWeight::SEMIBOLD)
797                .bg(if active {
798                    rgba(theme_color(&self.theme, "selection", 0x3347_6fff).as_rgba_hex())
799                } else {
800                    rgba(0x0000_0000)
801                })
802                .hover(|style| {
803                    style.bg(rgba(
804                        theme_color(&self.theme, "surface_hover", 0x252a_34ff).as_rgba_hex(),
805                    ))
806                })
807                .on_mouse_down(MouseButton::Left, move |_, _, cx| {
808                    let _ = weak.update(cx, |viewer, cx| viewer.toggle_search_option(option, cx));
809                })
810                .child(label)
811        };
812        let status = self.search.error.as_ref().map_or_else(
813            || {
814                if self.search.matches.is_empty() {
815                    "No matches".to_owned()
816                } else {
817                    format!(
818                        "{} / {}",
819                        self.search.current.unwrap_or(0) + 1,
820                        self.search.matches.len()
821                    )
822                }
823            },
824            |error| format!("Invalid regex: {error}"),
825        );
826        let bar = div()
827            .absolute()
828            .top(px(8.0))
829            .right(px(8.0))
830            .w(px(430.0))
831            .h(px(38.0))
832            .flex()
833            .items_center()
834            .gap(px(4.0))
835            .px(px(6.0))
836            .bg(rgba(
837                theme_color(&self.theme, "surface_raised", 0x1a1d_24ff).as_rgba_hex(),
838            ))
839            .border_1()
840            .border_color(rgba(
841                theme_color(&self.theme, "border", 0x353b_48ff).as_rgba_hex(),
842            ))
843            .child(
844                div()
845                    .h(px(26.0))
846                    .flex_1()
847                    .min_w(px(0.0))
848                    .px(px(6.0))
849                    .border_1()
850                    .border_color(rgba(
851                        theme_color(&self.theme, "border", 0x353b_48ff).as_rgba_hex(),
852                    ))
853                    .child(self.search_input.clone()),
854            )
855            .child(button("Aa", "case", self.search.options.case_sensitive))
856            .child(button("W", "word", self.search.options.whole_word))
857            .child(button(".*", "regex", self.search.options.regex))
858            .child(div().w(px(62.0)).text_size(px(10.0)).child(status));
859        crate::renderer::apply_style_override(
860            bar,
861            &self.config.search_style,
862            &self.theme,
863            self.theme.direction(),
864        )
865        .into_any_element()
866    }
867
868    fn content(&self, cx: &mut Context<Self>) -> AnyElement {
869        if let Some(error) = self.error.as_ref() {
870            return crate::renderer::apply_style_override(
871                div()
872                    .size_full()
873                    .flex()
874                    .items_center()
875                    .justify_center()
876                    .text_color(rgba(
877                        theme_color(&self.theme, "danger", 0xf776_8eff).as_rgba_hex(),
878                    ))
879                    .child(format!("CodeViewer error: {error}")),
880                &self.config.error_style,
881                &self.theme,
882                self.theme.direction(),
883            )
884            .into_any_element();
885        }
886        let Some(document) = self.prepared.clone() else {
887            return crate::renderer::apply_style_override(
888                div()
889                    .size_full()
890                    .flex()
891                    .items_center()
892                    .justify_center()
893                    .text_color(rgba(
894                        theme_color(&self.theme, "text_muted", 0x929a_a8ff).as_rgba_hex(),
895                    ))
896                    .child("Loading document…"),
897                &self.config.loading_style,
898                &self.theme,
899                self.theme.direction(),
900            )
901            .into_any_element();
902        };
903        let display = Arc::clone(&self.display);
904        let selection = self.selection.clone();
905        let search_matches = Arc::<[Range<usize>]>::from(self.search.matches.clone());
906        let current_match = self.search.current;
907        let theme = self.theme.clone();
908        let config = self.config.clone();
909        let events = self.events.clone();
910        let focus = self.focus.clone();
911        let disabled = self.loading;
912        let line_digits = document.lines().len().max(1).to_string().len();
913        let width_index = display
914            .iter()
915            .enumerate()
916            .max_by_key(|(_, segment)| segment.range.end.saturating_sub(segment.range.start))
917            .map(|(index, _)| index);
918        let list = uniform_list(
919            "gpui-rhai-code-viewer-lines",
920            display.len(),
921            move |range, _, _| {
922                range
923                    .filter_map(|index| {
924                        let segment = display.get(index)?;
925                        Some(code_line_element(
926                            &document,
927                            segment,
928                            line_digits,
929                            &config,
930                            &theme,
931                            &selection,
932                            &search_matches,
933                            current_match,
934                            &events,
935                            &focus,
936                            disabled,
937                        ))
938                    })
939                    .collect::<Vec<_>>()
940            },
941        )
942        .track_scroll(&self.scroll)
943        .with_width_from_item(width_index)
944        .when(matches!(self.config.wrap, DocumentWrap::None), |list| {
945            list.with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
946        })
947        .size_full()
948        .opacity(if self.loading { 0.62 } else { 1.0 });
949        let _ = cx;
950        list.into_any_element()
951    }
952}
953
954impl Render for CodeViewerEntity {
955    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
956        let mut root = div()
957            .relative()
958            .flex()
959            .flex_col()
960            .size_full()
961            .min_w(px(0.0))
962            .min_h(px(0.0))
963            .key_context(CODE_CONTEXT)
964            .track_focus(&self.focus)
965            .tab_stop(true)
966            .on_action(cx.listener(Self::find))
967            .on_action(cx.listener(Self::find_next))
968            .on_action(cx.listener(Self::find_previous))
969            .on_action(cx.listener(Self::close_find))
970            .on_action(cx.listener(Self::copy_selection))
971            .on_action(cx.listener(Self::reveal_line))
972            .on_action(cx.listener(Self::activate_location))
973            .child(self.content(cx))
974            .child(DocumentBoundsRecorder {
975                viewer: cx.entity(),
976            });
977        if self.search.open {
978            root = root.child(self.search_bar(cx));
979        }
980        if self.loading && self.prepared.is_some() {
981            root = root.child(
982                div()
983                    .absolute()
984                    .left(px(0.0))
985                    .right(px(0.0))
986                    .bottom(px(0.0))
987                    .h(px(22.0))
988                    .px(px(8.0))
989                    .flex()
990                    .items_center()
991                    .bg(rgba(
992                        theme_color(&self.theme, "surface_raised", 0x1a1d_24ee).as_rgba_hex(),
993                    ))
994                    .text_size(px(10.0))
995                    .child("Updating document…"),
996            );
997        } else if let Some(error) = self
998            .prepared
999            .as_ref()
1000            .and_then(|document| document.highlight_error())
1001        {
1002            root = root.child(
1003                div()
1004                    .absolute()
1005                    .left(px(0.0))
1006                    .right(px(0.0))
1007                    .bottom(px(0.0))
1008                    .h(px(22.0))
1009                    .px(px(8.0))
1010                    .flex()
1011                    .items_center()
1012                    .bg(rgba(
1013                        theme_color(&self.theme, "surface_raised", 0x1a1d_24ee).as_rgba_hex(),
1014                    ))
1015                    .text_size(px(10.0))
1016                    .text_color(rgba(
1017                        theme_color(&self.theme, "warning", 0xe0af_68ff).as_rgba_hex(),
1018                    ))
1019                    .child(format!("Syntax highlighting unavailable: {error}")),
1020            );
1021        }
1022        root
1023    }
1024}
1025
1026struct DocumentBoundsRecorder {
1027    viewer: Entity<CodeViewerEntity>,
1028}
1029
1030impl Element for DocumentBoundsRecorder {
1031    type RequestLayoutState = AnyElement;
1032    type PrepaintState = ();
1033
1034    fn id(&self) -> Option<ElementId> {
1035        None
1036    }
1037
1038    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
1039        None
1040    }
1041
1042    fn request_layout(
1043        &mut self,
1044        _: Option<&GlobalElementId>,
1045        _: Option<&InspectorElementId>,
1046        window: &mut Window,
1047        cx: &mut App,
1048    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
1049        let mut child = div().absolute().inset_0().into_any_element();
1050        let layout = child.request_layout(window, cx);
1051        (layout, child)
1052    }
1053
1054    fn prepaint(
1055        &mut self,
1056        _: Option<&GlobalElementId>,
1057        _: Option<&InspectorElementId>,
1058        bounds: Bounds<Pixels>,
1059        child: &mut Self::RequestLayoutState,
1060        window: &mut Window,
1061        cx: &mut App,
1062    ) {
1063        child.prepaint(window, cx);
1064        self.viewer.update(cx, |viewer, cx| {
1065            if viewer.viewport != Some(bounds) {
1066                viewer.viewport = Some(bounds);
1067            }
1068            if matches!(viewer.config.wrap, DocumentWrap::Viewport)
1069                && viewer.wrap_columns() != viewer.display_columns
1070            {
1071                viewer.start_display(cx, false);
1072            }
1073        });
1074    }
1075
1076    fn paint(
1077        &mut self,
1078        _: Option<&GlobalElementId>,
1079        _: Option<&InspectorElementId>,
1080        _: Bounds<Pixels>,
1081        child: &mut Self::RequestLayoutState,
1082        (): &mut Self::PrepaintState,
1083        window: &mut Window,
1084        cx: &mut App,
1085    ) {
1086        child.paint(window, cx);
1087    }
1088}
1089
1090impl IntoElement for DocumentBoundsRecorder {
1091    type Element = Self;
1092
1093    fn into_element(self) -> Self::Element {
1094        self
1095    }
1096}
1097
1098#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1099fn code_line_element(
1100    document: &Arc<PreparedDocument>,
1101    segment: &DisplaySegment,
1102    line_digits: usize,
1103    config: &CodeViewerConfig,
1104    theme: &PrimitiveTheme,
1105    selection: &DocumentSelection,
1106    matches: &[Range<usize>],
1107    current_match: Option<usize>,
1108    events: &PrimitiveContext,
1109    focus: &FocusHandle,
1110    disabled: bool,
1111) -> AnyElement {
1112    let line = &document.lines()[segment.logical_line];
1113    let text = document
1114        .text()
1115        .get(segment.range.clone())
1116        .unwrap_or_default();
1117    let mut highlights = Vec::new();
1118    for syntax in &line.syntax {
1119        let global = line.range.start + syntax.range.start..line.range.start + syntax.range.end;
1120        if let Some(local) = intersect_local(&global, &segment.range) {
1121            highlights.push((
1122                local,
1123                HighlightStyle {
1124                    color: Some(
1125                        rgba(
1126                            theme_color(theme, syntax.kind.theme_token(), 0xd9de_e8ff)
1127                                .as_rgba_hex(),
1128                        )
1129                        .into(),
1130                    ),
1131                    ..HighlightStyle::default()
1132                },
1133            ));
1134        }
1135    }
1136    for (index, found) in matches.iter().enumerate() {
1137        if let Some(local) = intersect_local(found, &segment.range) {
1138            highlights.push((
1139                local,
1140                HighlightStyle {
1141                    background_color: Some(
1142                        rgba(
1143                            theme_color(
1144                                theme,
1145                                if current_match == Some(index) {
1146                                    "document.search_current"
1147                                } else {
1148                                    "document.search_match"
1149                                },
1150                                if current_match == Some(index) {
1151                                    0xe0af_68aa
1152                                } else {
1153                                    0x7aa2_f766
1154                                },
1155                            )
1156                            .as_rgba_hex(),
1157                        )
1158                        .into(),
1159                    ),
1160                    ..HighlightStyle::default()
1161                },
1162            ));
1163        }
1164    }
1165    let source = document.text_arc();
1166    if let Some(range) = selection.range_for(DocumentSide::Single, &source, segment.range.clone()) {
1167        highlights.push((
1168            range,
1169            HighlightStyle {
1170                background_color: Some(
1171                    rgba(theme_color(theme, "selection", 0x3347_6fff).as_rgba_hex()).into(),
1172                ),
1173                ..HighlightStyle::default()
1174            },
1175        ));
1176    }
1177    let expanded = expand_tabs(text, config.tab_size, segment.column_offset);
1178    let highlights = compose_highlights(
1179        expanded.text.len(),
1180        &remap_highlights(highlights, &expanded.offsets),
1181    );
1182    let styled = StyledText::new(expanded.text).with_highlights(highlights);
1183    let interactive = InteractiveDocumentText {
1184        id: SharedString::from(format!(
1185            "code-line-{}-{}",
1186            segment.logical_line, segment.range.start
1187        ))
1188        .into(),
1189        text: styled,
1190        source,
1191        global_range: segment.range.clone(),
1192        offset_map: expanded.offsets.into(),
1193        line: segment.logical_line,
1194        line_start: line.range.start,
1195        side: DocumentSide::Single,
1196        selection: selection.clone(),
1197        focus: focus.clone(),
1198        events: events.clone(),
1199        disabled,
1200    };
1201    let (text_size, line_height) = document_text_metrics(&config.text_style, theme);
1202    let gutter_width = (usize_f32(line_digits) * 8.0 + 20.0).max(42.0);
1203    let gutter = crate::renderer::apply_style_override(
1204        div()
1205            .w(px(gutter_width))
1206            .h_full()
1207            .flex_none()
1208            .pr(px(10.0))
1209            .flex()
1210            .items_center()
1211            .justify_end()
1212            .font_family(config.monospace_family.clone())
1213            .text_size(px(text_size))
1214            .line_height(px(line_height))
1215            .text_color(rgba(
1216                theme_color(theme, "text_muted", 0x929a_a8ff).as_rgba_hex(),
1217            ))
1218            .child(if config.show_line_numbers && segment.first {
1219                (segment.logical_line + 1).to_string()
1220            } else {
1221                String::new()
1222            }),
1223        &config.gutter_style,
1224        theme,
1225        theme.direction(),
1226    );
1227    let content = crate::renderer::apply_style_override(
1228        div()
1229            .h_full()
1230            .flex_1()
1231            .min_w(px(0.0))
1232            .flex()
1233            .items_center()
1234            .font_family(config.monospace_family.clone())
1235            .text_size(px(text_size))
1236            .line_height(px(line_height))
1237            .text_color(rgba(
1238                theme_color(theme, "text_primary", 0xd9de_e8ff).as_rgba_hex(),
1239            ))
1240            .child(interactive),
1241        &config.text_style,
1242        theme,
1243        theme.direction(),
1244    );
1245    crate::renderer::apply_style_override(
1246        div()
1247            .w_full()
1248            .h(px(line_height.max(16.0)))
1249            .flex()
1250            .flex_row()
1251            .items_center()
1252            .child(gutter)
1253            .child(content),
1254        &config.line_style,
1255        theme,
1256        theme.direction(),
1257    )
1258    .into_any_element()
1259}
1260
1261struct InteractiveDocumentText {
1262    id: ElementId,
1263    text: StyledText,
1264    source: Arc<str>,
1265    global_range: Range<usize>,
1266    offset_map: Arc<[(usize, usize)]>,
1267    line: usize,
1268    line_start: usize,
1269    side: DocumentSide,
1270    selection: DocumentSelection,
1271    focus: FocusHandle,
1272    events: PrimitiveContext,
1273    disabled: bool,
1274}
1275
1276impl Element for InteractiveDocumentText {
1277    type RequestLayoutState = ();
1278    type PrepaintState = gpui::Hitbox;
1279
1280    fn id(&self) -> Option<ElementId> {
1281        Some(self.id.clone())
1282    }
1283
1284    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
1285        None
1286    }
1287
1288    fn request_layout(
1289        &mut self,
1290        _: Option<&GlobalElementId>,
1291        inspector: Option<&InspectorElementId>,
1292        window: &mut Window,
1293        cx: &mut App,
1294    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
1295        self.text.request_layout(None, inspector, window, cx)
1296    }
1297
1298    fn prepaint(
1299        &mut self,
1300        _: Option<&GlobalElementId>,
1301        inspector: Option<&InspectorElementId>,
1302        bounds: Bounds<Pixels>,
1303        state: &mut Self::RequestLayoutState,
1304        window: &mut Window,
1305        cx: &mut App,
1306    ) -> Self::PrepaintState {
1307        self.text
1308            .prepaint(None, inspector, bounds, state, window, cx);
1309        window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal)
1310    }
1311
1312    fn paint(
1313        &mut self,
1314        _: Option<&GlobalElementId>,
1315        inspector: Option<&InspectorElementId>,
1316        bounds: Bounds<Pixels>,
1317        state: &mut Self::RequestLayoutState,
1318        hitbox: &mut Self::PrepaintState,
1319        window: &mut Window,
1320        cx: &mut App,
1321    ) {
1322        self.text
1323            .paint(None, inspector, bounds, state, &mut (), window, cx);
1324        if self.disabled {
1325            return;
1326        }
1327        window.set_cursor_style(CursorStyle::IBeam, hitbox);
1328        let layout = self.text.layout().clone();
1329        let clamp = |index: Result<usize, usize>| match index {
1330            Ok(index) | Err(index) => index,
1331        };
1332        {
1333            let selection = self.selection.clone();
1334            let source = Arc::clone(&self.source);
1335            let side = self.side;
1336            let range = self.global_range.clone();
1337            let focus = self.focus.clone();
1338            let events = self.events.clone();
1339            let line = self.line;
1340            let line_start = self.line_start;
1341            let layout = layout.clone();
1342            let hitbox = hitbox.clone();
1343            let offset_map = Arc::clone(&self.offset_map);
1344            window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
1345                if !phase.bubble()
1346                    || event.button != MouseButton::Left
1347                    || !hitbox.is_hovered(window)
1348                {
1349                    return;
1350                }
1351                focus.focus(window, cx);
1352                let display = clamp(layout.index_for_position(event.position));
1353                let local = display_source_offset(&offset_map, display).min(range.len());
1354                let global = range.start + local;
1355                selection.begin(side, Arc::clone(&source), global);
1356                if event.click_count >= 2 {
1357                    let mut payload = BTreeMap::from([
1358                        (
1359                            "line".to_owned(),
1360                            UiValue::Integer(i64::try_from(line + 1).unwrap_or(i64::MAX)),
1361                        ),
1362                        (
1363                            "column".to_owned(),
1364                            UiValue::Integer(
1365                                i64::try_from(source[line_start..global].chars().count() + 1)
1366                                    .unwrap_or(i64::MAX),
1367                            ),
1368                        ),
1369                    ]);
1370                    if side != DocumentSide::Single {
1371                        payload
1372                            .insert("side".to_owned(), UiValue::String(side.as_str().to_owned()));
1373                    }
1374                    let payload = UiValue::Map(payload);
1375                    let events = events.clone();
1376                    window.defer(cx, move |window, cx| {
1377                        let _ = events.emit("location_activate", payload, window, cx);
1378                    });
1379                }
1380                window.refresh();
1381            });
1382        }
1383        {
1384            let selection = self.selection.clone();
1385            let side = self.side;
1386            let range = self.global_range.clone();
1387            let layout = layout.clone();
1388            let hitbox = hitbox.clone();
1389            let offset_map = Arc::clone(&self.offset_map);
1390            window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| {
1391                if phase.bubble()
1392                    && event.pressed_button == Some(MouseButton::Left)
1393                    && hitbox.is_hovered(window)
1394                {
1395                    let display = clamp(layout.index_for_position(event.position));
1396                    let local = display_source_offset(&offset_map, display).min(range.len());
1397                    selection.update(side, range.start + local);
1398                    cx.stop_propagation();
1399                    window.refresh();
1400                }
1401            });
1402        }
1403        {
1404            let hitbox = hitbox.clone();
1405            window.on_mouse_event(move |event: &MouseUpEvent, phase, window, cx| {
1406                if phase.bubble() && event.button == MouseButton::Left && hitbox.is_hovered(window)
1407                {
1408                    cx.stop_propagation();
1409                }
1410            });
1411        }
1412    }
1413}
1414
1415impl IntoElement for InteractiveDocumentText {
1416    type Element = Self;
1417
1418    fn into_element(self) -> Self::Element {
1419        self
1420    }
1421}
1422
1423#[derive(Default)]
1424pub struct CodeViewerPrimitiveHandler {
1425    instances: BTreeMap<PrimitiveInstanceId, Entity<CodeViewerEntity>>,
1426    syntaxes: Option<SyntaxRegistry>,
1427    document_runtime: Option<DocumentRuntimeConfig>,
1428    monospace_family: Option<SharedString>,
1429}
1430
1431impl CodeViewerPrimitiveHandler {
1432    #[must_use]
1433    pub fn new(syntaxes: SyntaxRegistry, document_runtime: DocumentRuntimeConfig) -> Self {
1434        Self {
1435            instances: BTreeMap::new(),
1436            syntaxes: Some(syntaxes),
1437            document_runtime: Some(document_runtime),
1438            monospace_family: None,
1439        }
1440    }
1441}
1442
1443impl PrimitiveHandler for CodeViewerPrimitiveHandler {
1444    fn render(
1445        &mut self,
1446        instance: &PrimitiveInstance,
1447        events: &PrimitiveContext,
1448        theme: &PrimitiveTheme,
1449        window: &mut Window,
1450        cx: &mut App,
1451    ) -> Result<AnyElement, String> {
1452        let id = instance
1453            .id
1454            .clone()
1455            .ok_or_else(|| "CodeViewPrimitive requires a stable key".to_owned())?;
1456        let monospace_family = self
1457            .monospace_family
1458            .get_or_insert_with(|| resolve_document_monospace_family(cx))
1459            .clone();
1460        let config = parse_code_config(&instance.node.props, monospace_family)?;
1461        let search_typography = native_typography(theme, "body_small", window)?;
1462        let entity = if let Some(entity) = self.instances.get(&id) {
1463            entity.clone()
1464        } else {
1465            let syntaxes = self
1466                .syntaxes
1467                .clone()
1468                .ok_or_else(|| "CodeViewer syntax registry is unavailable".to_owned())?;
1469            let config_for_entity = config.clone();
1470            let events_for_entity = events.clone();
1471            let theme_for_entity = theme.clone();
1472            let document_runtime = self
1473                .document_runtime
1474                .clone()
1475                .ok_or_else(|| "CodeViewer document runtime is unavailable".to_owned())?;
1476            let entity = cx.new(|cx| {
1477                CodeViewerEntity::new(
1478                    config_for_entity,
1479                    events_for_entity,
1480                    theme_for_entity,
1481                    syntaxes,
1482                    document_runtime,
1483                    search_typography.clone(),
1484                    cx,
1485                )
1486            });
1487            entity.update(cx, CodeViewerEntity::start_prepare);
1488            self.instances.insert(id, entity.clone());
1489            entity
1490        };
1491        entity.update(cx, |viewer, cx| {
1492            viewer.update(config, events.clone(), theme, search_typography, cx);
1493        });
1494        Ok(entity.into_any_element())
1495    }
1496
1497    fn unmount(&mut self, instance: &PrimitiveInstanceId) {
1498        self.instances.remove(instance);
1499    }
1500}
1501
1502fn parse_code_config(
1503    props: &PrimitiveProps,
1504    monospace_family: SharedString,
1505) -> Result<CodeViewerConfig, String> {
1506    let descriptor = DocumentDescriptor {
1507        source: document_source_prop(props, "source")?,
1508        label: props.string("label").unwrap_or("Code").to_owned(),
1509        file_name: optional_string_prop(props, "file_name"),
1510        language: optional_string_prop(props, "language"),
1511    };
1512    let wrap = match props.string("wrap") {
1513        None | Some("none") => DocumentWrap::None,
1514        Some("viewport") => DocumentWrap::Viewport,
1515        Some("column") => DocumentWrap::Column(props.usize("wrap_column").unwrap_or(100)),
1516        Some(other) => return Err(format!("unknown CodeViewer wrap mode `{other}`")),
1517    };
1518    Ok(CodeViewerConfig {
1519        descriptor,
1520        show_line_numbers: props.boolean("show_line_numbers").unwrap_or(true),
1521        wrap,
1522        tab_size: props.usize("tab_size").unwrap_or(4),
1523        monospace_family,
1524        gutter_style: props.style("gutter_style").cloned().unwrap_or_default(),
1525        line_style: props.style("line_style").cloned().unwrap_or_default(),
1526        text_style: props.style("text_style").cloned().unwrap_or_default(),
1527        loading_style: props.style("loading_style").cloned().unwrap_or_default(),
1528        error_style: props.style("error_style").cloned().unwrap_or_default(),
1529        search_style: props.style("search_style").cloned().unwrap_or_default(),
1530    })
1531}
1532
1533fn document_source_prop(props: &PrimitiveProps, name: &str) -> Result<DocumentSource, String> {
1534    match props.get(name) {
1535        Some(PrimitiveValue::Data(UiValue::String(value))) => Ok(value.clone().into()),
1536        Some(PrimitiveValue::Document(document)) => Ok(document.clone().into()),
1537        _ => Err(format!(
1538            "document `{name}` must be a string or NativeTextDocument"
1539        )),
1540    }
1541}
1542
1543fn optional_string_prop(props: &PrimitiveProps, name: &str) -> Option<String> {
1544    props
1545        .string(name)
1546        .filter(|value| !value.is_empty())
1547        .map(ToOwned::to_owned)
1548}
1549
1550fn theme_color(theme: &PrimitiveTheme, token: &str, fallback: u32) -> crate::Rgba8 {
1551    theme
1552        .color(token)
1553        .unwrap_or_else(|| crate::Rgba8::from_rgba_hex(fallback))
1554}
1555
1556fn resolved_pixels(length: crate::Length) -> Option<f32> {
1557    match length {
1558        crate::Length::Pixels(value) => value.to_string().parse().ok(),
1559        crate::Length::Rems(value) => (value * 16.0).to_string().parse().ok(),
1560        _ => None,
1561    }
1562}
1563
1564fn document_text_metrics(style: &Style, theme: &PrimitiveTheme) -> (f32, f32) {
1565    let role = style.base.typography.as_deref().unwrap_or("body");
1566    let typography = theme.typography(role).or_else(|| theme.typography("body"));
1567    let default_size = typography
1568        .as_ref()
1569        .and_then(|value| resolved_pixels(value.size))
1570        .unwrap_or(12.0);
1571    let default_line_height = typography
1572        .as_ref()
1573        .and_then(|value| resolved_pixels(value.line_height))
1574        .unwrap_or(18.0);
1575    let text_size = style
1576        .base
1577        .font_size
1578        .and_then(|value| theme.resolve_length(value))
1579        .and_then(resolved_pixels)
1580        .unwrap_or(default_size);
1581    let line_height = style
1582        .base
1583        .line_height
1584        .and_then(|value| theme.resolve_length(value))
1585        .and_then(resolved_pixels)
1586        .unwrap_or(default_line_height)
1587        .max(text_size);
1588    (text_size, line_height)
1589}
1590
1591fn usize_f32(value: usize) -> f32 {
1592    value.to_string().parse().unwrap_or(f32::MAX)
1593}
1594
1595struct ExpandedSegmentText {
1596    text: String,
1597    /// `(source byte offset, displayed byte offset)` at every character boundary.
1598    offsets: Vec<(usize, usize)>,
1599}
1600
1601fn wrap_source_line(
1602    source_range: &Range<usize>,
1603    text: &str,
1604    columns: Option<usize>,
1605    tab_size: usize,
1606) -> Vec<(Range<usize>, bool, usize)> {
1607    let Some(columns) = columns.map(|columns| columns.max(1)) else {
1608        return vec![(source_range.clone(), true, 0)];
1609    };
1610    if text.is_empty() {
1611        return vec![(source_range.clone(), true, 0)];
1612    }
1613    let tab_size = tab_size.max(1);
1614    let mut segments = Vec::new();
1615    let mut segment_start = 0usize;
1616    let mut segment_column = 0usize;
1617    let mut logical_column = 0usize;
1618    for (offset, grapheme) in text.grapheme_indices(true) {
1619        let width = if grapheme == "\t" {
1620            tab_size - (logical_column % tab_size)
1621        } else {
1622            1
1623        };
1624        if logical_column > segment_column && logical_column - segment_column + width > columns {
1625            segments.push((
1626                source_range.start + segment_start..source_range.start + offset,
1627                segments.is_empty(),
1628                segment_column,
1629            ));
1630            segment_start = offset;
1631            segment_column = logical_column;
1632        }
1633        logical_column = logical_column.saturating_add(width);
1634    }
1635    segments.push((
1636        source_range.start + segment_start..source_range.end,
1637        segments.is_empty(),
1638        segment_column,
1639    ));
1640    segments
1641}
1642
1643fn expand_tabs(text: &str, tab_size: usize, initial_column: usize) -> ExpandedSegmentText {
1644    let tab_size = tab_size.max(1);
1645    let mut output = String::with_capacity(text.len());
1646    let mut offsets = vec![(0, 0)];
1647    let mut column = initial_column;
1648    for (source, character) in text.char_indices() {
1649        if character == '\t' {
1650            let spaces = tab_size - (column % tab_size);
1651            output.extend(std::iter::repeat_n(' ', spaces));
1652            column += spaces;
1653        } else {
1654            output.push(character);
1655            column += 1;
1656        }
1657        offsets.push((source + character.len_utf8(), output.len()));
1658    }
1659    ExpandedSegmentText {
1660        text: output,
1661        offsets,
1662    }
1663}
1664
1665fn remap_highlights(
1666    highlights: Vec<(Range<usize>, HighlightStyle)>,
1667    offsets: &[(usize, usize)],
1668) -> Vec<(Range<usize>, HighlightStyle)> {
1669    highlights
1670        .into_iter()
1671        .filter_map(|(range, style)| {
1672            let start = source_display_offset(offsets, range.start)?;
1673            let end = source_display_offset(offsets, range.end)?;
1674            (start < end).then_some((start..end, style))
1675        })
1676        .collect()
1677}
1678
1679fn compose_highlights(
1680    text_len: usize,
1681    layers: &[(Range<usize>, HighlightStyle)],
1682) -> Vec<(Range<usize>, HighlightStyle)> {
1683    let mut boundaries = layers
1684        .iter()
1685        .flat_map(|(range, _)| [range.start.min(text_len), range.end.min(text_len)])
1686        .collect::<Vec<_>>();
1687    boundaries.sort_unstable();
1688    boundaries.dedup();
1689    let mut composed: Vec<(Range<usize>, HighlightStyle)> = Vec::new();
1690    for window in boundaries.windows(2) {
1691        let range = window[0]..window[1];
1692        if range.is_empty() {
1693            continue;
1694        }
1695        let mut style = HighlightStyle::default();
1696        let mut styled = false;
1697        for (layer_range, layer_style) in layers {
1698            if layer_range.start < range.end && layer_range.end > range.start {
1699                overlay_highlight(&mut style, *layer_style);
1700                styled = true;
1701            }
1702        }
1703        if !styled {
1704            continue;
1705        }
1706        if let Some((previous_range, previous_style)) = composed.last_mut()
1707            && previous_range.end == range.start
1708            && *previous_style == style
1709        {
1710            previous_range.end = range.end;
1711        } else {
1712            composed.push((range, style));
1713        }
1714    }
1715    composed
1716}
1717
1718fn overlay_highlight(base: &mut HighlightStyle, overlay: HighlightStyle) {
1719    if overlay.color.is_some() {
1720        base.color = overlay.color;
1721    }
1722    if overlay.font_weight.is_some() {
1723        base.font_weight = overlay.font_weight;
1724    }
1725    if overlay.font_style.is_some() {
1726        base.font_style = overlay.font_style;
1727    }
1728    if overlay.background_color.is_some() {
1729        base.background_color = overlay.background_color;
1730    }
1731    if overlay.underline.is_some() {
1732        base.underline = overlay.underline;
1733    }
1734    if overlay.strikethrough.is_some() {
1735        base.strikethrough = overlay.strikethrough;
1736    }
1737    if overlay.fade_out.is_some() {
1738        base.fade_out = overlay.fade_out;
1739    }
1740}
1741
1742fn source_display_offset(offsets: &[(usize, usize)], source: usize) -> Option<usize> {
1743    offsets
1744        .binary_search_by_key(&source, |(source, _)| *source)
1745        .ok()
1746        .and_then(|index| offsets.get(index).map(|(_, display)| *display))
1747}
1748
1749fn display_source_offset(offsets: &[(usize, usize)], display: usize) -> usize {
1750    match offsets.binary_search_by_key(&display, |(_, display)| *display) {
1751        Ok(index) => offsets.get(index).map_or(0, |(source, _)| *source),
1752        Err(index) => offsets
1753            .get(index.saturating_sub(1))
1754            .map_or(0, |(source, _)| *source),
1755    }
1756}
1757
1758fn clamp_char_boundary(text: &str, requested: usize) -> usize {
1759    let mut offset = requested.min(text.len());
1760    while !text.is_char_boundary(offset) {
1761        offset = offset.saturating_sub(1);
1762    }
1763    offset
1764}
1765
1766fn intersect_local(range: &Range<usize>, segment: &Range<usize>) -> Option<Range<usize>> {
1767    let start = range.start.max(segment.start);
1768    let end = range.end.min(segment.end);
1769    (start < end).then(|| start - segment.start..end - segment.start)
1770}
1771
1772fn source_location_payload(
1773    document: &PreparedDocument,
1774    offset: usize,
1775    side: Option<DocumentSide>,
1776) -> Option<UiValue> {
1777    let offset = clamp_char_boundary(document.text(), offset);
1778    let (line, record) = document
1779        .lines()
1780        .iter()
1781        .enumerate()
1782        .find(|(_, line)| offset >= line.range.start && offset <= line.range.end)
1783        .or_else(|| document.lines().iter().enumerate().next_back())?;
1784    let column = document.text()[record.range.start..offset.min(record.range.end)]
1785        .chars()
1786        .count()
1787        + 1;
1788    let mut payload = BTreeMap::from([
1789        (
1790            "line".to_owned(),
1791            UiValue::Integer(i64::try_from(line + 1).unwrap_or(i64::MAX)),
1792        ),
1793        (
1794            "column".to_owned(),
1795            UiValue::Integer(i64::try_from(column).unwrap_or(i64::MAX)),
1796        ),
1797    ]);
1798    if let Some(side) = side {
1799        payload.insert("side".to_owned(), UiValue::String(side.as_str().to_owned()));
1800    }
1801    Some(UiValue::Map(payload))
1802}
1803
1804fn location_schema(include_side: bool) -> ValueSchema {
1805    let mut fields = BTreeMap::from([
1806        (
1807            "line".to_owned(),
1808            ObjectField::required(ValueSchema::integer()),
1809        ),
1810        (
1811            "column".to_owned(),
1812            ObjectField::required(ValueSchema::integer()),
1813        ),
1814    ]);
1815    if include_side {
1816        fields.insert(
1817            "side".to_owned(),
1818            ObjectField::required(ValueSchema::enumeration(["left", "right"])),
1819        );
1820    }
1821    ValueSchema::object(fields)
1822}
1823
1824#[must_use]
1825/// Return the public native code-viewer primitive contract.
1826///
1827/// # Panics
1828///
1829/// Panics only if its compile-time primitive ID becomes invalid.
1830pub fn code_viewer_primitive_descriptor() -> PrimitiveDescriptor {
1831    PrimitiveDescriptor {
1832        id: PrimitiveId::parse("gpui_rhai.code_viewer").expect("static primitive ID"),
1833        export: "CodeViewPrimitive".to_owned(),
1834        props: BTreeMap::from([
1835            (
1836                "source".to_owned(),
1837                ObjectField::required(ValueSchema::one_of([
1838                    ValueSchema::string(),
1839                    ValueSchema::Document,
1840                ])),
1841            ),
1842            (
1843                "label".to_owned(),
1844                ObjectField::required(ValueSchema::string()),
1845            ),
1846            (
1847                "file_name".to_owned(),
1848                ObjectField::optional(ValueSchema::optional(ValueSchema::string())),
1849            ),
1850            (
1851                "language".to_owned(),
1852                ObjectField::optional(ValueSchema::optional(ValueSchema::string())),
1853            ),
1854            (
1855                "show_line_numbers".to_owned(),
1856                ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(true)),
1857            ),
1858            (
1859                "wrap".to_owned(),
1860                ObjectField::optional(ValueSchema::enumeration(["none", "viewport", "column"]))
1861                    .with_default(UiValue::String("none".to_owned())),
1862            ),
1863            (
1864                "wrap_column".to_owned(),
1865                ObjectField::optional(ValueSchema::bounded_integer(Some(20), Some(500)))
1866                    .with_default(UiValue::Integer(100)),
1867            ),
1868            (
1869                "tab_size".to_owned(),
1870                ObjectField::optional(ValueSchema::bounded_integer(Some(1), Some(16)))
1871                    .with_default(UiValue::Integer(4)),
1872            ),
1873            (
1874                "gutter_style".to_owned(),
1875                ObjectField::required(ValueSchema::Style),
1876            ),
1877            (
1878                "line_style".to_owned(),
1879                ObjectField::required(ValueSchema::Style),
1880            ),
1881            (
1882                "text_style".to_owned(),
1883                ObjectField::required(ValueSchema::Style),
1884            ),
1885            (
1886                "loading_style".to_owned(),
1887                ObjectField::required(ValueSchema::Style),
1888            ),
1889            (
1890                "error_style".to_owned(),
1891                ObjectField::required(ValueSchema::Style),
1892            ),
1893            (
1894                "search_style".to_owned(),
1895                ObjectField::required(ValueSchema::Style),
1896            ),
1897            (
1898                "on_location_activate".to_owned(),
1899                ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
1900            ),
1901        ]),
1902        events: BTreeMap::from([(
1903            "location_activate".to_owned(),
1904            EventSchema {
1905                payload: location_schema(false),
1906            },
1907        )]),
1908        state: crate::ComponentStateSchema::default(),
1909        lifecycle: true,
1910        effect: None,
1911    }
1912}
1913
1914#[derive(Clone, Debug, PartialEq)]
1915struct DiffViewerConfig {
1916    left: DocumentDescriptor,
1917    right: DocumentDescriptor,
1918    mode: DiffViewMode,
1919    whitespace: DiffWhitespace,
1920    context_lines: Option<usize>,
1921    show_line_numbers: bool,
1922    wrap: DocumentWrap,
1923    tab_size: usize,
1924    monospace_family: SharedString,
1925    header_style: Style,
1926    gutter_style: Style,
1927    line_style: Style,
1928    text_style: Style,
1929    fold_style: Style,
1930    loading_style: Style,
1931    error_style: Style,
1932    search_style: Style,
1933    status_style: Style,
1934}
1935
1936impl DiffViewerConfig {
1937    fn preparation_changed(&self, next: &Self) -> bool {
1938        self.left.source != next.left.source
1939            || self.left.file_name != next.left.file_name
1940            || self.left.language != next.left.language
1941            || self.right.source != next.right.source
1942            || self.right.file_name != next.right.file_name
1943            || self.right.language != next.right.language
1944            || self.whitespace != next.whitespace
1945            || self.context_lines != next.context_lines
1946    }
1947}
1948
1949#[derive(Clone, Debug)]
1950struct DiffTextSegment {
1951    line: usize,
1952    range: Range<usize>,
1953    first: bool,
1954    column_offset: usize,
1955}
1956
1957#[derive(Clone, Debug)]
1958enum DiffRenderRow {
1959    Content {
1960        aligned: usize,
1961        left: Option<DiffTextSegment>,
1962        right: Option<DiffTextSegment>,
1963        kind: DiffRowKind,
1964        hunk: Option<usize>,
1965    },
1966    Unified {
1967        aligned: usize,
1968        side: DocumentSide,
1969        segment: DiffTextSegment,
1970        kind: DiffRowKind,
1971        hunk: Option<usize>,
1972    },
1973    Fold {
1974        id: usize,
1975        hidden_rows: usize,
1976    },
1977}
1978
1979impl DiffRenderRow {
1980    const fn hunk(&self) -> Option<usize> {
1981        match self {
1982            Self::Content { hunk, .. } | Self::Unified { hunk, .. } => *hunk,
1983            Self::Fold { .. } => None,
1984        }
1985    }
1986
1987    const fn aligned(&self) -> Option<usize> {
1988        match self {
1989            Self::Content { aligned, .. } | Self::Unified { aligned, .. } => Some(*aligned),
1990            Self::Fold { .. } => None,
1991        }
1992    }
1993}
1994
1995#[derive(Clone, Debug)]
1996struct DiffSearchMatch {
1997    side: DocumentSide,
1998    range: Range<usize>,
1999}
2000
2001#[derive(Clone, Debug, Default)]
2002struct DiffSearchState {
2003    open: bool,
2004    query: String,
2005    options: SearchOptions,
2006    matches: Vec<DiffSearchMatch>,
2007    current: Option<usize>,
2008    error: Option<String>,
2009}
2010
2011struct DiffViewerEntity {
2012    config: DiffViewerConfig,
2013    events: PrimitiveContext,
2014    theme: PrimitiveTheme,
2015    syntaxes: SyntaxRegistry,
2016    document_runtime: DocumentRuntimeConfig,
2017    focus: FocusHandle,
2018    scroll: gpui::UniformListScrollHandle,
2019    left_horizontal: ScrollHandle,
2020    right_horizontal: ScrollHandle,
2021    prepared: Option<Arc<PreparedDiff>>,
2022    display: Arc<[DiffRenderRow]>,
2023    display_columns: Option<usize>,
2024    display_job: u64,
2025    display_task: Option<Task<()>>,
2026    pending_display_columns: Option<PendingDisplayColumns>,
2027    viewport: Option<Bounds<Pixels>>,
2028    expanded_folds: BTreeSet<usize>,
2029    current_hunk: Option<usize>,
2030    selection: DocumentSelection,
2031    search: DiffSearchState,
2032    search_input: Entity<TextInputEntity>,
2033    search_job: u64,
2034    search_task: Option<Task<()>>,
2035    job: u64,
2036    loading: bool,
2037    error: Option<String>,
2038    prepare_task: Option<Task<()>>,
2039    patch_task: Option<Task<()>>,
2040}
2041
2042impl DiffViewerEntity {
2043    fn new(
2044        config: DiffViewerConfig,
2045        events: PrimitiveContext,
2046        theme: PrimitiveTheme,
2047        syntaxes: SyntaxRegistry,
2048        document_runtime: DocumentRuntimeConfig,
2049        search_typography: NativeTypography,
2050        cx: &mut Context<Self>,
2051    ) -> Self {
2052        let weak = cx.entity().downgrade();
2053        let callbacks = TextInputCallbacks {
2054            change: Some(Rc::new({
2055                let weak = weak.clone();
2056                move |value, _, cx| {
2057                    let _ = weak.update(cx, |viewer, cx| {
2058                        viewer.search.query = value;
2059                        viewer.start_search(cx);
2060                        cx.notify();
2061                    });
2062                }
2063            })),
2064            submit: Some(Rc::new(move |_, _, cx| {
2065                let _ = weak.update(cx, |viewer, cx| viewer.advance_match(false, cx));
2066            })),
2067            ..TextInputCallbacks::default()
2068        };
2069        let search_input = cx.new(|cx| {
2070            TextInputEntity::new(
2071                TextInputConfig {
2072                    value: String::new(),
2073                    placeholder: "Find on both sides".to_owned(),
2074                    disabled: false,
2075                    read_only: false,
2076                    autofocus: false,
2077                    selection_color: theme_color(&theme, "selection", 0x3347_6fff),
2078                    caret_color: theme_color(&theme, "accent", 0x7aa2_f7ff),
2079                    typography: search_typography,
2080                },
2081                callbacks,
2082                cx,
2083            )
2084        });
2085        Self {
2086            config,
2087            events,
2088            theme,
2089            syntaxes,
2090            document_runtime,
2091            focus: cx.focus_handle(),
2092            scroll: gpui::UniformListScrollHandle::new(),
2093            left_horizontal: ScrollHandle::new(),
2094            right_horizontal: ScrollHandle::new(),
2095            prepared: None,
2096            display: Arc::from([]),
2097            display_columns: None,
2098            display_job: 0,
2099            display_task: None,
2100            pending_display_columns: None,
2101            viewport: None,
2102            expanded_folds: BTreeSet::new(),
2103            current_hunk: None,
2104            selection: DocumentSelection::default(),
2105            search: DiffSearchState::default(),
2106            search_input,
2107            search_job: 0,
2108            search_task: None,
2109            job: 0,
2110            loading: false,
2111            error: None,
2112            prepare_task: None,
2113            patch_task: None,
2114        }
2115    }
2116
2117    fn update(
2118        &mut self,
2119        config: DiffViewerConfig,
2120        events: PrimitiveContext,
2121        theme: &PrimitiveTheme,
2122        search_typography: NativeTypography,
2123        cx: &mut Context<Self>,
2124    ) {
2125        let needs_prepare = self.config.preparation_changed(&config);
2126        let presentation_changed = self.config.mode != config.mode
2127            || self.config.wrap != config.wrap
2128            || self.config.tab_size != config.tab_size;
2129        if self.config.left.label != config.left.label
2130            || self.config.right.label != config.right.label
2131        {
2132            self.patch_task = None;
2133        }
2134        self.config = config;
2135        self.events = events;
2136        self.theme = theme.clone();
2137        let callbacks = self.search_input.read(cx).callbacks();
2138        self.search_input.update(cx, |input, cx| {
2139            input.update_props(
2140                &TextInputConfig {
2141                    value: self.search.query.clone(),
2142                    placeholder: "Find on both sides".to_owned(),
2143                    disabled: false,
2144                    read_only: false,
2145                    autofocus: false,
2146                    selection_color: theme_color(theme, "selection", 0x3347_6fff),
2147                    caret_color: theme_color(theme, "accent", 0x7aa2_f7ff),
2148                    typography: search_typography,
2149                },
2150                callbacks,
2151                cx,
2152            );
2153        });
2154        if needs_prepare || (self.loading && presentation_changed) {
2155            self.start_prepare(cx);
2156        } else if presentation_changed {
2157            self.start_display(cx, true, None);
2158        } else {
2159            cx.notify();
2160        }
2161    }
2162
2163    fn start_prepare(&mut self, cx: &mut Context<Self>) {
2164        self.job = self.job.saturating_add(1);
2165        self.display_job = self.display_job.saturating_add(1);
2166        self.display_task = None;
2167        self.pending_display_columns = None;
2168        let job = self.job;
2169        let left = self.config.left.clone();
2170        let right = self.config.right.clone();
2171        let whitespace = self.config.whitespace;
2172        let context = self.config.context_lines;
2173        let syntaxes = self.syntaxes.clone();
2174        let limits = self.document_runtime.limits();
2175        let columns = self.wrap_columns();
2176        let mode = self.config.mode;
2177        let tab_size = self.config.tab_size;
2178        let expanded_folds = self.expanded_folds.clone();
2179        self.loading = true;
2180        self.error = None;
2181        self.prepare_task = Some(cx.spawn(async move |this, cx| {
2182            let result = cx
2183                .background_spawn(async move {
2184                    prepare_diff_with_limits(&left, &right, whitespace, context, &syntaxes, limits)
2185                        .map(|diff| {
2186                            let display =
2187                                build_diff_display(&diff, mode, columns, tab_size, &expanded_folds);
2188                            (diff, display)
2189                        })
2190                })
2191                .await;
2192            let _ = this.update(cx, |viewer, cx| {
2193                if viewer.job != job {
2194                    return;
2195                }
2196                viewer.loading = false;
2197                match result {
2198                    Ok((diff, display)) => {
2199                        let diff = Arc::new(diff);
2200                        viewer.display_job = viewer.display_job.saturating_add(1);
2201                        viewer.display_task = None;
2202                        viewer.pending_display_columns = None;
2203                        let old_hunk = viewer.current_hunk;
2204                        if let Some(previous) = viewer.prepared.as_ref() {
2205                            let same_pair = previous.left.identity() == diff.left.identity()
2206                                && previous.right.identity() == diff.right.identity();
2207                            if same_pair {
2208                                viewer.selection.rebase(
2209                                    DocumentSide::Left,
2210                                    &previous.left.text_arc(),
2211                                    diff.left.text_arc(),
2212                                );
2213                                viewer.selection.rebase(
2214                                    DocumentSide::Right,
2215                                    &previous.right.text_arc(),
2216                                    diff.right.text_arc(),
2217                                );
2218                            } else {
2219                                viewer.selection.clear();
2220                                viewer.scroll.scroll_to_item_strict(0, ScrollStrategy::Top);
2221                            }
2222                        }
2223                        viewer.error = None;
2224                        viewer.prepared = Some(diff);
2225                        let valid_folds = viewer
2226                            .prepared
2227                            .as_ref()
2228                            .into_iter()
2229                            .flat_map(|diff| diff.collapsed.iter())
2230                            .filter_map(|row| match row {
2231                                DiffDisplayRow::Fold { id, .. } => Some(*id),
2232                                DiffDisplayRow::Content(_) => None,
2233                            })
2234                            .collect::<BTreeSet<_>>();
2235                        viewer.expanded_folds.retain(|id| valid_folds.contains(id));
2236                        viewer.display = display;
2237                        viewer.display_columns = columns;
2238                        viewer.start_search(cx);
2239                        viewer.current_hunk = old_hunk.filter(|hunk| {
2240                            viewer
2241                                .prepared
2242                                .as_ref()
2243                                .is_some_and(|diff| *hunk < diff.hunk_count)
2244                        });
2245                    }
2246                    Err(error) => viewer.error = Some(error.to_string()),
2247                }
2248                cx.notify();
2249            });
2250        }));
2251    }
2252
2253    fn wrap_columns(&self) -> Option<usize> {
2254        match self.config.wrap {
2255            DocumentWrap::None => None,
2256            DocumentWrap::Column(columns) => Some(columns.max(1)),
2257            DocumentWrap::Viewport => self.viewport.map(|bounds| {
2258                let divisor = if self.config.mode == DiffViewMode::Split {
2259                    2.0
2260                } else {
2261                    1.0
2262                };
2263                let pane = f64::from(bounds.size.width) / divisor;
2264                let (text_size, _) = document_text_metrics(&self.config.text_style, &self.theme);
2265                let character_width = (f64::from(text_size) * 0.604).max(1.0);
2266                ((pane - 74.0).max(20.0) / character_width)
2267                    .floor()
2268                    .to_string()
2269                    .parse()
2270                    .unwrap_or(80)
2271                    .max(1)
2272            }),
2273        }
2274    }
2275
2276    fn start_display(
2277        &mut self,
2278        cx: &mut Context<Self>,
2279        force: bool,
2280        reveal_aligned: Option<usize>,
2281    ) {
2282        let Some(diff) = self.prepared.clone() else {
2283            return;
2284        };
2285        let columns = self.wrap_columns();
2286        if !force && self.display_columns == columns && !self.display.is_empty() {
2287            return;
2288        }
2289        if !force && self.pending_display_columns == Some(columns.into()) {
2290            return;
2291        }
2292        self.display_job = self.display_job.saturating_add(1);
2293        let job = self.display_job;
2294        let mode = self.config.mode;
2295        let tab_size = self.config.tab_size;
2296        let expanded_folds = self.expanded_folds.clone();
2297        self.pending_display_columns = Some(columns.into());
2298        self.display_task = Some(cx.spawn(async move |this, cx| {
2299            let display = cx
2300                .background_spawn(async move {
2301                    build_diff_display(&diff, mode, columns, tab_size, &expanded_folds)
2302                })
2303                .await;
2304            let _ = this.update(cx, |viewer, cx| {
2305                if viewer.display_job == job {
2306                    viewer.display = display;
2307                    viewer.display_columns = columns;
2308                    viewer.pending_display_columns = None;
2309                    if let Some(index) = reveal_aligned.and_then(|aligned| {
2310                        viewer
2311                            .display
2312                            .iter()
2313                            .position(|row| row.aligned() == Some(aligned))
2314                    }) {
2315                        viewer.scroll.scroll_to_item(index, ScrollStrategy::Center);
2316                    }
2317                    cx.notify();
2318                }
2319            });
2320        }));
2321    }
2322
2323    fn start_search(&mut self, cx: &mut Context<Self>) {
2324        self.search_job = self.search_job.saturating_add(1);
2325        let job = self.search_job;
2326        self.search.matches.clear();
2327        self.search.error = None;
2328        self.search.current = None;
2329        let Some(diff) = self.prepared.as_ref() else {
2330            return;
2331        };
2332        if self.search.query.is_empty() {
2333            self.search_task = None;
2334            return;
2335        }
2336        let left = diff.left.text_arc();
2337        let right = diff.right.text_arc();
2338        let query = self.search.query.clone();
2339        let options = self.search.options;
2340        self.search_task = Some(cx.spawn(async move |this, cx| {
2341            let result = cx
2342                .background_spawn(async move {
2343                    let left = search_ranges(&left, &query, options, 50_000)?;
2344                    let remaining = 100_000usize.saturating_sub(left.len());
2345                    let right = search_ranges(&right, &query, options, remaining)?;
2346                    Ok::<_, String>(
2347                        left.into_iter()
2348                            .map(|range| DiffSearchMatch {
2349                                side: DocumentSide::Left,
2350                                range,
2351                            })
2352                            .chain(right.into_iter().map(|range| DiffSearchMatch {
2353                                side: DocumentSide::Right,
2354                                range,
2355                            }))
2356                            .collect::<Vec<_>>(),
2357                    )
2358                })
2359                .await;
2360            let _ = this.update(cx, |viewer, cx| {
2361                if viewer.search_job != job {
2362                    return;
2363                }
2364                match result {
2365                    Ok(matches) => {
2366                        viewer.search.matches = matches;
2367                        viewer.search.current = (!viewer.search.matches.is_empty()).then_some(0);
2368                    }
2369                    Err(error) => viewer.search.error = Some(error),
2370                }
2371                cx.notify();
2372            });
2373        }));
2374    }
2375
2376    fn reveal_match(&mut self, index: usize, cx: &mut Context<Self>) {
2377        let Some(target) = self.search.matches.get(index).cloned() else {
2378            return;
2379        };
2380        let mut expanded = false;
2381        let aligned = self
2382            .prepared
2383            .as_ref()
2384            .and_then(|diff| diff_aligned_for_offset(diff, target.side, target.range.start));
2385        if let Some(diff) = self.prepared.as_ref() {
2386            for entry in diff.collapsed.iter() {
2387                let DiffDisplayRow::Fold { id, full_range, .. } = entry else {
2388                    continue;
2389                };
2390                let contains = full_range.clone().any(|row| {
2391                    let row = &diff.rows[row];
2392                    let line = match target.side {
2393                        DocumentSide::Left => row.left,
2394                        DocumentSide::Right => row.right,
2395                        DocumentSide::Single => None,
2396                    };
2397                    line.is_some_and(|line| {
2398                        let range = &match target.side {
2399                            DocumentSide::Left => &diff.left,
2400                            DocumentSide::Right => &diff.right,
2401                            DocumentSide::Single => return false,
2402                        }
2403                        .lines()[line]
2404                            .range;
2405                        target.range.start >= range.start && target.range.start <= range.end
2406                    })
2407                });
2408                if contains {
2409                    expanded |= self.expanded_folds.insert(*id);
2410                }
2411            }
2412        }
2413        if expanded {
2414            self.start_display(cx, true, aligned);
2415            return;
2416        }
2417        if let Some(display_index) = self
2418            .display
2419            .iter()
2420            .position(|row| diff_render_row_contains(row, target.side, target.range.start))
2421            .or_else(|| {
2422                aligned.and_then(|aligned| {
2423                    self.display
2424                        .iter()
2425                        .position(|row| row.aligned() == Some(aligned))
2426                })
2427            })
2428        {
2429            self.scroll
2430                .scroll_to_item(display_index, ScrollStrategy::Center);
2431        }
2432    }
2433
2434    fn advance_match(&mut self, backwards: bool, cx: &mut Context<Self>) {
2435        if self.loading || self.search.matches.is_empty() {
2436            return;
2437        }
2438        let current = self.search.current.unwrap_or(0);
2439        let next = if backwards {
2440            (current + self.search.matches.len() - 1) % self.search.matches.len()
2441        } else {
2442            (current + 1) % self.search.matches.len()
2443        };
2444        self.search.current = Some(next);
2445        self.reveal_match(next, cx);
2446        cx.notify();
2447    }
2448
2449    fn advance_hunk(&mut self, backwards: bool, cx: &mut Context<Self>) {
2450        if self.loading {
2451            return;
2452        }
2453        let Some(diff) = self.prepared.as_ref() else {
2454            return;
2455        };
2456        if diff.hunk_count == 0 {
2457            return;
2458        }
2459        let current = self
2460            .current_hunk
2461            .unwrap_or(if backwards { 0 } else { diff.hunk_count - 1 });
2462        let next = if backwards {
2463            (current + diff.hunk_count - 1) % diff.hunk_count
2464        } else {
2465            (current + 1) % diff.hunk_count
2466        };
2467        self.current_hunk = Some(next);
2468        if let Some(index) = self.display.iter().position(|row| row.hunk() == Some(next)) {
2469            self.scroll.scroll_to_item(index, ScrollStrategy::Center);
2470        }
2471        cx.notify();
2472    }
2473
2474    fn find(&mut self, _: &Find, window: &mut Window, cx: &mut Context<Self>) {
2475        self.search.open = true;
2476        self.search_input.focus_handle(cx).focus(window, cx);
2477        cx.notify();
2478    }
2479
2480    fn find_next(&mut self, _: &FindNext, _: &mut Window, cx: &mut Context<Self>) {
2481        self.advance_match(false, cx);
2482    }
2483
2484    fn find_previous(&mut self, _: &FindPrevious, _: &mut Window, cx: &mut Context<Self>) {
2485        self.advance_match(true, cx);
2486    }
2487
2488    fn close_find(&mut self, _: &CloseFind, window: &mut Window, cx: &mut Context<Self>) {
2489        if self.search.open {
2490            self.search.open = false;
2491            self.focus.focus(window, cx);
2492            cx.notify();
2493        }
2494    }
2495
2496    fn copy_selection(
2497        &mut self,
2498        _: &CopyDocumentSelection,
2499        _: &mut Window,
2500        cx: &mut Context<Self>,
2501    ) {
2502        if self.loading {
2503            return;
2504        }
2505        if let Some(text) = self.selection.selected_text() {
2506            cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
2507            cx.stop_propagation();
2508        }
2509    }
2510
2511    fn next_change(&mut self, _: &NextChange, _: &mut Window, cx: &mut Context<Self>) {
2512        self.advance_hunk(false, cx);
2513    }
2514
2515    fn previous_change(&mut self, _: &PreviousChange, _: &mut Window, cx: &mut Context<Self>) {
2516        self.advance_hunk(true, cx);
2517    }
2518
2519    fn expand_all(&mut self, _: &ExpandAll, _: &mut Window, cx: &mut Context<Self>) {
2520        if self.loading {
2521            return;
2522        }
2523        let Some(diff) = self.prepared.as_ref() else {
2524            return;
2525        };
2526        self.expanded_folds = diff
2527            .collapsed
2528            .iter()
2529            .filter_map(|row| match row {
2530                DiffDisplayRow::Fold { id, .. } => Some(*id),
2531                DiffDisplayRow::Content(_) => None,
2532            })
2533            .collect();
2534        self.start_display(cx, true, None);
2535    }
2536
2537    fn collapse_all(&mut self, _: &CollapseAll, _: &mut Window, cx: &mut Context<Self>) {
2538        if self.loading {
2539            return;
2540        }
2541        self.expanded_folds.clear();
2542        self.start_display(cx, true, None);
2543    }
2544
2545    fn copy_unified_diff(&mut self, _: &CopyUnifiedDiff, _: &mut Window, cx: &mut Context<Self>) {
2546        if self.loading {
2547            return;
2548        }
2549        let Some(diff) = self.prepared.as_ref() else {
2550            return;
2551        };
2552        let left = diff.left.text_arc();
2553        let right = diff.right.text_arc();
2554        let left_label = self.config.left.label.clone();
2555        let right_label = self.config.right.label.clone();
2556        let context = self.config.context_lines.unwrap_or(diff.rows.len());
2557        let timeout = self.document_runtime.limits().diff_timeout;
2558        let job = self.job;
2559        cx.stop_propagation();
2560        self.patch_task = Some(cx.spawn(async move |this, cx| {
2561            let patch = cx
2562                .background_spawn(async move {
2563                    let mut config = TextDiff::configure();
2564                    config
2565                        .algorithm(similar::Algorithm::Patience)
2566                        .timeout(timeout);
2567                    config
2568                        .diff_lines(left.as_ref(), right.as_ref())
2569                        .unified_diff()
2570                        .header(&left_label, &right_label)
2571                        .context_radius(context)
2572                        .to_string()
2573                })
2574                .await;
2575            let _ = this.update(cx, |viewer, cx| {
2576                if viewer.job == job && !viewer.loading {
2577                    cx.write_to_clipboard(gpui::ClipboardItem::new_string(patch));
2578                }
2579            });
2580        }));
2581    }
2582
2583    fn activate_location(
2584        &mut self,
2585        _: &ActivateDocumentLocation,
2586        window: &mut Window,
2587        cx: &mut Context<Self>,
2588    ) {
2589        if self.loading {
2590            return;
2591        }
2592        let Some(diff) = self.prepared.as_ref() else {
2593            return;
2594        };
2595        let Some((side, source, offset)) = self.selection.position() else {
2596            return;
2597        };
2598        let document = match side {
2599            DocumentSide::Left => &diff.left,
2600            DocumentSide::Right => &diff.right,
2601            DocumentSide::Single => return,
2602        };
2603        if !Arc::ptr_eq(&source, &document.text_arc()) {
2604            return;
2605        }
2606        if let Some(payload) = source_location_payload(document, offset, Some(side)) {
2607            let events = self.events.clone();
2608            window.defer(cx, move |window, cx| {
2609                let _ = events.emit("location_activate", payload, window, cx);
2610            });
2611        }
2612    }
2613
2614    fn reveal_line(&mut self, action: &RevealDocumentLine, _: &mut Window, cx: &mut Context<Self>) {
2615        if action.line == 0 {
2616            return;
2617        }
2618        let side = match action.side {
2619            Some(DiffSide::Left) => DocumentSide::Left,
2620            Some(DiffSide::Right) => DocumentSide::Right,
2621            _ => return,
2622        };
2623        let line = action.line - 1;
2624        let mut expanded = false;
2625        let mut aligned = None;
2626        if let Some(diff) = self.prepared.as_ref() {
2627            aligned = diff_aligned_for_line(diff, side, line);
2628            for entry in diff.collapsed.iter() {
2629                let DiffDisplayRow::Fold { id, full_range, .. } = entry else {
2630                    continue;
2631                };
2632                if full_range.clone().any(|row| {
2633                    let row = &diff.rows[row];
2634                    (match side {
2635                        DocumentSide::Left => row.left,
2636                        DocumentSide::Right => row.right,
2637                        DocumentSide::Single => None,
2638                    }) == Some(line)
2639                }) {
2640                    expanded |= self.expanded_folds.insert(*id);
2641                }
2642            }
2643        }
2644        if expanded {
2645            self.start_display(cx, true, aligned);
2646            return;
2647        }
2648        if let Some(index) = aligned.and_then(|aligned| {
2649            self.display
2650                .iter()
2651                .position(|row| row.aligned() == Some(aligned))
2652        }) {
2653            self.scroll.scroll_to_item(index, ScrollStrategy::Center);
2654            cx.notify();
2655        }
2656    }
2657
2658    fn toggle_search_option(&mut self, option: &'static str, cx: &mut Context<Self>) {
2659        match option {
2660            "case" => self.search.options.case_sensitive = !self.search.options.case_sensitive,
2661            "word" => self.search.options.whole_word = !self.search.options.whole_word,
2662            "regex" => self.search.options.regex = !self.search.options.regex,
2663            _ => return,
2664        }
2665        self.start_search(cx);
2666        cx.notify();
2667    }
2668
2669    fn expand_fold(&mut self, id: usize, cx: &mut Context<Self>) {
2670        if self.loading {
2671            return;
2672        }
2673        self.expanded_folds.insert(id);
2674        self.start_display(cx, true, None);
2675    }
2676
2677    fn search_bar(&self, cx: &mut Context<Self>) -> AnyElement {
2678        let weak = cx.entity().downgrade();
2679        let option = |label: &'static str, name: &'static str, active: bool| {
2680            let weak = weak.clone();
2681            div()
2682                .id(SharedString::from(format!("diff-search-option-{name}")))
2683                .w(px(28.0))
2684                .h(px(24.0))
2685                .flex()
2686                .items_center()
2687                .justify_center()
2688                .text_size(px(11.0))
2689                .font_weight(FontWeight::SEMIBOLD)
2690                .bg(if active {
2691                    rgba(theme_color(&self.theme, "selection", 0x3347_6fff).as_rgba_hex())
2692                } else {
2693                    rgba(0x0000_0000)
2694                })
2695                .hover(|style| {
2696                    style.bg(rgba(
2697                        theme_color(&self.theme, "surface_hover", 0x252a_34ff).as_rgba_hex(),
2698                    ))
2699                })
2700                .on_mouse_down(MouseButton::Left, move |_, _, cx| {
2701                    let _ = weak.update(cx, |viewer, cx| viewer.toggle_search_option(name, cx));
2702                })
2703                .child(label)
2704        };
2705        let status = self.search.error.as_ref().map_or_else(
2706            || {
2707                if self.search.matches.is_empty() {
2708                    "No matches".to_owned()
2709                } else {
2710                    format!(
2711                        "{} / {}",
2712                        self.search.current.unwrap_or(0) + 1,
2713                        self.search.matches.len()
2714                    )
2715                }
2716            },
2717            |error| format!("Invalid regex: {error}"),
2718        );
2719        let bar = div()
2720            .absolute()
2721            .top(px(36.0))
2722            .right(px(8.0))
2723            .w(px(430.0))
2724            .h(px(38.0))
2725            .flex()
2726            .items_center()
2727            .gap(px(4.0))
2728            .px(px(6.0))
2729            .bg(rgba(
2730                theme_color(&self.theme, "surface_raised", 0x1a1d_24ff).as_rgba_hex(),
2731            ))
2732            .border_1()
2733            .border_color(rgba(
2734                theme_color(&self.theme, "border", 0x353b_48ff).as_rgba_hex(),
2735            ))
2736            .child(
2737                div()
2738                    .h(px(26.0))
2739                    .flex_1()
2740                    .min_w(px(0.0))
2741                    .px(px(6.0))
2742                    .border_1()
2743                    .border_color(rgba(
2744                        theme_color(&self.theme, "border", 0x353b_48ff).as_rgba_hex(),
2745                    ))
2746                    .child(self.search_input.clone()),
2747            )
2748            .child(option("Aa", "case", self.search.options.case_sensitive))
2749            .child(option("W", "word", self.search.options.whole_word))
2750            .child(option(".*", "regex", self.search.options.regex))
2751            .child(div().w(px(62.0)).text_size(px(10.0)).child(status));
2752        crate::renderer::apply_style_override(
2753            bar,
2754            &self.config.search_style,
2755            &self.theme,
2756            self.theme.direction(),
2757        )
2758        .into_any_element()
2759    }
2760
2761    fn header(&self) -> AnyElement {
2762        let base = div()
2763            .h(px(30.0))
2764            .flex_none()
2765            .flex()
2766            .items_center()
2767            .border_b_1()
2768            .border_color(rgba(
2769                theme_color(&self.theme, "border", 0x353b_48ff).as_rgba_hex(),
2770            ))
2771            .bg(rgba(
2772                theme_color(&self.theme, "surface_raised", 0x1a1d_24ff).as_rgba_hex(),
2773            ))
2774            .text_size(px(11.0))
2775            .font_weight(FontWeight::SEMIBOLD);
2776        let header = if self.config.mode == DiffViewMode::Split {
2777            base.child(
2778                div()
2779                    .w(relative(0.5))
2780                    .px(px(10.0))
2781                    .child(self.config.left.label.clone()),
2782            )
2783            .child(
2784                div()
2785                    .w(relative(0.5))
2786                    .px(px(10.0))
2787                    .border_l_1()
2788                    .border_color(rgba(
2789                        theme_color(&self.theme, "border", 0x353b_48ff).as_rgba_hex(),
2790                    ))
2791                    .child(self.config.right.label.clone()),
2792            )
2793        } else {
2794            base.px(px(10.0)).child(format!(
2795                "{}  ↔  {}",
2796                self.config.left.label, self.config.right.label
2797            ))
2798        };
2799        crate::renderer::apply_style_override(
2800            header,
2801            &self.config.header_style,
2802            &self.theme,
2803            self.theme.direction(),
2804        )
2805        .into_any_element()
2806    }
2807
2808    fn content(&self, cx: &mut Context<Self>) -> AnyElement {
2809        if let Some(error) = self.error.as_ref() {
2810            return crate::renderer::apply_style_override(
2811                div()
2812                    .flex_1()
2813                    .min_h(px(0.0))
2814                    .flex()
2815                    .items_center()
2816                    .justify_center()
2817                    .text_color(rgba(
2818                        theme_color(&self.theme, "danger", 0xf776_8eff).as_rgba_hex(),
2819                    ))
2820                    .child(format!("DiffViewer error: {error}")),
2821                &self.config.error_style,
2822                &self.theme,
2823                self.theme.direction(),
2824            )
2825            .into_any_element();
2826        }
2827        let Some(diff) = self.prepared.clone() else {
2828            return crate::renderer::apply_style_override(
2829                div()
2830                    .flex_1()
2831                    .min_h(px(0.0))
2832                    .flex()
2833                    .items_center()
2834                    .justify_center()
2835                    .text_color(rgba(
2836                        theme_color(&self.theme, "text_muted", 0x929a_a8ff).as_rgba_hex(),
2837                    ))
2838                    .child("Calculating comparison…"),
2839                &self.config.loading_style,
2840                &self.theme,
2841                self.theme.direction(),
2842            )
2843            .into_any_element();
2844        };
2845        if diff.hunk_count == 0 {
2846            return div()
2847                .flex_1()
2848                .min_h(px(0.0))
2849                .flex()
2850                .items_center()
2851                .justify_center()
2852                .text_color(rgba(
2853                    theme_color(&self.theme, "text_muted", 0x929a_a8ff).as_rgba_hex(),
2854                ))
2855                .child("No differences")
2856                .into_any_element();
2857        }
2858        let display = Arc::clone(&self.display);
2859        let selection = self.selection.clone();
2860        let matches = Arc::<[DiffSearchMatch]>::from(self.search.matches.clone());
2861        let current_match = self.search.current;
2862        let theme = self.theme.clone();
2863        let config = self.config.clone();
2864        let events = self.events.clone();
2865        let focus = self.focus.clone();
2866        let weak = cx.entity().downgrade();
2867        let left_horizontal = self.left_horizontal.clone();
2868        let right_horizontal = self.right_horizontal.clone();
2869        let disabled = self.loading;
2870        let list = uniform_list(
2871            "gpui-rhai-diff-viewer-lines",
2872            display.len(),
2873            move |range, _, _| {
2874                range
2875                    .filter_map(|index| {
2876                        let row = display.get(index)?;
2877                        Some(diff_line_element(
2878                            &diff,
2879                            row,
2880                            &config,
2881                            &theme,
2882                            &selection,
2883                            &matches,
2884                            current_match,
2885                            &events,
2886                            &focus,
2887                            &weak,
2888                            &left_horizontal,
2889                            &right_horizontal,
2890                            disabled,
2891                        ))
2892                    })
2893                    .collect::<Vec<_>>()
2894            },
2895        )
2896        .track_scroll(&self.scroll)
2897        .when(
2898            matches!(self.config.wrap, DocumentWrap::None)
2899                && self.config.mode == DiffViewMode::Unified,
2900            |list| {
2901                list.with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
2902            },
2903        )
2904        .flex_1()
2905        .min_h(px(0.0))
2906        .opacity(if self.loading { 0.62 } else { 1.0 });
2907        list.into_any_element()
2908    }
2909}
2910
2911impl Render for DiffViewerEntity {
2912    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2913        let status = self.prepared.as_ref().map_or_else(
2914            || "Preparing".to_owned(),
2915            |diff| {
2916                if diff.hunk_count == 0 {
2917                    "No differences".to_owned()
2918                } else {
2919                    format!(
2920                        "Change {} of {}",
2921                        self.current_hunk.unwrap_or(0) + 1,
2922                        diff.hunk_count
2923                    )
2924                }
2925            },
2926        );
2927        let syntax_warning = self.prepared.as_ref().and_then(|diff| {
2928            diff.left
2929                .highlight_error()
2930                .or_else(|| diff.right.highlight_error())
2931        });
2932        let status_bar = crate::renderer::apply_style_override(
2933            div()
2934                .h(px(22.0))
2935                .flex_none()
2936                .px(px(8.0))
2937                .flex()
2938                .items_center()
2939                .justify_between()
2940                .border_t_1()
2941                .border_color(rgba(
2942                    theme_color(&self.theme, "border", 0x353b_48ff).as_rgba_hex(),
2943                ))
2944                .bg(rgba(
2945                    theme_color(&self.theme, "surface_raised", 0x1a1d_24ff).as_rgba_hex(),
2946                ))
2947                .text_size(px(10.0))
2948                .text_color(rgba(
2949                    theme_color(&self.theme, "text_muted", 0x929a_a8ff).as_rgba_hex(),
2950                ))
2951                .child(if let Some(error) = syntax_warning {
2952                    format!("{status} · syntax fallback: {error}")
2953                } else {
2954                    status
2955                })
2956                .child(if self.loading {
2957                    "Updating comparison…"
2958                } else {
2959                    "⌥↑ / ⌥↓"
2960                }),
2961            &self.config.status_style,
2962            &self.theme,
2963            self.theme.direction(),
2964        );
2965        let mut root = div()
2966            .relative()
2967            .flex()
2968            .flex_col()
2969            .size_full()
2970            .min_w(px(0.0))
2971            .min_h(px(0.0))
2972            .key_context(DIFF_CONTEXT)
2973            .track_focus(&self.focus)
2974            .tab_stop(true)
2975            .on_action(cx.listener(Self::find))
2976            .on_action(cx.listener(Self::find_next))
2977            .on_action(cx.listener(Self::find_previous))
2978            .on_action(cx.listener(Self::close_find))
2979            .on_action(cx.listener(Self::copy_selection))
2980            .on_action(cx.listener(Self::next_change))
2981            .on_action(cx.listener(Self::previous_change))
2982            .on_action(cx.listener(Self::expand_all))
2983            .on_action(cx.listener(Self::collapse_all))
2984            .on_action(cx.listener(Self::copy_unified_diff))
2985            .on_action(cx.listener(Self::reveal_line))
2986            .on_action(cx.listener(Self::activate_location))
2987            .child(self.header())
2988            .child(self.content(cx))
2989            .child(status_bar)
2990            .child(DiffBoundsRecorder {
2991                viewer: cx.entity(),
2992            });
2993        if self.search.open {
2994            root = root.child(self.search_bar(cx));
2995        }
2996        root
2997    }
2998}
2999
3000struct DiffBoundsRecorder {
3001    viewer: Entity<DiffViewerEntity>,
3002}
3003
3004impl Element for DiffBoundsRecorder {
3005    type RequestLayoutState = AnyElement;
3006    type PrepaintState = ();
3007
3008    fn id(&self) -> Option<ElementId> {
3009        None
3010    }
3011
3012    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
3013        None
3014    }
3015
3016    fn request_layout(
3017        &mut self,
3018        _: Option<&GlobalElementId>,
3019        _: Option<&InspectorElementId>,
3020        window: &mut Window,
3021        cx: &mut App,
3022    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
3023        let mut child = div().absolute().inset_0().into_any_element();
3024        let layout = child.request_layout(window, cx);
3025        (layout, child)
3026    }
3027
3028    fn prepaint(
3029        &mut self,
3030        _: Option<&GlobalElementId>,
3031        _: Option<&InspectorElementId>,
3032        bounds: Bounds<Pixels>,
3033        child: &mut Self::RequestLayoutState,
3034        window: &mut Window,
3035        cx: &mut App,
3036    ) {
3037        child.prepaint(window, cx);
3038        self.viewer.update(cx, |viewer, cx| {
3039            if viewer.viewport != Some(bounds) {
3040                viewer.viewport = Some(bounds);
3041            }
3042            if matches!(viewer.config.wrap, DocumentWrap::Viewport)
3043                && viewer.wrap_columns() != viewer.display_columns
3044            {
3045                viewer.start_display(cx, false, None);
3046            }
3047        });
3048    }
3049
3050    fn paint(
3051        &mut self,
3052        _: Option<&GlobalElementId>,
3053        _: Option<&InspectorElementId>,
3054        _: Bounds<Pixels>,
3055        child: &mut Self::RequestLayoutState,
3056        (): &mut Self::PrepaintState,
3057        window: &mut Window,
3058        cx: &mut App,
3059    ) {
3060        child.paint(window, cx);
3061    }
3062}
3063
3064impl IntoElement for DiffBoundsRecorder {
3065    type Element = Self;
3066
3067    fn into_element(self) -> Self::Element {
3068        self
3069    }
3070}
3071
3072fn append_diff_content(
3073    output: &mut Vec<DiffRenderRow>,
3074    diff: &PreparedDiff,
3075    aligned: usize,
3076    mode: DiffViewMode,
3077    columns: Option<usize>,
3078    tab_size: usize,
3079) {
3080    let row = &diff.rows[aligned];
3081    let left = row
3082        .left
3083        .map(|line| diff_segments(&diff.left, line, columns, tab_size));
3084    let right = row
3085        .right
3086        .map(|line| diff_segments(&diff.right, line, columns, tab_size));
3087    if mode == DiffViewMode::Split {
3088        let count = left
3089            .as_ref()
3090            .map_or(0, Vec::len)
3091            .max(right.as_ref().map_or(0, Vec::len))
3092            .max(1);
3093        for index in 0..count {
3094            output.push(DiffRenderRow::Content {
3095                aligned,
3096                left: left
3097                    .as_ref()
3098                    .and_then(|segments| segments.get(index))
3099                    .cloned(),
3100                right: right
3101                    .as_ref()
3102                    .and_then(|segments| segments.get(index))
3103                    .cloned(),
3104                kind: row.kind,
3105                hunk: row.hunk,
3106            });
3107        }
3108    } else {
3109        match row.kind {
3110            DiffRowKind::Equal => {
3111                if let Some(segments) = right.or(left) {
3112                    output.extend(segments.into_iter().map(|segment| DiffRenderRow::Unified {
3113                        aligned,
3114                        side: DocumentSide::Right,
3115                        segment,
3116                        kind: row.kind,
3117                        hunk: row.hunk,
3118                    }));
3119                }
3120            }
3121            DiffRowKind::LeftOnly => {
3122                if let Some(segments) = left {
3123                    output.extend(segments.into_iter().map(|segment| DiffRenderRow::Unified {
3124                        aligned,
3125                        side: DocumentSide::Left,
3126                        segment,
3127                        kind: row.kind,
3128                        hunk: row.hunk,
3129                    }));
3130                }
3131            }
3132            DiffRowKind::RightOnly => {
3133                if let Some(segments) = right {
3134                    output.extend(segments.into_iter().map(|segment| DiffRenderRow::Unified {
3135                        aligned,
3136                        side: DocumentSide::Right,
3137                        segment,
3138                        kind: row.kind,
3139                        hunk: row.hunk,
3140                    }));
3141                }
3142            }
3143            DiffRowKind::Modified => {
3144                if let Some(segments) = left {
3145                    output.extend(segments.into_iter().map(|segment| DiffRenderRow::Unified {
3146                        aligned,
3147                        side: DocumentSide::Left,
3148                        segment,
3149                        kind: row.kind,
3150                        hunk: row.hunk,
3151                    }));
3152                }
3153                if let Some(segments) = right {
3154                    output.extend(segments.into_iter().map(|segment| DiffRenderRow::Unified {
3155                        aligned,
3156                        side: DocumentSide::Right,
3157                        segment,
3158                        kind: row.kind,
3159                        hunk: row.hunk,
3160                    }));
3161                }
3162            }
3163        }
3164    }
3165}
3166
3167fn build_diff_display(
3168    diff: &PreparedDiff,
3169    mode: DiffViewMode,
3170    columns: Option<usize>,
3171    tab_size: usize,
3172    expanded_folds: &BTreeSet<usize>,
3173) -> Arc<[DiffRenderRow]> {
3174    let mut rows = Vec::new();
3175    for entry in diff.collapsed.iter() {
3176        match entry {
3177            DiffDisplayRow::Content(index) => {
3178                append_diff_content(&mut rows, diff, *index, mode, columns, tab_size);
3179            }
3180            DiffDisplayRow::Fold { id, full_range, .. } if expanded_folds.contains(id) => {
3181                for index in full_range.clone() {
3182                    append_diff_content(&mut rows, diff, index, mode, columns, tab_size);
3183                }
3184            }
3185            DiffDisplayRow::Fold {
3186                id, hidden_rows, ..
3187            } => rows.push(DiffRenderRow::Fold {
3188                id: *id,
3189                hidden_rows: *hidden_rows,
3190            }),
3191        }
3192    }
3193    rows.into()
3194}
3195
3196fn diff_segments(
3197    document: &PreparedDocument,
3198    line: usize,
3199    columns: Option<usize>,
3200    tab_size: usize,
3201) -> Vec<DiffTextSegment> {
3202    let record = &document.lines()[line];
3203    let text = document.line_text(line).unwrap_or_default();
3204    wrap_source_line(&record.range, text, columns, tab_size)
3205        .into_iter()
3206        .map(|(range, first, column_offset)| DiffTextSegment {
3207            line,
3208            range,
3209            first,
3210            column_offset,
3211        })
3212        .collect()
3213}
3214
3215fn diff_render_row_contains(row: &DiffRenderRow, side: DocumentSide, offset: usize) -> bool {
3216    match row {
3217        DiffRenderRow::Content { left, right, .. } => match side {
3218            DocumentSide::Left => left.as_ref(),
3219            DocumentSide::Right => right.as_ref(),
3220            DocumentSide::Single => None,
3221        }
3222        .is_some_and(|segment| offset >= segment.range.start && offset <= segment.range.end),
3223        DiffRenderRow::Unified {
3224            side: row_side,
3225            segment,
3226            ..
3227        } => *row_side == side && offset >= segment.range.start && offset <= segment.range.end,
3228        DiffRenderRow::Fold { .. } => false,
3229    }
3230}
3231
3232fn diff_aligned_for_offset(
3233    diff: &PreparedDiff,
3234    side: DocumentSide,
3235    offset: usize,
3236) -> Option<usize> {
3237    let document = match side {
3238        DocumentSide::Left => &diff.left,
3239        DocumentSide::Right => &diff.right,
3240        DocumentSide::Single => return None,
3241    };
3242    let line = document
3243        .lines()
3244        .partition_point(|line| line.range.start <= offset)
3245        .saturating_sub(1);
3246    diff_aligned_for_line(diff, side, line)
3247}
3248
3249fn diff_aligned_for_line(diff: &PreparedDiff, side: DocumentSide, line: usize) -> Option<usize> {
3250    diff.rows.iter().position(|row| {
3251        (match side {
3252            DocumentSide::Left => row.left,
3253            DocumentSide::Right => row.right,
3254            DocumentSide::Single => None,
3255        }) == Some(line)
3256    })
3257}
3258
3259#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
3260fn diff_line_element(
3261    diff: &Arc<PreparedDiff>,
3262    row: &DiffRenderRow,
3263    config: &DiffViewerConfig,
3264    theme: &PrimitiveTheme,
3265    selection: &DocumentSelection,
3266    matches: &[DiffSearchMatch],
3267    current_match: Option<usize>,
3268    events: &PrimitiveContext,
3269    focus: &FocusHandle,
3270    weak: &gpui::WeakEntity<DiffViewerEntity>,
3271    left_horizontal: &ScrollHandle,
3272    right_horizontal: &ScrollHandle,
3273    disabled: bool,
3274) -> AnyElement {
3275    let (_, line_height) = document_text_metrics(&config.text_style, theme);
3276    let line_height = line_height.max(16.0);
3277    match row {
3278        DiffRenderRow::Fold { id, hidden_rows } => {
3279            let id_value = *id;
3280            let weak = weak.clone();
3281            let fold = crate::renderer::apply_style_override(
3282                div()
3283                    .w_full()
3284                    .h(px(line_height))
3285                    .px(px(10.0))
3286                    .flex()
3287                    .items_center()
3288                    .justify_center()
3289                    .when(!disabled, Styled::cursor_pointer)
3290                    .bg(rgba(
3291                        theme_color(theme, "diff.fold", 0x252a_34ff).as_rgba_hex(),
3292                    ))
3293                    .text_color(rgba(
3294                        theme_color(theme, "text_muted", 0x929a_a8ff).as_rgba_hex(),
3295                    ))
3296                    .child(format!("⋯ {hidden_rows} unchanged lines ⋯")),
3297                &config.fold_style,
3298                theme,
3299                theme.direction(),
3300            );
3301            if disabled {
3302                fold.into_any_element()
3303            } else {
3304                fold.id(("diff-fold", id_value))
3305                    .on_mouse_down(MouseButton::Left, move |_, _, cx| {
3306                        let _ = weak.update(cx, |viewer, cx| viewer.expand_fold(id_value, cx));
3307                    })
3308                    .into_any_element()
3309            }
3310        }
3311        DiffRenderRow::Content {
3312            aligned,
3313            left,
3314            right,
3315            kind,
3316            ..
3317        } => {
3318            let aligned_row = &diff.rows[*aligned];
3319            let left_cell = diff_cell(
3320                &diff.left,
3321                left.as_ref(),
3322                DocumentSide::Left,
3323                *kind,
3324                &aligned_row.left_inline,
3325                config,
3326                theme,
3327                selection,
3328                matches,
3329                current_match,
3330                events,
3331                focus,
3332                Some(left_horizontal),
3333                disabled,
3334            );
3335            let right_cell = diff_cell(
3336                &diff.right,
3337                right.as_ref(),
3338                DocumentSide::Right,
3339                *kind,
3340                &aligned_row.right_inline,
3341                config,
3342                theme,
3343                selection,
3344                matches,
3345                current_match,
3346                events,
3347                focus,
3348                Some(right_horizontal),
3349                disabled,
3350            );
3351            crate::renderer::apply_style_override(
3352                div()
3353                    .w_full()
3354                    .h(px(line_height))
3355                    .flex()
3356                    .flex_row()
3357                    .child(div().w(relative(0.5)).h_full().child(left_cell))
3358                    .child(
3359                        div()
3360                            .w(relative(0.5))
3361                            .h_full()
3362                            .border_l_1()
3363                            .border_color(rgba(
3364                                theme_color(theme, "border", 0x353b_48ff).as_rgba_hex(),
3365                            ))
3366                            .child(right_cell),
3367                    ),
3368                &config.line_style,
3369                theme,
3370                theme.direction(),
3371            )
3372            .into_any_element()
3373        }
3374        DiffRenderRow::Unified {
3375            aligned,
3376            side,
3377            segment,
3378            kind,
3379            ..
3380        } => {
3381            let aligned_row = &diff.rows[*aligned];
3382            let inline = if *side == DocumentSide::Left {
3383                &aligned_row.left_inline
3384            } else {
3385                &aligned_row.right_inline
3386            };
3387            crate::renderer::apply_style_override(
3388                div().w_full().h(px(line_height)).child(diff_cell(
3389                    if *side == DocumentSide::Left {
3390                        &diff.left
3391                    } else {
3392                        &diff.right
3393                    },
3394                    Some(segment),
3395                    *side,
3396                    *kind,
3397                    inline,
3398                    config,
3399                    theme,
3400                    selection,
3401                    matches,
3402                    current_match,
3403                    events,
3404                    focus,
3405                    None,
3406                    disabled,
3407                )),
3408                &config.line_style,
3409                theme,
3410                theme.direction(),
3411            )
3412            .into_any_element()
3413        }
3414    }
3415}
3416
3417#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
3418fn diff_cell(
3419    document: &PreparedDocument,
3420    segment: Option<&DiffTextSegment>,
3421    side: DocumentSide,
3422    kind: DiffRowKind,
3423    inline_ranges: &[Range<usize>],
3424    config: &DiffViewerConfig,
3425    theme: &PrimitiveTheme,
3426    selection: &DocumentSelection,
3427    matches: &[DiffSearchMatch],
3428    current_match: Option<usize>,
3429    events: &PrimitiveContext,
3430    focus: &FocusHandle,
3431    horizontal: Option<&ScrollHandle>,
3432    disabled: bool,
3433) -> AnyElement {
3434    let background = match (kind, side) {
3435        (DiffRowKind::LeftOnly | DiffRowKind::Modified, DocumentSide::Left) => {
3436            theme_color(theme, "diff.left_only", 0x5b2b_36aa)
3437        }
3438        (DiffRowKind::RightOnly | DiffRowKind::Modified, DocumentSide::Right) => {
3439            theme_color(theme, "diff.right_only", 0x244d_3aaa)
3440        }
3441        _ => crate::Rgba8::from_rgba_hex(0x0000_0000),
3442    };
3443    let Some(segment) = segment else {
3444        return div()
3445            .size_full()
3446            .bg(rgba(background.as_rgba_hex()))
3447            .into_any_element();
3448    };
3449    let line = &document.lines()[segment.line];
3450    let text = document
3451        .text()
3452        .get(segment.range.clone())
3453        .unwrap_or_default();
3454    let mut highlights = Vec::new();
3455    for syntax in &line.syntax {
3456        let global = line.range.start + syntax.range.start..line.range.start + syntax.range.end;
3457        if let Some(local) = intersect_local(&global, &segment.range) {
3458            highlights.push((
3459                local,
3460                HighlightStyle {
3461                    color: Some(
3462                        rgba(
3463                            theme_color(theme, syntax.kind.theme_token(), 0xd9de_e8ff)
3464                                .as_rgba_hex(),
3465                        )
3466                        .into(),
3467                    ),
3468                    ..HighlightStyle::default()
3469                },
3470            ));
3471        }
3472    }
3473    for inline in inline_ranges {
3474        let global = line.range.start + inline.start..line.range.start + inline.end;
3475        if let Some(local) = intersect_local(&global, &segment.range) {
3476            highlights.push((
3477                local,
3478                HighlightStyle {
3479                    background_color: Some(
3480                        rgba(
3481                            theme_color(
3482                                theme,
3483                                if side == DocumentSide::Left {
3484                                    "diff.inline_left"
3485                                } else {
3486                                    "diff.inline_right"
3487                                },
3488                                if side == DocumentSide::Left {
3489                                    0x8b3f_4faa
3490                                } else {
3491                                    0x3570_50aa
3492                                },
3493                            )
3494                            .as_rgba_hex(),
3495                        )
3496                        .into(),
3497                    ),
3498                    ..HighlightStyle::default()
3499                },
3500            ));
3501        }
3502    }
3503    for (index, found) in matches.iter().enumerate() {
3504        if found.side == side
3505            && let Some(local) = intersect_local(&found.range, &segment.range)
3506        {
3507            highlights.push((
3508                local,
3509                HighlightStyle {
3510                    background_color: Some(
3511                        rgba(
3512                            theme_color(
3513                                theme,
3514                                if current_match == Some(index) {
3515                                    "document.search_current"
3516                                } else {
3517                                    "document.search_match"
3518                                },
3519                                if current_match == Some(index) {
3520                                    0xe0af_68aa
3521                                } else {
3522                                    0x7aa2_f766
3523                                },
3524                            )
3525                            .as_rgba_hex(),
3526                        )
3527                        .into(),
3528                    ),
3529                    ..HighlightStyle::default()
3530                },
3531            ));
3532        }
3533    }
3534    let source = document.text_arc();
3535    if let Some(range) = selection.range_for(side, &source, segment.range.clone()) {
3536        highlights.push((
3537            range,
3538            HighlightStyle {
3539                background_color: Some(
3540                    rgba(theme_color(theme, "selection", 0x3347_6fff).as_rgba_hex()).into(),
3541                ),
3542                ..HighlightStyle::default()
3543            },
3544        ));
3545    }
3546    let expanded = expand_tabs(text, config.tab_size, segment.column_offset);
3547    let highlights = compose_highlights(
3548        expanded.text.len(),
3549        &remap_highlights(highlights, &expanded.offsets),
3550    );
3551    let styled = StyledText::new(expanded.text).with_highlights(highlights);
3552    let text = InteractiveDocumentText {
3553        id: SharedString::from(format!(
3554            "diff-{}-{}-{}",
3555            side.as_str(),
3556            segment.line,
3557            segment.range.start
3558        ))
3559        .into(),
3560        text: styled,
3561        source,
3562        global_range: segment.range.clone(),
3563        offset_map: expanded.offsets.into(),
3564        line: segment.line,
3565        line_start: line.range.start,
3566        side,
3567        selection: selection.clone(),
3568        focus: focus.clone(),
3569        events: events.clone(),
3570        disabled,
3571    };
3572    let (text_size, line_height) = document_text_metrics(&config.text_style, theme);
3573    let digits = document.lines().len().max(1).to_string().len();
3574    let gutter_width = (usize_f32(digits) * 8.0 + 24.0).max(46.0);
3575    let marker = match (kind, side) {
3576        (DiffRowKind::LeftOnly | DiffRowKind::Modified, DocumentSide::Left) => "‹",
3577        (DiffRowKind::RightOnly | DiffRowKind::Modified, DocumentSide::Right) => "›",
3578        _ => " ",
3579    };
3580    let gutter = crate::renderer::apply_style_override(
3581        div()
3582            .w(px(gutter_width))
3583            .h_full()
3584            .flex_none()
3585            .px(px(6.0))
3586            .flex()
3587            .items_center()
3588            .justify_between()
3589            .font_family(config.monospace_family.clone())
3590            .text_size(px(text_size))
3591            .line_height(px(line_height))
3592            .bg(rgba(
3593                theme_color(theme, "diff.gutter", 0x1a1d_24ff).as_rgba_hex(),
3594            ))
3595            .text_color(rgba(
3596                theme_color(theme, "text_muted", 0x929a_a8ff).as_rgba_hex(),
3597            ))
3598            .child(marker)
3599            .child(if config.show_line_numbers && segment.first {
3600                (segment.line + 1).to_string()
3601            } else {
3602                String::new()
3603            }),
3604        &config.gutter_style,
3605        theme,
3606        theme.direction(),
3607    );
3608    let content = crate::renderer::apply_style_override(
3609        div()
3610            .h_full()
3611            .flex_1()
3612            .min_w(px(0.0))
3613            .px(px(8.0))
3614            .flex()
3615            .items_center()
3616            .font_family(config.monospace_family.clone())
3617            .text_size(px(text_size))
3618            .line_height(px(line_height))
3619            .text_color(rgba(
3620                theme_color(theme, "text_primary", 0xd9de_e8ff).as_rgba_hex(),
3621            ))
3622            .child(text),
3623        &config.text_style,
3624        theme,
3625        theme.direction(),
3626    );
3627    let row = div()
3628        .size_full()
3629        .flex()
3630        .bg(rgba(background.as_rgba_hex()))
3631        .child(gutter)
3632        .child(content);
3633    if matches!(config.wrap, DocumentWrap::None)
3634        && let Some(horizontal) = horizontal
3635    {
3636        let (text_size, _) = document_text_metrics(&config.text_style, theme);
3637        let character_width = text_size * 0.604;
3638        let width = (usize_f32(document.max_line_chars()) * character_width + gutter_width + 16.0)
3639            .max(80.0);
3640        div()
3641            .size_full()
3642            .id(SharedString::from(format!(
3643                "diff-horizontal-{}-{}-{}",
3644                side.as_str(),
3645                segment.line,
3646                segment.range.start
3647            )))
3648            .overflow_x_scroll()
3649            .restrict_scroll_to_axis()
3650            .track_scroll(horizontal)
3651            .bg(rgba(background.as_rgba_hex()))
3652            .child(row.w(px(width)))
3653            .into_any_element()
3654    } else {
3655        row.into_any_element()
3656    }
3657}
3658
3659#[derive(Default)]
3660pub struct DiffViewerPrimitiveHandler {
3661    instances: BTreeMap<PrimitiveInstanceId, Entity<DiffViewerEntity>>,
3662    syntaxes: Option<SyntaxRegistry>,
3663    document_runtime: Option<DocumentRuntimeConfig>,
3664    monospace_family: Option<SharedString>,
3665}
3666
3667impl DiffViewerPrimitiveHandler {
3668    #[must_use]
3669    pub fn new(syntaxes: SyntaxRegistry, document_runtime: DocumentRuntimeConfig) -> Self {
3670        Self {
3671            instances: BTreeMap::new(),
3672            syntaxes: Some(syntaxes),
3673            document_runtime: Some(document_runtime),
3674            monospace_family: None,
3675        }
3676    }
3677}
3678
3679impl PrimitiveHandler for DiffViewerPrimitiveHandler {
3680    fn render(
3681        &mut self,
3682        instance: &PrimitiveInstance,
3683        events: &PrimitiveContext,
3684        theme: &PrimitiveTheme,
3685        window: &mut Window,
3686        cx: &mut App,
3687    ) -> Result<AnyElement, String> {
3688        let id = instance
3689            .id
3690            .clone()
3691            .ok_or_else(|| "DiffViewPrimitive requires a stable key".to_owned())?;
3692        let monospace_family = self
3693            .monospace_family
3694            .get_or_insert_with(|| resolve_document_monospace_family(cx))
3695            .clone();
3696        let config = parse_diff_config(&instance.node.props, monospace_family)?;
3697        let search_typography = native_typography(theme, "body_small", window)?;
3698        let entity = if let Some(entity) = self.instances.get(&id) {
3699            entity.clone()
3700        } else {
3701            let syntaxes = self
3702                .syntaxes
3703                .clone()
3704                .ok_or_else(|| "DiffViewer syntax registry is unavailable".to_owned())?;
3705            let document_runtime = self
3706                .document_runtime
3707                .clone()
3708                .ok_or_else(|| "DiffViewer document runtime is unavailable".to_owned())?;
3709            let entity = cx.new(|cx| {
3710                DiffViewerEntity::new(
3711                    config.clone(),
3712                    events.clone(),
3713                    theme.clone(),
3714                    syntaxes,
3715                    document_runtime,
3716                    search_typography.clone(),
3717                    cx,
3718                )
3719            });
3720            entity.update(cx, DiffViewerEntity::start_prepare);
3721            self.instances.insert(id, entity.clone());
3722            entity
3723        };
3724        entity.update(cx, |viewer, cx| {
3725            viewer.update(config, events.clone(), theme, search_typography, cx);
3726        });
3727        Ok(entity.into_any_element())
3728    }
3729
3730    fn unmount(&mut self, instance: &PrimitiveInstanceId) {
3731        self.instances.remove(instance);
3732    }
3733}
3734
3735fn parse_diff_config(
3736    props: &PrimitiveProps,
3737    monospace_family: SharedString,
3738) -> Result<DiffViewerConfig, String> {
3739    let descriptor = |prefix: &str| -> Result<DocumentDescriptor, String> {
3740        Ok(DocumentDescriptor {
3741            source: document_source_prop(props, &format!("{prefix}_source"))?,
3742            label: props
3743                .string(&format!("{prefix}_label"))
3744                .unwrap_or(prefix)
3745                .to_owned(),
3746            file_name: optional_string_prop(props, &format!("{prefix}_file_name")),
3747            language: optional_string_prop(props, &format!("{prefix}_language")),
3748        })
3749    };
3750    let mode = match props.string("mode") {
3751        None | Some("unified") => DiffViewMode::Unified,
3752        Some("split") => DiffViewMode::Split,
3753        Some(other) => return Err(format!("unknown DiffViewer mode `{other}`")),
3754    };
3755    let whitespace = match props.string("whitespace") {
3756        None | Some("exact") => DiffWhitespace::Exact,
3757        Some("ignore_changes") => DiffWhitespace::IgnoreChanges,
3758        Some("ignore_all") => DiffWhitespace::IgnoreAll,
3759        Some(other) => return Err(format!("unknown DiffViewer whitespace mode `{other}`")),
3760    };
3761    let context_lines = match props.get("context_lines") {
3762        Some(PrimitiveValue::Data(UiValue::String(value))) if value == "all" => None,
3763        Some(PrimitiveValue::Data(UiValue::Integer(value))) => Some(
3764            usize::try_from(*value).map_err(|_| "context_lines must be non-negative".to_owned())?,
3765        ),
3766        _ => Some(3),
3767    };
3768    let wrap = match props.string("wrap") {
3769        None | Some("none") => DocumentWrap::None,
3770        Some("viewport") => DocumentWrap::Viewport,
3771        Some("column") => DocumentWrap::Column(props.usize("wrap_column").unwrap_or(100)),
3772        Some(other) => return Err(format!("unknown DiffViewer wrap mode `{other}`")),
3773    };
3774    Ok(DiffViewerConfig {
3775        left: descriptor("left")?,
3776        right: descriptor("right")?,
3777        mode,
3778        whitespace,
3779        context_lines,
3780        show_line_numbers: props.boolean("show_line_numbers").unwrap_or(true),
3781        wrap,
3782        tab_size: props.usize("tab_size").unwrap_or(4),
3783        monospace_family,
3784        header_style: props.style("header_style").cloned().unwrap_or_default(),
3785        gutter_style: props.style("gutter_style").cloned().unwrap_or_default(),
3786        line_style: props.style("line_style").cloned().unwrap_or_default(),
3787        text_style: props.style("text_style").cloned().unwrap_or_default(),
3788        fold_style: props.style("fold_style").cloned().unwrap_or_default(),
3789        loading_style: props.style("loading_style").cloned().unwrap_or_default(),
3790        error_style: props.style("error_style").cloned().unwrap_or_default(),
3791        search_style: props.style("search_style").cloned().unwrap_or_default(),
3792        status_style: props.style("status_style").cloned().unwrap_or_default(),
3793    })
3794}
3795
3796fn optional_document_fields(prefix: &str) -> Vec<(String, ObjectField)> {
3797    vec![
3798        (
3799            format!("{prefix}_source"),
3800            ObjectField::required(ValueSchema::one_of([
3801                ValueSchema::string(),
3802                ValueSchema::Document,
3803            ])),
3804        ),
3805        (
3806            format!("{prefix}_label"),
3807            ObjectField::required(ValueSchema::string()),
3808        ),
3809        (
3810            format!("{prefix}_file_name"),
3811            ObjectField::optional(ValueSchema::optional(ValueSchema::string())),
3812        ),
3813        (
3814            format!("{prefix}_language"),
3815            ObjectField::optional(ValueSchema::optional(ValueSchema::string())),
3816        ),
3817    ]
3818}
3819
3820#[must_use]
3821/// Return the public native diff-viewer primitive contract.
3822///
3823/// # Panics
3824///
3825/// Panics only if its compile-time primitive ID becomes invalid.
3826#[allow(clippy::too_many_lines)]
3827pub fn diff_viewer_primitive_descriptor() -> PrimitiveDescriptor {
3828    let mut props = BTreeMap::new();
3829    props.extend(optional_document_fields("left"));
3830    props.extend(optional_document_fields("right"));
3831    props.extend([
3832        (
3833            "mode".to_owned(),
3834            ObjectField::optional(ValueSchema::enumeration(["unified", "split"]))
3835                .with_default(UiValue::String("unified".to_owned())),
3836        ),
3837        (
3838            "whitespace".to_owned(),
3839            ObjectField::optional(ValueSchema::enumeration([
3840                "exact",
3841                "ignore_changes",
3842                "ignore_all",
3843            ]))
3844            .with_default(UiValue::String("exact".to_owned())),
3845        ),
3846        (
3847            "context_lines".to_owned(),
3848            ObjectField::optional(ValueSchema::one_of([
3849                ValueSchema::bounded_integer(Some(0), Some(100)),
3850                ValueSchema::enumeration(["all"]),
3851            ]))
3852            .with_default(UiValue::Integer(3)),
3853        ),
3854        (
3855            "show_line_numbers".to_owned(),
3856            ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(true)),
3857        ),
3858        (
3859            "wrap".to_owned(),
3860            ObjectField::optional(ValueSchema::enumeration(["none", "viewport", "column"]))
3861                .with_default(UiValue::String("none".to_owned())),
3862        ),
3863        (
3864            "wrap_column".to_owned(),
3865            ObjectField::optional(ValueSchema::bounded_integer(Some(20), Some(500)))
3866                .with_default(UiValue::Integer(100)),
3867        ),
3868        (
3869            "tab_size".to_owned(),
3870            ObjectField::optional(ValueSchema::bounded_integer(Some(1), Some(16)))
3871                .with_default(UiValue::Integer(4)),
3872        ),
3873        (
3874            "header_style".to_owned(),
3875            ObjectField::required(ValueSchema::Style),
3876        ),
3877        (
3878            "gutter_style".to_owned(),
3879            ObjectField::required(ValueSchema::Style),
3880        ),
3881        (
3882            "line_style".to_owned(),
3883            ObjectField::required(ValueSchema::Style),
3884        ),
3885        (
3886            "text_style".to_owned(),
3887            ObjectField::required(ValueSchema::Style),
3888        ),
3889        (
3890            "fold_style".to_owned(),
3891            ObjectField::required(ValueSchema::Style),
3892        ),
3893        (
3894            "loading_style".to_owned(),
3895            ObjectField::required(ValueSchema::Style),
3896        ),
3897        (
3898            "error_style".to_owned(),
3899            ObjectField::required(ValueSchema::Style),
3900        ),
3901        (
3902            "search_style".to_owned(),
3903            ObjectField::required(ValueSchema::Style),
3904        ),
3905        (
3906            "status_style".to_owned(),
3907            ObjectField::required(ValueSchema::Style),
3908        ),
3909        (
3910            "on_location_activate".to_owned(),
3911            ObjectField::optional(ValueSchema::optional(ValueSchema::Callback)),
3912        ),
3913    ]);
3914    PrimitiveDescriptor {
3915        id: PrimitiveId::parse("gpui_rhai.diff_viewer").expect("static primitive ID"),
3916        export: "DiffViewPrimitive".to_owned(),
3917        props,
3918        events: BTreeMap::from([(
3919            "location_activate".to_owned(),
3920            EventSchema {
3921                payload: location_schema(true),
3922            },
3923        )]),
3924        state: crate::ComponentStateSchema::default(),
3925        lifecycle: true,
3926        effect: None,
3927    }
3928}
3929
3930#[cfg(test)]
3931mod tests {
3932    use super::*;
3933
3934    #[test]
3935    fn document_selection_copies_original_cross_line_text() {
3936        let source: Arc<str> = Arc::from("one\ntwo\nthree");
3937        let selection = DocumentSelection::default();
3938        selection.begin(DocumentSide::Single, Arc::clone(&source), 2);
3939        selection.update(DocumentSide::Single, 9);
3940        assert_eq!(selection.selected_text().as_deref(), Some("e\ntwo\nt"));
3941        assert_eq!(
3942            selection.range_for(DocumentSide::Single, &source, 4..7),
3943            Some(0..3)
3944        );
3945    }
3946
3947    #[test]
3948    fn code_descriptor_exposes_string_or_native_document_without_rhai_rows() {
3949        let descriptor = code_viewer_primitive_descriptor();
3950        let source = &descriptor.props["source"].schema;
3951        assert!(
3952            matches!(source, ValueSchema::OneOf { variants } if variants.contains(&ValueSchema::Document))
3953        );
3954        assert!(descriptor.lifecycle);
3955    }
3956
3957    #[test]
3958    fn tab_expansion_preserves_source_and_display_offsets() {
3959        let expanded = expand_tabs("\tvalue", 4, 0);
3960        assert_eq!(expanded.text, "    value");
3961        assert_eq!(source_display_offset(&expanded.offsets, 1), Some(4));
3962        assert_eq!(display_source_offset(&expanded.offsets, 2), 0);
3963        assert_eq!(display_source_offset(&expanded.offsets, 4), 1);
3964    }
3965
3966    #[test]
3967    fn wrapping_respects_tabs_and_unicode_grapheme_boundaries() {
3968        let tabbed = wrap_source_line(&(10..13), "\t12", Some(4), 4);
3969        assert_eq!(tabbed, vec![(10..11, true, 0), (11..13, false, 4)]);
3970
3971        let family = "👨‍👩‍👧x";
3972        let unicode = wrap_source_line(&(0..family.len()), family, Some(1), 4);
3973        assert_eq!(unicode.len(), 2);
3974        assert_eq!(&family[unicode[0].0.clone()], "👨‍👩‍👧");
3975        assert_eq!(&family[unicode[1].0.clone()], "x");
3976    }
3977
3978    #[test]
3979    fn document_font_resolution_uses_an_installed_monospace_before_fallback() {
3980        let available = vec!["Helvetica".to_owned(), "Menlo".to_owned()];
3981        assert_eq!(
3982            select_document_monospace_family(&available, &["Lilex", "Menlo"], "Fallback")
3983                .to_string(),
3984            "Menlo"
3985        );
3986        assert_eq!(
3987            select_document_monospace_family(&available, &["Lilex"], "Fallback").to_string(),
3988            "Fallback"
3989        );
3990    }
3991
3992    #[test]
3993    fn overlapping_highlight_layers_become_ordered_composed_runs() {
3994        let syntax_color = rgba(0x1122_33ff).into();
3995        let selection_color = rgba(0x4455_66ff).into();
3996        let runs = compose_highlights(
3997            4,
3998            &[
3999                (
4000                    0..4,
4001                    HighlightStyle {
4002                        color: Some(syntax_color),
4003                        ..HighlightStyle::default()
4004                    },
4005                ),
4006                (
4007                    1..3,
4008                    HighlightStyle {
4009                        background_color: Some(selection_color),
4010                        ..HighlightStyle::default()
4011                    },
4012                ),
4013            ],
4014        );
4015        assert_eq!(runs.len(), 3);
4016        assert_eq!(runs[0].0, 0..1);
4017        assert_eq!(runs[1].0, 1..3);
4018        assert_eq!(runs[2].0, 3..4);
4019        assert_eq!(runs[1].1.color, Some(syntax_color));
4020        assert_eq!(runs[1].1.background_color, Some(selection_color));
4021    }
4022
4023    #[test]
4024    fn native_search_supports_case_word_and_regex_policies() {
4025        let exact = search_ranges(
4026            "Alpha alphabet alpha",
4027            "alpha",
4028            SearchOptions {
4029                case_sensitive: true,
4030                whole_word: true,
4031                regex: false,
4032            },
4033            10,
4034        )
4035        .unwrap();
4036        assert_eq!(exact, vec![15..20]);
4037        let regex = search_ranges(
4038            "port=80 port=443",
4039            r"port=\d+",
4040            SearchOptions {
4041                regex: true,
4042                ..SearchOptions::default()
4043            },
4044            10,
4045        )
4046        .unwrap();
4047        assert_eq!(regex, vec![0..7, 8..16]);
4048    }
4049}