Add a syntax tree view, for developing and debugging language support (#2601)

Max Brunsfeld created

This PR adds a syntax tree view, which lets you view the syntax tree of
any layer in the active editor's `SyntaxMap`.

This view uses some new APIs that I added to Tree-sitter, which allow us
to efficiently render the syntax tree using a `UniformList`. Tree-sitter
PR: https://github.com/tree-sitter/tree-sitter/pull/2316

![Screen Shot 2023-06-12 at 3 33 36
PM](https://github.com/zed-industries/zed/assets/326587/2a27ee7b-bf29-4b3b-bfa8-fb47f97a2785)

Release Notes:

- Added a *syntax tree view* that shows Zed's internal syntax tree(s)
for the active editor. You can open it running the `debug: open syntax
tree view` command from the command palette.

Change summary

Cargo.lock                                    |  53 
Cargo.toml                                    |   5 
crates/editor/Cargo.toml                      |   2 
crates/editor/src/editor.rs                   |   2 
crates/editor/src/multi_buffer.rs             |  14 
crates/language/Cargo.toml                    |   2 
crates/language/src/buffer.rs                 |  19 
crates/language/src/buffer_tests.rs           |   2 
crates/language/src/language.rs               |   1 
crates/language/src/syntax_map.rs             |  94 +-
crates/language_tools/Cargo.toml              |   5 
crates/language_tools/src/language_tools.rs   |  15 
crates/language_tools/src/lsp_log.rs          |  59 
crates/language_tools/src/lsp_log_tests.rs    |   9 
crates/language_tools/src/syntax_tree_view.rs | 675 ++++++++++++++++++++
crates/settings/Cargo.toml                    |   2 
crates/theme/src/theme.rs                     |  21 
crates/zed/Cargo.toml                         |   4 
crates/zed/src/main.rs                        |   2 
crates/zed/src/zed.rs                         |   5 
styles/src/styleTree/app.ts                   |   4 
styles/src/styleTree/toolbarDropdownMenu.ts   |   8 
22 files changed, 877 insertions(+), 126 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3515,6 +3515,29 @@ dependencies = [
  "workspace",
 ]
 
+[[package]]
+name = "language_tools"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "client",
+ "collections",
+ "editor",
+ "env_logger 0.9.3",
+ "futures 0.3.28",
+ "gpui",
+ "language",
+ "lsp",
+ "project",
+ "serde",
+ "settings",
+ "theme",
+ "tree-sitter",
+ "unindent",
+ "util",
+ "workspace",
+]
+
 [[package]]
 name = "lazy_static"
 version = "1.4.0"
@@ -3759,28 +3782,6 @@ dependencies = [
  "url",
 ]
 
-[[package]]
-name = "lsp_log"
-version = "0.1.0"
-dependencies = [
- "anyhow",
- "client",
- "collections",
- "editor",
- "env_logger 0.9.3",
- "futures 0.3.28",
- "gpui",
- "language",
- "lsp",
- "project",
- "serde",
- "settings",
- "theme",
- "unindent",
- "util",
- "workspace",
-]
-
 [[package]]
 name = "mach"
 version = "0.3.2"
@@ -7358,8 +7359,8 @@ dependencies = [
 
 [[package]]
 name = "tree-sitter"
-version = "0.20.9"
-source = "git+https://github.com/tree-sitter/tree-sitter?rev=c51896d32dcc11a38e41f36e3deb1a6a9c4f4b14#c51896d32dcc11a38e41f36e3deb1a6a9c4f4b14"
+version = "0.20.10"
+source = "git+https://github.com/tree-sitter/tree-sitter?rev=49226023693107fba9a1191136a4f47f38cdca73#49226023693107fba9a1191136a4f47f38cdca73"
 dependencies = [
  "cc",
  "regex",
@@ -7559,7 +7560,7 @@ dependencies = [
 [[package]]
 name = "tree-sitter-yaml"
 version = "0.0.1"
-source = "git+https://github.com/zed-industries/tree-sitter-yaml?rev=5694b7f290cd9ef998829a0a6d8391a666370886#5694b7f290cd9ef998829a0a6d8391a666370886"
+source = "git+https://github.com/zed-industries/tree-sitter-yaml?rev=f545a41f57502e1b5ddf2a6668896c1b0620f930#f545a41f57502e1b5ddf2a6668896c1b0620f930"
 dependencies = [
  "cc",
  "tree-sitter",
@@ -8829,11 +8830,11 @@ dependencies = [
  "journal",
  "language",
  "language_selector",
+ "language_tools",
  "lazy_static",
  "libc",
  "log",
  "lsp",
- "lsp_log",
  "node_runtime",
  "num_cpus",
  "outline",

Cargo.toml 🔗

@@ -32,10 +32,10 @@ members = [
     "crates/journal",
     "crates/language",
     "crates/language_selector",
+    "crates/language_tools",
     "crates/live_kit_client",
     "crates/live_kit_server",
     "crates/lsp",
-    "crates/lsp_log",
     "crates/media",
     "crates/menu",
     "crates/node_runtime",
@@ -98,10 +98,11 @@ tempdir = { version = "0.3.7" }
 thiserror = { version = "1.0.29" }
 time = { version = "0.3", features = ["serde", "serde-well-known"] }
 toml = { version = "0.5" }
+tree-sitter = "0.20"
 unindent = { version = "0.1.7" }
 
 [patch.crates-io]
-tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "c51896d32dcc11a38e41f36e3deb1a6a9c4f4b14" }
+tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "49226023693107fba9a1191136a4f47f38cdca73" }
 async-task = { git = "https://github.com/zed-industries/async-task", rev = "341b57d6de98cdfd7b418567b8de2022ca993a6e" }
 
 # TODO - Remove when a version is released with this PR: https://github.com/servo/core-foundation-rs/pull/457

crates/editor/Cargo.toml 🔗

@@ -83,7 +83,7 @@ ctor.workspace = true
 env_logger.workspace = true
 rand.workspace = true
 unindent.workspace = true
-tree-sitter = "0.20"
+tree-sitter.workspace = true
 tree-sitter-rust = "0.20"
 tree-sitter-html = "0.19"
 tree-sitter-typescript = { git = "https://github.com/tree-sitter/tree-sitter-typescript", rev = "5d20856f34315b068c41edaee2ac8a100081d259" }

crates/editor/src/editor.rs 🔗

@@ -7102,7 +7102,7 @@ impl Editor {
 
         let mut new_selections_by_buffer = HashMap::default();
         for selection in editor.selections.all::<usize>(cx) {
-            for (buffer, mut range) in
+            for (buffer, mut range, _) in
                 buffer.range_to_buffer_ranges(selection.start..selection.end, cx)
             {
                 if selection.reversed {

crates/editor/src/multi_buffer.rs 🔗

@@ -1118,7 +1118,7 @@ impl MultiBuffer {
         &self,
         point: T,
         cx: &AppContext,
-    ) -> Option<(ModelHandle<Buffer>, usize)> {
+    ) -> Option<(ModelHandle<Buffer>, usize, ExcerptId)> {
         let snapshot = self.read(cx);
         let offset = point.to_offset(&snapshot);
         let mut cursor = snapshot.excerpts.cursor::<usize>();
@@ -1132,7 +1132,7 @@ impl MultiBuffer {
             let buffer_point = excerpt_start + offset - *cursor.start();
             let buffer = self.buffers.borrow()[&excerpt.buffer_id].buffer.clone();
 
-            (buffer, buffer_point)
+            (buffer, buffer_point, excerpt.id)
         })
     }
 
@@ -1140,7 +1140,7 @@ impl MultiBuffer {
         &self,
         range: Range<T>,
         cx: &AppContext,
-    ) -> Vec<(ModelHandle<Buffer>, Range<usize>)> {
+    ) -> Vec<(ModelHandle<Buffer>, Range<usize>, ExcerptId)> {
         let snapshot = self.read(cx);
         let start = range.start.to_offset(&snapshot);
         let end = range.end.to_offset(&snapshot);
@@ -1165,7 +1165,7 @@ impl MultiBuffer {
             let start = excerpt_start + (cmp::max(start, *cursor.start()) - *cursor.start());
             let end = excerpt_start + (cmp::min(end, end_before_newline) - *cursor.start());
             let buffer = self.buffers.borrow()[&excerpt.buffer_id].buffer.clone();
-            result.push((buffer, start..end));
+            result.push((buffer, start..end, excerpt.id));
             cursor.next(&());
         }
 
@@ -1387,7 +1387,7 @@ impl MultiBuffer {
         cx: &'a AppContext,
     ) -> Option<Arc<Language>> {
         self.point_to_buffer_offset(point, cx)
-            .and_then(|(buffer, offset)| buffer.read(cx).language_at(offset))
+            .and_then(|(buffer, offset, _)| buffer.read(cx).language_at(offset))
     }
 
     pub fn settings_at<'a, T: ToOffset>(
@@ -1397,7 +1397,7 @@ impl MultiBuffer {
     ) -> &'a LanguageSettings {
         let mut language = None;
         let mut file = None;
-        if let Some((buffer, offset)) = self.point_to_buffer_offset(point, cx) {
+        if let Some((buffer, offset, _)) = self.point_to_buffer_offset(point, cx) {
             let buffer = buffer.read(cx);
             language = buffer.language_at(offset);
             file = buffer.file();
@@ -5196,7 +5196,7 @@ mod tests {
                     .range_to_buffer_ranges(start_ix..end_ix, cx);
                 let excerpted_buffers_text = excerpted_buffer_ranges
                     .iter()
-                    .map(|(buffer, buffer_range)| {
+                    .map(|(buffer, buffer_range, _)| {
                         buffer
                             .read(cx)
                             .text_for_range(buffer_range.clone())

crates/language/Cargo.toml 🔗

@@ -55,7 +55,7 @@ serde_json.workspace = true
 similar = "1.3"
 smallvec.workspace = true
 smol.workspace = true
-tree-sitter = "0.20"
+tree-sitter.workspace = true
 tree-sitter-rust = { version = "*", optional = true }
 tree-sitter-typescript = { version = "*", optional = true }
 unicase = "2.6"

crates/language/src/buffer.rs 🔗

@@ -8,7 +8,8 @@ use crate::{
     language_settings::{language_settings, LanguageSettings},
     outline::OutlineItem,
     syntax_map::{
-        SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxSnapshot, ToTreeSitterPoint,
+        SyntaxLayerInfo, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxSnapshot,
+        ToTreeSitterPoint,
     },
     CodeLabel, LanguageScope, Outline,
 };
@@ -2116,12 +2117,20 @@ impl BufferSnapshot {
         }
     }
 
-    pub fn language_at<D: ToOffset>(&self, position: D) -> Option<&Arc<Language>> {
+    pub fn syntax_layers(&self) -> impl Iterator<Item = SyntaxLayerInfo> + '_ {
+        self.syntax.layers_for_range(0..self.len(), &self.text)
+    }
+
+    pub fn syntax_layer_at<D: ToOffset>(&self, position: D) -> Option<SyntaxLayerInfo> {
         let offset = position.to_offset(self);
         self.syntax
             .layers_for_range(offset..offset, &self.text)
-            .filter(|l| l.node.end_byte() > offset)
+            .filter(|l| l.node().end_byte() > offset)
             .last()
+    }
+
+    pub fn language_at<D: ToOffset>(&self, position: D) -> Option<&Arc<Language>> {
+        self.syntax_layer_at(position)
             .map(|info| info.language)
             .or(self.language.as_ref())
     }
@@ -2140,7 +2149,7 @@ impl BufferSnapshot {
         if let Some(layer_info) = self
             .syntax
             .layers_for_range(offset..offset, &self.text)
-            .filter(|l| l.node.end_byte() > offset)
+            .filter(|l| l.node().end_byte() > offset)
             .last()
         {
             Some(LanguageScope {
@@ -2188,7 +2197,7 @@ impl BufferSnapshot {
         let range = range.start.to_offset(self)..range.end.to_offset(self);
         let mut result: Option<Range<usize>> = None;
         'outer: for layer in self.syntax.layers_for_range(range.clone(), &self.text) {
-            let mut cursor = layer.node.walk();
+            let mut cursor = layer.node().walk();
 
             // Descend to the first leaf that touches the start of the range,
             // and if the range is non-empty, extends beyond the start.

crates/language/src/buffer_tests.rs 🔗

@@ -2242,7 +2242,7 @@ fn get_tree_sexp(buffer: &ModelHandle<Buffer>, cx: &gpui::TestAppContext) -> Str
     buffer.read_with(cx, |buffer, _| {
         let snapshot = buffer.snapshot();
         let layers = snapshot.syntax.layers(buffer.as_text_snapshot());
-        layers[0].node.to_sexp()
+        layers[0].node().to_sexp()
     })
 }
 

crates/language/src/language.rs 🔗

@@ -57,6 +57,7 @@ pub use buffer::*;
 pub use diagnostic_set::DiagnosticEntry;
 pub use lsp::LanguageServerId;
 pub use outline::{Outline, OutlineItem};
+pub use syntax_map::{OwnedSyntaxLayerInfo, SyntaxLayerInfo};
 pub use tree_sitter::{Parser, Tree};
 
 pub fn init(cx: &mut AppContext) {

crates/language/src/syntax_map.rs 🔗

@@ -125,8 +125,17 @@ impl SyntaxLayerContent {
 #[derive(Debug)]
 pub struct SyntaxLayerInfo<'a> {
     pub depth: usize,
-    pub node: Node<'a>,
     pub language: &'a Arc<Language>,
+    tree: &'a Tree,
+    offset: (usize, tree_sitter::Point),
+}
+
+#[derive(Clone)]
+pub struct OwnedSyntaxLayerInfo {
+    pub depth: usize,
+    pub language: Arc<Language>,
+    tree: tree_sitter::Tree,
+    offset: (usize, tree_sitter::Point),
 }
 
 #[derive(Debug, Clone)]
@@ -664,8 +673,9 @@ impl SyntaxSnapshot {
             text,
             [SyntaxLayerInfo {
                 language,
+                tree,
                 depth: 0,
-                node: tree.root_node(),
+                offset: (0, tree_sitter::Point::new(0, 0)),
             }]
             .into_iter(),
             query,
@@ -728,9 +738,10 @@ impl SyntaxSnapshot {
             while let Some(layer) = cursor.item() {
                 if let SyntaxLayerContent::Parsed { tree, language } = &layer.content {
                     let info = SyntaxLayerInfo {
+                        tree,
                         language,
                         depth: layer.depth,
-                        node: tree.root_node_with_offset(
+                        offset: (
                             layer.range.start.to_offset(buffer),
                             layer.range.start.to_point(buffer).to_ts_point(),
                         ),
@@ -766,13 +777,8 @@ impl<'a> SyntaxMapCaptures<'a> {
             grammars: Vec::new(),
             active_layer_count: 0,
         };
-        for SyntaxLayerInfo {
-            language,
-            depth,
-            node,
-        } in layers
-        {
-            let grammar = match &language.grammar {
+        for layer in layers {
+            let grammar = match &layer.language.grammar {
                 Some(grammar) => grammar,
                 None => continue,
             };
@@ -789,7 +795,7 @@ impl<'a> SyntaxMapCaptures<'a> {
             };
 
             cursor.set_byte_range(range.clone());
-            let captures = cursor.captures(query, node, TextProvider(text));
+            let captures = cursor.captures(query, layer.node(), TextProvider(text));
             let grammar_index = result
                 .grammars
                 .iter()
@@ -799,7 +805,7 @@ impl<'a> SyntaxMapCaptures<'a> {
                     result.grammars.len() - 1
                 });
             let mut layer = SyntaxMapCapturesLayer {
-                depth,
+                depth: layer.depth,
                 grammar_index,
                 next_capture: None,
                 captures,
@@ -889,13 +895,8 @@ impl<'a> SyntaxMapMatches<'a> {
         query: fn(&Grammar) -> Option<&Query>,
     ) -> Self {
         let mut result = Self::default();
-        for SyntaxLayerInfo {
-            language,
-            depth,
-            node,
-        } in layers
-        {
-            let grammar = match &language.grammar {
+        for layer in layers {
+            let grammar = match &layer.language.grammar {
                 Some(grammar) => grammar,
                 None => continue,
             };
@@ -912,7 +913,7 @@ impl<'a> SyntaxMapMatches<'a> {
             };
 
             cursor.set_byte_range(range.clone());
-            let matches = cursor.matches(query, node, TextProvider(text));
+            let matches = cursor.matches(query, layer.node(), TextProvider(text));
             let grammar_index = result
                 .grammars
                 .iter()
@@ -922,7 +923,7 @@ impl<'a> SyntaxMapMatches<'a> {
                     result.grammars.len() - 1
                 });
             let mut layer = SyntaxMapMatchesLayer {
-                depth,
+                depth: layer.depth,
                 grammar_index,
                 matches,
                 next_pattern_index: 0,
@@ -1290,7 +1291,28 @@ fn splice_included_ranges(
     ranges
 }
 
+impl OwnedSyntaxLayerInfo {
+    pub fn node(&self) -> Node {
+        self.tree
+            .root_node_with_offset(self.offset.0, self.offset.1)
+    }
+}
+
 impl<'a> SyntaxLayerInfo<'a> {
+    pub fn to_owned(&self) -> OwnedSyntaxLayerInfo {
+        OwnedSyntaxLayerInfo {
+            tree: self.tree.clone(),
+            offset: self.offset,
+            depth: self.depth,
+            language: self.language.clone(),
+        }
+    }
+
+    pub fn node(&self) -> Node<'a> {
+        self.tree
+            .root_node_with_offset(self.offset.0, self.offset.1)
+    }
+
     pub(crate) fn override_id(&self, offset: usize, text: &text::BufferSnapshot) -> Option<u32> {
         let text = TextProvider(text.as_rope());
         let config = self.language.grammar.as_ref()?.override_config.as_ref()?;
@@ -1299,7 +1321,7 @@ impl<'a> SyntaxLayerInfo<'a> {
         query_cursor.set_byte_range(offset..offset);
 
         let mut smallest_match: Option<(u32, Range<usize>)> = None;
-        for mat in query_cursor.matches(&config.query, self.node, text) {
+        for mat in query_cursor.matches(&config.query, self.node(), text) {
             for capture in mat.captures {
                 if !config.values.contains_key(&capture.index) {
                     continue;
@@ -2328,8 +2350,11 @@ mod tests {
         let reference_layers = reference_syntax_map.layers(&buffer);
         for (edited_layer, reference_layer) in layers.into_iter().zip(reference_layers.into_iter())
         {
-            assert_eq!(edited_layer.node.to_sexp(), reference_layer.node.to_sexp());
-            assert_eq!(edited_layer.node.range(), reference_layer.node.range());
+            assert_eq!(
+                edited_layer.node().to_sexp(),
+                reference_layer.node().to_sexp()
+            );
+            assert_eq!(edited_layer.node().range(), reference_layer.node().range());
         }
     }
 
@@ -2411,8 +2436,11 @@ mod tests {
         let reference_layers = reference_syntax_map.layers(&buffer);
         for (edited_layer, reference_layer) in layers.into_iter().zip(reference_layers.into_iter())
         {
-            assert_eq!(edited_layer.node.to_sexp(), reference_layer.node.to_sexp());
-            assert_eq!(edited_layer.node.range(), reference_layer.node.range());
+            assert_eq!(
+                edited_layer.node().to_sexp(),
+                reference_layer.node().to_sexp()
+            );
+            assert_eq!(edited_layer.node().range(), reference_layer.node().range());
         }
     }
 
@@ -2563,13 +2591,13 @@ mod tests {
                 mutated_layers.into_iter().zip(reference_layers.into_iter())
             {
                 assert_eq!(
-                    edited_layer.node.to_sexp(),
-                    reference_layer.node.to_sexp(),
+                    edited_layer.node().to_sexp(),
+                    reference_layer.node().to_sexp(),
                     "different layer at step {i}"
                 );
                 assert_eq!(
-                    edited_layer.node.range(),
-                    reference_layer.node.range(),
+                    edited_layer.node().range(),
+                    reference_layer.node().range(),
                     "different layer at step {i}"
                 );
             }
@@ -2709,10 +2737,8 @@ mod tests {
             expected_layers.len(),
             "wrong number of layers"
         );
-        for (i, (SyntaxLayerInfo { node, .. }, expected_s_exp)) in
-            layers.iter().zip(expected_layers.iter()).enumerate()
-        {
-            let actual_s_exp = node.to_sexp();
+        for (i, (layer, expected_s_exp)) in layers.iter().zip(expected_layers.iter()).enumerate() {
+            let actual_s_exp = layer.node().to_sexp();
             assert!(
                 string_contains_sequence(
                     &actual_s_exp,

crates/lsp_log/Cargo.toml → crates/language_tools/Cargo.toml 🔗

@@ -1,11 +1,11 @@
 [package]
-name = "lsp_log"
+name = "language_tools"
 version = "0.1.0"
 edition = "2021"
 publish = false
 
 [lib]
-path = "src/lsp_log.rs"
+path = "src/language_tools.rs"
 doctest = false
 
 [dependencies]
@@ -22,6 +22,7 @@ lsp = { path = "../lsp" }
 futures.workspace = true
 serde.workspace = true
 anyhow.workspace = true
+tree-sitter.workspace = true
 
 [dev-dependencies]
 client = { path = "../client", features = ["test-support"] }

crates/language_tools/src/language_tools.rs 🔗

@@ -0,0 +1,15 @@
+mod lsp_log;
+mod syntax_tree_view;
+
+#[cfg(test)]
+mod lsp_log_tests;
+
+use gpui::AppContext;
+
+pub use lsp_log::{LogStore, LspLogToolbarItemView, LspLogView};
+pub use syntax_tree_view::{SyntaxTreeToolbarItemView, SyntaxTreeView};
+
+pub fn init(cx: &mut AppContext) {
+    lsp_log::init(cx);
+    syntax_tree_view::init(cx);
+}

crates/lsp_log/src/lsp_log.rs → crates/language_tools/src/lsp_log.rs 🔗

@@ -1,6 +1,3 @@
-#[cfg(test)]
-mod lsp_log_tests;
-
 use collections::HashMap;
 use editor::Editor;
 use futures::{channel::mpsc, StreamExt};
@@ -27,7 +24,7 @@ use workspace::{
 const SEND_LINE: &str = "// Send:\n";
 const RECEIVE_LINE: &str = "// Receive:\n";
 
-struct LogStore {
+pub struct LogStore {
     projects: HashMap<WeakModelHandle<Project>, ProjectState>,
     io_tx: mpsc::UnboundedSender<(WeakModelHandle<Project>, LanguageServerId, bool, String)>,
 }
@@ -49,10 +46,10 @@ struct LanguageServerRpcState {
 }
 
 pub struct LspLogView {
+    pub(crate) editor: ViewHandle<Editor>,
     log_store: ModelHandle<LogStore>,
     current_server_id: Option<LanguageServerId>,
     is_showing_rpc_trace: bool,
-    editor: ViewHandle<Editor>,
     project: ModelHandle<Project>,
 }
 
@@ -68,13 +65,13 @@ enum MessageKind {
 }
 
 #[derive(Clone, Debug, PartialEq)]
-struct LogMenuItem {
-    server_id: LanguageServerId,
-    server_name: LanguageServerName,
-    worktree: ModelHandle<Worktree>,
-    rpc_trace_enabled: bool,
-    rpc_trace_selected: bool,
-    logs_selected: bool,
+pub(crate) struct LogMenuItem {
+    pub server_id: LanguageServerId,
+    pub server_name: LanguageServerName,
+    pub worktree: ModelHandle<Worktree>,
+    pub rpc_trace_enabled: bool,
+    pub rpc_trace_selected: bool,
+    pub logs_selected: bool,
 }
 
 actions!(log, [OpenLanguageServerLogs]);
@@ -114,7 +111,7 @@ pub fn init(cx: &mut AppContext) {
 }
 
 impl LogStore {
-    fn new(cx: &mut ModelContext<Self>) -> Self {
+    pub fn new(cx: &mut ModelContext<Self>) -> Self {
         let (io_tx, mut io_rx) = mpsc::unbounded();
         let this = Self {
             projects: HashMap::default(),
@@ -320,7 +317,7 @@ impl LogStore {
 }
 
 impl LspLogView {
-    fn new(
+    pub fn new(
         project: ModelHandle<Project>,
         log_store: ModelHandle<LogStore>,
         cx: &mut ViewContext<Self>,
@@ -360,7 +357,7 @@ impl LspLogView {
         editor
     }
 
-    fn menu_items<'a>(&'a self, cx: &'a AppContext) -> Option<Vec<LogMenuItem>> {
+    pub(crate) fn menu_items<'a>(&'a self, cx: &'a AppContext) -> Option<Vec<LogMenuItem>> {
         let log_store = self.log_store.read(cx);
         let state = log_store.projects.get(&self.project.downgrade())?;
         let mut rows = self
@@ -544,12 +541,7 @@ impl View for LspLogToolbarItemView {
         let theme = theme::current(cx).clone();
         let Some(log_view) = self.log_view.as_ref() else { return Empty::new().into_any() };
         let log_view = log_view.read(cx);
-
-        let menu_rows = self
-            .log_view
-            .as_ref()
-            .and_then(|view| view.read(cx).menu_items(cx))
-            .unwrap_or_default();
+        let menu_rows = log_view.menu_items(cx).unwrap_or_default();
 
         let current_server_id = log_view.current_server_id;
         let current_server = current_server_id.and_then(|current_server_id| {
@@ -586,7 +578,7 @@ impl View for LspLogToolbarItemView {
                                     )
                                 }))
                                 .contained()
-                                .with_style(theme.lsp_log_menu.container)
+                                .with_style(theme.toolbar_dropdown_menu.container)
                                 .constrained()
                                 .with_width(400.)
                                 .with_height(400.)
@@ -596,6 +588,7 @@ impl View for LspLogToolbarItemView {
                             cx.notify()
                         }),
                     )
+                    .with_hoverable(true)
                     .with_fit_mode(OverlayFitMode::SwitchAnchor)
                     .with_anchor_corner(AnchorCorner::TopLeft)
                     .with_z_index(999)
@@ -688,7 +681,7 @@ impl LspLogToolbarItemView {
                     )
                 })
                 .unwrap_or_else(|| "No server selected".into());
-            let style = theme.lsp_log_menu.header.style_for(state, false);
+            let style = theme.toolbar_dropdown_menu.header.style_for(state, false);
             Label::new(label, style.text.clone())
                 .contained()
                 .with_style(style.container)
@@ -714,7 +707,7 @@ impl LspLogToolbarItemView {
 
         Flex::column()
             .with_child({
-                let style = &theme.lsp_log_menu.server;
+                let style = &theme.toolbar_dropdown_menu.section_header;
                 Label::new(
                     format!("{} ({})", name.0, worktree.read(cx).root_name()),
                     style.text.clone(),
@@ -722,16 +715,19 @@ impl LspLogToolbarItemView {
                 .contained()
                 .with_style(style.container)
                 .constrained()
-                .with_height(theme.lsp_log_menu.row_height)
+                .with_height(theme.toolbar_dropdown_menu.row_height)
             })
             .with_child(
                 MouseEventHandler::<ActivateLog, _>::new(id.0, cx, move |state, _| {
-                    let style = theme.lsp_log_menu.item.style_for(state, logs_selected);
+                    let style = theme
+                        .toolbar_dropdown_menu
+                        .item
+                        .style_for(state, logs_selected);
                     Label::new(SERVER_LOGS, style.text.clone())
                         .contained()
                         .with_style(style.container)
                         .constrained()
-                        .with_height(theme.lsp_log_menu.row_height)
+                        .with_height(theme.toolbar_dropdown_menu.row_height)
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
                 .on_click(MouseButton::Left, move |_, view, cx| {
@@ -740,12 +736,15 @@ impl LspLogToolbarItemView {
             )
             .with_child(
                 MouseEventHandler::<ActivateRpcTrace, _>::new(id.0, cx, move |state, cx| {
-                    let style = theme.lsp_log_menu.item.style_for(state, rpc_trace_selected);
+                    let style = theme
+                        .toolbar_dropdown_menu
+                        .item
+                        .style_for(state, rpc_trace_selected);
                     Flex::row()
                         .with_child(
                             Label::new(RPC_MESSAGES, style.text.clone())
                                 .constrained()
-                                .with_height(theme.lsp_log_menu.row_height),
+                                .with_height(theme.toolbar_dropdown_menu.row_height),
                         )
                         .with_child(
                             ui::checkbox_with_label::<Self, _, Self, _>(
@@ -764,7 +763,7 @@ impl LspLogToolbarItemView {
                         .contained()
                         .with_style(style.container)
                         .constrained()
-                        .with_height(theme.lsp_log_menu.row_height)
+                        .with_height(theme.toolbar_dropdown_menu.row_height)
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
                 .on_click(MouseButton::Left, move |_, view, cx| {

crates/lsp_log/src/lsp_log_tests.rs → crates/language_tools/src/lsp_log_tests.rs 🔗

@@ -1,7 +1,12 @@
+use std::sync::Arc;
+
+use crate::lsp_log::LogMenuItem;
+
 use super::*;
+use futures::StreamExt;
 use gpui::{serde_json::json, TestAppContext};
-use language::{tree_sitter_rust, FakeLspAdapter, Language, LanguageConfig};
-use project::FakeFs;
+use language::{tree_sitter_rust, FakeLspAdapter, Language, LanguageConfig, LanguageServerName};
+use project::{FakeFs, Project};
 use settings::SettingsStore;
 
 #[gpui::test]

crates/language_tools/src/syntax_tree_view.rs 🔗

@@ -0,0 +1,675 @@
+use editor::{scroll::autoscroll::Autoscroll, Anchor, Editor, ExcerptId};
+use gpui::{
+    actions,
+    elements::{
+        AnchorCorner, Empty, Flex, Label, MouseEventHandler, Overlay, OverlayFitMode,
+        ParentElement, ScrollTarget, Stack, UniformList, UniformListState,
+    },
+    fonts::TextStyle,
+    platform::{CursorStyle, MouseButton},
+    AppContext, Element, Entity, ModelHandle, View, ViewContext, ViewHandle, WeakViewHandle,
+};
+use language::{Buffer, OwnedSyntaxLayerInfo, SyntaxLayerInfo};
+use std::{mem, ops::Range, sync::Arc};
+use theme::{Theme, ThemeSettings};
+use tree_sitter::{Node, TreeCursor};
+use workspace::{
+    item::{Item, ItemHandle},
+    ToolbarItemLocation, ToolbarItemView, Workspace,
+};
+
+actions!(log, [OpenSyntaxTreeView]);
+
+pub fn init(cx: &mut AppContext) {
+    cx.add_action(
+        move |workspace: &mut Workspace, _: &OpenSyntaxTreeView, cx: _| {
+            let active_item = workspace.active_item(cx);
+            let workspace_handle = workspace.weak_handle();
+            let syntax_tree_view =
+                cx.add_view(|cx| SyntaxTreeView::new(workspace_handle, active_item, cx));
+            workspace.add_item(Box::new(syntax_tree_view), cx);
+        },
+    );
+}
+
+pub struct SyntaxTreeView {
+    workspace_handle: WeakViewHandle<Workspace>,
+    editor: Option<EditorState>,
+    mouse_y: Option<f32>,
+    line_height: Option<f32>,
+    list_state: UniformListState,
+    selected_descendant_ix: Option<usize>,
+    hovered_descendant_ix: Option<usize>,
+}
+
+pub struct SyntaxTreeToolbarItemView {
+    tree_view: Option<ViewHandle<SyntaxTreeView>>,
+    subscription: Option<gpui::Subscription>,
+    menu_open: bool,
+}
+
+struct EditorState {
+    editor: ViewHandle<Editor>,
+    active_buffer: Option<BufferState>,
+    _subscription: gpui::Subscription,
+}
+
+#[derive(Clone)]
+struct BufferState {
+    buffer: ModelHandle<Buffer>,
+    excerpt_id: ExcerptId,
+    active_layer: Option<OwnedSyntaxLayerInfo>,
+}
+
+impl SyntaxTreeView {
+    pub fn new(
+        workspace_handle: WeakViewHandle<Workspace>,
+        active_item: Option<Box<dyn ItemHandle>>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let mut this = Self {
+            workspace_handle: workspace_handle.clone(),
+            list_state: UniformListState::default(),
+            editor: None,
+            mouse_y: None,
+            line_height: None,
+            hovered_descendant_ix: None,
+            selected_descendant_ix: None,
+        };
+
+        this.workspace_updated(active_item, cx);
+        cx.observe(
+            &workspace_handle.upgrade(cx).unwrap(),
+            |this, workspace, cx| {
+                this.workspace_updated(workspace.read(cx).active_item(cx), cx);
+            },
+        )
+        .detach();
+
+        this
+    }
+
+    fn workspace_updated(
+        &mut self,
+        active_item: Option<Box<dyn ItemHandle>>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if let Some(item) = active_item {
+            if item.id() != cx.view_id() {
+                if let Some(editor) = item.act_as::<Editor>(cx) {
+                    self.set_editor(editor, cx);
+                }
+            }
+        }
+    }
+
+    fn set_editor(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
+        if let Some(state) = &self.editor {
+            if state.editor == editor {
+                return;
+            }
+            editor.update(cx, |editor, cx| {
+                editor.clear_background_highlights::<Self>(cx)
+            });
+        }
+
+        let subscription = cx.subscribe(&editor, |this, _, event, cx| {
+            let did_reparse = match event {
+                editor::Event::Reparsed => true,
+                editor::Event::SelectionsChanged { .. } => false,
+                _ => return,
+            };
+            this.editor_updated(did_reparse, cx);
+        });
+
+        self.editor = Some(EditorState {
+            editor,
+            _subscription: subscription,
+            active_buffer: None,
+        });
+        self.editor_updated(true, cx);
+    }
+
+    fn editor_updated(&mut self, did_reparse: bool, cx: &mut ViewContext<Self>) -> Option<()> {
+        // Find which excerpt the cursor is in, and the position within that excerpted buffer.
+        let editor_state = self.editor.as_mut()?;
+        let editor = &editor_state.editor.read(cx);
+        let selection_range = editor.selections.last::<usize>(cx).range();
+        let multibuffer = editor.buffer().read(cx);
+        let (buffer, range, excerpt_id) = multibuffer
+            .range_to_buffer_ranges(selection_range, cx)
+            .pop()?;
+
+        // If the cursor has moved into a different excerpt, retrieve a new syntax layer
+        // from that buffer.
+        let buffer_state = editor_state
+            .active_buffer
+            .get_or_insert_with(|| BufferState {
+                buffer: buffer.clone(),
+                excerpt_id,
+                active_layer: None,
+            });
+        let mut prev_layer = None;
+        if did_reparse {
+            prev_layer = buffer_state.active_layer.take();
+        }
+        if buffer_state.buffer != buffer || buffer_state.excerpt_id != buffer_state.excerpt_id {
+            buffer_state.buffer = buffer.clone();
+            buffer_state.excerpt_id = excerpt_id;
+            buffer_state.active_layer = None;
+        }
+
+        let layer = match &mut buffer_state.active_layer {
+            Some(layer) => layer,
+            None => {
+                let snapshot = buffer.read(cx).snapshot();
+                let layer = if let Some(prev_layer) = prev_layer {
+                    let prev_range = prev_layer.node().byte_range();
+                    snapshot
+                        .syntax_layers()
+                        .filter(|layer| layer.language == &prev_layer.language)
+                        .min_by_key(|layer| {
+                            let range = layer.node().byte_range();
+                            ((range.start as i64) - (prev_range.start as i64)).abs()
+                                + ((range.end as i64) - (prev_range.end as i64)).abs()
+                        })?
+                } else {
+                    snapshot.syntax_layers().next()?
+                };
+                buffer_state.active_layer.insert(layer.to_owned())
+            }
+        };
+
+        // Within the active layer, find the syntax node under the cursor,
+        // and scroll to it.
+        let mut cursor = layer.node().walk();
+        while cursor.goto_first_child_for_byte(range.start).is_some() {
+            if !range.is_empty() && cursor.node().end_byte() == range.start {
+                cursor.goto_next_sibling();
+            }
+        }
+
+        // Ascend to the smallest ancestor that contains the range.
+        loop {
+            let node_range = cursor.node().byte_range();
+            if node_range.start <= range.start && node_range.end >= range.end {
+                break;
+            }
+            if !cursor.goto_parent() {
+                break;
+            }
+        }
+
+        let descendant_ix = cursor.descendant_index();
+        self.selected_descendant_ix = Some(descendant_ix);
+        self.list_state.scroll_to(ScrollTarget::Show(descendant_ix));
+
+        cx.notify();
+        Some(())
+    }
+
+    fn handle_click(&mut self, y: f32, cx: &mut ViewContext<SyntaxTreeView>) -> Option<()> {
+        let line_height = self.line_height?;
+        let ix = ((self.list_state.scroll_top() + y) / line_height) as usize;
+
+        self.update_editor_with_range_for_descendant_ix(ix, cx, |editor, mut range, cx| {
+            // Put the cursor at the beginning of the node.
+            mem::swap(&mut range.start, &mut range.end);
+
+            editor.change_selections(Some(Autoscroll::newest()), cx, |selections| {
+                selections.select_ranges(vec![range]);
+            });
+        });
+        Some(())
+    }
+
+    fn hover_state_changed(&mut self, cx: &mut ViewContext<SyntaxTreeView>) {
+        if let Some((y, line_height)) = self.mouse_y.zip(self.line_height) {
+            let ix = ((self.list_state.scroll_top() + y) / line_height) as usize;
+            if self.hovered_descendant_ix != Some(ix) {
+                self.hovered_descendant_ix = Some(ix);
+                self.update_editor_with_range_for_descendant_ix(ix, cx, |editor, range, cx| {
+                    editor.clear_background_highlights::<Self>(cx);
+                    editor.highlight_background::<Self>(
+                        vec![range],
+                        |theme| theme.editor.document_highlight_write_background,
+                        cx,
+                    );
+                });
+                cx.notify();
+            }
+        }
+    }
+
+    fn update_editor_with_range_for_descendant_ix(
+        &self,
+        descendant_ix: usize,
+        cx: &mut ViewContext<Self>,
+        mut f: impl FnMut(&mut Editor, Range<Anchor>, &mut ViewContext<Editor>),
+    ) -> Option<()> {
+        let editor_state = self.editor.as_ref()?;
+        let buffer_state = editor_state.active_buffer.as_ref()?;
+        let layer = buffer_state.active_layer.as_ref()?;
+
+        // Find the node.
+        let mut cursor = layer.node().walk();
+        cursor.goto_descendant(descendant_ix);
+        let node = cursor.node();
+        let range = node.byte_range();
+
+        // Build a text anchor range.
+        let buffer = buffer_state.buffer.read(cx);
+        let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
+
+        // Build a multibuffer anchor range.
+        let multibuffer = editor_state.editor.read(cx).buffer();
+        let multibuffer = multibuffer.read(cx).snapshot(cx);
+        let excerpt_id = buffer_state.excerpt_id;
+        let range = multibuffer.anchor_in_excerpt(excerpt_id, range.start)
+            ..multibuffer.anchor_in_excerpt(excerpt_id, range.end);
+
+        // Update the editor with the anchor range.
+        editor_state.editor.update(cx, |editor, cx| {
+            f(editor, range, cx);
+        });
+        Some(())
+    }
+
+    fn render_node(
+        cursor: &TreeCursor,
+        depth: u32,
+        selected: bool,
+        hovered: bool,
+        list_hovered: bool,
+        style: &TextStyle,
+        editor_theme: &theme::Editor,
+        cx: &AppContext,
+    ) -> gpui::AnyElement<SyntaxTreeView> {
+        let node = cursor.node();
+        let mut range_style = style.clone();
+        let em_width = style.em_width(cx.font_cache());
+        let gutter_padding = (em_width * editor_theme.gutter_padding_factor).round();
+
+        range_style.color = editor_theme.line_number;
+
+        let mut anonymous_node_style = style.clone();
+        let string_color = editor_theme
+            .syntax
+            .highlights
+            .iter()
+            .find_map(|(name, style)| (name == "string").then(|| style.color)?);
+        let property_color = editor_theme
+            .syntax
+            .highlights
+            .iter()
+            .find_map(|(name, style)| (name == "property").then(|| style.color)?);
+        if let Some(color) = string_color {
+            anonymous_node_style.color = color;
+        }
+
+        let mut row = Flex::row();
+        if let Some(field_name) = cursor.field_name() {
+            let mut field_style = style.clone();
+            if let Some(color) = property_color {
+                field_style.color = color;
+            }
+
+            row.add_children([
+                Label::new(field_name, field_style),
+                Label::new(": ", style.clone()),
+            ]);
+        }
+
+        return row
+            .with_child(
+                if node.is_named() {
+                    Label::new(node.kind(), style.clone())
+                } else {
+                    Label::new(format!("\"{}\"", node.kind()), anonymous_node_style)
+                }
+                .contained()
+                .with_margin_right(em_width),
+            )
+            .with_child(Label::new(format_node_range(node), range_style))
+            .contained()
+            .with_background_color(if selected {
+                editor_theme.selection.selection
+            } else if hovered && list_hovered {
+                editor_theme.active_line_background
+            } else {
+                Default::default()
+            })
+            .with_padding_left(gutter_padding + depth as f32 * 18.0)
+            .into_any();
+    }
+}
+
+impl Entity for SyntaxTreeView {
+    type Event = ();
+}
+
+impl View for SyntaxTreeView {
+    fn ui_name() -> &'static str {
+        "SyntaxTreeView"
+    }
+
+    fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
+        let settings = settings::get::<ThemeSettings>(cx);
+        let font_family_id = settings.buffer_font_family;
+        let font_family_name = cx.font_cache().family_name(font_family_id).unwrap();
+        let font_properties = Default::default();
+        let font_id = cx
+            .font_cache()
+            .select_font(font_family_id, &font_properties)
+            .unwrap();
+        let font_size = settings.buffer_font_size(cx);
+
+        let editor_theme = settings.theme.editor.clone();
+        let style = TextStyle {
+            color: editor_theme.text_color,
+            font_family_name,
+            font_family_id,
+            font_id,
+            font_size,
+            font_properties: Default::default(),
+            underline: Default::default(),
+        };
+
+        let line_height = cx.font_cache().line_height(font_size);
+        if Some(line_height) != self.line_height {
+            self.line_height = Some(line_height);
+            self.hover_state_changed(cx);
+        }
+
+        if let Some(layer) = self
+            .editor
+            .as_ref()
+            .and_then(|editor| editor.active_buffer.as_ref())
+            .and_then(|buffer| buffer.active_layer.as_ref())
+        {
+            let layer = layer.clone();
+            let theme = editor_theme.clone();
+            return MouseEventHandler::<Self, Self>::new(0, cx, move |state, cx| {
+                let list_hovered = state.hovered();
+                UniformList::new(
+                    self.list_state.clone(),
+                    layer.node().descendant_count(),
+                    cx,
+                    move |this, range, items, cx| {
+                        let mut cursor = layer.node().walk();
+                        let mut descendant_ix = range.start as usize;
+                        cursor.goto_descendant(descendant_ix);
+                        let mut depth = cursor.depth();
+                        let mut visited_children = false;
+                        while descendant_ix < range.end {
+                            if visited_children {
+                                if cursor.goto_next_sibling() {
+                                    visited_children = false;
+                                } else if cursor.goto_parent() {
+                                    depth -= 1;
+                                } else {
+                                    break;
+                                }
+                            } else {
+                                items.push(Self::render_node(
+                                    &cursor,
+                                    depth,
+                                    Some(descendant_ix) == this.selected_descendant_ix,
+                                    Some(descendant_ix) == this.hovered_descendant_ix,
+                                    list_hovered,
+                                    &style,
+                                    &theme,
+                                    cx,
+                                ));
+                                descendant_ix += 1;
+                                if cursor.goto_first_child() {
+                                    depth += 1;
+                                } else {
+                                    visited_children = true;
+                                }
+                            }
+                        }
+                    },
+                )
+            })
+            .on_move(move |event, this, cx| {
+                let y = event.position.y() - event.region.origin_y();
+                this.mouse_y = Some(y);
+                this.hover_state_changed(cx);
+            })
+            .on_click(MouseButton::Left, move |event, this, cx| {
+                let y = event.position.y() - event.region.origin_y();
+                this.handle_click(y, cx);
+            })
+            .contained()
+            .with_background_color(editor_theme.background)
+            .into_any();
+        }
+
+        Empty::new().into_any()
+    }
+}
+
+impl Item for SyntaxTreeView {
+    fn tab_content<V: View>(
+        &self,
+        _: Option<usize>,
+        style: &theme::Tab,
+        _: &AppContext,
+    ) -> gpui::AnyElement<V> {
+        Label::new("Syntax Tree", style.label.clone()).into_any()
+    }
+
+    fn clone_on_split(
+        &self,
+        _workspace_id: workspace::WorkspaceId,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<Self>
+    where
+        Self: Sized,
+    {
+        let mut clone = Self::new(self.workspace_handle.clone(), None, cx);
+        if let Some(editor) = &self.editor {
+            clone.set_editor(editor.editor.clone(), cx)
+        }
+        Some(clone)
+    }
+}
+
+impl SyntaxTreeToolbarItemView {
+    pub fn new() -> Self {
+        Self {
+            menu_open: false,
+            tree_view: None,
+            subscription: None,
+        }
+    }
+
+    fn render_menu(
+        &mut self,
+        cx: &mut ViewContext<'_, '_, Self>,
+    ) -> Option<gpui::AnyElement<Self>> {
+        let theme = theme::current(cx).clone();
+        let tree_view = self.tree_view.as_ref()?;
+        let tree_view = tree_view.read(cx);
+
+        let editor_state = tree_view.editor.as_ref()?;
+        let buffer_state = editor_state.active_buffer.as_ref()?;
+        let active_layer = buffer_state.active_layer.clone()?;
+        let active_buffer = buffer_state.buffer.read(cx).snapshot();
+
+        enum Menu {}
+
+        Some(
+            Stack::new()
+                .with_child(Self::render_header(&theme, &active_layer, cx))
+                .with_children(self.menu_open.then(|| {
+                    Overlay::new(
+                        MouseEventHandler::<Menu, _>::new(0, cx, move |_, cx| {
+                            Flex::column()
+                                .with_children(active_buffer.syntax_layers().enumerate().map(
+                                    |(ix, layer)| {
+                                        Self::render_menu_item(&theme, &active_layer, layer, ix, cx)
+                                    },
+                                ))
+                                .contained()
+                                .with_style(theme.toolbar_dropdown_menu.container)
+                                .constrained()
+                                .with_width(400.)
+                                .with_height(400.)
+                        })
+                        .on_down_out(MouseButton::Left, |_, this, cx| {
+                            this.menu_open = false;
+                            cx.notify()
+                        }),
+                    )
+                    .with_hoverable(true)
+                    .with_fit_mode(OverlayFitMode::SwitchAnchor)
+                    .with_anchor_corner(AnchorCorner::TopLeft)
+                    .with_z_index(999)
+                    .aligned()
+                    .bottom()
+                    .left()
+                }))
+                .aligned()
+                .left()
+                .clipped()
+                .into_any(),
+        )
+    }
+
+    fn toggle_menu(&mut self, cx: &mut ViewContext<Self>) {
+        self.menu_open = !self.menu_open;
+        cx.notify();
+    }
+
+    fn select_layer(&mut self, layer_ix: usize, cx: &mut ViewContext<Self>) -> Option<()> {
+        let tree_view = self.tree_view.as_ref()?;
+        tree_view.update(cx, |view, cx| {
+            let editor_state = view.editor.as_mut()?;
+            let buffer_state = editor_state.active_buffer.as_mut()?;
+            let snapshot = buffer_state.buffer.read(cx).snapshot();
+            let layer = snapshot.syntax_layers().nth(layer_ix)?;
+            buffer_state.active_layer = Some(layer.to_owned());
+            view.selected_descendant_ix = None;
+            self.menu_open = false;
+            cx.notify();
+            Some(())
+        })
+    }
+
+    fn render_header(
+        theme: &Arc<Theme>,
+        active_layer: &OwnedSyntaxLayerInfo,
+        cx: &mut ViewContext<Self>,
+    ) -> impl Element<Self> {
+        enum ToggleMenu {}
+        MouseEventHandler::<ToggleMenu, Self>::new(0, cx, move |state, _| {
+            let style = theme.toolbar_dropdown_menu.header.style_for(state, false);
+            Flex::row()
+                .with_child(
+                    Label::new(active_layer.language.name().to_string(), style.text.clone())
+                        .contained()
+                        .with_margin_right(style.secondary_text_spacing),
+                )
+                .with_child(Label::new(
+                    format_node_range(active_layer.node()),
+                    style
+                        .secondary_text
+                        .clone()
+                        .unwrap_or_else(|| style.text.clone()),
+                ))
+                .contained()
+                .with_style(style.container)
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, view, cx| {
+            view.toggle_menu(cx);
+        })
+    }
+
+    fn render_menu_item(
+        theme: &Arc<Theme>,
+        active_layer: &OwnedSyntaxLayerInfo,
+        layer: SyntaxLayerInfo,
+        layer_ix: usize,
+        cx: &mut ViewContext<Self>,
+    ) -> impl Element<Self> {
+        enum ActivateLayer {}
+        MouseEventHandler::<ActivateLayer, _>::new(layer_ix, cx, move |state, _| {
+            let is_selected = layer.node() == active_layer.node();
+            let style = theme
+                .toolbar_dropdown_menu
+                .item
+                .style_for(state, is_selected);
+            Flex::row()
+                .with_child(
+                    Label::new(layer.language.name().to_string(), style.text.clone())
+                        .contained()
+                        .with_margin_right(style.secondary_text_spacing),
+                )
+                .with_child(Label::new(
+                    format_node_range(layer.node()),
+                    style
+                        .secondary_text
+                        .clone()
+                        .unwrap_or_else(|| style.text.clone()),
+                ))
+                .contained()
+                .with_style(style.container)
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, view, cx| {
+            view.select_layer(layer_ix, cx);
+        })
+    }
+}
+
+fn format_node_range(node: Node) -> String {
+    let start = node.start_position();
+    let end = node.end_position();
+    format!(
+        "[{}:{} - {}:{}]",
+        start.row + 1,
+        start.column + 1,
+        end.row + 1,
+        end.column + 1,
+    )
+}
+
+impl Entity for SyntaxTreeToolbarItemView {
+    type Event = ();
+}
+
+impl View for SyntaxTreeToolbarItemView {
+    fn ui_name() -> &'static str {
+        "SyntaxTreeToolbarItemView"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
+        self.render_menu(cx)
+            .unwrap_or_else(|| Empty::new().into_any())
+    }
+}
+
+impl ToolbarItemView for SyntaxTreeToolbarItemView {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn ItemHandle>,
+        cx: &mut ViewContext<Self>,
+    ) -> workspace::ToolbarItemLocation {
+        self.menu_open = false;
+        if let Some(item) = active_pane_item {
+            if let Some(view) = item.downcast::<SyntaxTreeView>() {
+                self.tree_view = Some(view.clone());
+                self.subscription = Some(cx.observe(&view, |_, _, cx| cx.notify()));
+                return ToolbarItemLocation::PrimaryLeft {
+                    flex: Some((1., false)),
+                };
+            }
+        }
+        self.tree_view = None;
+        self.subscription = None;
+        ToolbarItemLocation::Hidden
+    }
+}

crates/settings/Cargo.toml 🔗

@@ -31,7 +31,7 @@ serde_derive.workspace = true
 serde_json.workspace = true
 smallvec.workspace = true
 toml.workspace = true
-tree-sitter = "*"
+tree-sitter.workspace = true
 tree-sitter-json = "*"
 
 [dev-dependencies]

crates/theme/src/theme.rs 🔗

@@ -44,7 +44,7 @@ pub struct Theme {
     pub context_menu: ContextMenu,
     pub contacts_popover: ContactsPopover,
     pub contact_list: ContactList,
-    pub lsp_log_menu: LspLogMenu,
+    pub toolbar_dropdown_menu: DropdownMenu,
     pub copilot: Copilot,
     pub contact_finder: ContactFinder,
     pub project_panel: ProjectPanel,
@@ -246,15 +246,26 @@ pub struct ContactFinder {
 }
 
 #[derive(Deserialize, Default)]
-pub struct LspLogMenu {
+pub struct DropdownMenu {
     #[serde(flatten)]
     pub container: ContainerStyle,
-    pub header: Interactive<ContainedText>,
-    pub server: ContainedText,
-    pub item: Interactive<ContainedText>,
+    pub header: Interactive<DropdownMenuItem>,
+    pub section_header: ContainedText,
+    pub item: Interactive<DropdownMenuItem>,
     pub row_height: f32,
 }
 
+#[derive(Deserialize, Default)]
+pub struct DropdownMenuItem {
+    #[serde(flatten)]
+    pub container: ContainerStyle,
+    #[serde(flatten)]
+    pub text: TextStyle,
+    pub secondary_text: Option<TextStyle>,
+    #[serde(default)]
+    pub secondary_text_spacing: f32,
+}
+
 #[derive(Clone, Deserialize, Default)]
 pub struct TabBar {
     #[serde(flatten)]

crates/zed/Cargo.toml 🔗

@@ -45,7 +45,7 @@ journal = { path = "../journal" }
 language = { path = "../language" }
 language_selector = { path = "../language_selector" }
 lsp = { path = "../lsp" }
-lsp_log = { path = "../lsp_log" }
+language_tools = { path = "../language_tools" }
 node_runtime = { path = "../node_runtime" }
 ai = { path = "../ai" }
 outline = { path = "../outline" }
@@ -102,7 +102,7 @@ tempdir.workspace = true
 thiserror.workspace = true
 tiny_http = "0.8"
 toml.workspace = true
-tree-sitter = "0.20"
+tree-sitter.workspace = true
 tree-sitter-c = "0.20.1"
 tree-sitter-cpp = "0.20.0"
 tree-sitter-css = { git = "https://github.com/tree-sitter/tree-sitter-css", rev = "769203d0f9abe1a9a691ac2b9fe4bb4397a73c51" }

crates/zed/src/main.rs 🔗

@@ -191,7 +191,7 @@ fn main() {
         language_selector::init(cx);
         theme_selector::init(cx);
         activity_indicator::init(cx);
-        lsp_log::init(cx);
+        language_tools::init(cx);
         call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
         collab_ui::init(&app_state, cx);
         feedback::init(cx);

crates/zed/src/zed.rs 🔗

@@ -312,8 +312,11 @@ pub fn initialize_workspace(
                                 let feedback_info_text = cx.add_view(|_| FeedbackInfoText::new());
                                 toolbar.add_item(feedback_info_text, cx);
                                 let lsp_log_item =
-                                    cx.add_view(|_| lsp_log::LspLogToolbarItemView::new());
+                                    cx.add_view(|_| language_tools::LspLogToolbarItemView::new());
                                 toolbar.add_item(lsp_log_item, cx);
+                                let syntax_tree_item = cx
+                                    .add_view(|_| language_tools::SyntaxTreeToolbarItemView::new());
+                                toolbar.add_item(syntax_tree_item, cx);
                             })
                         });
                     }

styles/src/styleTree/app.ts 🔗

@@ -17,7 +17,7 @@ import projectSharedNotification from "./projectSharedNotification"
 import tooltip from "./tooltip"
 import terminal from "./terminal"
 import contactList from "./contactList"
-import lspLogMenu from "./lspLogMenu"
+import toolbarDropdownMenu from "./toolbarDropdownMenu"
 import incomingCallNotification from "./incomingCallNotification"
 import { ColorScheme } from "../theme/colorScheme"
 import feedback from "./feedback"
@@ -46,7 +46,7 @@ export default function app(colorScheme: ColorScheme): Object {
         contactsPopover: contactsPopover(colorScheme),
         contactFinder: contactFinder(colorScheme),
         contactList: contactList(colorScheme),
-        lspLogMenu: lspLogMenu(colorScheme),
+        toolbarDropdownMenu: toolbarDropdownMenu(colorScheme),
         search: search(colorScheme),
         sharedScreen: sharedScreen(colorScheme),
         updateNotification: updateNotification(colorScheme),

styles/src/styleTree/lspLogMenu.ts → styles/src/styleTree/toolbarDropdownMenu.ts 🔗

@@ -1,7 +1,7 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { background, border, text } from "./components"
 
-export default function contactsPanel(colorScheme: ColorScheme) {
+export default function dropdownMenu(colorScheme: ColorScheme) {
     let layer = colorScheme.middle
 
     return {
@@ -11,6 +11,8 @@ export default function contactsPanel(colorScheme: ColorScheme) {
         shadow: colorScheme.popoverShadow,
         header: {
             ...text(layer, "sans", { size: "sm" }),
+            secondaryText: text(layer, "sans", { size: "sm", color: "#aaaaaa" }),
+            secondaryTextSpacing: 10,
             padding: { left: 8, right: 8, top: 2, bottom: 2 },
             cornerRadius: 6,
             background: background(layer, "on"),
@@ -20,12 +22,14 @@ export default function contactsPanel(colorScheme: ColorScheme) {
                 ...text(layer, "sans", "hovered", { size: "sm" }),
             }
         },
-        server: {
+        sectionHeader: {
             ...text(layer, "sans", { size: "sm" }),
             padding: { left: 8, right: 8, top: 8, bottom: 8 },
         },
         item: {
             ...text(layer, "sans", { size: "sm" }),
+            secondaryTextSpacing: 10,
+            secondaryText: text(layer, "sans", { size: "sm" }),
             padding: { left: 18, right: 18, top: 2, bottom: 2 },
             hover: {
                 background: background(layer, "hovered"),