Merge pull request #342 from zed-industries/symbolic-nav

Max Brunsfeld created

Introduce outline view

Change summary

Cargo.lock                                  |  17 
crates/editor/src/editor.rs                 |  28 
crates/editor/src/element.rs                | 146 ++---
crates/editor/src/multi_buffer.rs           |  24 
crates/file_finder/src/file_finder.rs       |  22 
crates/fuzzy/src/char_bag.rs                |   1 
crates/fuzzy/src/fuzzy.rs                   |  31 
crates/go_to_line/src/go_to_line.rs         |  25 
crates/gpui/src/elements/container.rs       |   5 
crates/gpui/src/elements/text.rs            | 189 ++++++-
crates/gpui/src/elements/uniform_list.rs    |  48 +
crates/gpui/src/fonts.rs                    |   2 
crates/gpui/src/keymap.rs                   |  19 
crates/language/Cargo.toml                  |   1 
crates/language/src/buffer.rs               | 124 +++++
crates/language/src/language.rs             |  14 
crates/language/src/outline.rs              | 146 ++++++
crates/language/src/tests.rs                | 149 ++++++
crates/lsp/src/lsp.rs                       |   6 
crates/outline/Cargo.toml                   |  18 
crates/outline/src/outline.rs               | 540 +++++++++++++++++++++++
crates/project_panel/src/project_panel.rs   |  17 
crates/theme_selector/src/theme_selector.rs |  27 
crates/workspace/src/menu.rs                |  19 
crates/workspace/src/workspace.rs           |   5 
crates/zed/Cargo.toml                       |   1 
crates/zed/assets/themes/_base.toml         |   2 
crates/zed/languages/rust/outline.scm       |  63 ++
crates/zed/src/language.rs                  |   2 
crates/zed/src/main.rs                      |   1 
30 files changed, 1,457 insertions(+), 235 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2602,6 +2602,7 @@ dependencies = [
  "ctor",
  "env_logger",
  "futures",
+ "fuzzy",
  "gpui",
  "lazy_static",
  "log",
@@ -3121,6 +3122,21 @@ version = "2.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "afb2e1c3ee07430c2cf76151675e583e0f19985fa6efae47d6848a3e2c824f85"
 
+[[package]]
+name = "outline"
+version = "0.1.0"
+dependencies = [
+ "editor",
+ "fuzzy",
+ "gpui",
+ "language",
+ "ordered-float",
+ "postage",
+ "smol",
+ "text",
+ "workspace",
+]
+
 [[package]]
 name = "p256"
 version = "0.9.0"
@@ -5724,6 +5740,7 @@ dependencies = [
  "log-panics",
  "lsp",
  "num_cpus",
+ "outline",
  "parking_lot",
  "postage",
  "project",

crates/editor/src/editor.rs 🔗

@@ -28,8 +28,10 @@ use language::{
     BracketPair, Buffer, Diagnostic, DiagnosticSeverity, Language, Point, Selection, SelectionGoal,
     TransactionId,
 };
-pub use multi_buffer::{Anchor, ExcerptId, ExcerptProperties, MultiBuffer, ToOffset, ToPoint};
-use multi_buffer::{AnchorRangeExt, MultiBufferChunks, MultiBufferSnapshot};
+pub use multi_buffer::{
+    Anchor, AnchorRangeExt, ExcerptId, ExcerptProperties, MultiBuffer, ToOffset, ToPoint,
+};
+use multi_buffer::{MultiBufferChunks, MultiBufferSnapshot};
 use postage::watch;
 use serde::{Deserialize, Serialize};
 use smallvec::SmallVec;
@@ -374,7 +376,7 @@ pub struct Editor {
     blinking_paused: bool,
     mode: EditorMode,
     placeholder_text: Option<Arc<str>>,
-    highlighted_row: Option<u32>,
+    highlighted_rows: Option<Range<u32>>,
 }
 
 pub struct EditorSnapshot {
@@ -503,7 +505,7 @@ impl Editor {
             blinking_paused: false,
             mode: EditorMode::Full,
             placeholder_text: None,
-            highlighted_row: None,
+            highlighted_rows: None,
         };
         let selection = Selection {
             id: post_inc(&mut this.next_selection_id),
@@ -2388,6 +2390,11 @@ impl Editor {
     }
 
     pub fn move_to_beginning(&mut self, _: &MoveToBeginning, cx: &mut ViewContext<Self>) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate_action();
+            return;
+        }
+
         let selection = Selection {
             id: post_inc(&mut self.next_selection_id),
             start: 0,
@@ -2405,6 +2412,11 @@ impl Editor {
     }
 
     pub fn move_to_end(&mut self, _: &MoveToEnd, cx: &mut ViewContext<Self>) {
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate_action();
+            return;
+        }
+
         let cursor = self.buffer.read(cx).read(cx).len();
         let selection = Selection {
             id: post_inc(&mut self.next_selection_id),
@@ -3544,12 +3556,12 @@ impl Editor {
             .update(cx, |map, cx| map.set_wrap_width(width, cx))
     }
 
-    pub fn set_highlighted_row(&mut self, row: Option<u32>) {
-        self.highlighted_row = row;
+    pub fn set_highlighted_rows(&mut self, rows: Option<Range<u32>>) {
+        self.highlighted_rows = rows;
     }
 
-    pub fn highlighted_row(&mut self) -> Option<u32> {
-        self.highlighted_row
+    pub fn highlighted_rows(&self) -> Option<Range<u32>> {
+        self.highlighted_rows.clone()
     }
 
     fn next_blink_epoch(&mut self) -> usize {

crates/editor/src/element.rs 🔗

@@ -7,6 +7,8 @@ use clock::ReplicaId;
 use collections::{BTreeMap, HashMap};
 use gpui::{
     color::Color,
+    elements::layout_highlighted_chunks,
+    fonts::HighlightStyle,
     geometry::{
         rect::RectF,
         vector::{vec2f, Vector2F},
@@ -19,7 +21,7 @@ use gpui::{
     MutableAppContext, PaintContext, Quad, Scene, SizeConstraint, ViewContext, WeakViewHandle,
 };
 use json::json;
-use language::{Bias, Chunk};
+use language::Bias;
 use smallvec::SmallVec;
 use std::{
     cmp::{self, Ordering},
@@ -263,12 +265,16 @@ impl EditorElement {
                 }
             }
 
-            if let Some(highlighted_row) = layout.highlighted_row {
+            if let Some(highlighted_rows) = &layout.highlighted_rows {
                 let origin = vec2f(
                     bounds.origin_x(),
-                    bounds.origin_y() + (layout.line_height * highlighted_row as f32) - scroll_top,
+                    bounds.origin_y() + (layout.line_height * highlighted_rows.start as f32)
+                        - scroll_top,
+                );
+                let size = vec2f(
+                    bounds.width(),
+                    layout.line_height * highlighted_rows.len() as f32,
                 );
-                let size = vec2f(bounds.width(), layout.line_height);
                 cx.scene.push_quad(Quad {
                     bounds: RectF::new(origin, size),
                     background: Some(style.highlighted_line_background),
@@ -537,86 +543,37 @@ impl EditorElement {
                     )
                 })
                 .collect();
-        }
-
-        let style = &self.settings.style;
-        let mut prev_font_properties = style.text.font_properties.clone();
-        let mut prev_font_id = style.text.font_id;
-
-        let mut layouts = Vec::with_capacity(rows.len());
-        let mut line = String::new();
-        let mut styles = Vec::new();
-        let mut row = rows.start;
-        let mut line_exceeded_max_len = false;
-        let chunks = snapshot.chunks(rows.clone(), Some(&style.syntax));
-
-        let newline_chunk = Chunk {
-            text: "\n",
-            ..Default::default()
-        };
-        'outer: for chunk in chunks.chain([newline_chunk]) {
-            for (ix, mut line_chunk) in chunk.text.split('\n').enumerate() {
-                if ix > 0 {
-                    layouts.push(cx.text_layout_cache.layout_str(
-                        &line,
-                        style.text.font_size,
-                        &styles,
-                    ));
-                    line.clear();
-                    styles.clear();
-                    row += 1;
-                    line_exceeded_max_len = false;
-                    if row == rows.end {
-                        break 'outer;
-                    }
-                }
-
-                if !line_chunk.is_empty() && !line_exceeded_max_len {
-                    let highlight_style =
-                        chunk.highlight_style.unwrap_or(style.text.clone().into());
-                    // Avoid a lookup if the font properties match the previous ones.
-                    let font_id = if highlight_style.font_properties == prev_font_properties {
-                        prev_font_id
-                    } else {
-                        cx.font_cache
-                            .select_font(
-                                style.text.font_family_id,
-                                &highlight_style.font_properties,
-                            )
-                            .unwrap_or(style.text.font_id)
-                    };
-
-                    if line.len() + line_chunk.len() > MAX_LINE_LEN {
-                        let mut chunk_len = MAX_LINE_LEN - line.len();
-                        while !line_chunk.is_char_boundary(chunk_len) {
-                            chunk_len -= 1;
+        } else {
+            let style = &self.settings.style;
+            let chunks = snapshot
+                .chunks(rows.clone(), Some(&style.syntax))
+                .map(|chunk| {
+                    let highlight = if let Some(severity) = chunk.diagnostic {
+                        let underline = Some(super::diagnostic_style(severity, true, style).text);
+                        if let Some(mut highlight) = chunk.highlight_style {
+                            highlight.underline = underline;
+                            Some(highlight)
+                        } else {
+                            Some(HighlightStyle {
+                                underline,
+                                color: style.text.color,
+                                font_properties: style.text.font_properties,
+                            })
                         }
-                        line_chunk = &line_chunk[..chunk_len];
-                        line_exceeded_max_len = true;
-                    }
-
-                    let underline = if let Some(severity) = chunk.diagnostic {
-                        Some(super::diagnostic_style(severity, true, style).text)
                     } else {
-                        highlight_style.underline
+                        chunk.highlight_style
                     };
-
-                    line.push_str(line_chunk);
-                    styles.push((
-                        line_chunk.len(),
-                        RunStyle {
-                            font_id,
-                            color: highlight_style.color,
-                            underline,
-                        },
-                    ));
-                    prev_font_id = font_id;
-                    prev_font_properties = highlight_style.font_properties;
-                }
-            }
+                    (chunk.text, highlight)
+                });
+            layout_highlighted_chunks(
+                chunks,
+                &style.text,
+                &cx.text_layout_cache,
+                &cx.font_cache,
+                MAX_LINE_LEN,
+                rows.len() as usize,
+            )
         }
-
-        layouts
     }
 
     fn layout_blocks(
@@ -640,15 +597,20 @@ impl EditorElement {
                     .to_display_point(snapshot)
                     .row();
 
-                let anchor_x = text_x + if rows.contains(&anchor_row) {
-                    line_layouts[(anchor_row - rows.start) as usize]
-                        .x_for_index(block.column() as usize)
-                } else {
-                    layout_line(anchor_row, snapshot, style, cx.text_layout_cache)
-                        .x_for_index(block.column() as usize)
-                };
+                let anchor_x = text_x
+                    + if rows.contains(&anchor_row) {
+                        line_layouts[(anchor_row - rows.start) as usize]
+                            .x_for_index(block.column() as usize)
+                    } else {
+                        layout_line(anchor_row, snapshot, style, cx.text_layout_cache)
+                            .x_for_index(block.column() as usize)
+                    };
 
-                let mut element = block.render(&BlockContext { cx, anchor_x, line_number_x, });
+                let mut element = block.render(&BlockContext {
+                    cx,
+                    anchor_x,
+                    line_number_x,
+                });
                 element.layout(
                     SizeConstraint {
                         min: Vector2F::zero(),
@@ -750,9 +712,9 @@ impl Element for EditorElement {
 
         let mut selections = HashMap::default();
         let mut active_rows = BTreeMap::new();
-        let mut highlighted_row = None;
+        let mut highlighted_rows = None;
         self.update_view(cx.app, |view, cx| {
-            highlighted_row = view.highlighted_row();
+            highlighted_rows = view.highlighted_rows();
             let display_map = view.display_map.update(cx, |map, cx| map.snapshot(cx));
 
             let local_selections = view
@@ -831,7 +793,7 @@ impl Element for EditorElement {
             snapshot,
             style: self.settings.style.clone(),
             active_rows,
-            highlighted_row,
+            highlighted_rows,
             line_layouts,
             line_number_layouts,
             blocks,
@@ -962,7 +924,7 @@ pub struct LayoutState {
     style: EditorStyle,
     snapshot: EditorSnapshot,
     active_rows: BTreeMap<u32, bool>,
-    highlighted_row: Option<u32>,
+    highlighted_rows: Option<Range<u32>>,
     line_layouts: Vec<text_layout::Line>,
     line_number_layouts: Vec<Option<text_layout::Line>>,
     blocks: Vec<(u32, ElementBox)>,

crates/editor/src/multi_buffer.rs 🔗

@@ -6,8 +6,8 @@ use clock::ReplicaId;
 use collections::{HashMap, HashSet};
 use gpui::{AppContext, Entity, ModelContext, ModelHandle, Task};
 use language::{
-    Buffer, BufferChunks, BufferSnapshot, Chunk, DiagnosticEntry, Event, File, Language, Selection,
-    ToOffset as _, ToPoint as _, TransactionId,
+    Buffer, BufferChunks, BufferSnapshot, Chunk, DiagnosticEntry, Event, File, Language, Outline,
+    OutlineItem, Selection, ToOffset as _, ToPoint as _, TransactionId,
 };
 use std::{
     cell::{Ref, RefCell},
@@ -1698,6 +1698,26 @@ impl MultiBufferSnapshot {
             })
     }
 
+    pub fn outline(&self, theme: Option<&SyntaxTheme>) -> Option<Outline<Anchor>> {
+        let buffer = self.as_singleton()?;
+        let outline = buffer.outline(theme)?;
+        let excerpt_id = &self.excerpts.iter().next().unwrap().id;
+        Some(Outline::new(
+            outline
+                .items
+                .into_iter()
+                .map(|item| OutlineItem {
+                    depth: item.depth,
+                    range: self.anchor_in_excerpt(excerpt_id.clone(), item.range.start)
+                        ..self.anchor_in_excerpt(excerpt_id.clone(), item.range.end),
+                    text: item.text,
+                    highlight_ranges: item.highlight_ranges,
+                    name_ranges: item.name_ranges,
+                })
+                .collect(),
+        ))
+    }
+
     fn buffer_snapshot_for_excerpt<'a>(
         &'a self,
         excerpt_id: &'a ExcerptId,

crates/file_finder/src/file_finder.rs 🔗

@@ -3,11 +3,7 @@ use fuzzy::PathMatch;
 use gpui::{
     action,
     elements::*,
-    keymap::{
-        self,
-        menu::{SelectNext, SelectPrev},
-        Binding,
-    },
+    keymap::{self, Binding},
     AppContext, Axis, Entity, ModelHandle, MutableAppContext, RenderContext, Task, View,
     ViewContext, ViewHandle, WeakViewHandle,
 };
@@ -22,7 +18,10 @@ use std::{
     },
 };
 use util::post_inc;
-use workspace::{Settings, Workspace};
+use workspace::{
+    menu::{Confirm, SelectNext, SelectPrev},
+    Settings, Workspace,
+};
 
 pub struct FileFinder {
     handle: WeakViewHandle<Self>,
@@ -40,7 +39,6 @@ pub struct FileFinder {
 }
 
 action!(Toggle);
-action!(Confirm);
 action!(Select, ProjectPath);
 
 pub fn init(cx: &mut MutableAppContext) {
@@ -53,7 +51,6 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_bindings(vec![
         Binding::new("cmd-p", Toggle, None),
         Binding::new("escape", Toggle, Some("FileFinder")),
-        Binding::new("enter", Confirm, Some("FileFinder")),
     ]);
 }
 
@@ -353,7 +350,8 @@ impl FileFinder {
             let mat = &self.matches[selected_index];
             self.selected = Some((mat.worktree_id, mat.path.clone()));
         }
-        self.list_state.scroll_to(selected_index);
+        self.list_state
+            .scroll_to(ScrollTarget::Show(selected_index));
         cx.notify();
     }
 
@@ -364,7 +362,8 @@ impl FileFinder {
             let mat = &self.matches[selected_index];
             self.selected = Some((mat.worktree_id, mat.path.clone()));
         }
-        self.list_state.scroll_to(selected_index);
+        self.list_state
+            .scroll_to(ScrollTarget::Show(selected_index));
         cx.notify();
     }
 
@@ -415,7 +414,8 @@ impl FileFinder {
             }
             self.latest_search_query = query;
             self.latest_search_did_cancel = did_cancel;
-            self.list_state.scroll_to(self.selected_index());
+            self.list_state
+                .scroll_to(ScrollTarget::Show(self.selected_index()));
             cx.notify();
         }
     }

crates/fuzzy/src/char_bag.rs 🔗

@@ -9,6 +9,7 @@ impl CharBag {
     }
 
     fn insert(&mut self, c: char) {
+        let c = c.to_ascii_lowercase();
         if c >= 'a' && c <= 'z' {
             let mut count = self.0;
             let idx = c as u8 - 'a' as u8;

crates/fuzzy/src/fuzzy.rs 🔗

@@ -55,6 +55,7 @@ pub struct PathMatch {
 
 #[derive(Clone, Debug)]
 pub struct StringMatchCandidate {
+    pub id: usize,
     pub string: String,
     pub char_bag: CharBag,
 }
@@ -109,6 +110,7 @@ impl<'a> MatchCandidate for &'a StringMatchCandidate {
 
 #[derive(Clone, Debug)]
 pub struct StringMatch {
+    pub candidate_id: usize,
     pub score: f64,
     pub positions: Vec<usize>,
     pub string: String,
@@ -116,7 +118,7 @@ pub struct StringMatch {
 
 impl PartialEq for StringMatch {
     fn eq(&self, other: &Self) -> bool {
-        self.score.eq(&other.score)
+        self.cmp(other).is_eq()
     }
 }
 
@@ -133,13 +135,13 @@ impl Ord for StringMatch {
         self.score
             .partial_cmp(&other.score)
             .unwrap_or(Ordering::Equal)
-            .then_with(|| self.string.cmp(&other.string))
+            .then_with(|| self.candidate_id.cmp(&other.candidate_id))
     }
 }
 
 impl PartialEq for PathMatch {
     fn eq(&self, other: &Self) -> bool {
-        self.score.eq(&other.score)
+        self.cmp(other).is_eq()
     }
 }
 
@@ -187,8 +189,8 @@ pub async fn match_strings(
             for (segment_idx, results) in segment_results.iter_mut().enumerate() {
                 let cancel_flag = &cancel_flag;
                 scope.spawn(async move {
-                    let segment_start = segment_idx * segment_size;
-                    let segment_end = segment_start + segment_size;
+                    let segment_start = cmp::min(segment_idx * segment_size, candidates.len());
+                    let segment_end = cmp::min(segment_start + segment_size, candidates.len());
                     let mut matcher = Matcher::new(
                         query,
                         lowercase_query,
@@ -330,6 +332,7 @@ impl<'a> Matcher<'a> {
             results,
             cancel_flag,
             |candidate, score| StringMatch {
+                candidate_id: candidate.id,
                 score,
                 positions: Vec::new(),
                 string: candidate.string.to_string(),
@@ -433,13 +436,17 @@ impl<'a> Matcher<'a> {
         }
     }
 
-    fn find_last_positions(&mut self, prefix: &[char], path: &[char]) -> bool {
-        let mut path = path.iter();
-        let mut prefix_iter = prefix.iter();
-        for (i, char) in self.query.iter().enumerate().rev() {
-            if let Some(j) = path.rposition(|c| c == char) {
-                self.last_positions[i] = j + prefix.len();
-            } else if let Some(j) = prefix_iter.rposition(|c| c == char) {
+    fn find_last_positions(
+        &mut self,
+        lowercase_prefix: &[char],
+        lowercase_candidate: &[char],
+    ) -> bool {
+        let mut lowercase_prefix = lowercase_prefix.iter();
+        let mut lowercase_candidate = lowercase_candidate.iter();
+        for (i, char) in self.lowercase_query.iter().enumerate().rev() {
+            if let Some(j) = lowercase_candidate.rposition(|c| c == char) {
+                self.last_positions[i] = j + lowercase_prefix.len();
+            } else if let Some(j) = lowercase_prefix.rposition(|c| c == char) {
                 self.last_positions[i] = j;
             } else {
                 return false;

crates/go_to_line/src/go_to_line.rs 🔗

@@ -26,7 +26,7 @@ pub struct GoToLine {
     line_editor: ViewHandle<Editor>,
     active_editor: ViewHandle<Editor>,
     restore_state: Option<RestoreState>,
-    line_selection: Option<Selection<usize>>,
+    line_selection_id: Option<usize>,
     cursor_point: Point,
     max_point: Point,
 }
@@ -84,7 +84,7 @@ impl GoToLine {
             line_editor,
             active_editor,
             restore_state,
-            line_selection: None,
+            line_selection_id: None,
             cursor_point,
             max_point,
         }
@@ -139,13 +139,18 @@ impl GoToLine {
                         column.map(|column| column.saturating_sub(1)).unwrap_or(0),
                     )
                 }) {
-                    self.line_selection = self.active_editor.update(cx, |active_editor, cx| {
+                    self.line_selection_id = self.active_editor.update(cx, |active_editor, cx| {
                         let snapshot = active_editor.snapshot(cx).display_snapshot;
                         let point = snapshot.buffer_snapshot.clip_point(point, Bias::Left);
                         let display_point = point.to_display_point(&snapshot);
+                        let row = display_point.row();
                         active_editor.select_ranges([point..point], Some(Autoscroll::Center), cx);
-                        active_editor.set_highlighted_row(Some(display_point.row()));
-                        Some(active_editor.newest_selection(&snapshot.buffer_snapshot))
+                        active_editor.set_highlighted_rows(Some(row..row + 1));
+                        Some(
+                            active_editor
+                                .newest_selection::<usize>(&snapshot.buffer_snapshot)
+                                .id,
+                        )
                     });
                     cx.notify();
                 }
@@ -159,14 +164,14 @@ impl Entity for GoToLine {
     type Event = Event;
 
     fn release(&mut self, cx: &mut MutableAppContext) {
-        let line_selection = self.line_selection.take();
+        let line_selection_id = self.line_selection_id.take();
         let restore_state = self.restore_state.take();
         self.active_editor.update(cx, |editor, cx| {
-            editor.set_highlighted_row(None);
-            if let Some((line_selection, restore_state)) = line_selection.zip(restore_state) {
+            editor.set_highlighted_rows(None);
+            if let Some((line_selection_id, restore_state)) = line_selection_id.zip(restore_state) {
                 let newest_selection =
                     editor.newest_selection::<usize>(&editor.buffer().read(cx).read(cx));
-                if line_selection.id == newest_selection.id {
+                if line_selection_id == newest_selection.id {
                     editor.set_scroll_position(restore_state.scroll_position, cx);
                     editor.update_selections(restore_state.selections, None, cx);
                 }
@@ -219,6 +224,4 @@ impl View for GoToLine {
     fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
         cx.focus(&self.line_editor);
     }
-
-    fn on_blur(&mut self, _: &mut ViewContext<Self>) {}
 }

crates/gpui/src/elements/container.rs 🔗

@@ -52,6 +52,11 @@ impl Container {
         self
     }
 
+    pub fn with_margin_bottom(mut self, margin: f32) -> Self {
+        self.style.margin.bottom = margin;
+        self
+    }
+
     pub fn with_margin_left(mut self, margin: f32) -> Self {
         self.style.margin.left = margin;
         self

crates/gpui/src/elements/text.rs 🔗

@@ -1,13 +1,16 @@
+use std::{ops::Range, sync::Arc};
+
 use crate::{
     color::Color,
-    fonts::TextStyle,
+    fonts::{HighlightStyle, TextStyle},
     geometry::{
         rect::RectF,
         vector::{vec2f, Vector2F},
     },
     json::{ToJson, Value},
-    text_layout::{Line, ShapedBoundary},
-    DebugContext, Element, Event, EventContext, LayoutContext, PaintContext, SizeConstraint,
+    text_layout::{Line, RunStyle, ShapedBoundary},
+    DebugContext, Element, Event, EventContext, FontCache, LayoutContext, PaintContext,
+    SizeConstraint, TextLayoutCache,
 };
 use serde_json::json;
 
@@ -15,10 +18,12 @@ pub struct Text {
     text: String,
     style: TextStyle,
     soft_wrap: bool,
+    highlights: Vec<(Range<usize>, HighlightStyle)>,
 }
 
 pub struct LayoutState {
-    lines: Vec<(Line, Vec<ShapedBoundary>)>,
+    shaped_lines: Vec<Line>,
+    wrap_boundaries: Vec<Vec<ShapedBoundary>>,
     line_height: f32,
 }
 
@@ -28,6 +33,7 @@ impl Text {
             text,
             style,
             soft_wrap: true,
+            highlights: Vec::new(),
         }
     }
 
@@ -36,6 +42,11 @@ impl Text {
         self
     }
 
+    pub fn with_highlights(mut self, runs: Vec<(Range<usize>, HighlightStyle)>) -> Self {
+        self.highlights = runs;
+        self
+    }
+
     pub fn with_soft_wrap(mut self, soft_wrap: bool) -> Self {
         self.soft_wrap = soft_wrap;
         self
@@ -51,32 +62,59 @@ impl Element for Text {
         constraint: SizeConstraint,
         cx: &mut LayoutContext,
     ) -> (Vector2F, Self::LayoutState) {
-        let font_id = self.style.font_id;
-        let line_height = cx.font_cache.line_height(font_id, self.style.font_size);
+        // Convert the string and highlight ranges into an iterator of highlighted chunks.
+        let mut offset = 0;
+        let mut highlight_ranges = self.highlights.iter().peekable();
+        let chunks = std::iter::from_fn(|| {
+            let result;
+            if let Some((range, highlight)) = highlight_ranges.peek() {
+                if offset < range.start {
+                    result = Some((&self.text[offset..range.start], None));
+                    offset = range.start;
+                } else {
+                    result = Some((&self.text[range.clone()], Some(*highlight)));
+                    highlight_ranges.next();
+                    offset = range.end;
+                }
+            } else if offset < self.text.len() {
+                result = Some((&self.text[offset..], None));
+                offset = self.text.len();
+            } else {
+                result = None;
+            }
+            result
+        });
 
-        let mut wrapper = cx.font_cache.line_wrapper(font_id, self.style.font_size);
-        let mut lines = Vec::new();
+        // Perform shaping on these highlighted chunks
+        let shaped_lines = layout_highlighted_chunks(
+            chunks,
+            &self.style,
+            cx.text_layout_cache,
+            &cx.font_cache,
+            usize::MAX,
+            self.text.matches('\n').count() + 1,
+        );
+
+        // If line wrapping is enabled, wrap each of the shaped lines.
+        let font_id = self.style.font_id;
         let mut line_count = 0;
         let mut max_line_width = 0_f32;
-        for line in self.text.lines() {
-            let shaped_line = cx.text_layout_cache.layout_str(
-                line,
-                self.style.font_size,
-                &[(line.len(), self.style.to_run())],
-            );
-            let wrap_boundaries = if self.soft_wrap {
-                wrapper
-                    .wrap_shaped_line(line, &shaped_line, constraint.max.x())
-                    .collect::<Vec<_>>()
+        let mut wrap_boundaries = Vec::new();
+        let mut wrapper = cx.font_cache.line_wrapper(font_id, self.style.font_size);
+        for (line, shaped_line) in self.text.lines().zip(&shaped_lines) {
+            if self.soft_wrap {
+                let boundaries = wrapper
+                    .wrap_shaped_line(line, shaped_line, constraint.max.x())
+                    .collect::<Vec<_>>();
+                line_count += boundaries.len() + 1;
+                wrap_boundaries.push(boundaries);
             } else {
-                Vec::new()
-            };
-
+                line_count += 1;
+            }
             max_line_width = max_line_width.max(shaped_line.width());
-            line_count += wrap_boundaries.len() + 1;
-            lines.push((shaped_line, wrap_boundaries));
         }
 
+        let line_height = cx.font_cache.line_height(font_id, self.style.font_size);
         let size = vec2f(
             max_line_width
                 .ceil()
@@ -84,7 +122,14 @@ impl Element for Text {
                 .min(constraint.max.x()),
             (line_height * line_count as f32).ceil(),
         );
-        (size, LayoutState { lines, line_height })
+        (
+            size,
+            LayoutState {
+                shaped_lines,
+                wrap_boundaries,
+                line_height,
+            },
+        )
     }
 
     fn paint(
@@ -95,8 +140,10 @@ impl Element for Text {
         cx: &mut PaintContext,
     ) -> Self::PaintState {
         let mut origin = bounds.origin();
-        for (line, wrap_boundaries) in &layout.lines {
-            let wrapped_line_boundaries = RectF::new(
+        let empty = Vec::new();
+        for (ix, line) in layout.shaped_lines.iter().enumerate() {
+            let wrap_boundaries = layout.wrap_boundaries.get(ix).unwrap_or(&empty);
+            let boundaries = RectF::new(
                 origin,
                 vec2f(
                     bounds.width(),
@@ -104,16 +151,20 @@ impl Element for Text {
                 ),
             );
 
-            if wrapped_line_boundaries.intersects(visible_bounds) {
-                line.paint_wrapped(
-                    origin,
-                    visible_bounds,
-                    layout.line_height,
-                    wrap_boundaries.iter().copied(),
-                    cx,
-                );
+            if boundaries.intersects(visible_bounds) {
+                if self.soft_wrap {
+                    line.paint_wrapped(
+                        origin,
+                        visible_bounds,
+                        layout.line_height,
+                        wrap_boundaries.iter().copied(),
+                        cx,
+                    );
+                } else {
+                    line.paint(origin, visible_bounds, layout.line_height, cx);
+                }
             }
-            origin.set_y(wrapped_line_boundaries.max_y());
+            origin.set_y(boundaries.max_y());
         }
     }
 
@@ -143,3 +194,71 @@ impl Element for Text {
         })
     }
 }
+
+/// Perform text layout on a series of highlighted chunks of text.
+pub fn layout_highlighted_chunks<'a>(
+    chunks: impl Iterator<Item = (&'a str, Option<HighlightStyle>)>,
+    style: &'a TextStyle,
+    text_layout_cache: &'a TextLayoutCache,
+    font_cache: &'a Arc<FontCache>,
+    max_line_len: usize,
+    max_line_count: usize,
+) -> Vec<Line> {
+    let mut layouts = Vec::with_capacity(max_line_count);
+    let mut prev_font_properties = style.font_properties.clone();
+    let mut prev_font_id = style.font_id;
+    let mut line = String::new();
+    let mut styles = Vec::new();
+    let mut row = 0;
+    let mut line_exceeded_max_len = false;
+    for (chunk, highlight_style) in chunks.chain([("\n", None)]) {
+        for (ix, mut line_chunk) in chunk.split('\n').enumerate() {
+            if ix > 0 {
+                layouts.push(text_layout_cache.layout_str(&line, style.font_size, &styles));
+                line.clear();
+                styles.clear();
+                row += 1;
+                line_exceeded_max_len = false;
+                if row == max_line_count {
+                    return layouts;
+                }
+            }
+
+            if !line_chunk.is_empty() && !line_exceeded_max_len {
+                let highlight_style = highlight_style.unwrap_or(style.clone().into());
+
+                // Avoid a lookup if the font properties match the previous ones.
+                let font_id = if highlight_style.font_properties == prev_font_properties {
+                    prev_font_id
+                } else {
+                    font_cache
+                        .select_font(style.font_family_id, &highlight_style.font_properties)
+                        .unwrap_or(style.font_id)
+                };
+
+                if line.len() + line_chunk.len() > max_line_len {
+                    let mut chunk_len = max_line_len - line.len();
+                    while !line_chunk.is_char_boundary(chunk_len) {
+                        chunk_len -= 1;
+                    }
+                    line_chunk = &line_chunk[..chunk_len];
+                    line_exceeded_max_len = true;
+                }
+
+                line.push_str(line_chunk);
+                styles.push((
+                    line_chunk.len(),
+                    RunStyle {
+                        font_id,
+                        color: highlight_style.color,
+                        underline: highlight_style.underline,
+                    },
+                ));
+                prev_font_id = font_id;
+                prev_font_properties = highlight_style.font_properties;
+            }
+        }
+    }
+
+    layouts
+}

crates/gpui/src/elements/uniform_list.rs 🔗

@@ -14,9 +14,15 @@ use std::{cmp, ops::Range, sync::Arc};
 #[derive(Clone, Default)]
 pub struct UniformListState(Arc<Mutex<StateInner>>);
 
+#[derive(Debug)]
+pub enum ScrollTarget {
+    Show(usize),
+    Center(usize),
+}
+
 impl UniformListState {
-    pub fn scroll_to(&self, item_ix: usize) {
-        self.0.lock().scroll_to = Some(item_ix);
+    pub fn scroll_to(&self, scroll_to: ScrollTarget) {
+        self.0.lock().scroll_to = Some(scroll_to);
     }
 
     pub fn scroll_top(&self) -> f32 {
@@ -27,7 +33,7 @@ impl UniformListState {
 #[derive(Default)]
 struct StateInner {
     scroll_top: f32,
-    scroll_to: Option<usize>,
+    scroll_to: Option<ScrollTarget>,
 }
 
 pub struct LayoutState {
@@ -93,20 +99,38 @@ where
     fn autoscroll(&mut self, scroll_max: f32, list_height: f32, item_height: f32) {
         let mut state = self.state.0.lock();
 
-        if state.scroll_top > scroll_max {
-            state.scroll_top = scroll_max;
-        }
+        if let Some(scroll_to) = state.scroll_to.take() {
+            let item_ix;
+            let center;
+            match scroll_to {
+                ScrollTarget::Show(ix) => {
+                    item_ix = ix;
+                    center = false;
+                }
+                ScrollTarget::Center(ix) => {
+                    item_ix = ix;
+                    center = true;
+                }
+            }
 
-        if let Some(item_ix) = state.scroll_to.take() {
             let item_top = self.padding_top + item_ix as f32 * item_height;
             let item_bottom = item_top + item_height;
-
-            if item_top < state.scroll_top {
-                state.scroll_top = item_top;
-            } else if item_bottom > (state.scroll_top + list_height) {
-                state.scroll_top = item_bottom - list_height;
+            if center {
+                let item_center = item_top + item_height / 2.;
+                state.scroll_top = (item_center - list_height / 2.).max(0.);
+            } else {
+                let scroll_bottom = state.scroll_top + list_height;
+                if item_top < state.scroll_top {
+                    state.scroll_top = item_top;
+                } else if item_bottom > scroll_bottom {
+                    state.scroll_top = item_bottom - list_height;
+                }
             }
         }
+
+        if state.scroll_top > scroll_max {
+            state.scroll_top = scroll_max;
+        }
     }
 
     fn scroll_top(&self) -> f32 {

crates/gpui/src/fonts.rs 🔗

@@ -30,7 +30,7 @@ pub struct TextStyle {
     pub underline: Option<Color>,
 }
 
-#[derive(Copy, Clone, Debug, Default)]
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
 pub struct HighlightStyle {
     pub color: Color,
     pub font_properties: Properties,

crates/gpui/src/keymap.rs 🔗

@@ -23,6 +23,7 @@ struct Pending {
     context: Option<Context>,
 }
 
+#[derive(Default)]
 pub struct Keymap(Vec<Binding>);
 
 pub struct Binding {
@@ -153,24 +154,6 @@ impl Keymap {
     }
 }
 
-pub mod menu {
-    use crate::action;
-
-    action!(SelectPrev);
-    action!(SelectNext);
-}
-
-impl Default for Keymap {
-    fn default() -> Self {
-        Self(vec![
-            Binding::new("up", menu::SelectPrev, Some("menu")),
-            Binding::new("ctrl-p", menu::SelectPrev, Some("menu")),
-            Binding::new("down", menu::SelectNext, Some("menu")),
-            Binding::new("ctrl-n", menu::SelectNext, Some("menu")),
-        ])
-    }
-}
-
 impl Binding {
     pub fn new<A: Action>(keystrokes: &str, action: A, context: Option<&str>) -> Self {
         let context = if let Some(context) = context {

crates/language/Cargo.toml 🔗

@@ -19,6 +19,7 @@ test-support = [
 [dependencies]
 clock = { path = "../clock" }
 collections = { path = "../collections" }
+fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
 lsp = { path = "../lsp" }
 rpc = { path = "../rpc" }

crates/language/src/buffer.rs 🔗

@@ -6,7 +6,8 @@ pub use crate::{
 };
 use crate::{
     diagnostic_set::{DiagnosticEntry, DiagnosticGroup},
-    range_from_lsp,
+    outline::OutlineItem,
+    range_from_lsp, Outline,
 };
 use anyhow::{anyhow, Result};
 use clock::ReplicaId;
@@ -193,7 +194,7 @@ pub trait File {
     fn as_any(&self) -> &dyn Any;
 }
 
-struct QueryCursorHandle(Option<QueryCursor>);
+pub(crate) struct QueryCursorHandle(Option<QueryCursor>);
 
 #[derive(Clone)]
 struct SyntaxTree {
@@ -1264,6 +1265,13 @@ impl Buffer {
         self.edit_internal(ranges_iter, new_text, true, cx)
     }
 
+    /*
+    impl Buffer
+        pub fn edit
+        pub fn edit_internal
+        pub fn edit_with_autoindent
+    */
+
     pub fn edit_internal<I, S, T>(
         &mut self,
         ranges_iter: I,
@@ -1827,6 +1835,110 @@ impl BufferSnapshot {
         }
     }
 
+    pub fn outline(&self, theme: Option<&SyntaxTheme>) -> Option<Outline<Anchor>> {
+        let tree = self.tree.as_ref()?;
+        let grammar = self
+            .language
+            .as_ref()
+            .and_then(|language| language.grammar.as_ref())?;
+
+        let mut cursor = QueryCursorHandle::new();
+        let matches = cursor.matches(
+            &grammar.outline_query,
+            tree.root_node(),
+            TextProvider(self.as_rope()),
+        );
+
+        let mut chunks = self.chunks(0..self.len(), theme);
+
+        let item_capture_ix = grammar.outline_query.capture_index_for_name("item")?;
+        let name_capture_ix = grammar.outline_query.capture_index_for_name("name")?;
+        let context_capture_ix = grammar
+            .outline_query
+            .capture_index_for_name("context")
+            .unwrap_or(u32::MAX);
+
+        let mut stack = Vec::<Range<usize>>::new();
+        let items = matches
+            .filter_map(|mat| {
+                let item_node = mat.nodes_for_capture_index(item_capture_ix).next()?;
+                let range = item_node.start_byte()..item_node.end_byte();
+                let mut text = String::new();
+                let mut name_ranges = Vec::new();
+                let mut highlight_ranges = Vec::new();
+
+                for capture in mat.captures {
+                    let node_is_name;
+                    if capture.index == name_capture_ix {
+                        node_is_name = true;
+                    } else if capture.index == context_capture_ix {
+                        node_is_name = false;
+                    } else {
+                        continue;
+                    }
+
+                    let range = capture.node.start_byte()..capture.node.end_byte();
+                    if !text.is_empty() {
+                        text.push(' ');
+                    }
+                    if node_is_name {
+                        let mut start = text.len();
+                        let end = start + range.len();
+
+                        // When multiple names are captured, then the matcheable text
+                        // includes the whitespace in between the names.
+                        if !name_ranges.is_empty() {
+                            start -= 1;
+                        }
+
+                        name_ranges.push(start..end);
+                    }
+
+                    let mut offset = range.start;
+                    chunks.seek(offset);
+                    while let Some(mut chunk) = chunks.next() {
+                        if chunk.text.len() > range.end - offset {
+                            chunk.text = &chunk.text[0..(range.end - offset)];
+                            offset = range.end;
+                        } else {
+                            offset += chunk.text.len();
+                        }
+                        if let Some(style) = chunk.highlight_style {
+                            let start = text.len();
+                            let end = start + chunk.text.len();
+                            highlight_ranges.push((start..end, style));
+                        }
+                        text.push_str(chunk.text);
+                        if offset >= range.end {
+                            break;
+                        }
+                    }
+                }
+
+                while stack.last().map_or(false, |prev_range| {
+                    !prev_range.contains(&range.start) || !prev_range.contains(&range.end)
+                }) {
+                    stack.pop();
+                }
+                stack.push(range.clone());
+
+                Some(OutlineItem {
+                    depth: stack.len() - 1,
+                    range: self.anchor_after(range.start)..self.anchor_before(range.end),
+                    text,
+                    highlight_ranges,
+                    name_ranges,
+                })
+            })
+            .collect::<Vec<_>>();
+
+        if items.is_empty() {
+            None
+        } else {
+            Some(Outline::new(items))
+        }
+    }
+
     pub fn enclosing_bracket_ranges<T: ToOffset>(
         &self,
         range: Range<T>,
@@ -1854,6 +1966,12 @@ impl BufferSnapshot {
             .min_by_key(|(open_range, close_range)| close_range.end - open_range.start)
     }
 
+    /*
+    impl BufferSnapshot
+      pub fn remote_selections_in_range(&self, Range<Anchor>) -> impl Iterator<Item = (ReplicaId, impl Iterator<Item = &Selection<Anchor>>)>
+      pub fn remote_selections_in_range(&self, Range<Anchor>) -> impl Iterator<Item = (ReplicaId, i
+    */
+
     pub fn remote_selections_in_range<'a>(
         &'a self,
         range: Range<Anchor>,
@@ -2108,7 +2226,7 @@ impl<'a> Iterator for BufferChunks<'a> {
 }
 
 impl QueryCursorHandle {
-    fn new() -> Self {
+    pub(crate) fn new() -> Self {
         QueryCursorHandle(Some(
             QUERY_CURSORS
                 .lock()

crates/language/src/language.rs 🔗

@@ -1,6 +1,7 @@
 mod buffer;
 mod diagnostic_set;
 mod highlight_map;
+mod outline;
 pub mod proto;
 #[cfg(test)]
 mod tests;
@@ -13,6 +14,7 @@ pub use diagnostic_set::DiagnosticEntry;
 use gpui::AppContext;
 use highlight_map::HighlightMap;
 use lazy_static::lazy_static;
+pub use outline::{Outline, OutlineItem};
 use parking_lot::Mutex;
 use serde::Deserialize;
 use std::{ops::Range, path::Path, str, sync::Arc};
@@ -74,6 +76,7 @@ pub struct Grammar {
     pub(crate) highlights_query: Query,
     pub(crate) brackets_query: Query,
     pub(crate) indents_query: Query,
+    pub(crate) outline_query: Query,
     pub(crate) highlight_map: Mutex<HighlightMap>,
 }
 
@@ -127,6 +130,7 @@ impl Language {
                     brackets_query: Query::new(ts_language, "").unwrap(),
                     highlights_query: Query::new(ts_language, "").unwrap(),
                     indents_query: Query::new(ts_language, "").unwrap(),
+                    outline_query: Query::new(ts_language, "").unwrap(),
                     ts_language,
                     highlight_map: Default::default(),
                 })
@@ -164,6 +168,16 @@ impl Language {
         Ok(self)
     }
 
+    pub fn with_outline_query(mut self, source: &str) -> Result<Self> {
+        let grammar = self
+            .grammar
+            .as_mut()
+            .and_then(Arc::get_mut)
+            .ok_or_else(|| anyhow!("grammar does not exist or is already being used"))?;
+        grammar.outline_query = Query::new(grammar.ts_language, source)?;
+        Ok(self)
+    }
+
     pub fn name(&self) -> &str {
         self.config.name.as_str()
     }

crates/language/src/outline.rs 🔗

@@ -0,0 +1,146 @@
+use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::{executor::Background, fonts::HighlightStyle};
+use std::{ops::Range, sync::Arc};
+
+#[derive(Debug)]
+pub struct Outline<T> {
+    pub items: Vec<OutlineItem<T>>,
+    candidates: Vec<StringMatchCandidate>,
+    path_candidates: Vec<StringMatchCandidate>,
+    path_candidate_prefixes: Vec<usize>,
+}
+
+#[derive(Clone, Debug)]
+pub struct OutlineItem<T> {
+    pub depth: usize,
+    pub range: Range<T>,
+    pub text: String,
+    pub highlight_ranges: Vec<(Range<usize>, HighlightStyle)>,
+    pub name_ranges: Vec<Range<usize>>,
+}
+
+impl<T> Outline<T> {
+    pub fn new(items: Vec<OutlineItem<T>>) -> Self {
+        let mut candidates = Vec::new();
+        let mut path_candidates = Vec::new();
+        let mut path_candidate_prefixes = Vec::new();
+        let mut path_text = String::new();
+        let mut path_stack = Vec::new();
+
+        for (id, item) in items.iter().enumerate() {
+            if item.depth < path_stack.len() {
+                path_stack.truncate(item.depth);
+                path_text.truncate(path_stack.last().copied().unwrap_or(0));
+            }
+            if !path_text.is_empty() {
+                path_text.push(' ');
+            }
+            path_candidate_prefixes.push(path_text.len());
+            path_text.push_str(&item.text);
+            path_stack.push(path_text.len());
+
+            let candidate_text = item
+                .name_ranges
+                .iter()
+                .map(|range| &item.text[range.start as usize..range.end as usize])
+                .collect::<String>();
+
+            path_candidates.push(StringMatchCandidate {
+                id,
+                char_bag: path_text.as_str().into(),
+                string: path_text.clone(),
+            });
+            candidates.push(StringMatchCandidate {
+                id,
+                char_bag: candidate_text.as_str().into(),
+                string: candidate_text,
+            });
+        }
+
+        Self {
+            candidates,
+            path_candidates,
+            path_candidate_prefixes,
+            items,
+        }
+    }
+
+    pub async fn search(&self, query: &str, executor: Arc<Background>) -> Vec<StringMatch> {
+        let query = query.trim_start();
+        let is_path_query = query.contains(' ');
+        let smart_case = query.chars().any(|c| c.is_uppercase());
+        let mut matches = fuzzy::match_strings(
+            if is_path_query {
+                &self.path_candidates
+            } else {
+                &self.candidates
+            },
+            query,
+            smart_case,
+            100,
+            &Default::default(),
+            executor.clone(),
+        )
+        .await;
+        matches.sort_unstable_by_key(|m| m.candidate_id);
+
+        let mut tree_matches = Vec::new();
+
+        let mut prev_item_ix = 0;
+        for mut string_match in matches {
+            let outline_match = &self.items[string_match.candidate_id];
+
+            if is_path_query {
+                let prefix_len = self.path_candidate_prefixes[string_match.candidate_id];
+                string_match
+                    .positions
+                    .retain(|position| *position >= prefix_len);
+                for position in &mut string_match.positions {
+                    *position -= prefix_len;
+                }
+            } else {
+                let mut name_ranges = outline_match.name_ranges.iter();
+                let mut name_range = name_ranges.next().unwrap();
+                let mut preceding_ranges_len = 0;
+                for position in &mut string_match.positions {
+                    while *position >= preceding_ranges_len + name_range.len() as usize {
+                        preceding_ranges_len += name_range.len();
+                        name_range = name_ranges.next().unwrap();
+                    }
+                    *position = name_range.start as usize + (*position - preceding_ranges_len);
+                }
+            }
+
+            let insertion_ix = tree_matches.len();
+            let mut cur_depth = outline_match.depth;
+            for (ix, item) in self.items[prev_item_ix..string_match.candidate_id]
+                .iter()
+                .enumerate()
+                .rev()
+            {
+                if cur_depth == 0 {
+                    break;
+                }
+
+                let candidate_index = ix + prev_item_ix;
+                if item.depth == cur_depth - 1 {
+                    tree_matches.insert(
+                        insertion_ix,
+                        StringMatch {
+                            candidate_id: candidate_index,
+                            score: Default::default(),
+                            positions: Default::default(),
+                            string: Default::default(),
+                        },
+                    );
+                    cur_depth -= 1;
+                }
+            }
+
+            prev_item_ix = string_match.candidate_id + 1;
+            tree_matches.push(string_match);
+        }
+
+        tree_matches
+    }
+}

crates/language/src/tests.rs 🔗

@@ -278,6 +278,139 @@ async fn test_reparse(mut cx: gpui::TestAppContext) {
     }
 }
 
+#[gpui::test]
+async fn test_outline(mut cx: gpui::TestAppContext) {
+    let language = Some(Arc::new(
+        rust_lang()
+            .with_outline_query(
+                r#"
+                (struct_item
+                    "struct" @context
+                    name: (_) @name) @item
+                (enum_item
+                    "enum" @context
+                    name: (_) @name) @item
+                (enum_variant
+                    name: (_) @name) @item
+                (field_declaration
+                    name: (_) @name) @item
+                (impl_item
+                    "impl" @context
+                    trait: (_) @name
+                    "for" @context
+                    type: (_) @name) @item
+                (function_item
+                    "fn" @context
+                    name: (_) @name) @item
+                (mod_item
+                    "mod" @context
+                    name: (_) @name) @item
+                "#,
+            )
+            .unwrap(),
+    ));
+
+    let text = r#"
+        struct Person {
+            name: String,
+            age: usize,
+        }
+
+        mod module {
+            enum LoginState {
+                LoggedOut,
+                LoggingOn,
+                LoggedIn {
+                    person: Person,
+                    time: Instant,
+                }
+            }
+        }
+
+        impl Eq for Person {}
+
+        impl Drop for Person {
+            fn drop(&mut self) {
+                println!("bye");
+            }
+        }
+    "#
+    .unindent();
+
+    let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, None, cx));
+    let outline = buffer
+        .read_with(&cx, |buffer, _| buffer.snapshot().outline(None))
+        .unwrap();
+
+    assert_eq!(
+        outline
+            .items
+            .iter()
+            .map(|item| (item.text.as_str(), item.depth))
+            .collect::<Vec<_>>(),
+        &[
+            ("struct Person", 0),
+            ("name", 1),
+            ("age", 1),
+            ("mod module", 0),
+            ("enum LoginState", 1),
+            ("LoggedOut", 2),
+            ("LoggingOn", 2),
+            ("LoggedIn", 2),
+            ("person", 3),
+            ("time", 3),
+            ("impl Eq for Person", 0),
+            ("impl Drop for Person", 0),
+            ("fn drop", 1),
+        ]
+    );
+
+    // Without space, we only match on names
+    assert_eq!(
+        search(&outline, "oon", &cx).await,
+        &[
+            ("mod module", vec![]),                    // included as the parent of a match
+            ("enum LoginState", vec![]),               // included as the parent of a match
+            ("LoggingOn", vec![1, 7, 8]),              // matches
+            ("impl Drop for Person", vec![7, 18, 19]), // matches in two disjoint names
+        ]
+    );
+
+    assert_eq!(
+        search(&outline, "dp p", &cx).await,
+        &[
+            ("impl Drop for Person", vec![5, 8, 9, 14]),
+            ("fn drop", vec![]),
+        ]
+    );
+    assert_eq!(
+        search(&outline, "dpn", &cx).await,
+        &[("impl Drop for Person", vec![5, 14, 19])]
+    );
+    assert_eq!(
+        search(&outline, "impl ", &cx).await,
+        &[
+            ("impl Eq for Person", vec![0, 1, 2, 3, 4]),
+            ("impl Drop for Person", vec![0, 1, 2, 3, 4]),
+            ("fn drop", vec![]),
+        ]
+    );
+
+    async fn search<'a>(
+        outline: &'a Outline<Anchor>,
+        query: &str,
+        cx: &gpui::TestAppContext,
+    ) -> Vec<(&'a str, Vec<usize>)> {
+        let matches = cx
+            .read(|cx| outline.search(query, cx.background().clone()))
+            .await;
+        matches
+            .into_iter()
+            .map(|mat| (outline.items[mat.candidate_id].text.as_str(), mat.positions))
+            .collect::<Vec<_>>()
+    }
+}
+
 #[gpui::test]
 fn test_enclosing_bracket_ranges(cx: &mut MutableAppContext) {
     let buffer = cx.add_model(|cx| {
@@ -1017,14 +1150,18 @@ fn rust_lang() -> Language {
     )
     .with_indents_query(
         r#"
-                (call_expression) @indent
-                (field_expression) @indent
-                (_ "(" ")" @end) @indent
-                (_ "{" "}" @end) @indent
-            "#,
+        (call_expression) @indent
+        (field_expression) @indent
+        (_ "(" ")" @end) @indent
+        (_ "{" "}" @end) @indent
+        "#,
     )
     .unwrap()
-    .with_brackets_query(r#" ("{" @open "}" @close) "#)
+    .with_brackets_query(
+        r#"
+        ("{" @open "}" @close)
+        "#,
+    )
     .unwrap()
 }
 

crates/lsp/src/lsp.rs 🔗

@@ -16,7 +16,7 @@ use std::{
     io::Write,
     str::FromStr,
     sync::{
-        atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst},
+        atomic::{AtomicUsize, Ordering::SeqCst},
         Arc,
     },
 };
@@ -431,7 +431,7 @@ pub struct FakeLanguageServer {
     buffer: Vec<u8>,
     stdin: smol::io::BufReader<async_pipe::PipeReader>,
     stdout: smol::io::BufWriter<async_pipe::PipeWriter>,
-    pub started: Arc<AtomicBool>,
+    pub started: Arc<std::sync::atomic::AtomicBool>,
 }
 
 #[cfg(any(test, feature = "test-support"))]
@@ -449,7 +449,7 @@ impl LanguageServer {
             stdin: smol::io::BufReader::new(stdin.1),
             stdout: smol::io::BufWriter::new(stdout.0),
             buffer: Vec::new(),
-            started: Arc::new(AtomicBool::new(true)),
+            started: Arc::new(std::sync::atomic::AtomicBool::new(true)),
         };
 
         let server = Self::new_internal(stdin.0, stdout.1, Path::new("/"), executor).unwrap();

crates/outline/Cargo.toml 🔗

@@ -0,0 +1,18 @@
+[package]
+name = "outline"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/outline.rs"
+
+[dependencies]
+editor = { path = "../editor" }
+fuzzy = { path = "../fuzzy" }
+gpui = { path = "../gpui" }
+language = { path = "../language" }
+text = { path = "../text" }
+workspace = { path = "../workspace" }
+ordered-float = "2.1.1"
+postage = { version = "0.4", features = ["futures-traits"] }
+smol = "1.2"

crates/outline/src/outline.rs 🔗

@@ -0,0 +1,540 @@
+use editor::{
+    display_map::ToDisplayPoint, Anchor, AnchorRangeExt, Autoscroll, Editor, EditorSettings,
+    ToPoint,
+};
+use fuzzy::StringMatch;
+use gpui::{
+    action,
+    elements::*,
+    fonts::{self, HighlightStyle},
+    geometry::vector::Vector2F,
+    keymap::{self, Binding},
+    AppContext, Axis, Entity, MutableAppContext, RenderContext, View, ViewContext, ViewHandle,
+    WeakViewHandle,
+};
+use language::{Outline, Selection};
+use ordered_float::OrderedFloat;
+use postage::watch;
+use std::{
+    cmp::{self, Reverse},
+    ops::Range,
+    sync::Arc,
+};
+use workspace::{
+    menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev},
+    Settings, Workspace,
+};
+
+action!(Toggle);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_bindings([
+        Binding::new("cmd-shift-O", Toggle, Some("Editor")),
+        Binding::new("escape", Toggle, Some("OutlineView")),
+    ]);
+    cx.add_action(OutlineView::toggle);
+    cx.add_action(OutlineView::confirm);
+    cx.add_action(OutlineView::select_prev);
+    cx.add_action(OutlineView::select_next);
+    cx.add_action(OutlineView::select_first);
+    cx.add_action(OutlineView::select_last);
+}
+
+struct OutlineView {
+    handle: WeakViewHandle<Self>,
+    active_editor: ViewHandle<Editor>,
+    outline: Outline<Anchor>,
+    selected_match_index: usize,
+    restore_state: Option<RestoreState>,
+    symbol_selection_id: Option<usize>,
+    matches: Vec<StringMatch>,
+    query_editor: ViewHandle<Editor>,
+    list_state: UniformListState,
+    settings: watch::Receiver<Settings>,
+}
+
+struct RestoreState {
+    scroll_position: Vector2F,
+    selections: Vec<Selection<usize>>,
+}
+
+pub enum Event {
+    Dismissed,
+}
+
+impl Entity for OutlineView {
+    type Event = Event;
+
+    fn release(&mut self, cx: &mut MutableAppContext) {
+        self.restore_active_editor(cx);
+    }
+}
+
+impl View for OutlineView {
+    fn ui_name() -> &'static str {
+        "OutlineView"
+    }
+
+    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
+        let mut cx = Self::default_keymap_context();
+        cx.set.insert("menu".into());
+        cx
+    }
+
+    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
+        let settings = self.settings.borrow();
+
+        Flex::new(Axis::Vertical)
+            .with_child(
+                Container::new(ChildView::new(self.query_editor.id()).boxed())
+                    .with_style(settings.theme.selector.input_editor.container)
+                    .boxed(),
+            )
+            .with_child(Flexible::new(1.0, false, self.render_matches()).boxed())
+            .contained()
+            .with_style(settings.theme.selector.container)
+            .constrained()
+            .with_max_width(800.0)
+            .with_max_height(1200.0)
+            .aligned()
+            .top()
+            .named("outline view")
+    }
+
+    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
+        cx.focus(&self.query_editor);
+    }
+}
+
+impl OutlineView {
+    fn new(
+        outline: Outline<Anchor>,
+        editor: ViewHandle<Editor>,
+        settings: watch::Receiver<Settings>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let query_editor = cx.add_view(|cx| {
+            Editor::single_line(
+                {
+                    let settings = settings.clone();
+                    Arc::new(move |_| {
+                        let settings = settings.borrow();
+                        EditorSettings {
+                            style: settings.theme.selector.input_editor.as_editor(),
+                            tab_size: settings.tab_size,
+                            soft_wrap: editor::SoftWrap::None,
+                        }
+                    })
+                },
+                cx,
+            )
+        });
+        cx.subscribe(&query_editor, Self::on_query_editor_event)
+            .detach();
+
+        let restore_state = editor.update(cx, |editor, cx| {
+            Some(RestoreState {
+                scroll_position: editor.scroll_position(cx),
+                selections: editor.local_selections::<usize>(cx),
+            })
+        });
+
+        let mut this = Self {
+            handle: cx.weak_handle(),
+            active_editor: editor,
+            matches: Default::default(),
+            selected_match_index: 0,
+            restore_state,
+            symbol_selection_id: None,
+            outline,
+            query_editor,
+            list_state: Default::default(),
+            settings,
+        };
+        this.update_matches(cx);
+        this
+    }
+
+    fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
+        if let Some(editor) = workspace
+            .active_item(cx)
+            .and_then(|item| item.to_any().downcast::<Editor>())
+        {
+            let settings = workspace.settings();
+            let buffer = editor
+                .read(cx)
+                .buffer()
+                .read(cx)
+                .read(cx)
+                .outline(Some(settings.borrow().theme.editor.syntax.as_ref()));
+            if let Some(outline) = buffer {
+                workspace.toggle_modal(cx, |cx, _| {
+                    let view = cx.add_view(|cx| OutlineView::new(outline, editor, settings, cx));
+                    cx.subscribe(&view, Self::on_event).detach();
+                    view
+                })
+            }
+        }
+    }
+
+    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
+        if self.selected_match_index > 0 {
+            self.select(self.selected_match_index - 1, true, false, cx);
+        }
+    }
+
+    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+        if self.selected_match_index + 1 < self.matches.len() {
+            self.select(self.selected_match_index + 1, true, false, cx);
+        }
+    }
+
+    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
+        self.select(0, true, false, cx);
+    }
+
+    fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
+        self.select(self.matches.len().saturating_sub(1), true, false, cx);
+    }
+
+    fn select(&mut self, index: usize, navigate: bool, center: bool, cx: &mut ViewContext<Self>) {
+        self.selected_match_index = index;
+        self.list_state.scroll_to(if center {
+            ScrollTarget::Center(index)
+        } else {
+            ScrollTarget::Show(index)
+        });
+        if navigate {
+            let selected_match = &self.matches[self.selected_match_index];
+            let outline_item = &self.outline.items[selected_match.candidate_id];
+            self.symbol_selection_id = self.active_editor.update(cx, |active_editor, cx| {
+                let snapshot = active_editor.snapshot(cx).display_snapshot;
+                let buffer_snapshot = &snapshot.buffer_snapshot;
+                let start = outline_item.range.start.to_point(&buffer_snapshot);
+                let end = outline_item.range.end.to_point(&buffer_snapshot);
+                let display_rows = start.to_display_point(&snapshot).row()
+                    ..end.to_display_point(&snapshot).row() + 1;
+                active_editor.select_ranges([start..start], Some(Autoscroll::Center), cx);
+                active_editor.set_highlighted_rows(Some(display_rows));
+                Some(active_editor.newest_selection::<usize>(&buffer_snapshot).id)
+            });
+            cx.notify();
+        }
+    }
+
+    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
+        self.restore_state.take();
+        cx.emit(Event::Dismissed);
+    }
+
+    fn restore_active_editor(&mut self, cx: &mut MutableAppContext) {
+        let symbol_selection_id = self.symbol_selection_id.take();
+        self.active_editor.update(cx, |editor, cx| {
+            editor.set_highlighted_rows(None);
+            if let Some((symbol_selection_id, restore_state)) =
+                symbol_selection_id.zip(self.restore_state.as_ref())
+            {
+                let newest_selection =
+                    editor.newest_selection::<usize>(&editor.buffer().read(cx).read(cx));
+                if symbol_selection_id == newest_selection.id {
+                    editor.set_scroll_position(restore_state.scroll_position, cx);
+                    editor.update_selections(restore_state.selections.clone(), None, cx);
+                }
+            }
+        })
+    }
+
+    fn on_event(
+        workspace: &mut Workspace,
+        _: ViewHandle<Self>,
+        event: &Event,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        match event {
+            Event::Dismissed => workspace.dismiss_modal(cx),
+        }
+    }
+
+    fn on_query_editor_event(
+        &mut self,
+        _: ViewHandle<Editor>,
+        event: &editor::Event,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match event {
+            editor::Event::Blurred => cx.emit(Event::Dismissed),
+            editor::Event::Edited => self.update_matches(cx),
+            _ => {}
+        }
+    }
+
+    fn update_matches(&mut self, cx: &mut ViewContext<Self>) {
+        let selected_index;
+        let navigate_to_selected_index;
+        let query = self.query_editor.update(cx, |buffer, cx| buffer.text(cx));
+        if query.is_empty() {
+            self.restore_active_editor(cx);
+            self.matches = self
+                .outline
+                .items
+                .iter()
+                .enumerate()
+                .map(|(index, _)| StringMatch {
+                    candidate_id: index,
+                    score: Default::default(),
+                    positions: Default::default(),
+                    string: Default::default(),
+                })
+                .collect();
+
+            let editor = self.active_editor.read(cx);
+            let buffer = editor.buffer().read(cx).read(cx);
+            let cursor_offset = editor.newest_selection::<usize>(&buffer).head();
+            selected_index = self
+                .outline
+                .items
+                .iter()
+                .enumerate()
+                .map(|(ix, item)| {
+                    let range = item.range.to_offset(&buffer);
+                    let distance_to_closest_endpoint = cmp::min(
+                        (range.start as isize - cursor_offset as isize).abs() as usize,
+                        (range.end as isize - cursor_offset as isize).abs() as usize,
+                    );
+                    let depth = if range.contains(&cursor_offset) {
+                        Some(item.depth)
+                    } else {
+                        None
+                    };
+                    (ix, depth, distance_to_closest_endpoint)
+                })
+                .max_by_key(|(_, depth, distance)| (*depth, Reverse(*distance)))
+                .unwrap()
+                .0;
+            navigate_to_selected_index = false;
+        } else {
+            self.matches = smol::block_on(self.outline.search(&query, cx.background().clone()));
+            selected_index = self
+                .matches
+                .iter()
+                .enumerate()
+                .max_by_key(|(_, m)| OrderedFloat(m.score))
+                .map(|(ix, _)| ix)
+                .unwrap_or(0);
+            navigate_to_selected_index = !self.matches.is_empty();
+        }
+        self.select(selected_index, navigate_to_selected_index, true, cx);
+    }
+
+    fn render_matches(&self) -> ElementBox {
+        if self.matches.is_empty() {
+            let settings = self.settings.borrow();
+            return Container::new(
+                Label::new(
+                    "No matches".into(),
+                    settings.theme.selector.empty.label.clone(),
+                )
+                .boxed(),
+            )
+            .with_style(settings.theme.selector.empty.container)
+            .named("empty matches");
+        }
+
+        let handle = self.handle.clone();
+        let list = UniformList::new(
+            self.list_state.clone(),
+            self.matches.len(),
+            move |mut range, items, cx| {
+                let cx = cx.as_ref();
+                let view = handle.upgrade(cx).unwrap();
+                let view = view.read(cx);
+                let start = range.start;
+                range.end = cmp::min(range.end, view.matches.len());
+                items.extend(
+                    view.matches[range]
+                        .iter()
+                        .enumerate()
+                        .map(move |(ix, m)| view.render_match(m, start + ix)),
+                );
+            },
+        );
+
+        Container::new(list.boxed())
+            .with_margin_top(6.0)
+            .named("matches")
+    }
+
+    fn render_match(&self, string_match: &StringMatch, index: usize) -> ElementBox {
+        let settings = self.settings.borrow();
+        let style = if index == self.selected_match_index {
+            &settings.theme.selector.active_item
+        } else {
+            &settings.theme.selector.item
+        };
+        let outline_item = &self.outline.items[string_match.candidate_id];
+
+        Text::new(outline_item.text.clone(), style.label.text.clone())
+            .with_soft_wrap(false)
+            .with_highlights(combine_syntax_and_fuzzy_match_highlights(
+                &outline_item.text,
+                style.label.text.clone().into(),
+                &outline_item.highlight_ranges,
+                &string_match.positions,
+            ))
+            .contained()
+            .with_padding_left(20. * outline_item.depth as f32)
+            .contained()
+            .with_style(style.container)
+            .boxed()
+    }
+}
+
+fn combine_syntax_and_fuzzy_match_highlights(
+    text: &str,
+    default_style: HighlightStyle,
+    syntax_ranges: &[(Range<usize>, HighlightStyle)],
+    match_indices: &[usize],
+) -> Vec<(Range<usize>, HighlightStyle)> {
+    let mut result = Vec::new();
+    let mut match_indices = match_indices.iter().copied().peekable();
+
+    for (range, mut syntax_highlight) in syntax_ranges
+        .iter()
+        .cloned()
+        .chain([(usize::MAX..0, Default::default())])
+    {
+        syntax_highlight.font_properties.weight(Default::default());
+
+        // Add highlights for any fuzzy match characters before the next
+        // syntax highlight range.
+        while let Some(&match_index) = match_indices.peek() {
+            if match_index >= range.start {
+                break;
+            }
+            match_indices.next();
+            let end_index = char_ix_after(match_index, text);
+            let mut match_style = default_style;
+            match_style.font_properties.weight(fonts::Weight::BOLD);
+            result.push((match_index..end_index, match_style));
+        }
+
+        if range.start == usize::MAX {
+            break;
+        }
+
+        // Add highlights for any fuzzy match characters within the
+        // syntax highlight range.
+        let mut offset = range.start;
+        while let Some(&match_index) = match_indices.peek() {
+            if match_index >= range.end {
+                break;
+            }
+
+            match_indices.next();
+            if match_index > offset {
+                result.push((offset..match_index, syntax_highlight));
+            }
+
+            let mut end_index = char_ix_after(match_index, text);
+            while let Some(&next_match_index) = match_indices.peek() {
+                if next_match_index == end_index && next_match_index < range.end {
+                    end_index = char_ix_after(next_match_index, text);
+                    match_indices.next();
+                } else {
+                    break;
+                }
+            }
+
+            let mut match_style = syntax_highlight;
+            match_style.font_properties.weight(fonts::Weight::BOLD);
+            result.push((match_index..end_index, match_style));
+            offset = end_index;
+        }
+
+        if offset < range.end {
+            result.push((offset..range.end, syntax_highlight));
+        }
+    }
+
+    result
+}
+
+fn char_ix_after(ix: usize, text: &str) -> usize {
+    ix + text[ix..].chars().next().unwrap().len_utf8()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use gpui::{color::Color, fonts::HighlightStyle};
+
+    #[test]
+    fn test_combine_syntax_and_fuzzy_match_highlights() {
+        let string = "abcdefghijklmnop";
+        let default = HighlightStyle::default();
+        let syntax_ranges = [
+            (
+                0..3,
+                HighlightStyle {
+                    color: Color::red(),
+                    ..default
+                },
+            ),
+            (
+                4..8,
+                HighlightStyle {
+                    color: Color::green(),
+                    ..default
+                },
+            ),
+        ];
+        let match_indices = [4, 6, 7, 8];
+        assert_eq!(
+            combine_syntax_and_fuzzy_match_highlights(
+                &string,
+                default,
+                &syntax_ranges,
+                &match_indices,
+            ),
+            &[
+                (
+                    0..3,
+                    HighlightStyle {
+                        color: Color::red(),
+                        ..default
+                    },
+                ),
+                (
+                    4..5,
+                    HighlightStyle {
+                        color: Color::green(),
+                        font_properties: *fonts::Properties::default().weight(fonts::Weight::BOLD),
+                        ..default
+                    },
+                ),
+                (
+                    5..6,
+                    HighlightStyle {
+                        color: Color::green(),
+                        ..default
+                    },
+                ),
+                (
+                    6..8,
+                    HighlightStyle {
+                        color: Color::green(),
+                        font_properties: *fonts::Properties::default().weight(fonts::Weight::BOLD),
+                        ..default
+                    },
+                ),
+                (
+                    8..9,
+                    HighlightStyle {
+                        font_properties: *fonts::Properties::default().weight(fonts::Weight::BOLD),
+                        ..default
+                    },
+                ),
+            ]
+        );
+    }
+}

crates/project_panel/src/project_panel.rs 🔗

@@ -1,14 +1,10 @@
 use gpui::{
     action,
     elements::{
-        Align, ConstrainedBox, Empty, Flex, Label, MouseEventHandler, ParentElement, Svg,
-        UniformList, UniformListState,
-    },
-    keymap::{
-        self,
-        menu::{SelectNext, SelectPrev},
-        Binding,
+        Align, ConstrainedBox, Empty, Flex, Label, MouseEventHandler, ParentElement, ScrollTarget,
+        Svg, UniformList, UniformListState,
     },
+    keymap::{self, Binding},
     platform::CursorStyle,
     AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, ReadModel, View,
     ViewContext, ViewHandle, WeakViewHandle,
@@ -20,7 +16,10 @@ use std::{
     ffi::OsStr,
     ops::Range,
 };
-use workspace::{Settings, Workspace};
+use workspace::{
+    menu::{SelectNext, SelectPrev},
+    Settings, Workspace,
+};
 
 pub struct ProjectPanel {
     project: ModelHandle<Project>,
@@ -278,7 +277,7 @@ impl ProjectPanel {
 
     fn autoscroll(&mut self) {
         if let Some(selection) = self.selection {
-            self.list.scroll_to(selection.index);
+            self.list.scroll_to(ScrollTarget::Show(selection.index));
         }
     }
 

crates/theme_selector/src/theme_selector.rs 🔗

@@ -3,7 +3,7 @@ use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
 use gpui::{
     action,
     elements::*,
-    keymap::{self, menu, Binding},
+    keymap::{self, Binding},
     AppContext, Axis, Element, ElementBox, Entity, MutableAppContext, RenderContext, View,
     ViewContext, ViewHandle,
 };
@@ -11,7 +11,10 @@ use parking_lot::Mutex;
 use postage::watch;
 use std::{cmp, sync::Arc};
 use theme::ThemeRegistry;
-use workspace::{AppState, Settings, Workspace};
+use workspace::{
+    menu::{Confirm, SelectNext, SelectPrev},
+    AppState, Settings, Workspace,
+};
 
 #[derive(Clone)]
 pub struct ThemeSelectorParams {
@@ -30,7 +33,6 @@ pub struct ThemeSelector {
     selected_index: usize,
 }
 
-action!(Confirm);
 action!(Toggle, ThemeSelectorParams);
 action!(Reload, ThemeSelectorParams);
 
@@ -45,7 +47,6 @@ pub fn init(params: ThemeSelectorParams, cx: &mut MutableAppContext) {
         Binding::new("cmd-k cmd-t", Toggle(params.clone()), None),
         Binding::new("cmd-k t", Reload(params.clone()), None),
         Binding::new("escape", Toggle(params.clone()), Some("ThemeSelector")),
-        Binding::new("enter", Confirm, Some("ThemeSelector")),
     ]);
 }
 
@@ -136,19 +137,21 @@ impl ThemeSelector {
         }
     }
 
-    fn select_prev(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
+    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
         if self.selected_index > 0 {
             self.selected_index -= 1;
         }
-        self.list_state.scroll_to(self.selected_index);
+        self.list_state
+            .scroll_to(ScrollTarget::Show(self.selected_index));
         cx.notify();
     }
 
-    fn select_next(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
+    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
         if self.selected_index + 1 < self.matches.len() {
             self.selected_index += 1;
         }
-        self.list_state.scroll_to(self.selected_index);
+        self.list_state
+            .scroll_to(ScrollTarget::Show(self.selected_index));
         cx.notify();
     }
 
@@ -157,7 +160,9 @@ impl ThemeSelector {
         let candidates = self
             .themes
             .list()
-            .map(|name| StringMatchCandidate {
+            .enumerate()
+            .map(|(id, name)| StringMatchCandidate {
+                id,
                 char_bag: name.as_str().into(),
                 string: name,
             })
@@ -167,7 +172,9 @@ impl ThemeSelector {
         self.matches = if query.is_empty() {
             candidates
                 .into_iter()
-                .map(|candidate| StringMatch {
+                .enumerate()
+                .map(|(index, candidate)| StringMatch {
+                    candidate_id: index,
                     string: candidate.string,
                     positions: Vec::new(),
                     score: 0.0,

crates/workspace/src/menu.rs 🔗

@@ -0,0 +1,19 @@
+use gpui::{action, keymap::Binding, MutableAppContext};
+
+action!(Confirm);
+action!(SelectPrev);
+action!(SelectNext);
+action!(SelectFirst);
+action!(SelectLast);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_bindings([
+        Binding::new("up", SelectPrev, Some("menu")),
+        Binding::new("ctrl-p", SelectPrev, Some("menu")),
+        Binding::new("down", SelectNext, Some("menu")),
+        Binding::new("ctrl-n", SelectNext, Some("menu")),
+        Binding::new("cmd-up", SelectFirst, Some("menu")),
+        Binding::new("cmd-down", SelectLast, Some("menu")),
+        Binding::new("enter", Confirm, Some("menu")),
+    ]);
+}

crates/workspace/src/workspace.rs 🔗

@@ -1,3 +1,4 @@
+pub mod menu;
 pub mod pane;
 pub mod pane_group;
 pub mod settings;
@@ -48,6 +49,9 @@ action!(Save);
 action!(DebugElements);
 
 pub fn init(cx: &mut MutableAppContext) {
+    pane::init(cx);
+    menu::init(cx);
+
     cx.add_global_action(open);
     cx.add_global_action(move |action: &OpenPaths, cx: &mut MutableAppContext| {
         open_paths(&action.0.paths, &action.0.app_state, cx).detach();
@@ -84,7 +88,6 @@ pub fn init(cx: &mut MutableAppContext) {
             None,
         ),
     ]);
-    pane::init(cx);
 }
 
 pub struct AppState {

crates/zed/Cargo.toml 🔗

@@ -43,6 +43,7 @@ gpui = { path = "../gpui" }
 journal = { path = "../journal" }
 language = { path = "../language" }
 lsp = { path = "../lsp" }
+outline = { path = "../outline" }
 project = { path = "../project" }
 project_panel = { path = "../project_panel" }
 rpc = { path = "../rpc" }

crates/zed/assets/themes/_base.toml 🔗

@@ -211,7 +211,7 @@ text = { extends = "$text.0" }
 [selector]
 background = "$surface.0"
 padding = 8
-margin.top = 52
+margin = { top = 52, bottom = 52 }
 corner_radius = 6
 shadow = { offset = [0, 2], blur = 16, color = "$shadow.0" }
 border = { width = 1, color = "$border.0" }

crates/zed/languages/rust/outline.scm 🔗

@@ -0,0 +1,63 @@
+(struct_item
+    (visibility_modifier)? @context
+    "struct" @context
+    name: (_) @name) @item
+
+(enum_item
+    (visibility_modifier)? @context
+    "enum" @context
+    name: (_) @name) @item
+
+(enum_variant
+    (visibility_modifier)? @context
+    name: (_) @name) @item
+
+(impl_item
+    "impl" @context
+    trait: (_)? @name
+    "for"? @context
+    type: (_) @name) @item
+
+(trait_item
+    (visibility_modifier)? @context
+    "trait" @context
+    name: (_) @name) @item
+
+(function_item
+    (visibility_modifier)? @context
+    (function_modifiers)? @context
+    "fn" @context
+    name: (_) @name) @item
+
+(function_signature_item
+    (visibility_modifier)? @context
+    (function_modifiers)? @context
+    "fn" @context
+    name: (_) @name) @item
+
+(macro_definition
+    . "macro_rules!" @context
+    name: (_) @name) @item
+
+(mod_item
+    (visibility_modifier)? @context
+    "mod" @context
+    name: (_) @name) @item
+
+(type_item
+    (visibility_modifier)? @context
+    "type" @context
+    name: (_) @name) @item
+
+(associated_type
+    "type" @context
+    name: (_) @name) @item
+
+(const_item
+    (visibility_modifier)? @context
+    "const" @context
+    name: (_) @name) @item
+
+(field_declaration
+    (visibility_modifier)? @context
+    name: (_) @name) @item

crates/zed/src/language.rs 🔗

@@ -24,6 +24,8 @@ fn rust() -> Language {
         .unwrap()
         .with_indents_query(load_query("rust/indents.scm").as_ref())
         .unwrap()
+        .with_outline_query(load_query("rust/outline.scm").as_ref())
+        .unwrap()
 }
 
 fn markdown() -> Language {

crates/zed/src/main.rs 🔗

@@ -59,6 +59,7 @@ fn main() {
         go_to_line::init(cx);
         file_finder::init(cx);
         chat_panel::init(cx);
+        outline::init(cx);
         project_panel::init(cx);
         diagnostics::init(cx);
         cx.spawn({