1use 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 pub line: usize,
59}
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub enum DiffSide {
64 Left,
66 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 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]
1825pub 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#[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}