main
1diff --git a/book/src/editor.md b/book/src/editor.md
2index 872fa8a6..d2b5016b 100644
3--- a/book/src/editor.md
4+++ b/book/src/editor.md
5@@ -1,6 +1,7 @@
6 ## Editor
7
8 - [`[editor]` Section](#editor-section)
9+- [`[editor.breadcrumb]` Section](#editorbreadcrumb-section)
10 - [`[editor.clipboard-provider]` Section](#editorclipboard-provider-section)
11 - [`[editor.statusline]` Section](#editorstatusline-section)
12 - [`[editor.lsp]` Section](#editorlsp-section)
13@@ -73,6 +74,26 @@ ### `[editor]` Section
14
15 [^3]: In most cases, you also need to enable the `auto-format` setting under `languages.toml`. You can find the reasoning [here](https://github.com/helix-editor/helix/discussions/9043#discussioncomment-7811497).
16
17+### `[editor.breadcrumb]` Section
18+
19+Allows configuration of the breadcrumb navigation bar.
20+
21+| Setting | Description | Default |
22+|----------|-------------------------------------------------|---------|
23+| `enable` | Whether to enable the breadcrumb navigation bar | `false` |
24+| `path` | Selecting how much path information is shown | `"full"`|
25+
26+```toml
27+[editor.breadcrumb]
28+enable = true
29+# full: helix-term > src > commands > typed.rs > TypeableCommand > name
30+# file: typed.rs > TypeableCommand > name
31+# none: TypeableCommand > name
32+path = "full|file|none"
33+```
34+
35+The bar is displayed at the top of the view.
36+
37 ### `[editor.clipboard-provider]` Section
38
39 Helix can be configured either to use a builtin clipboard configuration or to use
40diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs
41index 08e95b8f..d7da020d 100644
42--- a/helix-lsp/src/client.rs
43+++ b/helix-lsp/src/client.rs
44@@ -739,7 +739,7 @@ pub(crate) async fn initialize(&self, enable_snippets: bool) -> Result<lsp::Init
45 symbol_kind: Some(lsp::SymbolKindCapability {
46 value_set: Some(lsp::SymbolKind::all()),
47 }),
48- hierarchical_document_symbol_support: Some(false),
49+ hierarchical_document_symbol_support: Some(true),
50 ..Default::default()
51 }),
52 ..Default::default()
53diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
54index ef6953b8..cd11400c 100644
55--- a/helix-term/src/commands.rs
56+++ b/helix-term/src/commands.rs
57@@ -1166,7 +1166,7 @@ fn goto_window(cx: &mut Context, align: Align) {
58 let (view, doc) = current!(cx.editor);
59 let view_offset = doc.view_offset(view.id);
60
61- let height = view.inner_height();
62+ let height = view.inner_height(doc);
63
64 // respect user given count if any
65 // - 1 so we have at least one gap in the middle.
66@@ -1957,7 +1957,7 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction, sync_cursor
67 let text = doc.text().slice(..);
68
69 let cursor = range.cursor(text);
70- let height = view.inner_height();
71+ let height = view.inner_height(doc);
72
73 let scrolloff = config.scrolloff.min(height.saturating_sub(1) / 2);
74 let offset = match direction {
75@@ -2058,50 +2058,50 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction, sync_cursor
76 }
77
78 fn page_up(cx: &mut Context) {
79- let view = view!(cx.editor);
80- let offset = view.inner_height();
81+ let (view, doc) = current_ref!(cx.editor);
82+ let offset = view.inner_height(doc);
83 scroll(cx, offset, Direction::Backward, false);
84 }
85
86 fn page_down(cx: &mut Context) {
87- let view = view!(cx.editor);
88- let offset = view.inner_height();
89+ let (view, doc) = current_ref!(cx.editor);
90+ let offset = view.inner_height(doc);
91 scroll(cx, offset, Direction::Forward, false);
92 }
93
94 fn half_page_up(cx: &mut Context) {
95- let view = view!(cx.editor);
96- let offset = view.inner_height() / 2;
97+ let (view, doc) = current_ref!(cx.editor);
98+ let offset = view.inner_height(doc) / 2;
99 scroll(cx, offset, Direction::Backward, false);
100 }
101
102 fn half_page_down(cx: &mut Context) {
103- let view = view!(cx.editor);
104- let offset = view.inner_height() / 2;
105+ let (view, doc) = current_ref!(cx.editor);
106+ let offset = view.inner_height(doc) / 2;
107 scroll(cx, offset, Direction::Forward, false);
108 }
109
110 fn page_cursor_up(cx: &mut Context) {
111- let view = view!(cx.editor);
112- let offset = view.inner_height();
113+ let (view, doc) = current_ref!(cx.editor);
114+ let offset = view.inner_height(doc);
115 scroll(cx, offset, Direction::Backward, true);
116 }
117
118 fn page_cursor_down(cx: &mut Context) {
119- let view = view!(cx.editor);
120- let offset = view.inner_height();
121+ let (view, doc) = current_ref!(cx.editor);
122+ let offset = view.inner_height(doc);
123 scroll(cx, offset, Direction::Forward, true);
124 }
125
126 fn page_cursor_half_up(cx: &mut Context) {
127- let view = view!(cx.editor);
128- let offset = view.inner_height() / 2;
129+ let (view, doc) = current_ref!(cx.editor);
130+ let offset = view.inner_height(doc) / 2;
131 scroll(cx, offset, Direction::Backward, true);
132 }
133
134 fn page_cursor_half_down(cx: &mut Context) {
135- let view = view!(cx.editor);
136- let offset = view.inner_height() / 2;
137+ let (view, doc) = current_ref!(cx.editor);
138+ let offset = view.inner_height(doc) / 2;
139 scroll(cx, offset, Direction::Forward, true);
140 }
141
142diff --git a/helix-term/src/commands/engine/steel/mod.rs b/helix-term/src/commands/engine/steel/mod.rs
143index 3ea5be1f..8a166638 100644
144--- a/helix-term/src/commands/engine/steel/mod.rs
145+++ b/helix-term/src/commands/engine/steel/mod.rs
146@@ -5545,7 +5545,7 @@ pub fn add_inlay_hint(
147 let doc_text = doc.text();
148 let len_lines = doc_text.len_lines();
149
150- let view_height = view.inner_height();
151+ let view_height = view.inner_height(doc);
152 let first_visible_line =
153 doc_text.char_to_line(doc.view_offset(view_id).anchor.min(doc_text.len_chars()));
154 let first_line = first_visible_line.saturating_sub(view_height);
155@@ -5597,7 +5597,7 @@ pub fn remove_inlay_hint_by_id(
156 let doc_text = doc.text();
157 let len_lines = doc_text.len_lines();
158
159- let view_height = view.inner_height();
160+ let view_height = view.inner_height(doc);
161 let first_visible_line =
162 doc_text.char_to_line(doc.view_offset(view_id).anchor.min(doc_text.len_chars()));
163 let first_line = first_visible_line.saturating_sub(view_height);
164diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs
165index 93bf1e40..fc62cea4 100644
166--- a/helix-term/src/commands/lsp.rs
167+++ b/helix-term/src/commands/lsp.rs
168@@ -1335,7 +1335,7 @@ fn compute_inlay_hints_for_view(
169 // will not show half the view with hints and half without while still being faster
170 // than computing all the hints for the full file (which could be dozens of time
171 // longer than the view is).
172- let view_height = view.inner_height();
173+ let view_height = view.inner_height(doc);
174 let first_visible_line =
175 doc_text.char_to_line(doc.view_offset(view_id).anchor.min(doc_text.len_chars()));
176 let first_line = first_visible_line.saturating_sub(view_height);
177diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs
178index 3cfed93f..6bd8da73 100644
179--- a/helix-term/src/commands/typed.rs
180+++ b/helix-term/src/commands/typed.rs
181@@ -1918,6 +1918,7 @@ fn lsp_stop(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> any
182 doc.clear_diagnostics_for_language_server(client.id());
183 doc.reset_all_inlay_hints();
184 doc.inlay_hints_oudated = true;
185+ doc.clear_document_symbols();
186 }
187 }
188 }
189diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs
190index 46afc4ce..5f3dd22f 100644
191--- a/helix-term/src/handlers.rs
192+++ b/helix-term/src/handlers.rs
193@@ -22,6 +22,7 @@
194 mod document_colors;
195 mod document_highlight;
196 mod document_links;
197+mod document_symbols;
198 mod prompt;
199 mod signature_help;
200 mod snippet;
201@@ -57,6 +58,7 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
202 signature_help::register_hooks(&handlers);
203 document_highlight::register_hooks(&handlers);
204 code_action_hint::register_hooks(&handlers);
205+ document_symbols::register_hooks(&handlers);
206 auto_save::register_hooks(&handlers);
207 diagnostics::register_hooks(&handlers);
208 snippet::register_hooks(&handlers);
209diff --git a/helix-term/src/handlers/document_symbols.rs b/helix-term/src/handlers/document_symbols.rs
210new file mode 100644
211index 00000000..629a9e5b
212--- /dev/null
213+++ b/helix-term/src/handlers/document_symbols.rs
214@@ -0,0 +1,139 @@
215+use crate::job;
216+use helix_core::syntax::config::LanguageServerFeature;
217+use helix_event::{cancelable_future, register_hook};
218+use helix_lsp::lsp::DocumentSymbolResponse;
219+use helix_view::{
220+ events::{
221+ ConfigDidChange, DocumentDidChange, DocumentDidOpen, LanguageServerExited,
222+ LanguageServerInitialized, SelectionDidChange,
223+ },
224+ handlers::Handlers,
225+ DocumentId, Editor,
226+};
227+
228+fn request_document_symbols(editor: &mut Editor, doc_id: DocumentId) {
229+ if !editor.config().breadcrumb.enable {
230+ return;
231+ }
232+
233+ let Some(doc) = editor.document_mut(doc_id) else {
234+ return;
235+ };
236+
237+ let Some(language_server) = doc
238+ // Get the first LSP Server that supports `DocumentSymbols`.
239+ .language_servers_with_feature(LanguageServerFeature::DocumentSymbols)
240+ .next()
241+ else {
242+ return;
243+ };
244+
245+ let offset_encoding = language_server.offset_encoding();
246+ let Some(future) = language_server.document_symbols(doc.identifier()) else {
247+ return;
248+ };
249+ let cancel = doc.document_symbols_controller.restart();
250+
251+ tokio::spawn(async move {
252+ let Some(Ok(Some(response))) = cancelable_future(future, &cancel).await else {
253+ return;
254+ };
255+
256+ job::dispatch(move |editor, _| {
257+ if let Some(doc) = editor.document_mut(doc_id) {
258+ match response {
259+ DocumentSymbolResponse::Nested(symbols) => {
260+ doc.set_document_symbols(symbols, offset_encoding);
261+ }
262+ // TODO: Using the `Location`, it should be possible to map cursor
263+ // to a hierarchical tree?
264+ DocumentSymbolResponse::Flat(_) => {}
265+ }
266+ }
267+ })
268+ .await;
269+ });
270+}
271+
272+pub(super) fn register_hooks(_handlers: &Handlers) {
273+ register_hook!(move |event: &mut DocumentDidOpen<'_>| {
274+ let doc_id = event.doc;
275+ let view_id = event.editor.tree.focus;
276+ request_document_symbols(event.editor, doc_id);
277+ if let Some(doc) = event.editor.document_mut(doc_id) {
278+ doc.update_breadcrumbs_for_view(view_id);
279+ }
280+ Ok(())
281+ });
282+
283+ register_hook!(move |event: &mut DocumentDidChange<'_>| {
284+ if !event.ghost_transaction {
285+ // Cancel the ongoing request, if present.
286+ event.doc.document_symbols_controller.cancel();
287+ let view_id = event.view;
288+ let doc_id = event.doc.id();
289+ job::dispatch_blocking(move |editor, _| {
290+ request_document_symbols(editor, doc_id);
291+ if let Some(doc) = editor.document_mut(doc_id) {
292+ doc.update_breadcrumbs_for_view(view_id);
293+ }
294+ });
295+ }
296+ Ok(())
297+ });
298+
299+ register_hook!(move |event: &mut LanguageServerInitialized<'_>| {
300+ let view_id = event.editor.tree.focus;
301+ if let Some(view) = event.editor.tree.try_get(view_id) {
302+ let doc_id = view.doc;
303+ request_document_symbols(event.editor, doc_id);
304+ if let Some(doc) = event.editor.document_mut(doc_id) {
305+ doc.update_breadcrumbs_for_view(view_id);
306+ }
307+ }
308+ Ok(())
309+ });
310+
311+ register_hook!(move |event: &mut LanguageServerExited<'_>| {
312+ for doc in event.editor.documents_mut() {
313+ if doc.supports_language_server(event.server_id) {
314+ doc.clear_document_symbols();
315+ }
316+ }
317+ Ok(())
318+ });
319+
320+ register_hook!(move |event: &mut ConfigDidChange<'_>| {
321+ if !event.old.breadcrumb.enable && event.new.breadcrumb.enable {
322+ let view_id = event.editor.tree.focus;
323+ if let Some(view) = event.editor.tree.try_get(view_id) {
324+ let doc_id = view.doc;
325+ request_document_symbols(event.editor, doc_id);
326+ if let Some(doc) = event.editor.document_mut(doc_id) {
327+ doc.update_breadcrumbs_for_view(view_id);
328+ }
329+ }
330+ return Ok(());
331+ }
332+
333+ if event.old.breadcrumb.enable && !event.new.breadcrumb.enable {
334+ for doc in event.editor.documents_mut() {
335+ doc.clear_document_symbols();
336+ }
337+ }
338+
339+ Ok(())
340+ });
341+
342+ register_hook!(move |event: &mut SelectionDidChange<'_>| {
343+ let doc_id = event.doc.id();
344+ let view_id = event.view;
345+
346+ job::dispatch_blocking(move |editor, _| {
347+ if let Some(doc) = editor.document_mut(doc_id) {
348+ doc.update_breadcrumbs_for_view_inlined(view_id);
349+ }
350+ });
351+ Ok(())
352+ });
353+}
354diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
355index f2f84123..05cf7769 100644
356--- a/helix-term/src/ui/editor.rs
357+++ b/helix-term/src/ui/editor.rs
358@@ -22,10 +22,11 @@
359 unicode::width::UnicodeWidthStr,
360 visual_offset_from_block, Change, Position, Range, Selection, Transaction,
361 };
362+use helix_lsp::lsp::SymbolKind;
363 use helix_view::{
364 annotations::diagnostics::DiagnosticFilter,
365 document::{Mode, SCRATCH_BUFFER_NAME},
366- editor::{CompleteAction, CursorShapeConfig},
367+ editor::{BufferLine, CompleteAction, CursorShapeConfig},
368 graphics::{Color, CursorKind, Modifier, Rect, Style},
369 input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind},
370 keyboard::{KeyCode, KeyModifiers},
371@@ -74,6 +75,7 @@ pub fn spinners_mut(&mut self) -> &mut ProgressSpinners {
372 &mut self.spinners
373 }
374
375+ #[allow(clippy::too_many_lines)]
376 pub fn render_view(
377 &self,
378 doc_id: DocumentId,
379@@ -99,6 +101,10 @@ pub fn render_view(
380 let text_annotations = view.text_annotations(doc, Some(theme));
381 let mut decorations = DecorationManager::default();
382
383+ if config.breadcrumb.enable {
384+ Self::render_breadcrumb(editor, doc, view, area.with_height(1), surface);
385+ }
386+
387 if is_focused && config.cursorline {
388 decorations.add_decoration(Self::cursorline(doc, view, theme));
389 }
390@@ -729,6 +735,135 @@ pub fn render_bufferline(editor: &Editor, viewport: Rect, surface: &mut Surface)
391 }
392 }
393
394+ #[allow(clippy::too_many_lines)]
395+ pub fn render_breadcrumb(
396+ editor: &Editor,
397+ doc: &Document,
398+ view: &View,
399+ viewport: Rect,
400+ surface: &mut Surface,
401+ ) {
402+ use helix_view::editor::BreadcrumbPathOptions::{File, Full};
403+
404+ #[inline]
405+ #[must_use]
406+ fn draw_element(
407+ surface: &mut Surface,
408+ viewport: Rect,
409+ x: u16,
410+ content: &str,
411+ style: Style,
412+ ) -> u16 {
413+ let remaining = viewport.right().saturating_sub(x) as usize;
414+ surface
415+ .set_stringn(x, viewport.y, content, remaining, style)
416+ .0
417+ }
418+
419+ let config = editor.config();
420+
421+ let style = editor
422+ .theme
423+ .try_get_exact("ui.breadcrumb")
424+ .unwrap_or_else(|| editor.theme.get("ui.text"));
425+
426+ surface.clear_with(viewport, style);
427+
428+ let mut x = viewport.x.saturating_add(1);
429+
430+ let separator = " > ";
431+ let separator_style = editor.theme.get("ui.breadcrumb.separator");
432+ let mut draw_separator = false;
433+
434+ if matches!(config.breadcrumb.path, Full | File) {
435+ if let Some(path) = doc.relative_path() {
436+ let mut components = path.components().peekable();
437+
438+ if matches!(config.breadcrumb.path, File) {
439+ // Skip until the filename component.
440+ //
441+ // NOTE:
442+ // We could use `next_back` to get this element directly, but
443+ // this keeps our logic below simpler, with the logic for `Full`
444+ // and `File` being the same below this point.
445+ //
446+ // PERF: The `clone` is cheap; should be equivalent to a `Copy`.
447+ while components.clone().nth(1).is_some() {
448+ components.next();
449+ }
450+ }
451+
452+ while let Some(component) = components.next() {
453+ if draw_separator {
454+ x = draw_element(surface, viewport, x, separator, separator_style);
455+ } else {
456+ draw_separator = true;
457+ }
458+
459+ let segment = component.as_os_str().to_string_lossy();
460+ let is_directory = components.peek().is_some();
461+
462+ let style = if is_directory {
463+ editor.theme.get("ui.text.directory")
464+ } else {
465+ style
466+ };
467+
468+ x = draw_element(surface, viewport, x, &segment, style);
469+ }
470+ } else {
471+ // Handle `[scratch]`
472+ x = draw_element(surface, viewport, x, SCRATCH_BUFFER_NAME, style);
473+ }
474+ }
475+
476+ // Draw symbols, if any.
477+ if let Some(breadcrumb) = doc.breadcrumbs.get(&view.id) {
478+ for symbol in breadcrumb.iter() {
479+ if draw_separator {
480+ x = draw_element(surface, viewport, x, separator, separator_style);
481+ } else {
482+ draw_separator = true;
483+ }
484+
485+ let style = match symbol.kind {
486+ SymbolKind::MODULE
487+ | SymbolKind::NAMESPACE
488+ | SymbolKind::PACKAGE => editor.theme.get("namespace"),
489+
490+ SymbolKind::OBJECT // impl Block
491+ | SymbolKind::STRUCT
492+ | SymbolKind::INTERFACE
493+ | SymbolKind::CLASS => editor.theme.get("type"),
494+
495+ SymbolKind::METHOD => editor.theme.get("function.method"),
496+ SymbolKind::FUNCTION => editor.theme.get("function"),
497+
498+ SymbolKind::ENUM => editor.theme.get("type.enum"),
499+ SymbolKind::ENUM_MEMBER => editor.theme.get("type.enum.variant"),
500+
501+ SymbolKind::FIELD | SymbolKind::PROPERTY => {
502+ editor.theme.get("variable.other.member")
503+ }
504+
505+ SymbolKind::VARIABLE => editor.theme.get("variable"),
506+ SymbolKind::CONSTANT => editor.theme.get("constant"),
507+ SymbolKind::CONSTRUCTOR => editor.theme.get("constructor"),
508+ SymbolKind::STRING => editor.theme.get("string"),
509+ SymbolKind::NUMBER => editor.theme.get("constant.numeric"),
510+ SymbolKind::BOOLEAN => editor.theme.get("constant.builtin.boolean"),
511+ SymbolKind::ARRAY => editor.theme.get("punctuation.bracket"),
512+ SymbolKind::KEY => editor.theme.get("label"),
513+ SymbolKind::NULL => editor.theme.get("constant.builtin"),
514+ SymbolKind::TYPE_PARAMETER => editor.theme.get("type.parameter"),
515+ _ => style,
516+ };
517+
518+ x = draw_element(surface, viewport, x, symbol.name.as_ref(), style);
519+ }
520+ }
521+ }
522+
523 pub fn render_gutter<'d>(
524 editor: &'d Editor,
525 doc: &'d Document,
526@@ -1644,7 +1779,6 @@ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
527 let config = cx.editor.config();
528
529 // check if bufferline should be rendered
530- use helix_view::editor::BufferLine;
531 let use_bufferline = match config.bufferline {
532 BufferLine::Always => true,
533 BufferLine::Multiple if cx.editor.documents.len() > 1 => true,
534diff --git a/helix-term/tests/test/commands/write.rs b/helix-term/tests/test/commands/write.rs
535index fa6c97c5..f8fc6c5d 100644
536--- a/helix-term/tests/test/commands/write.rs
537+++ b/helix-term/tests/test/commands/write.rs
538@@ -284,7 +284,7 @@ async fn test_write_quit() -> anyhow::Result<()> {
539 async fn test_write_concurrent() -> anyhow::Result<()> {
540 let mut file = tempfile::NamedTempFile::new()?;
541 let mut command = String::new();
542- const RANGE: RangeInclusive<i32> = 1..=1000;
543+ const RANGE: RangeInclusive<i32> = 1..=100;
544 let mut app = helpers::AppBuilder::new()
545 .with_file(file.path(), None)
546 .build()?;
547diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs
548index 17d4002a..b17fd230 100644
549--- a/helix-view/src/document.rs
550+++ b/helix-view/src/document.rs
551@@ -13,6 +13,7 @@
552 use helix_core::syntax::config::LanguageServerFeature;
553 use helix_core::text_annotations::{InlineAnnotation, Overlay};
554 use helix_event::TaskController;
555+use helix_lsp::lsp::DocumentSymbol;
556 use helix_lsp::util::lsp_pos_to_pos;
557 use helix_stdx::faccess::{copy_metadata, readonly};
558 use helix_vcs::{DiffHandle, DiffProviderRegistry};
559@@ -149,15 +150,22 @@ pub struct Document {
560 ///
561 /// To know if they're up-to-date, check the `id` field in `DocumentInlayHints`.
562 pub(crate) inlay_hints: HashMap<ViewId, DocumentInlayHints>,
563+ /// Set to `true` when the document is updated, reset to `false` on the next inlay hints
564+ /// update from the LSP
565+ pub inlay_hints_oudated: bool,
566+
567 /// Jump label overlays for each view.
568 pub(crate) jump_labels: HashMap<ViewId, Vec<Overlay>>,
569+
570 /// LSP document highlights for each view, stored as char ranges.
571 pub(crate) document_highlights: HashMap<ViewId, DocumentHighlights>,
572 /// LSP code action hints for each view.
573 pub(crate) code_action_hints: HashSet<ViewId>,
574- /// Set to `true` when the document is updated, reset to `false` on the next inlay hints
575- /// update from the LSP
576- pub inlay_hints_oudated: bool,
577+
578+ // Stores all the symbols of the Document.
579+ pub(crate) symbols: Option<DocumentSymbolCache>,
580+ /// Breadcrumb trail for each view showing this document.
581+ pub breadcrumbs: HashMap<ViewId, Breadcrumbs>,
582
583 path: Option<PathBuf>,
584 relative_path: OnceCell<Option<PathBuf>>,
585@@ -234,6 +242,7 @@ pub struct Document {
586 pub code_action_controllers: HashMap<ViewId, TaskController>,
587 pub pull_diagnostic_controller: TaskController,
588 pub document_link_controller: TaskController,
589+ pub document_symbols_controller: TaskController,
590
591 // NOTE: this field should eventually go away - we should use the Editor's syn_loader instead
592 // of storing a copy on every doc. Then we can remove the surrounding `Arc` and use the
593@@ -241,6 +250,83 @@ pub struct Document {
594 syn_loader: Arc<ArcSwap<syntax::Loader>>,
595 }
596
597+pub struct DocumentSymbolCache {
598+ pub tree: Vec<ThinDocumentSymbol>,
599+ pub offset_encoding: OffsetEncoding,
600+}
601+
602+#[derive(Debug, Clone)]
603+pub struct ThinDocumentSymbol {
604+ pub name: Box<str>,
605+ pub kind: lsp::SymbolKind,
606+ pub range: lsp::Range,
607+ pub children: Option<Box<[Self]>>,
608+}
609+
610+impl From<DocumentSymbol> for ThinDocumentSymbol {
611+ #[inline]
612+ fn from(symbol: DocumentSymbol) -> Self {
613+ Self {
614+ name: symbol.name.into(),
615+ kind: symbol.kind,
616+ range: symbol.range,
617+ children: symbol.children.map(|children| {
618+ let mut vec = Vec::with_capacity(children.len());
619+ vec.extend(children.into_iter().map(Self::from));
620+ vec.into_boxed_slice()
621+ }),
622+ }
623+ }
624+}
625+
626+#[derive(Debug, Clone, Default)]
627+pub struct Breadcrumbs(Vec<Crumb>);
628+
629+impl Breadcrumbs {
630+ #[inline]
631+ pub fn push(&mut self, crumb: Crumb) {
632+ self.0.push(crumb);
633+ }
634+
635+ #[inline]
636+ pub fn clear(&mut self) {
637+ self.0.clear();
638+ }
639+
640+ #[inline]
641+ #[must_use]
642+ pub const fn is_empty(&self) -> bool {
643+ self.0.is_empty()
644+ }
645+
646+ #[inline]
647+ #[must_use]
648+ pub const fn depth(&self) -> usize {
649+ self.0.len()
650+ }
651+
652+ #[inline]
653+ pub fn iter(&self) -> impl Iterator<Item = &Crumb> {
654+ self.0.iter()
655+ }
656+}
657+
658+#[derive(Debug, Clone)]
659+pub struct Crumb {
660+ pub name: Box<str>,
661+ pub kind: lsp::SymbolKind,
662+}
663+
664+impl From<&ThinDocumentSymbol> for Crumb {
665+ #[inline]
666+ fn from(symbol: &ThinDocumentSymbol) -> Self {
667+ Self {
668+ name: symbol.name.clone(),
669+ kind: symbol.kind,
670+ }
671+ }
672+}
673+
674 #[derive(Debug, Clone, Default)]
675 pub struct DocumentColorSwatches {
676 pub color_swatches: Vec<InlineAnnotation>,
677@@ -720,7 +806,7 @@ fn take_with<T, F>(mut_ref: &mut T, f: F)
678 *mut_ref = f(mem::take(mut_ref));
679 }
680
681-use helix_lsp::{lsp, Client, LanguageServerId, LanguageServerName};
682+use helix_lsp::{lsp, Client, LanguageServerId, LanguageServerName, OffsetEncoding};
683 use helix_stdx::Url;
684
685 impl Document {
686@@ -783,6 +869,9 @@ pub fn from(
687 previous_diagnostic_ids: HashMap::new(),
688 pull_diagnostic_controller: TaskController::new(),
689 document_link_controller: TaskController::new(),
690+ symbols: None,
691+ document_symbols_controller: TaskController::new(),
692+ breadcrumbs: HashMap::new(),
693 }
694 }
695
696@@ -1440,6 +1529,10 @@ pub fn ensure_view_init(&mut self, view_id: ViewId) {
697 }
698
699 self.view_data_mut(view_id);
700+
701+ if self.config.load().breadcrumb.enable {
702+ self.update_breadcrumbs_for_view(view_id);
703+ }
704 }
705
706 /// Mark document as recent used for MRU sorting
707@@ -1447,12 +1540,13 @@ pub fn mark_as_focused(&mut self) {
708 self.focused_at = std::time::Instant::now();
709 }
710
711- /// Remove a view's selection and inlay hints from this document.
712+ /// Remove any views' data and state that is stored in the `Document`.
713 pub fn remove_view(&mut self, view_id: ViewId) {
714 self.selections.remove(&view_id);
715 self.view_data.remove(&view_id);
716 self.inlay_hints.remove(&view_id);
717 self.jump_labels.remove(&view_id);
718+ self.breadcrumbs.remove(&view_id);
719 self.document_highlights.remove(&view_id);
720 self.document_highlight_controllers.remove(&view_id);
721 self.code_action_hints.remove(&view_id);
722@@ -2446,6 +2540,103 @@ pub fn remove_jump_labels(&mut self, view_id: ViewId) {
723 self.jump_labels.remove(&view_id);
724 }
725
726+ #[inline(always)]
727+ pub fn set_document_symbols(
728+ &mut self,
729+ symbols: Vec<DocumentSymbol>,
730+ offset_encoding: OffsetEncoding,
731+ ) {
732+ self.symbols = Some(DocumentSymbolCache {
733+ tree: {
734+ let mut tree = Vec::with_capacity(symbols.len());
735+ tree.extend(symbols.into_iter().map(ThinDocumentSymbol::from));
736+ tree
737+ },
738+ offset_encoding,
739+ });
740+ }
741+
742+ #[inline]
743+ pub fn clear_document_symbols(&mut self) {
744+ self.symbols = None;
745+ self.clear_breadcrumbs();
746+ }
747+
748+ #[inline]
749+ pub fn clear_breadcrumbs(&mut self) {
750+ self.breadcrumbs.clear();
751+ }
752+
753+ // For all non-hotpaths, we use this function to prevent code bloat.
754+ #[inline(never)]
755+ pub fn update_breadcrumbs_for_view(&mut self, view_id: ViewId) {
756+ self.update_breadcrumbs_for_view_inlined(view_id);
757+ }
758+
759+ // We want to make sure this is inlined in the hotpath (cursor position change).
760+ #[inline(always)]
761+ pub fn update_breadcrumbs_for_view_inlined(&mut self, view_id: ViewId) {
762+ #[inline(always)]
763+ const fn in_range(pos: lsp::Position, range: lsp::Range) -> bool {
764+ // PERF:
765+ // Line-based filtering is the most effective early exit indicator,
766+ // so do first, before other evaluations; this should be friendly to
767+ // the CPU branch predictor.
768+ if pos.line < range.start.line || pos.line > range.end.line {
769+ return false;
770+ }
771+
772+ // Check if the cursor position is "in" the symbols "depth".
773+ //
774+ // In the context of breadcrumbs, this would be the difference between
775+ // if the cursor is in an impl block or in an impl block and in a
776+ // function of the impl block (`|` is the cursor):
777+ //
778+ // ```rust
779+ // impl Foo {
780+ // f|n bar() {} // In `bar`: impl Foo > bar
781+ //
782+ // | fn baz() {} // Not in `baz`: impl Foo
783+ //
784+ // fn quux() {} | // Not in `quux`: impl Foo
785+ // }
786+ // ```
787+ if pos.line == range.start.line && pos.character < range.start.character {
788+ return false;
789+ }
790+ if pos.line == range.end.line && pos.character > range.end.character {
791+ return false;
792+ }
793+
794+ true
795+ }
796+
797+ let Some(symbols) = self.symbols.as_ref() else {
798+ return;
799+ };
800+
801+ let position = self.position(view_id, symbols.offset_encoding);
802+
803+ let breadcrumb = {
804+ let breadcrumb = self.breadcrumbs.entry(view_id).or_default();
805+ breadcrumb.clear();
806+ breadcrumb
807+ };
808+
809+ let mut current = symbols.tree.as_slice();
810+
811+ while let Some(symbol) = current
812+ .iter()
813+ .find(|&symbol| in_range(position, symbol.range))
814+ {
815+ breadcrumb.push(Crumb::from(symbol));
816+ match symbol.children.as_deref() {
817+ Some(children) => current = children,
818+ _ => break,
819+ }
820+ }
821+ }
822+
823 pub fn set_document_highlights(
824 &mut self,
825 view_id: ViewId,
826diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs
827index 3bfe5d7f..263a8542 100644
828--- a/helix-view/src/editor.rs
829+++ b/helix-view/src/editor.rs
830@@ -385,6 +385,8 @@ pub struct Config {
831 pub whitespace: WhitespaceConfig,
832 /// Persistently display open buffers along the top
833 pub bufferline: BufferLine,
834+ /// Persistently display breadcrumb along the top of each view, below any bufferline.
835+ pub breadcrumb: BreadcrumbConfig,
836 /// Vertical indent width guides.
837 pub indent_guides: IndentGuidesConfig,
838 /// Whether to color modes with different colors. Defaults to `false`.
839@@ -518,6 +520,23 @@ pub fn code_action_hint(&self) -> bool {
840 }
841 }
842
843+#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Clone, Copy)]
844+#[serde(rename_all = "kebab-case")]
845+pub struct BreadcrumbConfig {
846+ pub enable: bool,
847+ #[serde(default)]
848+ pub path: BreadcrumbPathOptions,
849+}
850+
851+#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Clone, Copy)]
852+#[serde(rename_all = "kebab-case")]
853+pub enum BreadcrumbPathOptions {
854+ #[default]
855+ Full,
856+ File,
857+ None,
858+}
859+
860 #[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Clone, Copy)]
861 #[serde(rename_all = "kebab-case")]
862 pub struct BufferPickerConfig {
863@@ -1267,6 +1286,7 @@ fn default() -> Self {
864 rulers: Vec::new(),
865 whitespace: WhitespaceConfig::default(),
866 bufferline: BufferLine::default(),
867+ breadcrumb: BreadcrumbConfig::default(),
868 indent_guides: IndentGuidesConfig::default(),
869 color_modes: false,
870 soft_wrap: SoftWrap {
871@@ -1881,6 +1901,7 @@ pub fn refresh_doc_language(&mut self, doc_id: DocumentId) {
872 let diagnostics = Editor::doc_diagnostics(&self.language_servers, &self.diagnostics, doc);
873 doc.replace_diagnostics(diagnostics, &[], None);
874 doc.reset_all_inlay_hints();
875+ doc.clear_document_symbols();
876 }
877
878 /// Launch a language server for a given document
879diff --git a/helix-view/src/graphics.rs b/helix-view/src/graphics.rs
880index 05483528..67281821 100644
881--- a/helix-view/src/graphics.rs
882+++ b/helix-view/src/graphics.rs
883@@ -200,6 +200,12 @@ pub fn clip_bottom(self, height: u16) -> Rect {
884 }
885 }
886
887+ #[inline]
888+ pub fn with_y(self, y: u16) -> Rect {
889+ // new y may make area > u16::max_value, so use new()
890+ Self::new(self.x, y, self.width, self.height)
891+ }
892+
893 pub fn with_height(self, height: u16) -> Rect {
894 // new height may make area > u16::max_value, so use new()
895 Self::new(self.x, self.y, self.width, height)
896diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs
897index 176890ca..cde457c2 100644
898--- a/helix-view/src/gutter.rs
899+++ b/helix-view/src/gutter.rs
900@@ -242,9 +242,10 @@ pub fn breakpoints<'doc>(
901
902 let breakpoints = doc.path().and_then(|path| editor.breakpoints.get(path));
903
904- let breakpoints = match breakpoints {
905- Some(breakpoints) => breakpoints,
906- None => return Box::new(move |_, _, _, _| None),
907+ let breadcrumbs_enabled = editor.config().breadcrumb.enable;
908+
909+ let Some(breakpoints) = breakpoints else {
910+ return Box::new(move |_, _, _, _| None);
911 };
912
913 Box::new(
914@@ -254,7 +255,7 @@ pub fn breakpoints<'doc>(
915 }
916 let breakpoint = breakpoints
917 .iter()
918- .find(|breakpoint| breakpoint.line == line)?;
919+ .find(|breakpoint| breakpoint.line == line + usize::from(breadcrumbs_enabled))?;
920
921 let style = if breakpoint.condition.is_some() && breakpoint.log_message.is_some() {
922 error.underline_style(UnderlineStyle::Line)
923diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs
924index d211d8e6..c0d72a3c 100644
925--- a/helix-view/src/view.rs
926+++ b/helix-view/src/view.rs
927@@ -208,11 +208,18 @@ pub fn add_to_history(&mut self, id: DocumentId) {
928 }
929
930 pub fn inner_area(&self, doc: &Document) -> Rect {
931- self.area.clip_left(self.gutter_offset(doc)).clip_bottom(1) // -1 for statusline
932+ self.area
933+ .clip_top(self.breadcrumb_offset(doc))
934+ .clip_left(self.gutter_offset(doc))
935+ .clip_bottom(1) // -1 for statusline
936 }
937
938- pub fn inner_height(&self) -> usize {
939- self.area.clip_bottom(1).height.into() // -1 for statusline
940+ pub fn inner_height(&self, doc: &Document) -> usize {
941+ self.area
942+ .clip_top(self.breadcrumb_offset(doc))
943+ .clip_bottom(1) // -1 for statusline
944+ .height
945+ .into()
946 }
947
948 pub fn inner_width(&self, doc: &Document) -> u16 {
949@@ -237,7 +244,10 @@ pub fn gutter_offset(&self, doc: &Document) -> u16 {
950 }
951 }
952
953- //
954+ pub fn breadcrumb_offset(&self, doc: &Document) -> u16 {
955+ u16::from(doc.config.load().breadcrumb.enable)
956+ }
957+
958 pub fn offset_coords_to_in_view(
959 &self,
960 doc: &Document,
961@@ -378,7 +388,7 @@ pub fn estimate_last_doc_line(&self, doc: &Document) -> usize {
962 let doc_text = doc.text().slice(..);
963 let line = doc_text.char_to_line(doc.view_offset(self.id).anchor.min(doc_text.len_chars()));
964 // Saturating subs to make it inclusive zero indexing.
965- (line + self.inner_height())
966+ (line + self.inner_height(doc))
967 .min(doc_text.len_lines())
968 .saturating_sub(1)
969 }