Skip to repository content1338 lines · 48.1 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:59:59.219Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
highlights_tree_view.rs
1use editor::{
2 Anchor, Editor, HighlightKey, MultiBufferSnapshot, SelectionEffects, ToPoint,
3 scroll::Autoscroll,
4};
5use gpui::{
6 Action, App, AppContext as _, Context, Div, Entity, EntityId, EventEmitter, FocusHandle,
7 Focusable, HighlightStyle, Hsla, InteractiveElement, IntoElement, MouseButton, MouseDownEvent,
8 MouseMoveEvent, ParentElement, Render, ScrollStrategy, SharedString, Styled, Task,
9 UniformListScrollHandle, WeakEntity, Window, actions, div, rems, uniform_list,
10};
11use language::{BufferId, Point, ToOffset};
12use menu::{SelectNext, SelectPrevious};
13use std::{mem, ops::Range, sync::Arc, time::Duration};
14use theme::ActiveTheme;
15use theme::SyntaxTheme;
16use ui::{
17 ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, WithScrollbar, prelude::*,
18};
19
20use workspace::{
21 Event as WorkspaceEvent, SplitDirection, ToolbarItemEvent, ToolbarItemLocation,
22 ToolbarItemView, Workspace,
23 item::{Item, ItemHandle},
24};
25
26actions!(
27 dev,
28 [
29 /// Opens the highlights tree view for the current file.
30 OpenHighlightsTreeView,
31 ]
32);
33
34actions!(
35 highlights_tree_view,
36 [
37 /// Toggles showing text highlights.
38 ToggleTextHighlights,
39 /// Toggles showing semantic token highlights.
40 ToggleSemanticTokens,
41 /// Toggles showing syntax token highlights.
42 ToggleSyntaxTokens,
43 ]
44);
45
46pub fn init(cx: &mut App) {
47 cx.observe_new(move |workspace: &mut Workspace, _, _| {
48 workspace.register_action(move |workspace, _: &OpenHighlightsTreeView, window, cx| {
49 let active_item = workspace.active_item(cx);
50 let workspace_handle = workspace.weak_handle();
51 let highlights_tree_view =
52 cx.new(|cx| HighlightsTreeView::new(workspace_handle, active_item, window, cx));
53 workspace.split_item(
54 SplitDirection::Right,
55 Box::new(highlights_tree_view),
56 window,
57 cx,
58 )
59 });
60 })
61 .detach();
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
65pub enum HighlightCategory {
66 Text(HighlightKey),
67 SyntaxToken {
68 capture_name: SharedString,
69 theme_key: Option<SharedString>,
70 },
71 SemanticToken {
72 token_type: Option<SharedString>,
73 token_modifiers: Option<SharedString>,
74 theme_key: Option<SharedString>,
75 },
76}
77
78impl HighlightCategory {
79 fn label(&self) -> SharedString {
80 match self {
81 HighlightCategory::Text(key) => format!("text: {key:?}").into(),
82 HighlightCategory::SyntaxToken {
83 capture_name,
84 theme_key: Some(theme_key),
85 } => format!("syntax: {capture_name} \u{2192} {theme_key}").into(),
86 HighlightCategory::SyntaxToken {
87 capture_name,
88 theme_key: None,
89 } => format!("syntax: {capture_name}").into(),
90 HighlightCategory::SemanticToken {
91 token_type,
92 token_modifiers,
93 theme_key,
94 } => {
95 let label = match (token_type, token_modifiers) {
96 (Some(token_type), Some(modifiers)) => {
97 format!("semantic token: {token_type} [{modifiers}]")
98 }
99 (Some(token_type), None) => format!("semantic token: {token_type}"),
100 (None, Some(modifiers)) => format!("semantic token [{modifiers}]"),
101 (None, None) => "semantic token".to_string(),
102 };
103
104 if let Some(theme_key) = theme_key {
105 format!("{label} \u{2192} {theme_key}").into()
106 } else {
107 label.into()
108 }
109 }
110 }
111 }
112}
113
114#[derive(Debug, Clone)]
115struct HighlightEntry {
116 range: Range<Anchor>,
117 buffer_id: BufferId,
118 buffer_point_range: Range<Point>,
119 range_display: SharedString,
120 style: HighlightStyle,
121 category: HighlightCategory,
122}
123
124/// An item in the display list: either a separator between excerpts or a highlight entry.
125#[derive(Debug, Clone)]
126enum DisplayItem {
127 ExcerptSeparator {
128 label: SharedString,
129 },
130 Entry {
131 /// Index into `cached_entries`.
132 entry_ix: usize,
133 },
134}
135
136pub struct HighlightsTreeView {
137 workspace_handle: WeakEntity<Workspace>,
138 editor: Option<EditorState>,
139 list_scroll_handle: UniformListScrollHandle,
140 selected_item_ix: Option<usize>,
141 hovered_item_ix: Option<usize>,
142 focus_handle: FocusHandle,
143 cached_entries: Vec<HighlightEntry>,
144 display_items: Vec<DisplayItem>,
145 is_singleton: bool,
146 show_text_highlights: bool,
147 show_syntax_tokens: bool,
148 show_semantic_tokens: bool,
149 skip_next_scroll: bool,
150 refresh_task: Task<()>,
151}
152
153pub struct HighlightsTreeToolbarItemView {
154 tree_view: Option<Entity<HighlightsTreeView>>,
155 _subscription: Option<gpui::Subscription>,
156 toggle_settings_handle: PopoverMenuHandle<ContextMenu>,
157}
158
159struct EditorState {
160 editor: Entity<Editor>,
161 _subscription: gpui::Subscription,
162}
163
164struct SemanticHighlightEntry {
165 range: Range<Anchor>,
166 style: HighlightStyle,
167 category: HighlightCategory,
168}
169
170struct HighlightRefreshInput {
171 multi_buffer_snapshot: MultiBufferSnapshot,
172 text_highlights: Vec<(HighlightKey, Arc<(HighlightStyle, Vec<Range<Anchor>>)>)>,
173 semantic_highlights: Vec<SemanticHighlightEntry>,
174 syntax_theme: Arc<SyntaxTheme>,
175}
176
177impl HighlightsTreeView {
178 pub fn new(
179 workspace_handle: WeakEntity<Workspace>,
180 active_item: Option<Box<dyn ItemHandle>>,
181 window: &mut Window,
182 cx: &mut Context<Self>,
183 ) -> Self {
184 let mut this = Self {
185 workspace_handle: workspace_handle.clone(),
186 list_scroll_handle: UniformListScrollHandle::new(),
187 editor: None,
188 hovered_item_ix: None,
189 selected_item_ix: None,
190 focus_handle: cx.focus_handle(),
191 cached_entries: Vec::new(),
192 display_items: Vec::new(),
193 is_singleton: true,
194 show_text_highlights: true,
195 show_syntax_tokens: true,
196 show_semantic_tokens: true,
197 skip_next_scroll: false,
198 refresh_task: Task::ready(()),
199 };
200
201 this.handle_item_updated(active_item, window, cx);
202
203 cx.subscribe_in(
204 &workspace_handle.upgrade().unwrap(),
205 window,
206 move |this, workspace, event, window, cx| match event {
207 WorkspaceEvent::ItemAdded { .. } | WorkspaceEvent::ActiveItemChanged => {
208 this.handle_item_updated(workspace.read(cx).active_item(cx), window, cx)
209 }
210 WorkspaceEvent::ItemRemoved { item_id } => {
211 this.handle_item_removed(item_id, window, cx);
212 }
213 _ => {}
214 },
215 )
216 .detach();
217
218 this
219 }
220
221 fn handle_item_updated(
222 &mut self,
223 active_item: Option<Box<dyn ItemHandle>>,
224 window: &mut Window,
225 cx: &mut Context<Self>,
226 ) {
227 let active_editor = match active_item {
228 Some(active_item) => {
229 if active_item.item_id() == cx.entity_id() {
230 return;
231 } else {
232 match active_item.downcast::<Editor>() {
233 Some(active_editor) => active_editor,
234 None => {
235 self.clear(cx);
236 return;
237 }
238 }
239 }
240 }
241 None => {
242 self.clear(cx);
243 return;
244 }
245 };
246
247 let is_different_editor = self
248 .editor
249 .as_ref()
250 .is_none_or(|state| state.editor != active_editor);
251 if is_different_editor {
252 self.set_editor(active_editor, window, cx);
253 }
254 }
255
256 fn handle_item_removed(
257 &mut self,
258 item_id: &EntityId,
259 _window: &mut Window,
260 cx: &mut Context<Self>,
261 ) {
262 if self
263 .editor
264 .as_ref()
265 .is_some_and(|state| state.editor.entity_id() == *item_id)
266 {
267 self.clear(cx);
268 }
269 }
270
271 fn clear(&mut self, cx: &mut Context<Self>) {
272 self.refresh_task = Task::ready(());
273 self.cached_entries.clear();
274 self.display_items.clear();
275 self.selected_item_ix = None;
276 self.hovered_item_ix = None;
277 if let Some(state) = self.editor.take() {
278 Self::clear_editor_highlights(&state.editor, cx);
279 }
280 cx.notify();
281 }
282
283 fn set_editor(&mut self, editor: Entity<Editor>, window: &mut Window, cx: &mut Context<Self>) {
284 if let Some(state) = &self.editor {
285 if state.editor == editor {
286 return;
287 }
288 Self::clear_editor_highlights(&state.editor, cx);
289 }
290
291 let subscription =
292 cx.subscribe_in(&editor, window, |this, _, event, window, cx| match event {
293 editor::EditorEvent::Reparsed(_) => this.schedule_refresh_highlights(window, cx),
294 editor::EditorEvent::SelectionsChanged { .. } => {
295 this.update_selection_from_editor(cx);
296 }
297 _ => return,
298 });
299
300 self.editor = Some(EditorState {
301 editor,
302 _subscription: subscription,
303 });
304 self.refresh_task = Task::ready(());
305 self.cached_entries.clear();
306 self.display_items.clear();
307 self.selected_item_ix = None;
308 self.hovered_item_ix = None;
309 self.schedule_refresh_highlights(window, cx);
310 cx.notify();
311 }
312
313 fn schedule_refresh_highlights(&mut self, window: &mut Window, cx: &mut Context<Self>) {
314 self.refresh_task = cx.spawn_in(window, async move |this, cx| {
315 cx.background_executor()
316 .timer(Duration::from_millis(30))
317 .await;
318
319 let Some(input) = this
320 .update(cx, |this, cx| this.highlight_refresh_input(cx))
321 .ok()
322 .flatten()
323 else {
324 return;
325 };
326
327 let new_highlights = cx
328 .background_spawn(async move { build_highlight_entries(input) })
329 .await;
330
331 this.update_in(cx, |this, _window, cx| {
332 this.apply_highlight_refresh(new_highlights, cx);
333 })
334 .ok();
335 });
336 }
337
338 fn highlight_refresh_input(&self, cx: &mut Context<Self>) -> Option<HighlightRefreshInput> {
339 let editor_state = self.editor.as_ref()?;
340 let editor = editor_state.editor.read(cx);
341 let display_map = editor.display_map.clone();
342 let project = editor.project().cloned()?;
343 let multi_buffer = editor.buffer().clone();
344 let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
345 let syntax_theme = cx.theme().syntax().clone();
346
347 let (text_highlights, semantic_token_highlights) =
348 display_map.update(cx, |display_map, _| {
349 let text_highlights = display_map
350 .all_text_highlights()
351 .map(|(key, highlights)| (*key, highlights.clone()))
352 .collect::<Vec<_>>();
353 let semantic_token_highlights = display_map
354 .all_semantic_token_highlights()
355 .map(|(buffer_id, (tokens, interner))| {
356 (*buffer_id, tokens.clone(), interner.clone())
357 })
358 .collect::<Vec<_>>();
359 (text_highlights, semantic_token_highlights)
360 });
361
362 let mut semantic_highlights = Vec::new();
363 project.read(cx).lsp_store().update(cx, |lsp_store, cx| {
364 for (buffer_id, tokens, interner) in semantic_token_highlights {
365 let language_name = multi_buffer
366 .read(cx)
367 .buffer(buffer_id)
368 .and_then(|buffer| buffer.read(cx).language().map(|language| language.name()));
369
370 for token in tokens.iter() {
371 let Some(stylizer) = lsp_store.get_or_create_token_stylizer(
372 token.server_id,
373 language_name.as_ref(),
374 cx,
375 ) else {
376 continue;
377 };
378
379 let theme_key = stylizer
380 .rules_for_token(token.token_type)
381 .and_then(|rules| {
382 rules
383 .iter()
384 .filter(|rule| {
385 rule.token_modifiers.iter().all(|modifier| {
386 stylizer.has_modifier(token.token_modifiers, modifier)
387 })
388 })
389 .fold(None, |theme_key, rule| {
390 rule.style
391 .iter()
392 .find(|style_name| {
393 syntax_theme.style_for_name(style_name).is_some()
394 })
395 .map(|style_name| SharedString::from(style_name.clone()))
396 .or(theme_key)
397 })
398 });
399
400 semantic_highlights.push(SemanticHighlightEntry {
401 range: token.range.start..token.range.end,
402 style: interner[token.style],
403 category: HighlightCategory::SemanticToken {
404 token_type: stylizer.token_type_name(token.token_type).cloned(),
405 token_modifiers: stylizer
406 .token_modifiers(token.token_modifiers)
407 .map(SharedString::from),
408 theme_key,
409 },
410 });
411 }
412 }
413 });
414
415 Some(HighlightRefreshInput {
416 multi_buffer_snapshot,
417 text_highlights,
418 semantic_highlights,
419 syntax_theme,
420 })
421 }
422
423 fn apply_highlight_refresh(
424 &mut self,
425 new_highlights: Vec<HighlightEntry>,
426 cx: &mut Context<Self>,
427 ) {
428 let Some(editor) = self.editor.as_ref().map(|state| state.editor.clone()) else {
429 return;
430 };
431 let editor = editor.read(cx);
432 let cursor_position = editor.selections.newest_anchor().head();
433 let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
434
435 self.is_singleton = multi_buffer_snapshot.is_singleton();
436 self.cached_entries = new_highlights;
437 self.hovered_item_ix = None;
438 self.rebuild_display_items(&multi_buffer_snapshot, cx);
439
440 let should_scroll = !mem::take(&mut self.skip_next_scroll);
441 self.update_selection_for_cursor(&cursor_position, &multi_buffer_snapshot, should_scroll);
442 self.sync_editor_highlight_for_selected_entry(cx);
443 cx.notify();
444 }
445
446 fn rebuild_display_items(&mut self, snapshot: &MultiBufferSnapshot, cx: &App) {
447 self.display_items.clear();
448
449 let mut last_range_end = None;
450 for (entry_ix, entry) in self.cached_entries.iter().enumerate() {
451 if !self.should_show_entry(entry) {
452 continue;
453 }
454
455 if !self.is_singleton {
456 let excerpt_changed = last_range_end.is_none_or(|anchor| {
457 snapshot
458 .excerpt_containing(anchor..entry.range.start)
459 .is_none()
460 });
461 if excerpt_changed {
462 last_range_end = Some(entry.range.end);
463 let label = excerpt_label_for(entry, snapshot, cx);
464 self.display_items
465 .push(DisplayItem::ExcerptSeparator { label });
466 }
467 }
468
469 self.display_items.push(DisplayItem::Entry { entry_ix });
470 }
471 }
472
473 fn should_show_entry(&self, entry: &HighlightEntry) -> bool {
474 match entry.category {
475 HighlightCategory::Text(_) => self.show_text_highlights,
476 HighlightCategory::SyntaxToken { .. } => self.show_syntax_tokens,
477 HighlightCategory::SemanticToken { .. } => self.show_semantic_tokens,
478 }
479 }
480
481 fn update_selection_from_editor(&mut self, cx: &mut Context<Self>) {
482 let Some(editor_state) = self.editor.as_ref() else {
483 return;
484 };
485
486 let (cursor_position, multi_buffer_snapshot) = {
487 let editor = editor_state.editor.read(cx);
488 (
489 editor.selections.newest_anchor().head(),
490 editor.buffer().read(cx).snapshot(cx),
491 )
492 };
493
494 let should_update_selection = !mem::take(&mut self.skip_next_scroll);
495 if !should_update_selection {
496 self.sync_editor_highlight_for_selected_entry(cx);
497 return;
498 }
499
500 let should_notify =
501 self.update_selection_for_cursor(&cursor_position, &multi_buffer_snapshot, true);
502 self.sync_editor_highlight_for_selected_entry(cx);
503 if should_notify {
504 cx.notify();
505 }
506 }
507
508 fn update_selection_for_cursor(
509 &mut self,
510 cursor: &Anchor,
511 snapshot: &MultiBufferSnapshot,
512 should_scroll: bool,
513 ) -> bool {
514 let cursor_point = cursor.to_point(snapshot);
515 let Some((cursor_buffer, cursor_point)) = snapshot.point_to_buffer_point(cursor_point)
516 else {
517 let changed = self.selected_item_ix.take().is_some();
518 return changed;
519 };
520 let cursor_buffer_id = cursor_buffer.remote_id();
521
522 let best = self
523 .display_items
524 .iter()
525 .enumerate()
526 .filter_map(|(display_ix, item)| match item {
527 DisplayItem::Entry { entry_ix } => {
528 let entry = &self.cached_entries[*entry_ix];
529 Some((display_ix, *entry_ix, entry))
530 }
531 _ => None,
532 })
533 .filter(|(_, _, entry)| {
534 entry.buffer_id == cursor_buffer_id
535 && entry.buffer_point_range.start <= cursor_point
536 && cursor_point < entry.buffer_point_range.end
537 })
538 .min_by_key(|(_, _, entry)| {
539 (
540 entry
541 .buffer_point_range
542 .end
543 .row
544 .saturating_sub(entry.buffer_point_range.start.row),
545 entry
546 .buffer_point_range
547 .end
548 .column
549 .saturating_sub(entry.buffer_point_range.start.column),
550 )
551 })
552 .map(|(display_ix, entry_ix, _)| (display_ix, entry_ix));
553
554 let selected_item_ix = best.map(|(_, entry_ix)| entry_ix);
555 let changed = self.selected_item_ix != selected_item_ix;
556 self.selected_item_ix = selected_item_ix;
557
558 if should_scroll && let Some((display_ix, _)) = best {
559 self.list_scroll_handle
560 .scroll_to_item(display_ix, ScrollStrategy::Center);
561 return true;
562 }
563
564 changed
565 }
566
567 fn update_editor_with_range_for_entry(
568 &self,
569 entry_ix: usize,
570 window: &mut Window,
571 cx: &mut Context<Self>,
572 f: &mut dyn FnMut(&mut Editor, Range<Anchor>, usize, &mut Window, &mut Context<Editor>),
573 ) -> Option<()> {
574 let editor_state = self.editor.as_ref()?;
575 let entry = self.cached_entries.get(entry_ix)?;
576 let range = entry.range.clone();
577 let key = cx.entity_id().as_u64() as usize;
578
579 editor_state.editor.update(cx, |editor, cx| {
580 f(editor, range, key, window, cx);
581 });
582 Some(())
583 }
584
585 fn render_entry(&self, entry: &HighlightEntry, selected: bool, cx: &App) -> Div {
586 let colors = cx.theme().colors();
587 let style_preview = render_style_preview(entry.style, selected, cx);
588
589 h_flex()
590 .gap_1()
591 .child(style_preview)
592 .child(Label::new(entry.range_display.clone()).color(Color::Default))
593 .child(
594 Label::new(entry.category.label())
595 .size(LabelSize::Small)
596 .color(Color::Muted),
597 )
598 .text_bg(if selected {
599 colors.element_selected
600 } else {
601 Hsla::default()
602 })
603 .pl(rems(0.5))
604 .hover(|style| style.bg(colors.element_hover))
605 }
606
607 fn render_separator(&self, label: &SharedString, cx: &App) -> Div {
608 let colors = cx.theme().colors();
609 h_flex()
610 .gap_1()
611 .px(rems(0.5))
612 .bg(colors.surface_background)
613 .border_b_1()
614 .border_color(colors.border_variant)
615 .child(
616 Label::new(label.clone())
617 .size(LabelSize::Small)
618 .color(Color::Muted),
619 )
620 }
621
622 fn compute_items(
623 &mut self,
624 visible_range: Range<usize>,
625 _window: &mut Window,
626 cx: &mut Context<Self>,
627 ) -> Vec<Div> {
628 let mut items = Vec::new();
629
630 for display_ix in visible_range {
631 let Some(display_item) = self.display_items.get(display_ix) else {
632 continue;
633 };
634
635 match display_item {
636 DisplayItem::ExcerptSeparator { label } => {
637 items.push(self.render_separator(label, cx));
638 }
639 DisplayItem::Entry { entry_ix } => {
640 let entry_ix = *entry_ix;
641 let entry = &self.cached_entries[entry_ix];
642 let selected = Some(entry_ix) == self.selected_item_ix;
643 let rendered = self
644 .render_entry(entry, selected, cx)
645 .on_mouse_down(
646 MouseButton::Left,
647 cx.listener(move |tree_view, _: &MouseDownEvent, window, cx| {
648 tree_view.selected_item_ix = Some(entry_ix);
649 tree_view.skip_next_scroll = true;
650 tree_view.update_editor_with_range_for_entry(
651 entry_ix,
652 window,
653 cx,
654 &mut |editor, mut range, _, window, cx| {
655 mem::swap(&mut range.start, &mut range.end);
656 editor.change_selections(
657 SelectionEffects::scroll(Autoscroll::newest()),
658 window,
659 cx,
660 |selections| {
661 selections.select_ranges([range]);
662 },
663 );
664 },
665 );
666 cx.notify();
667 }),
668 )
669 .on_mouse_move(cx.listener(
670 move |tree_view, _: &MouseMoveEvent, window, cx| {
671 if tree_view.hovered_item_ix != Some(entry_ix) {
672 tree_view.hovered_item_ix = Some(entry_ix);
673 tree_view.update_editor_with_range_for_entry(
674 entry_ix,
675 window,
676 cx,
677 &mut |editor, range, key, _, cx| {
678 Self::set_editor_highlights(editor, key, &[range], cx);
679 },
680 );
681 cx.notify();
682 }
683 },
684 ));
685
686 items.push(rendered);
687 }
688 }
689 }
690
691 items
692 }
693
694 fn set_editor_highlights(
695 editor: &mut Editor,
696 key: usize,
697 ranges: &[Range<Anchor>],
698 cx: &mut Context<Editor>,
699 ) {
700 editor.highlight_background(
701 HighlightKey::HighlightsTreeView(key),
702 ranges,
703 |_, theme| theme.colors().editor_document_highlight_write_background,
704 cx,
705 );
706 }
707
708 fn sync_editor_highlight_for_selected_entry(&self, cx: &mut Context<Self>) {
709 let Some(editor_state) = self.editor.as_ref() else {
710 return;
711 };
712 let key = cx.entity_id().as_u64() as usize;
713 let range = self
714 .selected_item_ix
715 .and_then(|entry_ix| self.cached_entries.get(entry_ix))
716 .map(|entry| entry.range.clone());
717
718 editor_state.editor.update(cx, |editor, cx| {
719 if let Some(range) = range {
720 Self::set_editor_highlights(editor, key, &[range], cx);
721 } else {
722 editor.clear_background_highlights(HighlightKey::HighlightsTreeView(key), cx);
723 }
724 });
725 }
726
727 fn clear_editor_highlights(editor: &Entity<Editor>, cx: &mut Context<Self>) {
728 let highlight_key = HighlightKey::HighlightsTreeView(cx.entity_id().as_u64() as usize);
729 editor.update(cx, |editor, cx| {
730 editor.clear_background_highlights(highlight_key, cx);
731 });
732 }
733
734 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
735 self.move_selection(-1, window, cx);
736 }
737
738 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
739 self.move_selection(1, window, cx);
740 }
741
742 fn move_selection(&mut self, delta: i32, window: &mut Window, cx: &mut Context<Self>) {
743 if self.display_items.is_empty() {
744 return;
745 }
746
747 let entry_display_items: Vec<(usize, usize)> = self
748 .display_items
749 .iter()
750 .enumerate()
751 .filter_map(|(display_ix, item)| match item {
752 DisplayItem::Entry { entry_ix } => Some((display_ix, *entry_ix)),
753 _ => None,
754 })
755 .collect();
756
757 if entry_display_items.is_empty() {
758 return;
759 }
760
761 let current_pos = self
762 .selected_item_ix
763 .and_then(|selected| {
764 entry_display_items
765 .iter()
766 .position(|(_, entry_ix)| *entry_ix == selected)
767 })
768 .unwrap_or(0);
769
770 let new_pos = if delta < 0 {
771 current_pos.saturating_sub((-delta) as usize)
772 } else {
773 (current_pos + delta as usize).min(entry_display_items.len() - 1)
774 };
775
776 if let Some(&(display_ix, entry_ix)) = entry_display_items.get(new_pos) {
777 self.selected_item_ix = Some(entry_ix);
778 self.skip_next_scroll = true;
779 self.list_scroll_handle
780 .scroll_to_item(display_ix, ScrollStrategy::Center);
781
782 self.update_editor_with_range_for_entry(
783 entry_ix,
784 window,
785 cx,
786 &mut |editor, mut range, _, window, cx| {
787 mem::swap(&mut range.start, &mut range.end);
788 editor.change_selections(
789 SelectionEffects::scroll(Autoscroll::newest()),
790 window,
791 cx,
792 |selections| {
793 selections.select_ranges([range]);
794 },
795 );
796 },
797 );
798
799 cx.notify();
800 }
801 }
802
803 fn entry_count(&self) -> usize {
804 self.cached_entries
805 .iter()
806 .filter(|entry| self.should_show_entry(entry))
807 .count()
808 }
809}
810
811impl Render for HighlightsTreeView {
812 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
813 let display_count = self.display_items.len();
814
815 div()
816 .flex_1()
817 .track_focus(&self.focus_handle)
818 .key_context("HighlightsTreeView")
819 .on_action(cx.listener(Self::select_previous))
820 .on_action(cx.listener(Self::select_next))
821 .bg(cx.theme().colors().editor_background)
822 .map(|this| {
823 if display_count > 0 {
824 this.child(
825 uniform_list(
826 "HighlightsTreeView",
827 display_count,
828 cx.processor(move |this, range: Range<usize>, window, cx| {
829 this.compute_items(range, window, cx)
830 }),
831 )
832 .size_full()
833 .track_scroll(&self.list_scroll_handle)
834 .text_bg(cx.theme().colors().background)
835 .into_any_element(),
836 )
837 .vertical_scrollbar_for(&self.list_scroll_handle, window, cx)
838 .into_any_element()
839 } else {
840 let inner_content = v_flex()
841 .items_center()
842 .text_center()
843 .gap_2()
844 .max_w_3_5()
845 .map(|this| {
846 if self.editor.is_some() {
847 let has_any = !self.cached_entries.is_empty();
848 if has_any {
849 this.child(Label::new("All highlights are filtered out"))
850 .child(
851 Label::new(
852 "Enable text, syntax, or semantic highlights in the toolbar",
853 )
854 .size(LabelSize::Small),
855 )
856 } else {
857 this.child(Label::new("No highlights found")).child(
858 Label::new(
859 "The editor has no text, syntax, or semantic token highlights",
860 )
861 .size(LabelSize::Small),
862 )
863 }
864 } else {
865 this.child(Label::new("Not attached to an editor")).child(
866 Label::new("Focus an editor to show highlights")
867 .size(LabelSize::Small),
868 )
869 }
870 });
871
872 this.h_flex()
873 .size_full()
874 .justify_center()
875 .child(inner_content)
876 .into_any_element()
877 }
878 })
879 }
880}
881
882impl EventEmitter<()> for HighlightsTreeView {}
883
884impl Focusable for HighlightsTreeView {
885 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
886 self.focus_handle.clone()
887 }
888}
889
890impl Item for HighlightsTreeView {
891 type Event = ();
892
893 fn to_item_events(_: &Self::Event, _: &mut dyn FnMut(workspace::item::ItemEvent)) {}
894
895 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
896 "Highlights".into()
897 }
898
899 fn telemetry_event_text(&self) -> Option<&'static str> {
900 None
901 }
902
903 fn can_split(&self) -> bool {
904 true
905 }
906
907 fn clone_on_split(
908 &self,
909 _: Option<workspace::WorkspaceId>,
910 window: &mut Window,
911 cx: &mut Context<Self>,
912 ) -> Task<Option<Entity<Self>>>
913 where
914 Self: Sized,
915 {
916 Task::ready(Some(cx.new(|cx| {
917 let mut clone = Self::new(self.workspace_handle.clone(), None, window, cx);
918 clone.show_text_highlights = self.show_text_highlights;
919 clone.show_syntax_tokens = self.show_syntax_tokens;
920 clone.show_semantic_tokens = self.show_semantic_tokens;
921 clone.skip_next_scroll = false;
922 if let Some(editor) = &self.editor {
923 clone.set_editor(editor.editor.clone(), window, cx)
924 }
925 clone
926 })))
927 }
928
929 fn on_removed(&self, cx: &mut Context<Self>) {
930 if let Some(state) = self.editor.as_ref() {
931 Self::clear_editor_highlights(&state.editor, cx);
932 }
933 }
934}
935
936impl Default for HighlightsTreeToolbarItemView {
937 fn default() -> Self {
938 Self::new()
939 }
940}
941
942impl HighlightsTreeToolbarItemView {
943 pub fn new() -> Self {
944 Self {
945 tree_view: None,
946 _subscription: None,
947 toggle_settings_handle: PopoverMenuHandle::default(),
948 }
949 }
950
951 fn render_header(&self, cx: &Context<Self>) -> Option<ButtonLike> {
952 let tree_view = self.tree_view.as_ref()?;
953 let tree_view = tree_view.read(cx);
954
955 let total = tree_view.cached_entries.len();
956 let filtered = tree_view.entry_count();
957
958 let label = if filtered == total {
959 format!("{} highlights", total)
960 } else {
961 format!("{} / {} highlights", filtered, total)
962 };
963
964 Some(ButtonLike::new("highlights header").child(Label::new(label)))
965 }
966
967 fn render_settings_button(&self, cx: &Context<Self>) -> PopoverMenu<ContextMenu> {
968 let (show_text, show_syntax, show_semantic) = self
969 .tree_view
970 .as_ref()
971 .map(|view| {
972 let v = view.read(cx);
973 (
974 v.show_text_highlights,
975 v.show_syntax_tokens,
976 v.show_semantic_tokens,
977 )
978 })
979 .unwrap_or((true, true, true));
980
981 let tree_view = self.tree_view.as_ref().map(|v| v.downgrade());
982
983 PopoverMenu::new("highlights-tree-settings")
984 .trigger_with_tooltip(
985 IconButton::new("toggle-highlights-settings-icon", IconName::Filter)
986 .icon_size(IconSize::Small)
987 .toggle_state(self.toggle_settings_handle.is_deployed()),
988 Tooltip::text("Highlights Settings"),
989 )
990 .anchor(gpui::Anchor::TopRight)
991 .with_handle(self.toggle_settings_handle.clone())
992 .menu(move |window, cx| {
993 let tree_view_for_text = tree_view.clone();
994 let tree_view_for_syntax = tree_view.clone();
995 let tree_view_for_semantic = tree_view.clone();
996
997 let menu = ContextMenu::build(window, cx, move |menu, _, _| {
998 menu.toggleable_entry(
999 "Text Highlights",
1000 show_text,
1001 IconPosition::Start,
1002 Some(ToggleTextHighlights.boxed_clone()),
1003 {
1004 let tree_view = tree_view_for_text.clone();
1005 move |_, cx| {
1006 if let Some(view) = tree_view.as_ref() {
1007 view.update(cx, |view, cx| {
1008 view.show_text_highlights = !view.show_text_highlights;
1009 let snapshot = view.editor.as_ref().map(|s| {
1010 s.editor.read(cx).buffer().read(cx).snapshot(cx)
1011 });
1012 if let Some(snapshot) = snapshot {
1013 view.rebuild_display_items(&snapshot, cx);
1014 }
1015 cx.notify();
1016 })
1017 .ok();
1018 }
1019 }
1020 },
1021 )
1022 .toggleable_entry(
1023 "Syntax Tokens",
1024 show_syntax,
1025 IconPosition::Start,
1026 Some(ToggleSyntaxTokens.boxed_clone()),
1027 {
1028 let tree_view = tree_view_for_syntax.clone();
1029 move |_, cx| {
1030 if let Some(view) = tree_view.as_ref() {
1031 view.update(cx, |view, cx| {
1032 view.show_syntax_tokens = !view.show_syntax_tokens;
1033 let snapshot = view.editor.as_ref().map(|s| {
1034 s.editor.read(cx).buffer().read(cx).snapshot(cx)
1035 });
1036 if let Some(snapshot) = snapshot {
1037 view.rebuild_display_items(&snapshot, cx);
1038 }
1039 cx.notify();
1040 })
1041 .ok();
1042 }
1043 }
1044 },
1045 )
1046 .toggleable_entry(
1047 "Semantic Tokens",
1048 show_semantic,
1049 IconPosition::Start,
1050 Some(ToggleSemanticTokens.boxed_clone()),
1051 {
1052 move |_, cx| {
1053 if let Some(view) = tree_view_for_semantic.as_ref() {
1054 view.update(cx, |view, cx| {
1055 view.show_semantic_tokens = !view.show_semantic_tokens;
1056 let snapshot = view.editor.as_ref().map(|s| {
1057 s.editor.read(cx).buffer().read(cx).snapshot(cx)
1058 });
1059 if let Some(snapshot) = snapshot {
1060 view.rebuild_display_items(&snapshot, cx);
1061 }
1062 cx.notify();
1063 })
1064 .ok();
1065 }
1066 }
1067 },
1068 )
1069 });
1070
1071 Some(menu)
1072 })
1073 }
1074}
1075
1076impl Render for HighlightsTreeToolbarItemView {
1077 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1078 h_flex()
1079 .gap_1()
1080 .children(self.render_header(cx))
1081 .child(self.render_settings_button(cx))
1082 }
1083}
1084
1085impl EventEmitter<ToolbarItemEvent> for HighlightsTreeToolbarItemView {}
1086
1087impl ToolbarItemView for HighlightsTreeToolbarItemView {
1088 fn set_active_pane_item(
1089 &mut self,
1090 active_pane_item: Option<&dyn ItemHandle>,
1091 window: &mut Window,
1092 cx: &mut Context<Self>,
1093 ) -> ToolbarItemLocation {
1094 if let Some(item) = active_pane_item
1095 && let Some(view) = item.downcast::<HighlightsTreeView>()
1096 {
1097 self.tree_view = Some(view.clone());
1098 self._subscription = Some(cx.observe_in(&view, window, |_, _, _, cx| cx.notify()));
1099 return ToolbarItemLocation::PrimaryLeft;
1100 }
1101 self.tree_view = None;
1102 self._subscription = None;
1103 ToolbarItemLocation::Hidden
1104 }
1105}
1106
1107fn excerpt_label_for(
1108 entry: &HighlightEntry,
1109 snapshot: &MultiBufferSnapshot,
1110 cx: &App,
1111) -> SharedString {
1112 let path_label = snapshot
1113 .anchor_to_buffer_anchor(entry.range.start)
1114 .and_then(|(anchor, _)| snapshot.buffer_for_id(anchor.buffer_id))
1115 .and_then(|buf| buf.file())
1116 .map(|file| {
1117 let full_path = file.full_path(cx);
1118 full_path.to_string_lossy().to_string()
1119 })
1120 .unwrap_or_else(|| "untitled".to_string());
1121 path_label.into()
1122}
1123
1124fn build_highlight_entries(
1125 HighlightRefreshInput {
1126 multi_buffer_snapshot,
1127 text_highlights,
1128 semantic_highlights,
1129 syntax_theme,
1130 }: HighlightRefreshInput,
1131) -> Vec<HighlightEntry> {
1132 let mut entries = Vec::new();
1133
1134 for (key, text_highlights) in text_highlights {
1135 for range in &text_highlights.1 {
1136 let Some((range_display, buffer_id, buffer_point_range)) =
1137 format_anchor_range(range, &multi_buffer_snapshot)
1138 else {
1139 continue;
1140 };
1141 entries.push(HighlightEntry {
1142 range: range.clone(),
1143 buffer_id,
1144 range_display,
1145 style: text_highlights.0,
1146 category: HighlightCategory::Text(key),
1147 buffer_point_range,
1148 });
1149 }
1150 }
1151
1152 for highlight in semantic_highlights {
1153 let Some((range_display, buffer_id, buffer_point_range)) =
1154 format_anchor_range(&highlight.range, &multi_buffer_snapshot)
1155 else {
1156 continue;
1157 };
1158
1159 entries.push(HighlightEntry {
1160 range: highlight.range,
1161 buffer_id,
1162 range_display,
1163 style: highlight.style,
1164 category: highlight.category,
1165 buffer_point_range,
1166 });
1167 }
1168
1169 for excerpt_range in multi_buffer_snapshot.excerpts() {
1170 let Some(buffer_snapshot) =
1171 multi_buffer_snapshot.buffer_for_id(excerpt_range.context.start.buffer_id)
1172 else {
1173 continue;
1174 };
1175
1176 let start_offset = excerpt_range.context.start.to_offset(buffer_snapshot);
1177 let end_offset = excerpt_range.context.end.to_offset(buffer_snapshot);
1178 let range = start_offset..end_offset;
1179
1180 let captures = buffer_snapshot.captures(range, |grammar| {
1181 grammar
1182 .highlights_config
1183 .as_ref()
1184 .map(|config| &config.query)
1185 });
1186 let grammars = captures.grammars().to_vec();
1187 let highlight_maps = grammars
1188 .iter()
1189 .map(|grammar| grammar.highlight_map())
1190 .collect::<Vec<_>>();
1191
1192 for capture in captures {
1193 let Some(highlight_id) = highlight_maps[capture.grammar_index].get(capture.index)
1194 else {
1195 continue;
1196 };
1197 let Some(style) = syntax_theme.get(highlight_id).cloned() else {
1198 continue;
1199 };
1200
1201 let theme_key = syntax_theme
1202 .get_capture_name(highlight_id)
1203 .map(|theme_key| SharedString::from(theme_key.to_string()));
1204
1205 let capture_name = grammars[capture.grammar_index]
1206 .highlights_config
1207 .as_ref()
1208 .and_then(|config| config.query.capture_names().get(capture.index as usize))
1209 .map(|capture_name| SharedString::from((*capture_name).to_string()))
1210 .unwrap_or_else(|| SharedString::from("unknown"));
1211
1212 let start_anchor = buffer_snapshot.anchor_before(capture.node.start_byte());
1213 let end_anchor = buffer_snapshot.anchor_after(capture.node.end_byte());
1214
1215 let start = multi_buffer_snapshot.anchor_in_excerpt(start_anchor);
1216 let end = multi_buffer_snapshot.anchor_in_excerpt(end_anchor);
1217
1218 let (start, end) = match (start, end) {
1219 (Some(start), Some(end)) => (start, end),
1220 _ => continue,
1221 };
1222
1223 let range = start..end;
1224 let Some((range_display, buffer_id, buffer_point_range)) =
1225 format_anchor_range(&range, &multi_buffer_snapshot)
1226 else {
1227 continue;
1228 };
1229
1230 entries.push(HighlightEntry {
1231 range,
1232 buffer_id,
1233 range_display,
1234 style,
1235 category: HighlightCategory::SyntaxToken {
1236 capture_name,
1237 theme_key,
1238 },
1239 buffer_point_range,
1240 });
1241 }
1242 }
1243
1244 entries.sort_by(|a, b| {
1245 a.buffer_id
1246 .cmp(&b.buffer_id)
1247 .then_with(|| a.buffer_point_range.start.cmp(&b.buffer_point_range.start))
1248 .then_with(|| a.buffer_point_range.end.cmp(&b.buffer_point_range.end))
1249 .then_with(|| a.category.cmp(&b.category))
1250 });
1251 entries.dedup_by(|a, b| {
1252 a.buffer_id == b.buffer_id
1253 && a.buffer_point_range == b.buffer_point_range
1254 && a.category == b.category
1255 });
1256
1257 entries
1258}
1259
1260fn format_anchor_range(
1261 range: &Range<Anchor>,
1262 snapshot: &MultiBufferSnapshot,
1263) -> Option<(SharedString, BufferId, Range<Point>)> {
1264 let start = range.start.to_point(snapshot);
1265 let end = range.end.to_point(snapshot);
1266 let ((start_buffer, start), (_, end)) = snapshot
1267 .point_to_buffer_point(start)
1268 .zip(snapshot.point_to_buffer_point(end))?;
1269 let display = SharedString::from(format!(
1270 "[{}:{} - {}:{}]",
1271 start.row + 1,
1272 start.column + 1,
1273 end.row + 1,
1274 end.column + 1,
1275 ));
1276 Some((display, start_buffer.remote_id(), start..end))
1277}
1278
1279fn render_style_preview(style: HighlightStyle, selected: bool, cx: &App) -> Div {
1280 let colors = cx.theme().colors();
1281
1282 let display_color = style.color.or(style.background_color);
1283
1284 let mut preview = div().px_1().rounded_sm();
1285
1286 if let Some(color) = display_color {
1287 if selected {
1288 preview = preview.border_1().border_color(color).text_color(color);
1289 } else {
1290 preview = preview.bg(color);
1291 }
1292 } else {
1293 preview = preview.bg(colors.element_background);
1294 }
1295
1296 let mut parts = Vec::new();
1297
1298 if let Some(color) = display_color {
1299 parts.push(format_hsla_as_hex(color));
1300 }
1301 if style.font_weight.is_some() {
1302 parts.push("bold".to_string());
1303 }
1304 if style.font_style.is_some() {
1305 parts.push("italic".to_string());
1306 }
1307 if style.strikethrough.is_some() {
1308 parts.push("strike".to_string());
1309 }
1310 if style.underline.is_some() {
1311 parts.push("underline".to_string());
1312 }
1313
1314 let label_text = if parts.is_empty() {
1315 "none".to_string()
1316 } else {
1317 parts.join(" ")
1318 };
1319
1320 preview.child(Label::new(label_text).size(LabelSize::Small).when_some(
1321 display_color.filter(|_| selected),
1322 |label, display_color| label.color(Color::Custom(display_color)),
1323 ))
1324}
1325
1326fn format_hsla_as_hex(color: Hsla) -> String {
1327 let rgba = color.to_rgb();
1328 let r = (rgba.r * 255.0).round() as u8;
1329 let g = (rgba.g * 255.0).round() as u8;
1330 let b = (rgba.b * 255.0).round() as u8;
1331 let a = (rgba.a * 255.0).round() as u8;
1332 if a == 255 {
1333 format!("#{:02X}{:02X}{:02X}", r, g, b)
1334 } else {
1335 format!("#{:02X}{:02X}{:02X}{:02X}", r, g, b, a)
1336 }
1337}
1338