Commit bfc17d6

HPCesia <me@hpcesia.com>
2026-10-17 14:23:07
helix: apply some patches from unmerged PRs
Assisted-by: crush:deepseek-v4.1-flash
1 parent 3cd7f8c
modules/dev/editor/helix/patches/11700-search-position.patch
@@ -0,0 +1,403 @@
+diff --git a/book/src/editor.md b/book/src/editor.md
+index d2b5016b..b7700866 100644
+--- a/book/src/editor.md
++++ b/book/src/editor.md
+@@ -173,6 +173,7 @@ ### `[editor.statusline]` Section
+ | `diagnostics` | The number of warnings and/or errors |
+ | `workspace-diagnostics` | The number of warnings and/or errors on workspace |
+ | `selections` | The primary selection index out of the number of active selections |
++| `search-position` | Current search match and total matches in the view `[<current>/<total>]` |
+ | `primary-selection-length` | The number of characters currently in primary selection |
+ | `position` | The cursor position |
+ | `position-percentage` | The cursor position as a percentage of the total number of lines |
+@@ -349,6 +350,7 @@ ### `[editor.search]` Section
+ |--|--|---------|
+ | `smart-case` | Enable smart case regex searching (case-insensitive unless pattern contains upper case characters) | `true` |
+ | `wrap-around`| Whether the search should wrap after depleting the matches | `true` |
++| `max-matches`| Maximum number of matches that will be counted for the denominator of `search-position`. Possible values are integers or `"none"` for no limit. | `100` |
+ 
+ ### `[editor.whitespace]` Section
+ 
+diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
+index cd11400c..507cb75f 100644
+--- a/helix-term/src/commands.rs
++++ b/helix-term/src/commands.rs
+@@ -49,8 +49,8 @@
+     Selection, SmallVec, Syntax, Tendril, Transaction,
+ };
+ use helix_view::{
+-    document::{FormatterError, Mode, SCRATCH_BUFFER_NAME},
+-    editor::{Action, Motion},
++    document::{FormatterError, Mode, SearchMatch, SearchMatchLimit, SCRATCH_BUFFER_NAME},
++    editor::{Action, Motion, OptionToml, SearchConfig},
+     expansion,
+     info::Info,
+     input::KeyEvent,
+@@ -2276,12 +2276,13 @@ fn search_impl(
+     movement: Movement,
+     direction: Direction,
+     scrolloff: usize,
+-    wrap_around: bool,
++    search_config: &SearchConfig,
+     show_warnings: bool,
+ ) {
+     let (view, doc) = current!(editor);
+     let text = doc.text().slice(..);
+     let selection = doc.selection(view.id);
++    let wrap_around = search_config.wrap_around;
+ 
+     // Get the right side of the primary block cursor for forward search, or the
+     // grapheme before the start of the selection for reverse search.
+@@ -2302,54 +2303,126 @@ fn search_impl(
+     // it out, we need to add it back to the position of the selection.
+     let doc = doc!(editor).text().slice(..);
+ 
+-    // use find_at to find the next match after the cursor, loop around the end
++    let mut all_matches = regex.find_iter(doc.regex_input()).enumerate().peekable();
++
++    if all_matches.peek().is_none() {
++        if show_warnings {
++            editor.set_error("No matches");
++        }
++        return;
++    }
++
++    // We will get the number of the current match and total matches from
++    // `all_matches`. So, here we look in the iterator until we find exactly
++    // the match we were looking for (either after or behind `start`). In the
++    // `Backward` case, in particular, we need to look the match ahead to know
++    // if this is the one we need.
++
+     // Careful, `Regex` uses `bytes` as offsets, not character indices!
+     let mut mat = match direction {
+-        Direction::Forward => regex.find(doc.regex_input_at_bytes(start..)),
+-        Direction::Backward => regex.find_iter(doc.regex_input_at_bytes(..start)).last(),
++        Direction::Forward => all_matches.by_ref().find(|&(_, m)| m.start() >= start),
++        Direction::Backward => {
++            let one_behind = std::iter::once(None).chain(all_matches.by_ref().map(Some));
++            one_behind
++                .zip(regex.find_iter(doc.regex_input()))
++                .find(|&(_, m1)| m1.start() >= start)
++                .map(|(m0, _)| m0)
++                .unwrap_or(None)
++        }
+     };
+ 
+-    if mat.is_none() {
+-        if wrap_around {
+-            mat = match direction {
+-                Direction::Forward => regex.find(doc.regex_input()),
+-                Direction::Backward => regex.find_iter(doc.regex_input_at_bytes(start..)).last(),
+-            };
++    if mat.is_none() && !wrap_around {
++        if show_warnings {
++            editor.set_error("No more matches");
+         }
++        return;
++    }
++
++    // If we didn't find a match before, lets wrap the search.
++    if mat.is_none() {
+         if show_warnings {
+-            if wrap_around && mat.is_some() {
+-                editor.set_status("Wrapped around document");
++            editor.set_status("Wrapped around document");
++        }
++
++        let doc = doc!(editor).text().slice(..);
++        all_matches = regex.find_iter(doc.regex_input()).enumerate().peekable();
++        mat = match direction {
++            Direction::Forward => all_matches.by_ref().next(),
++            Direction::Backward => all_matches.by_ref().last(),
++        };
++    }
++
++    let (idx, mat) = mat.unwrap();
++    let match_count = match search_config.max_matches {
++        OptionToml::None => match all_matches.last() {
++            None => SearchMatchLimit::Limitless(idx + 1),
++            Some((last_idx, _)) => SearchMatchLimit::Limitless(last_idx + 1),
++        },
++        OptionToml::Some(max) => {
++            if all_matches.peek().is_none() {
++                // Case #1: If we consumed `all_matches`, it means that we have
++                // the last match in `mat`. Hence, we know exactly how many
++                // matches there are. To respect the `max` option, if it goes
++                // beyong `max`, we limit the counter to `max`.
++                if idx + 1 > max {
++                    SearchMatchLimit::Limited(max)
++                } else {
++                    SearchMatchLimit::Limitless(idx + 1)
++                }
+             } else {
+-                editor.set_error("No more matches");
++                // Case #2: If we are here, we have at least one match in
++                // `all_matches`. We need to find the last match that's
++                // less than `max`. If we find it, we simply return it as a
++                // `Limitless` denominator because it doesn't go beyong the
++                // user option. The two remaining cases are `last_idx == max`
++                // and `None` (when the remaining matches are all greater than
++                // `max`) for which we return a `Limited` denominator.
++                match all_matches.take_while(|(idx, _)| idx <= &max).last() {
++                    Some((last_idx, _)) if last_idx < max => {
++                        SearchMatchLimit::Limitless(last_idx + 1)
++                    }
++                    _ => SearchMatchLimit::Limited(max),
++                }
+             }
+         }
+-    }
++    };
+ 
++    // Move the cursor to the match.
+     let (view, doc) = current!(editor);
+     let text = doc.text().slice(..);
+     let selection = doc.selection(view.id);
+ 
+-    if let Some(mat) = mat {
+-        let start = text.byte_to_char(mat.start());
+-        let end = text.byte_to_char(mat.end());
++    let start = text.byte_to_char(mat.start());
++    let end = text.byte_to_char(mat.end());
+ 
+-        if end == 0 {
+-            // skip empty matches that don't make sense
+-            return;
+-        }
+-
+-        // Determine range direction based on the primary range
+-        let primary = selection.primary();
+-        let range = Range::new(start, end).with_direction(primary.direction());
++    if end == 0 {
++        // skip empty matches that don't make sense
++        return;
++    }
+ 
+-        let selection = match movement {
+-            Movement::Extend => selection.clone().push(range),
+-            Movement::Move => selection.clone().replace(selection.primary_index(), range),
+-        };
++    // Determine range direction based on the primary range
++    let primary = selection.primary();
++    let range = Range::new(start, end).with_direction(primary.direction());
+ 
+-        doc.set_selection(view.id, selection);
+-        view.ensure_cursor_in_view_center(doc, scrolloff);
++    let selection = match movement {
++        Movement::Extend => selection.clone().push(range),
++        Movement::Move => selection.clone().replace(selection.primary_index(), range),
+     };
++
++    doc.set_selection(view.id, selection);
++    view.ensure_cursor_in_view_center(doc, scrolloff);
++
++    // Set the index of this match and total number of matchs in the doc. It's
++    // important to set it after `set_selection` since that method resets the
++    // last match position.
++    let (view, doc) = current!(editor);
++    doc.set_last_search_match(
++        view.id,
++        SearchMatch {
++            idx: idx + 1,
++            count: match_count,
++        },
++    );
+ }
+ 
+ fn search_completions(cx: &mut Context, reg: Option<char>) -> Vec<String> {
+@@ -2373,7 +2446,6 @@ fn searcher(cx: &mut Context, direction: Direction) {
+     let reg = cx.register.unwrap_or('/');
+     let config = cx.editor.config();
+     let scrolloff = config.scrolloff;
+-    let wrap_around = config.search.wrap_around;
+     let movement = if cx.editor.mode() == Mode::Select {
+         Movement::Extend
+     } else {
+@@ -2406,7 +2478,7 @@ fn searcher(cx: &mut Context, direction: Direction) {
+                 movement,
+                 direction,
+                 scrolloff,
+-                wrap_around,
++                &config.search,
+                 false,
+             );
+         },
+@@ -2427,7 +2499,6 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir
+         } else {
+             false
+         };
+-        let wrap_around = search_config.wrap_around;
+         let is_crlf = doc!(cx.editor).line_ending == LineEnding::Crlf;
+         if let Ok(regex) = rope::RegexBuilder::new()
+             .syntax(
+@@ -2445,7 +2516,7 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir
+                     movement,
+                     direction,
+                     scrolloff,
+-                    wrap_around,
++                    search_config,
+                     true,
+                 );
+             }
+diff --git a/helix-term/src/commands/engine/steel/mod.rs b/helix-term/src/commands/engine/steel/mod.rs
+index 8a166638..a8f64986 100644
+--- a/helix-term/src/commands/engine/steel/mod.rs
++++ b/helix-term/src/commands/engine/steel/mod.rs
+@@ -3104,6 +3104,7 @@ fn search(&self, smart_case: bool, wrap_around: bool) {
+         app_config.editor.search = SearchConfig {
+             smart_case,
+             wrap_around,
++            ..SearchConfig::default()
+         };
+         self.store_config(app_config);
+     }
+diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs
+index acca850d..e71b57d2 100644
+--- a/helix-term/src/ui/statusline.rs
++++ b/helix-term/src/ui/statusline.rs
+@@ -3,7 +3,7 @@
+ use helix_lsp::lsp::DiagnosticSeverity;
+ use helix_view::document::DEFAULT_LANGUAGE_NAME;
+ use helix_view::{
+-    document::{Mode, SCRATCH_BUFFER_NAME},
++    document::{Mode, SearchMatch, SearchMatchLimit, SCRATCH_BUFFER_NAME},
+     graphics::Rect,
+     theme::Style,
+     Document, Editor,
+@@ -184,6 +184,7 @@ fn get_render_function<'a, F>(
+         helix_view::editor::StatusLineElement::Register => render_register,
+         helix_view::editor::StatusLineElement::CurrentWorkingDirectory => render_cwd,
+         helix_view::editor::StatusLineElement::CodeActionHint => render_code_action_hint,
++        helix_view::editor::StatusLineElement::SearchPosition => render_search_position,
+         #[cfg(feature = "steel")]
+         _ => unreachable!(),
+     }
+@@ -633,3 +634,17 @@ fn render_code_action_hint<'a, F>(context: &mut RenderContext<'a>, write: F)
+         write(context, " ⋮ ".into())
+     }
+ }
++
++fn render_search_position<'a, F>(context: &mut RenderContext<'a>, write: F)
++where
++    F: Fn(&mut RenderContext<'a>, Span<'a>) + Copy,
++{
++    let doc = get_doc(context);
++    if let Some(SearchMatch { idx, count }) = doc.get_last_search_match(context.view_id) {
++        let count_str = match count {
++            SearchMatchLimit::Limitless(count) => format!("{}", count),
++            SearchMatchLimit::Limited(max) => format!(">{}", max),
++        };
++        write(context, format!(" [{}/{}] ", idx, count_str).into());
++    }
++}
+diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs
+index b17fd230..6e5fbd98 100644
+--- a/helix-view/src/document.rs
++++ b/helix-view/src/document.rs
+@@ -139,12 +139,28 @@ pub enum DocumentOpenError {
+     IoError(#[from] io::Error),
+ }
+ 
++#[derive(Debug, Clone, Copy)]
++pub enum SearchMatchLimit {
++    Limitless(usize),
++    Limited(usize),
++}
++
++#[derive(Debug, Clone, Copy)]
++pub struct SearchMatch {
++    /// nth match from the beginning of the document.
++    pub idx: usize,
++    /// Total number of matches in the document.
++    pub count: SearchMatchLimit,
++}
++
+ pub struct Document {
+     pub(crate) id: DocumentId,
+     text: Rope,
+     selections: HashMap<ViewId, Selection>,
+     view_data: HashMap<ViewId, ViewData>,
+     pub active_snippet: Option<ActiveSnippet>,
++    /// Current search information.
++    last_search_match: HashMap<ViewId, SearchMatch>,
+ 
+     /// Inlay hints annotations for the document, by view.
+     ///
+@@ -832,6 +848,7 @@ pub fn from(
+             text,
+             selections: HashMap::default(),
+             inlay_hints: HashMap::default(),
++            last_search_match: HashMap::default(),
+             inlay_hints_oudated: false,
+             view_data: Default::default(),
+             indent_style: DEFAULT_INDENT,
+@@ -1494,6 +1511,8 @@ pub fn set_language_by_language_id(
+ 
+     /// Select text within the [`Document`].
+     pub fn set_selection(&mut self, view_id: ViewId, selection: Selection) {
++        self.last_search_match.remove(&view_id);
++
+         // TODO: use a transaction?
+         self.selections
+             .insert(view_id, selection.ensure_invariants(self.text().slice(..)));
+@@ -1503,6 +1522,14 @@ pub fn set_selection(&mut self, view_id: ViewId, selection: Selection) {
+         })
+     }
+ 
++    pub fn set_last_search_match(&mut self, view_id: ViewId, search_match: SearchMatch) {
++        self.last_search_match.insert(view_id, search_match);
++    }
++
++    pub fn get_last_search_match(&self, view_id: ViewId) -> Option<SearchMatch> {
++        self.last_search_match.get(&view_id).copied()
++    }
++
+     /// Find the origin selection of the text in a document, i.e. where
+     /// a single cursor would go if it were on the first grapheme. If
+     /// the text is empty, returns (0, 0).
+diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs
+index 263a8542..bfa9acd7 100644
+--- a/helix-view/src/editor.rs
++++ b/helix-view/src/editor.rs
+@@ -687,6 +687,14 @@ fn default() -> Self {
+     }
+ }
+ 
++#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
++#[serde(rename_all = "kebab-case", deny_unknown_fields)]
++pub enum OptionToml<T> {
++    None,
++    #[serde(untagged)]
++    Some(T),
++}
++
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+ #[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
+ pub struct SearchConfig {
+@@ -694,6 +702,8 @@ pub struct SearchConfig {
+     pub smart_case: bool,
+     /// Whether the search should wrap after depleting the matches. Default to true.
+     pub wrap_around: bool,
++    /// Maximum number of counted matches when searching in a document. `None` means no limit. Default to 100.
++    pub max_matches: OptionToml<usize>,
+ }
+ 
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+@@ -853,6 +863,9 @@ pub enum StatusLineElement {
+     /// Indicator for selected register
+     Register,
+ 
++    /// Search index and count
++    SearchPosition,
++
+     /// The base of current working directory
+     CurrentWorkingDirectory,
+ 
+@@ -1327,6 +1340,7 @@ fn default() -> Self {
+         Self {
+             wrap_around: true,
+             smart_case: true,
++            max_matches: OptionToml::Some(100),
+         }
+     }
+ }
modules/dev/editor/helix/patches/12173-clickable-bufferline.patch
@@ -0,0 +1,139 @@
+diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
+index 05cf7769..4f5081bf 100644
+--- a/helix-term/src/ui/editor.rs
++++ b/helix-term/src/ui/editor.rs
+@@ -43,6 +43,7 @@ pub struct EditorView {
+     pub(crate) last_insert: (commands::MappableCommand, Vec<InsertEvent>),
+     pub(crate) completion: Option<Completion>,
+     spinners: ProgressSpinners,
++    bufferline_info: BufferLineInfo,
+     /// Tracks if the terminal window is focused by reaction to terminal focus events
+     terminal_focused: bool,
+ }
+@@ -67,6 +68,7 @@ pub fn new(keymaps: Keymaps) -> Self {
+             last_insert: (commands::MappableCommand::normal_mode, Vec::new()),
+             completion: None,
+             spinners: ProgressSpinners::default(),
++            bufferline_info: BufferLineInfo::default(),
+             terminal_focused: true,
+         }
+     }
+@@ -683,7 +685,7 @@ pub fn tabstop_highlights(doc: &Document, theme: &Theme) -> Option<OverlayHighli
+     }
+ 
+     /// Render bufferline at the top
+-    pub fn render_bufferline(editor: &Editor, viewport: Rect, surface: &mut Surface) {
++    pub fn render_bufferline(&mut self, editor: &Editor, viewport: Rect, surface: &mut Surface) {
+         let scratch = PathBuf::from(SCRATCH_BUFFER_NAME); // default filename to use for scratch buffer
+         surface.clear_with(
+             viewport,
+@@ -706,6 +708,8 @@ pub fn render_bufferline(editor: &Editor, viewport: Rect, surface: &mut Surface)
+         let mut x = viewport.x;
+         let current_doc = view!(editor).doc;
+ 
++        self.bufferline_info.clear();
++
+         for doc in editor.documents() {
+             let fname = doc
+                 .path()
+@@ -725,9 +729,14 @@ pub fn render_bufferline(editor: &Editor, viewport: Rect, surface: &mut Surface)
+             let used_width = viewport.x.saturating_sub(x);
+             let rem_width = surface.area.width.saturating_sub(used_width);
+ 
++            let start_x = x;
+             x = surface
+                 .set_stringn(x, viewport.y, &text, rem_width as usize, style)
+                 .0;
++            let end_x = x.min(surface.area.right());
++
++            self.bufferline_info
++                .add_buffer_info(doc.id(), start_x..end_x);
+ 
+             if x >= surface.area.right() {
+                 break;
+@@ -1396,6 +1405,14 @@ fn handle_mouse_event(
+             MouseEventKind::Down(MouseButton::Left) => {
+                 let editor = &mut cxt.editor;
+ 
++                if is_bufferline_visible(editor) && row == 0 {
++                    if let Some(buffer_info) = self.bufferline_info.get_clicked_buffer(column) {
++                        editor.switch(buffer_info.document_id, helix_view::editor::Action::Replace);
++                    }
++
++                    return EventResult::Consumed(None);
++                }
++
+                 if let Some((pos, view_id)) = pos_and_view(editor, row, column, true) {
+                     editor.focus(view_id);
+ 
+@@ -1778,12 +1795,7 @@ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
+         surface.set_style(area, cx.editor.theme.get("ui.background"));
+         let config = cx.editor.config();
+ 
+-        // check if bufferline should be rendered
+-        let use_bufferline = match config.bufferline {
+-            BufferLine::Always => true,
+-            BufferLine::Multiple if cx.editor.documents.len() > 1 => true,
+-            _ => false,
+-        };
++        let use_bufferline = is_bufferline_visible(cx.editor);
+ 
+         let mut area = area;
+ 
+@@ -1811,7 +1823,7 @@ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
+         cx.editor.resize(editor_area);
+ 
+         if use_bufferline {
+-            Self::render_bufferline(cx.editor, area.with_height(1), surface);
++            self.render_bufferline(cx.editor, area.with_height(1), surface);
+         }
+ 
+         let views: Vec<(ViewId, bool)> = {
+@@ -1936,6 +1948,47 @@ fn cursor(&self, _area: Rect, editor: &Editor) -> (Option<Position>, CursorKind)
+     }
+ }
+ 
++#[derive(Debug, Default)]
++struct BufferLineInfo {
++    visible_buffers: Vec<BufferInfo>,
++}
++
++impl BufferLineInfo {
++    fn clear(&mut self) {
++        self.visible_buffers.clear();
++    }
++
++    fn add_buffer_info(&mut self, document_id: DocumentId, columns: std::ops::Range<u16>) {
++        self.visible_buffers.push(BufferInfo {
++            document_id,
++            columns,
++        });
++    }
++
++    fn get_clicked_buffer(&self, column: u16) -> Option<&BufferInfo> {
++        self.visible_buffers
++            .iter()
++            .find(|cell| cell.columns.contains(&column))
++    }
++}
++
++#[derive(Debug)]
++struct BufferInfo {
++    document_id: DocumentId,
++    // The bufferline column span used to show the document name
++    columns: std::ops::Range<u16>,
++}
++
++fn is_bufferline_visible(editor: &Editor) -> bool {
++    let config = editor.config();
++
++    match config.bufferline {
++        BufferLine::Always => true,
++        BufferLine::Multiple => editor.documents.len() > 1,
++        BufferLine::Never => false,
++    }
++}
++
+ fn canonicalize_key(key: &mut KeyEvent) {
+     if let KeyEvent {
+         code: KeyCode::Char(_),
modules/dev/editor/helix/patches/13113-unique-buffer-names.patch
@@ -0,0 +1,106 @@
+diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
+index 4f5081bf..57354e61 100644
+--- a/helix-term/src/ui/editor.rs
++++ b/helix-term/src/ui/editor.rs
+@@ -32,7 +32,13 @@
+     keyboard::{KeyCode, KeyModifiers},
+     Document, DocumentId, Editor, Theme, View, ViewId,
+ };
+-use std::{mem::take, num::NonZeroUsize, ops, path::PathBuf, rc::Rc};
++use std::{
++    mem::take,
++    num::NonZeroUsize,
++    ops,
++    path::{PathBuf, MAIN_SEPARATOR, MAIN_SEPARATOR_STR},
++    rc::Rc,
++};
+ 
+ use tui::{buffer::Buffer as Surface, text::Span};
+ 
+@@ -686,7 +692,6 @@ pub fn tabstop_highlights(doc: &Document, theme: &Theme) -> Option<OverlayHighli
+ 
+     /// Render bufferline at the top
+     pub fn render_bufferline(&mut self, editor: &Editor, viewport: Rect, surface: &mut Surface) {
+-        let scratch = PathBuf::from(SCRATCH_BUFFER_NAME); // default filename to use for scratch buffer
+         surface.clear_with(
+             viewport,
+             editor
+@@ -711,13 +716,7 @@ pub fn render_bufferline(&mut self, editor: &Editor, viewport: Rect, surface: &m
+         self.bufferline_info.clear();
+ 
+         for doc in editor.documents() {
+-            let fname = doc
+-                .path()
+-                .unwrap_or(&scratch)
+-                .file_name()
+-                .unwrap_or_default()
+-                .to_str()
+-                .unwrap_or_default();
++            let fname = Self::make_document_name(doc, editor);
+ 
+             let style = if current_doc == doc.id() {
+                 bufferline_active
+@@ -873,6 +872,63 @@ fn draw_element(
+         }
+     }
+ 
++    fn make_document_name(doc: &Document, editor: &Editor) -> String {
++        let scratch = PathBuf::from(SCRATCH_BUFFER_NAME); // default filename to use for scratch buffer
++
++        let paths: Vec<String> = editor
++            .documents()
++            .map(|d| {
++                d.path()
++                    .unwrap_or(&scratch)
++                    .to_str()
++                    .unwrap_or_default()
++                    .to_string()
++            })
++            .collect();
++
++        let components: Vec<Vec<String>> = paths
++            .iter()
++            .map(|p| p.split(MAIN_SEPARATOR).map(String::from).collect())
++            .collect();
++
++        let doc_path = doc
++            .path()
++            .unwrap_or(&scratch)
++            .to_str()
++            .unwrap_or_default()
++            .to_string();
++
++        let doc_index = paths.iter().position(|p| p == &doc_path).unwrap();
++        let doc_components_len = components[doc_index].len();
++
++        let mut k = 1;
++
++        loop {
++            let start = doc_components_len.saturating_sub(k);
++            let curr_doc: Vec<&str> = components[doc_index][start..]
++                .iter()
++                .map(|s| s.as_str())
++                .collect();
++
++            let count = components
++                .iter()
++                .enumerate()
++                .filter(|&(index, _)| index != doc_index)
++                .filter(|&(_, parts)| {
++                    let len = parts.len();
++                    let start = len.saturating_sub(k);
++                    parts[start..] == curr_doc
++                })
++                .count();
++
++            if count == 0 {
++                return curr_doc.join(MAIN_SEPARATOR_STR);
++            }
++
++            k += 1;
++        }
++    }
++
+     pub fn render_gutter<'d>(
+         editor: &'d Editor,
+         doc: &'d Document,
modules/dev/editor/helix/patches/15266-prompt-history-search.patch
@@ -0,0 +1,103 @@
+diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs
+index 926f33a3..f3124c63 100644
+--- a/helix-term/src/ui/prompt.rs
++++ b/helix-term/src/ui/prompt.rs
+@@ -44,6 +44,8 @@ pub struct Prompt {
+     selection: Option<usize>,
+     history_register: Option<char>,
+     history_pos: Option<usize>,
++    history_pattern: Option<String>,
++    user_edited: bool,
+     completion_fn: CompletionFn,
+     callback_fn: CallbackFn,
+     pub doc_fn: DocFn,
+@@ -100,6 +102,8 @@ pub fn new(
+             selection: None,
+             history_register,
+             history_pos: None,
++            history_pattern: None,
++            user_edited: true,
+             completion_fn: Box::new(completion_fn),
+             callback_fn: Box::new(callback_fn),
+             doc_fn: Box::new(|_| None),
+@@ -346,27 +350,40 @@ pub fn change_history(
+         register: char,
+         direction: CompletionDirection,
+     ) {
++        if self.line.is_empty() {
++            self.history_pattern = None;
++            self.history_pos = None;
++        } else if self.user_edited {
++            self.history_pattern = Some(self.line.clone());
++            self.history_pos = None;
++        }
+         (self.callback_fn)(cx, &self.line, PromptEvent::Abort);
+-        let mut values = match cx.editor.registers.read(register, cx.editor) {
+-            Some(values) if values.len() > 0 => values.rev(),
++
++        let values: Vec<_> = match cx.editor.registers.read(register, cx.editor) {
++            Some(values) if values.len() > 0 => {
++                if let Some(pattern) = &self.history_pattern {
++                    values.filter(|the| the.contains(pattern)).rev().collect()
++                } else {
++                    values.rev().collect()
++                }
++            }
+             _ => return,
+         };
++        if values.is_empty() {
++            return;
++        }
+ 
+         let end = values.len().saturating_sub(1);
+ 
+         let index = match direction {
+             CompletionDirection::Forward => self.history_pos.map_or(0, |i| i + 1),
+-            CompletionDirection::Backward => self
+-                .history_pos
+-                .unwrap_or_else(|| values.len())
+-                .saturating_sub(1),
++            CompletionDirection::Backward => {
++                self.history_pos.unwrap_or(values.len()).saturating_sub(1)
++            }
+         }
+         .min(end);
+ 
+-        self.line = values.nth(index).unwrap().to_string();
+-        // Appease the borrow checker.
+-        drop(values);
+-
++        self.line = values.into_iter().nth(index).unwrap().to_string();
+         self.history_pos = Some(index);
+ 
+         self.move_end();
+@@ -627,6 +644,7 @@ fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult {
+             compositor.remove(id);
+         })));
+ 
++        let mut user_edited = true;
+         match event {
+             ctrl!('c') | key!(Esc) => {
+                 (self.callback_fn)(cx, &self.line, PromptEvent::Abort);
+@@ -713,11 +731,13 @@ fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult {
+                 }
+             }
+             ctrl!('p') | key!(Up) => {
++                user_edited = false;
+                 if let Some(register) = self.history_register {
+                     self.change_history(cx, register, CompletionDirection::Backward);
+                 }
+             }
+             ctrl!('n') | key!(Down) => {
++                user_edited = false;
+                 if let Some(register) = self.history_register {
+                     self.change_history(cx, register, CompletionDirection::Forward);
+                 }
+@@ -765,6 +785,7 @@ fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult {
+             }
+             _ => (),
+         };
++        self.user_edited = user_edited;
+ 
+         EventResult::Consumed(None)
+     }
modules/dev/editor/helix/patches/15573-breadcrumb.patch
@@ -0,0 +1,969 @@
+diff --git a/book/src/editor.md b/book/src/editor.md
+index 872fa8a6..d2b5016b 100644
+--- a/book/src/editor.md
++++ b/book/src/editor.md
+@@ -1,6 +1,7 @@
+ ## Editor
+ 
+ - [`[editor]` Section](#editor-section)
++- [`[editor.breadcrumb]` Section](#editorbreadcrumb-section)
+ - [`[editor.clipboard-provider]` Section](#editorclipboard-provider-section)
+ - [`[editor.statusline]` Section](#editorstatusline-section)
+ - [`[editor.lsp]` Section](#editorlsp-section)
+@@ -73,6 +74,26 @@ ### `[editor]` Section
+ 
+ [^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).
+ 
++### `[editor.breadcrumb]` Section
++
++Allows configuration of the breadcrumb navigation bar.
++
++|  Setting |                 Description                     | Default |
++|----------|-------------------------------------------------|---------|
++| `enable` | Whether to enable the breadcrumb navigation bar | `false` |
++|  `path`  | Selecting how much path information is shown    | `"full"`|
++
++```toml
++[editor.breadcrumb]
++enable = true
++# full: helix-term > src > commands > typed.rs > TypeableCommand > name
++# file: typed.rs > TypeableCommand > name
++# none: TypeableCommand > name
++path = "full|file|none"
++```
++
++The bar is displayed at the top of the view.
++
+ ### `[editor.clipboard-provider]` Section
+ 
+ Helix can be configured either to use a builtin clipboard configuration or to use
+diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs
+index 08e95b8f..d7da020d 100644
+--- a/helix-lsp/src/client.rs
++++ b/helix-lsp/src/client.rs
+@@ -739,7 +739,7 @@ pub(crate) async fn initialize(&self, enable_snippets: bool) -> Result<lsp::Init
+                         symbol_kind: Some(lsp::SymbolKindCapability {
+                             value_set: Some(lsp::SymbolKind::all()),
+                         }),
+-                        hierarchical_document_symbol_support: Some(false),
++                        hierarchical_document_symbol_support: Some(true),
+                         ..Default::default()
+                     }),
+                     ..Default::default()
+diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
+index ef6953b8..cd11400c 100644
+--- a/helix-term/src/commands.rs
++++ b/helix-term/src/commands.rs
+@@ -1166,7 +1166,7 @@ fn goto_window(cx: &mut Context, align: Align) {
+     let (view, doc) = current!(cx.editor);
+     let view_offset = doc.view_offset(view.id);
+ 
+-    let height = view.inner_height();
++    let height = view.inner_height(doc);
+ 
+     // respect user given count if any
+     // - 1 so we have at least one gap in the middle.
+@@ -1957,7 +1957,7 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction, sync_cursor
+     let text = doc.text().slice(..);
+ 
+     let cursor = range.cursor(text);
+-    let height = view.inner_height();
++    let height = view.inner_height(doc);
+ 
+     let scrolloff = config.scrolloff.min(height.saturating_sub(1) / 2);
+     let offset = match direction {
+@@ -2058,50 +2058,50 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction, sync_cursor
+ }
+ 
+ fn page_up(cx: &mut Context) {
+-    let view = view!(cx.editor);
+-    let offset = view.inner_height();
++    let (view, doc) = current_ref!(cx.editor);
++    let offset = view.inner_height(doc);
+     scroll(cx, offset, Direction::Backward, false);
+ }
+ 
+ fn page_down(cx: &mut Context) {
+-    let view = view!(cx.editor);
+-    let offset = view.inner_height();
++    let (view, doc) = current_ref!(cx.editor);
++    let offset = view.inner_height(doc);
+     scroll(cx, offset, Direction::Forward, false);
+ }
+ 
+ fn half_page_up(cx: &mut Context) {
+-    let view = view!(cx.editor);
+-    let offset = view.inner_height() / 2;
++    let (view, doc) = current_ref!(cx.editor);
++    let offset = view.inner_height(doc) / 2;
+     scroll(cx, offset, Direction::Backward, false);
+ }
+ 
+ fn half_page_down(cx: &mut Context) {
+-    let view = view!(cx.editor);
+-    let offset = view.inner_height() / 2;
++    let (view, doc) = current_ref!(cx.editor);
++    let offset = view.inner_height(doc) / 2;
+     scroll(cx, offset, Direction::Forward, false);
+ }
+ 
+ fn page_cursor_up(cx: &mut Context) {
+-    let view = view!(cx.editor);
+-    let offset = view.inner_height();
++    let (view, doc) = current_ref!(cx.editor);
++    let offset = view.inner_height(doc);
+     scroll(cx, offset, Direction::Backward, true);
+ }
+ 
+ fn page_cursor_down(cx: &mut Context) {
+-    let view = view!(cx.editor);
+-    let offset = view.inner_height();
++    let (view, doc) = current_ref!(cx.editor);
++    let offset = view.inner_height(doc);
+     scroll(cx, offset, Direction::Forward, true);
+ }
+ 
+ fn page_cursor_half_up(cx: &mut Context) {
+-    let view = view!(cx.editor);
+-    let offset = view.inner_height() / 2;
++    let (view, doc) = current_ref!(cx.editor);
++    let offset = view.inner_height(doc) / 2;
+     scroll(cx, offset, Direction::Backward, true);
+ }
+ 
+ fn page_cursor_half_down(cx: &mut Context) {
+-    let view = view!(cx.editor);
+-    let offset = view.inner_height() / 2;
++    let (view, doc) = current_ref!(cx.editor);
++    let offset = view.inner_height(doc) / 2;
+     scroll(cx, offset, Direction::Forward, true);
+ }
+ 
+diff --git a/helix-term/src/commands/engine/steel/mod.rs b/helix-term/src/commands/engine/steel/mod.rs
+index 3ea5be1f..8a166638 100644
+--- a/helix-term/src/commands/engine/steel/mod.rs
++++ b/helix-term/src/commands/engine/steel/mod.rs
+@@ -5545,7 +5545,7 @@ pub fn add_inlay_hint(
+         let doc_text = doc.text();
+         let len_lines = doc_text.len_lines();
+ 
+-        let view_height = view.inner_height();
++        let view_height = view.inner_height(doc);
+         let first_visible_line =
+             doc_text.char_to_line(doc.view_offset(view_id).anchor.min(doc_text.len_chars()));
+         let first_line = first_visible_line.saturating_sub(view_height);
+@@ -5597,7 +5597,7 @@ pub fn remove_inlay_hint_by_id(
+         let doc_text = doc.text();
+         let len_lines = doc_text.len_lines();
+ 
+-        let view_height = view.inner_height();
++        let view_height = view.inner_height(doc);
+         let first_visible_line =
+             doc_text.char_to_line(doc.view_offset(view_id).anchor.min(doc_text.len_chars()));
+         let first_line = first_visible_line.saturating_sub(view_height);
+diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs
+index 93bf1e40..fc62cea4 100644
+--- a/helix-term/src/commands/lsp.rs
++++ b/helix-term/src/commands/lsp.rs
+@@ -1335,7 +1335,7 @@ fn compute_inlay_hints_for_view(
+     // will not show half the view with hints and half without while still being faster
+     // than computing all the hints for the full file (which could be dozens of time
+     // longer than the view is).
+-    let view_height = view.inner_height();
++    let view_height = view.inner_height(doc);
+     let first_visible_line =
+         doc_text.char_to_line(doc.view_offset(view_id).anchor.min(doc_text.len_chars()));
+     let first_line = first_visible_line.saturating_sub(view_height);
+diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs
+index 3cfed93f..6bd8da73 100644
+--- a/helix-term/src/commands/typed.rs
++++ b/helix-term/src/commands/typed.rs
+@@ -1918,6 +1918,7 @@ fn lsp_stop(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> any
+                 doc.clear_diagnostics_for_language_server(client.id());
+                 doc.reset_all_inlay_hints();
+                 doc.inlay_hints_oudated = true;
++                doc.clear_document_symbols();
+             }
+         }
+     }
+diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs
+index 46afc4ce..5f3dd22f 100644
+--- a/helix-term/src/handlers.rs
++++ b/helix-term/src/handlers.rs
+@@ -22,6 +22,7 @@
+ mod document_colors;
+ mod document_highlight;
+ mod document_links;
++mod document_symbols;
+ mod prompt;
+ mod signature_help;
+ mod snippet;
+@@ -57,6 +58,7 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
+     signature_help::register_hooks(&handlers);
+     document_highlight::register_hooks(&handlers);
+     code_action_hint::register_hooks(&handlers);
++    document_symbols::register_hooks(&handlers);
+     auto_save::register_hooks(&handlers);
+     diagnostics::register_hooks(&handlers);
+     snippet::register_hooks(&handlers);
+diff --git a/helix-term/src/handlers/document_symbols.rs b/helix-term/src/handlers/document_symbols.rs
+new file mode 100644
+index 00000000..629a9e5b
+--- /dev/null
++++ b/helix-term/src/handlers/document_symbols.rs
+@@ -0,0 +1,139 @@
++use crate::job;
++use helix_core::syntax::config::LanguageServerFeature;
++use helix_event::{cancelable_future, register_hook};
++use helix_lsp::lsp::DocumentSymbolResponse;
++use helix_view::{
++    events::{
++        ConfigDidChange, DocumentDidChange, DocumentDidOpen, LanguageServerExited,
++        LanguageServerInitialized, SelectionDidChange,
++    },
++    handlers::Handlers,
++    DocumentId, Editor,
++};
++
++fn request_document_symbols(editor: &mut Editor, doc_id: DocumentId) {
++    if !editor.config().breadcrumb.enable {
++        return;
++    }
++
++    let Some(doc) = editor.document_mut(doc_id) else {
++        return;
++    };
++
++    let Some(language_server) = doc
++        // Get the first LSP Server that supports `DocumentSymbols`.
++        .language_servers_with_feature(LanguageServerFeature::DocumentSymbols)
++        .next()
++    else {
++        return;
++    };
++
++    let offset_encoding = language_server.offset_encoding();
++    let Some(future) = language_server.document_symbols(doc.identifier()) else {
++        return;
++    };
++    let cancel = doc.document_symbols_controller.restart();
++
++    tokio::spawn(async move {
++        let Some(Ok(Some(response))) = cancelable_future(future, &cancel).await else {
++            return;
++        };
++
++        job::dispatch(move |editor, _| {
++            if let Some(doc) = editor.document_mut(doc_id) {
++                match response {
++                    DocumentSymbolResponse::Nested(symbols) => {
++                        doc.set_document_symbols(symbols, offset_encoding);
++                    }
++                    // TODO: Using the `Location`, it should be possible to map cursor
++                    // to a hierarchical tree?
++                    DocumentSymbolResponse::Flat(_) => {}
++                }
++            }
++        })
++        .await;
++    });
++}
++
++pub(super) fn register_hooks(_handlers: &Handlers) {
++    register_hook!(move |event: &mut DocumentDidOpen<'_>| {
++        let doc_id = event.doc;
++        let view_id = event.editor.tree.focus;
++        request_document_symbols(event.editor, doc_id);
++        if let Some(doc) = event.editor.document_mut(doc_id) {
++            doc.update_breadcrumbs_for_view(view_id);
++        }
++        Ok(())
++    });
++
++    register_hook!(move |event: &mut DocumentDidChange<'_>| {
++        if !event.ghost_transaction {
++            // Cancel the ongoing request, if present.
++            event.doc.document_symbols_controller.cancel();
++            let view_id = event.view;
++            let doc_id = event.doc.id();
++            job::dispatch_blocking(move |editor, _| {
++                request_document_symbols(editor, doc_id);
++                if let Some(doc) = editor.document_mut(doc_id) {
++                    doc.update_breadcrumbs_for_view(view_id);
++                }
++            });
++        }
++        Ok(())
++    });
++
++    register_hook!(move |event: &mut LanguageServerInitialized<'_>| {
++        let view_id = event.editor.tree.focus;
++        if let Some(view) = event.editor.tree.try_get(view_id) {
++            let doc_id = view.doc;
++            request_document_symbols(event.editor, doc_id);
++            if let Some(doc) = event.editor.document_mut(doc_id) {
++                doc.update_breadcrumbs_for_view(view_id);
++            }
++        }
++        Ok(())
++    });
++
++    register_hook!(move |event: &mut LanguageServerExited<'_>| {
++        for doc in event.editor.documents_mut() {
++            if doc.supports_language_server(event.server_id) {
++                doc.clear_document_symbols();
++            }
++        }
++        Ok(())
++    });
++
++    register_hook!(move |event: &mut ConfigDidChange<'_>| {
++        if !event.old.breadcrumb.enable && event.new.breadcrumb.enable {
++            let view_id = event.editor.tree.focus;
++            if let Some(view) = event.editor.tree.try_get(view_id) {
++                let doc_id = view.doc;
++                request_document_symbols(event.editor, doc_id);
++                if let Some(doc) = event.editor.document_mut(doc_id) {
++                    doc.update_breadcrumbs_for_view(view_id);
++                }
++            }
++            return Ok(());
++        }
++
++        if event.old.breadcrumb.enable && !event.new.breadcrumb.enable {
++            for doc in event.editor.documents_mut() {
++                doc.clear_document_symbols();
++            }
++        }
++
++        Ok(())
++    });
++
++    register_hook!(move |event: &mut SelectionDidChange<'_>| {
++        let doc_id = event.doc.id();
++        let view_id = event.view;
++
++        job::dispatch_blocking(move |editor, _| {
++            if let Some(doc) = editor.document_mut(doc_id) {
++                doc.update_breadcrumbs_for_view_inlined(view_id);
++            }
++        });
++        Ok(())
++    });
++}
+diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
+index f2f84123..05cf7769 100644
+--- a/helix-term/src/ui/editor.rs
++++ b/helix-term/src/ui/editor.rs
+@@ -22,10 +22,11 @@
+     unicode::width::UnicodeWidthStr,
+     visual_offset_from_block, Change, Position, Range, Selection, Transaction,
+ };
++use helix_lsp::lsp::SymbolKind;
+ use helix_view::{
+     annotations::diagnostics::DiagnosticFilter,
+     document::{Mode, SCRATCH_BUFFER_NAME},
+-    editor::{CompleteAction, CursorShapeConfig},
++    editor::{BufferLine, CompleteAction, CursorShapeConfig},
+     graphics::{Color, CursorKind, Modifier, Rect, Style},
+     input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind},
+     keyboard::{KeyCode, KeyModifiers},
+@@ -74,6 +75,7 @@ pub fn spinners_mut(&mut self) -> &mut ProgressSpinners {
+         &mut self.spinners
+     }
+ 
++    #[allow(clippy::too_many_lines)]
+     pub fn render_view(
+         &self,
+         doc_id: DocumentId,
+@@ -99,6 +101,10 @@ pub fn render_view(
+             let text_annotations = view.text_annotations(doc, Some(theme));
+             let mut decorations = DecorationManager::default();
+ 
++            if config.breadcrumb.enable {
++                Self::render_breadcrumb(editor, doc, view, area.with_height(1), surface);
++            }
++
+             if is_focused && config.cursorline {
+                 decorations.add_decoration(Self::cursorline(doc, view, theme));
+             }
+@@ -729,6 +735,135 @@ pub fn render_bufferline(editor: &Editor, viewport: Rect, surface: &mut Surface)
+         }
+     }
+ 
++    #[allow(clippy::too_many_lines)]
++    pub fn render_breadcrumb(
++        editor: &Editor,
++        doc: &Document,
++        view: &View,
++        viewport: Rect,
++        surface: &mut Surface,
++    ) {
++        use helix_view::editor::BreadcrumbPathOptions::{File, Full};
++
++        #[inline]
++        #[must_use]
++        fn draw_element(
++            surface: &mut Surface,
++            viewport: Rect,
++            x: u16,
++            content: &str,
++            style: Style,
++        ) -> u16 {
++            let remaining = viewport.right().saturating_sub(x) as usize;
++            surface
++                .set_stringn(x, viewport.y, content, remaining, style)
++                .0
++        }
++
++        let config = editor.config();
++
++        let style = editor
++            .theme
++            .try_get_exact("ui.breadcrumb")
++            .unwrap_or_else(|| editor.theme.get("ui.text"));
++
++        surface.clear_with(viewport, style);
++
++        let mut x = viewport.x.saturating_add(1);
++
++        let separator = " > ";
++        let separator_style = editor.theme.get("ui.breadcrumb.separator");
++        let mut draw_separator = false;
++
++        if matches!(config.breadcrumb.path, Full | File) {
++            if let Some(path) = doc.relative_path() {
++                let mut components = path.components().peekable();
++
++                if matches!(config.breadcrumb.path, File) {
++                    // Skip until the filename component.
++                    //
++                    // NOTE:
++                    // We could use `next_back` to get this element directly, but
++                    // this keeps our logic below simpler, with the logic for `Full`
++                    // and `File` being the same below this point.
++                    //
++                    // PERF: The `clone` is cheap; should be equivalent to a `Copy`.
++                    while components.clone().nth(1).is_some() {
++                        components.next();
++                    }
++                }
++
++                while let Some(component) = components.next() {
++                    if draw_separator {
++                        x = draw_element(surface, viewport, x, separator, separator_style);
++                    } else {
++                        draw_separator = true;
++                    }
++
++                    let segment = component.as_os_str().to_string_lossy();
++                    let is_directory = components.peek().is_some();
++
++                    let style = if is_directory {
++                        editor.theme.get("ui.text.directory")
++                    } else {
++                        style
++                    };
++
++                    x = draw_element(surface, viewport, x, &segment, style);
++                }
++            } else {
++                // Handle `[scratch]`
++                x = draw_element(surface, viewport, x, SCRATCH_BUFFER_NAME, style);
++            }
++        }
++
++        // Draw symbols, if any.
++        if let Some(breadcrumb) = doc.breadcrumbs.get(&view.id) {
++            for symbol in breadcrumb.iter() {
++                if draw_separator {
++                    x = draw_element(surface, viewport, x, separator, separator_style);
++                } else {
++                    draw_separator = true;
++                }
++
++                let style = match symbol.kind {
++                    SymbolKind::MODULE
++                    | SymbolKind::NAMESPACE
++                    | SymbolKind::PACKAGE => editor.theme.get("namespace"),
++
++                    SymbolKind::OBJECT // impl Block
++                    | SymbolKind::STRUCT
++                    | SymbolKind::INTERFACE
++                    | SymbolKind::CLASS => editor.theme.get("type"),
++
++                    SymbolKind::METHOD => editor.theme.get("function.method"),
++                    SymbolKind::FUNCTION => editor.theme.get("function"),
++
++                    SymbolKind::ENUM => editor.theme.get("type.enum"),
++                    SymbolKind::ENUM_MEMBER => editor.theme.get("type.enum.variant"),
++
++                   SymbolKind::FIELD | SymbolKind::PROPERTY => {
++                        editor.theme.get("variable.other.member")
++                    }
++
++                    SymbolKind::VARIABLE => editor.theme.get("variable"),
++                    SymbolKind::CONSTANT => editor.theme.get("constant"),
++                    SymbolKind::CONSTRUCTOR => editor.theme.get("constructor"),
++                    SymbolKind::STRING => editor.theme.get("string"),
++                    SymbolKind::NUMBER => editor.theme.get("constant.numeric"),
++                    SymbolKind::BOOLEAN => editor.theme.get("constant.builtin.boolean"),
++                    SymbolKind::ARRAY => editor.theme.get("punctuation.bracket"),
++                    SymbolKind::KEY => editor.theme.get("label"),
++                    SymbolKind::NULL => editor.theme.get("constant.builtin"),
++                    SymbolKind::TYPE_PARAMETER => editor.theme.get("type.parameter"),
++                    _ => style,
++                };
++
++                x = draw_element(surface, viewport, x, symbol.name.as_ref(), style);
++            }
++        }
++    }
++
+     pub fn render_gutter<'d>(
+         editor: &'d Editor,
+         doc: &'d Document,
+@@ -1644,7 +1779,6 @@ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
+         let config = cx.editor.config();
+ 
+         // check if bufferline should be rendered
+-        use helix_view::editor::BufferLine;
+         let use_bufferline = match config.bufferline {
+             BufferLine::Always => true,
+             BufferLine::Multiple if cx.editor.documents.len() > 1 => true,
+diff --git a/helix-term/tests/test/commands/write.rs b/helix-term/tests/test/commands/write.rs
+index fa6c97c5..f8fc6c5d 100644
+--- a/helix-term/tests/test/commands/write.rs
++++ b/helix-term/tests/test/commands/write.rs
+@@ -284,7 +284,7 @@ async fn test_write_quit() -> anyhow::Result<()> {
+ async fn test_write_concurrent() -> anyhow::Result<()> {
+     let mut file = tempfile::NamedTempFile::new()?;
+     let mut command = String::new();
+-    const RANGE: RangeInclusive<i32> = 1..=1000;
++    const RANGE: RangeInclusive<i32> = 1..=100;
+     let mut app = helpers::AppBuilder::new()
+         .with_file(file.path(), None)
+         .build()?;
+diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs
+index 17d4002a..b17fd230 100644
+--- a/helix-view/src/document.rs
++++ b/helix-view/src/document.rs
+@@ -13,6 +13,7 @@
+ use helix_core::syntax::config::LanguageServerFeature;
+ use helix_core::text_annotations::{InlineAnnotation, Overlay};
+ use helix_event::TaskController;
++use helix_lsp::lsp::DocumentSymbol;
+ use helix_lsp::util::lsp_pos_to_pos;
+ use helix_stdx::faccess::{copy_metadata, readonly};
+ use helix_vcs::{DiffHandle, DiffProviderRegistry};
+@@ -149,15 +150,22 @@ pub struct Document {
+     ///
+     /// To know if they're up-to-date, check the `id` field in `DocumentInlayHints`.
+     pub(crate) inlay_hints: HashMap<ViewId, DocumentInlayHints>,
++    /// Set to `true` when the document is updated, reset to `false` on the next inlay hints
++    /// update from the LSP
++    pub inlay_hints_oudated: bool,
++
+     /// Jump label overlays for each view.
+     pub(crate) jump_labels: HashMap<ViewId, Vec<Overlay>>,
++
+     /// LSP document highlights for each view, stored as char ranges.
+     pub(crate) document_highlights: HashMap<ViewId, DocumentHighlights>,
+     /// LSP code action hints for each view.
+     pub(crate) code_action_hints: HashSet<ViewId>,
+-    /// Set to `true` when the document is updated, reset to `false` on the next inlay hints
+-    /// update from the LSP
+-    pub inlay_hints_oudated: bool,
++
++    // Stores all the symbols of the Document.
++    pub(crate) symbols: Option<DocumentSymbolCache>,
++    /// Breadcrumb trail for each view showing this document.
++    pub breadcrumbs: HashMap<ViewId, Breadcrumbs>,
+ 
+     path: Option<PathBuf>,
+     relative_path: OnceCell<Option<PathBuf>>,
+@@ -234,6 +242,7 @@ pub struct Document {
+     pub code_action_controllers: HashMap<ViewId, TaskController>,
+     pub pull_diagnostic_controller: TaskController,
+     pub document_link_controller: TaskController,
++    pub document_symbols_controller: TaskController,
+ 
+     // NOTE: this field should eventually go away - we should use the Editor's syn_loader instead
+     // of storing a copy on every doc. Then we can remove the surrounding `Arc` and use the
+@@ -241,6 +250,83 @@ pub struct Document {
+     syn_loader: Arc<ArcSwap<syntax::Loader>>,
+ }
+ 
++pub struct DocumentSymbolCache {
++    pub tree: Vec<ThinDocumentSymbol>,
++    pub offset_encoding: OffsetEncoding,
++}
++
++#[derive(Debug, Clone)]
++pub struct ThinDocumentSymbol {
++    pub name: Box<str>,
++    pub kind: lsp::SymbolKind,
++    pub range: lsp::Range,
++    pub children: Option<Box<[Self]>>,
++}
++
++impl From<DocumentSymbol> for ThinDocumentSymbol {
++    #[inline]
++    fn from(symbol: DocumentSymbol) -> Self {
++        Self {
++            name: symbol.name.into(),
++            kind: symbol.kind,
++            range: symbol.range,
++            children: symbol.children.map(|children| {
++                let mut vec = Vec::with_capacity(children.len());
++                vec.extend(children.into_iter().map(Self::from));
++                vec.into_boxed_slice()
++            }),
++        }
++    }
++}
++
++#[derive(Debug, Clone, Default)]
++pub struct Breadcrumbs(Vec<Crumb>);
++
++impl Breadcrumbs {
++    #[inline]
++    pub fn push(&mut self, crumb: Crumb) {
++        self.0.push(crumb);
++    }
++
++    #[inline]
++    pub fn clear(&mut self) {
++        self.0.clear();
++    }
++
++    #[inline]
++    #[must_use]
++    pub const fn is_empty(&self) -> bool {
++        self.0.is_empty()
++    }
++
++    #[inline]
++    #[must_use]
++    pub const fn depth(&self) -> usize {
++        self.0.len()
++    }
++
++    #[inline]
++    pub fn iter(&self) -> impl Iterator<Item = &Crumb> {
++        self.0.iter()
++    }
++}
++
++#[derive(Debug, Clone)]
++pub struct Crumb {
++    pub name: Box<str>,
++    pub kind: lsp::SymbolKind,
++}
++
++impl From<&ThinDocumentSymbol> for Crumb {
++    #[inline]
++    fn from(symbol: &ThinDocumentSymbol) -> Self {
++        Self {
++            name: symbol.name.clone(),
++            kind: symbol.kind,
++        }
++    }
++}
++
+ #[derive(Debug, Clone, Default)]
+ pub struct DocumentColorSwatches {
+     pub color_swatches: Vec<InlineAnnotation>,
+@@ -720,7 +806,7 @@ fn take_with<T, F>(mut_ref: &mut T, f: F)
+     *mut_ref = f(mem::take(mut_ref));
+ }
+ 
+-use helix_lsp::{lsp, Client, LanguageServerId, LanguageServerName};
++use helix_lsp::{lsp, Client, LanguageServerId, LanguageServerName, OffsetEncoding};
+ use helix_stdx::Url;
+ 
+ impl Document {
+@@ -783,6 +869,9 @@ pub fn from(
+             previous_diagnostic_ids: HashMap::new(),
+             pull_diagnostic_controller: TaskController::new(),
+             document_link_controller: TaskController::new(),
++            symbols: None,
++            document_symbols_controller: TaskController::new(),
++            breadcrumbs: HashMap::new(),
+         }
+     }
+ 
+@@ -1440,6 +1529,10 @@ pub fn ensure_view_init(&mut self, view_id: ViewId) {
+         }
+ 
+         self.view_data_mut(view_id);
++
++        if self.config.load().breadcrumb.enable {
++            self.update_breadcrumbs_for_view(view_id);
++        }
+     }
+ 
+     /// Mark document as recent used for MRU sorting
+@@ -1447,12 +1540,13 @@ pub fn mark_as_focused(&mut self) {
+         self.focused_at = std::time::Instant::now();
+     }
+ 
+-    /// Remove a view's selection and inlay hints from this document.
++    /// Remove any views' data and state that is stored in the `Document`.
+     pub fn remove_view(&mut self, view_id: ViewId) {
+         self.selections.remove(&view_id);
+         self.view_data.remove(&view_id);
+         self.inlay_hints.remove(&view_id);
+         self.jump_labels.remove(&view_id);
++        self.breadcrumbs.remove(&view_id);
+         self.document_highlights.remove(&view_id);
+         self.document_highlight_controllers.remove(&view_id);
+         self.code_action_hints.remove(&view_id);
+@@ -2446,6 +2540,103 @@ pub fn remove_jump_labels(&mut self, view_id: ViewId) {
+         self.jump_labels.remove(&view_id);
+     }
+ 
++    #[inline(always)]
++    pub fn set_document_symbols(
++        &mut self,
++        symbols: Vec<DocumentSymbol>,
++        offset_encoding: OffsetEncoding,
++    ) {
++        self.symbols = Some(DocumentSymbolCache {
++            tree: {
++                let mut tree = Vec::with_capacity(symbols.len());
++                tree.extend(symbols.into_iter().map(ThinDocumentSymbol::from));
++                tree
++            },
++            offset_encoding,
++        });
++    }
++
++    #[inline]
++    pub fn clear_document_symbols(&mut self) {
++        self.symbols = None;
++        self.clear_breadcrumbs();
++    }
++
++    #[inline]
++    pub fn clear_breadcrumbs(&mut self) {
++        self.breadcrumbs.clear();
++    }
++
++    // For all non-hotpaths, we use this function to prevent code bloat.
++    #[inline(never)]
++    pub fn update_breadcrumbs_for_view(&mut self, view_id: ViewId) {
++        self.update_breadcrumbs_for_view_inlined(view_id);
++    }
++
++    // We want to make sure this is inlined in the hotpath (cursor position change).
++    #[inline(always)]
++    pub fn update_breadcrumbs_for_view_inlined(&mut self, view_id: ViewId) {
++        #[inline(always)]
++        const fn in_range(pos: lsp::Position, range: lsp::Range) -> bool {
++            // PERF:
++            // Line-based filtering is the most effective early exit indicator,
++            // so do first, before other evaluations; this should be friendly to
++            // the CPU branch predictor.
++            if pos.line < range.start.line || pos.line > range.end.line {
++                return false;
++            }
++
++            // Check if the cursor position is "in" the symbols "depth".
++            //
++            // In the context of breadcrumbs, this would be the difference between
++            // if the cursor is in an impl block or in an impl block and in a
++            // function of the impl block (`|` is the cursor):
++            //
++            // ```rust
++            // impl Foo {
++            //     f|n bar() {} // In `bar`: impl Foo > bar
++            //
++            //   | fn baz() {} // Not in `baz`: impl Foo
++            //
++            //     fn quux() {} | // Not in `quux`: impl Foo
++            // }
++            // ```
++            if pos.line == range.start.line && pos.character < range.start.character {
++                return false;
++            }
++            if pos.line == range.end.line && pos.character > range.end.character {
++                return false;
++            }
++
++            true
++        }
++
++        let Some(symbols) = self.symbols.as_ref() else {
++            return;
++        };
++
++        let position = self.position(view_id, symbols.offset_encoding);
++
++        let breadcrumb = {
++            let breadcrumb = self.breadcrumbs.entry(view_id).or_default();
++            breadcrumb.clear();
++            breadcrumb
++        };
++
++        let mut current = symbols.tree.as_slice();
++
++        while let Some(symbol) = current
++            .iter()
++            .find(|&symbol| in_range(position, symbol.range))
++        {
++            breadcrumb.push(Crumb::from(symbol));
++            match symbol.children.as_deref() {
++                Some(children) => current = children,
++                _ => break,
++            }
++        }
++    }
++
+     pub fn set_document_highlights(
+         &mut self,
+         view_id: ViewId,
+diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs
+index 3bfe5d7f..263a8542 100644
+--- a/helix-view/src/editor.rs
++++ b/helix-view/src/editor.rs
+@@ -385,6 +385,8 @@ pub struct Config {
+     pub whitespace: WhitespaceConfig,
+     /// Persistently display open buffers along the top
+     pub bufferline: BufferLine,
++    /// Persistently display breadcrumb along the top of each view, below any bufferline.
++    pub breadcrumb: BreadcrumbConfig,
+     /// Vertical indent width guides.
+     pub indent_guides: IndentGuidesConfig,
+     /// Whether to color modes with different colors. Defaults to `false`.
+@@ -518,6 +520,23 @@ pub fn code_action_hint(&self) -> bool {
+     }
+ }
+ 
++#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Clone, Copy)]
++#[serde(rename_all = "kebab-case")]
++pub struct BreadcrumbConfig {
++    pub enable: bool,
++    #[serde(default)]
++    pub path: BreadcrumbPathOptions,
++}
++
++#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Clone, Copy)]
++#[serde(rename_all = "kebab-case")]
++pub enum BreadcrumbPathOptions {
++    #[default]
++    Full,
++    File,
++    None,
++}
++
+ #[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Clone, Copy)]
+ #[serde(rename_all = "kebab-case")]
+ pub struct BufferPickerConfig {
+@@ -1267,6 +1286,7 @@ fn default() -> Self {
+             rulers: Vec::new(),
+             whitespace: WhitespaceConfig::default(),
+             bufferline: BufferLine::default(),
++            breadcrumb: BreadcrumbConfig::default(),
+             indent_guides: IndentGuidesConfig::default(),
+             color_modes: false,
+             soft_wrap: SoftWrap {
+@@ -1881,6 +1901,7 @@ pub fn refresh_doc_language(&mut self, doc_id: DocumentId) {
+         let diagnostics = Editor::doc_diagnostics(&self.language_servers, &self.diagnostics, doc);
+         doc.replace_diagnostics(diagnostics, &[], None);
+         doc.reset_all_inlay_hints();
++        doc.clear_document_symbols();
+     }
+ 
+     /// Launch a language server for a given document
+diff --git a/helix-view/src/graphics.rs b/helix-view/src/graphics.rs
+index 05483528..67281821 100644
+--- a/helix-view/src/graphics.rs
++++ b/helix-view/src/graphics.rs
+@@ -200,6 +200,12 @@ pub fn clip_bottom(self, height: u16) -> Rect {
+         }
+     }
+ 
++    #[inline]
++    pub fn with_y(self, y: u16) -> Rect {
++        // new y may make area > u16::max_value, so use new()
++        Self::new(self.x, y, self.width, self.height)
++    }
++
+     pub fn with_height(self, height: u16) -> Rect {
+         // new height may make area > u16::max_value, so use new()
+         Self::new(self.x, self.y, self.width, height)
+diff --git a/helix-view/src/gutter.rs b/helix-view/src/gutter.rs
+index 176890ca..cde457c2 100644
+--- a/helix-view/src/gutter.rs
++++ b/helix-view/src/gutter.rs
+@@ -242,9 +242,10 @@ pub fn breakpoints<'doc>(
+ 
+     let breakpoints = doc.path().and_then(|path| editor.breakpoints.get(path));
+ 
+-    let breakpoints = match breakpoints {
+-        Some(breakpoints) => breakpoints,
+-        None => return Box::new(move |_, _, _, _| None),
++    let breadcrumbs_enabled = editor.config().breadcrumb.enable;
++
++    let Some(breakpoints) = breakpoints else {
++        return Box::new(move |_, _, _, _| None);
+     };
+ 
+     Box::new(
+@@ -254,7 +255,7 @@ pub fn breakpoints<'doc>(
+             }
+             let breakpoint = breakpoints
+                 .iter()
+-                .find(|breakpoint| breakpoint.line == line)?;
++                .find(|breakpoint| breakpoint.line == line + usize::from(breadcrumbs_enabled))?;
+ 
+             let style = if breakpoint.condition.is_some() && breakpoint.log_message.is_some() {
+                 error.underline_style(UnderlineStyle::Line)
+diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs
+index d211d8e6..c0d72a3c 100644
+--- a/helix-view/src/view.rs
++++ b/helix-view/src/view.rs
+@@ -208,11 +208,18 @@ pub fn add_to_history(&mut self, id: DocumentId) {
+     }
+ 
+     pub fn inner_area(&self, doc: &Document) -> Rect {
+-        self.area.clip_left(self.gutter_offset(doc)).clip_bottom(1) // -1 for statusline
++        self.area
++            .clip_top(self.breadcrumb_offset(doc))
++            .clip_left(self.gutter_offset(doc))
++            .clip_bottom(1) // -1 for statusline
+     }
+ 
+-    pub fn inner_height(&self) -> usize {
+-        self.area.clip_bottom(1).height.into() // -1 for statusline
++    pub fn inner_height(&self, doc: &Document) -> usize {
++        self.area
++            .clip_top(self.breadcrumb_offset(doc))
++            .clip_bottom(1) // -1 for statusline
++            .height
++            .into()
+     }
+ 
+     pub fn inner_width(&self, doc: &Document) -> u16 {
+@@ -237,7 +244,10 @@ pub fn gutter_offset(&self, doc: &Document) -> u16 {
+         }
+     }
+ 
+-    //
++    pub fn breadcrumb_offset(&self, doc: &Document) -> u16 {
++        u16::from(doc.config.load().breadcrumb.enable)
++    }
++
+     pub fn offset_coords_to_in_view(
+         &self,
+         doc: &Document,
+@@ -378,7 +388,7 @@ pub fn estimate_last_doc_line(&self, doc: &Document) -> usize {
+         let doc_text = doc.text().slice(..);
+         let line = doc_text.char_to_line(doc.view_offset(self.id).anchor.min(doc_text.len_chars()));
+         // Saturating subs to make it inclusive zero indexing.
+-        (line + self.inner_height())
++        (line + self.inner_height(doc))
+             .min(doc_text.len_lines())
+             .saturating_sub(1)
+     }
modules/dev/editor/helix/default.nix
@@ -36,7 +36,34 @@
 
       programs.helix = {
         enable = true;
-        package = pkgs.nur.repos.hpcesia.steelix;
+        package = pkgs.nur.repos.hpcesia.steelix.override {
+          steelix-unwrapped =
+            (pkgs.callPackage "${inputs.nur-hpcesia}/pkgs/steelix/unwrapped.nix" {}).overrideAttrs
+            (_: prev: {
+              patches =
+                prev.patches
+                ++ [
+                  # Breadcrumb navigation bar.
+                  # https://github.com/helix-editor/helix/pull/15573
+                  ./patches/15573-breadcrumb.patch
+                  # Search position statusline element and `search.max-matches`.
+                  # Rebased on top of the breadcrumb patch.
+                  # https://github.com/helix-editor/helix/pull/11700
+                  ./patches/11700-search-position.patch
+                  # Click a bufferline entry to switch buffers.
+                  # Rebased on top of the breadcrumb patch.
+                  # https://github.com/helix-editor/helix/pull/12173
+                  ./patches/12173-clickable-bufferline.patch
+                  # Disambiguate same-named buffers by prepending path components.
+                  # Rebased on top of the breadcrumb and clickable-bufferline patches.
+                  # https://github.com/helix-editor/helix/pull/13113
+                  ./patches/13113-unique-buffer-names.patch
+                  # Prompt history search by typed input.
+                  # https://github.com/helix-editor/helix/pull/15266
+                  ./patches/15266-prompt-history-search.patch
+                ];
+            });
+        };
         settings = {
           editor = {
             cursorline = true;
@@ -56,6 +83,7 @@
                 "file-type"
                 "file-encoding"
                 "separator"
+                "search-position"
                 "position"
                 "total-line-numbers"
               ];
@@ -76,6 +104,7 @@
             trim-trailing-whitespace = true;
             inline-diagnostics.cursor-line = "warning";
             end-of-line-diagnostics = "error";
+            breadcrumb.enable = true;
             lsp = {
               display-inlay-hints = true;
               inlay-hints-length-limit = 16;