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     }