From bc0f718df576b0e1bad816dd8241e4ebbb578fa5 Mon Sep 17 00:00:00 2001 From: Isaac Corbrey Date: Fri, 16 May 2025 13:19:58 -0400 Subject: [PATCH] feat: Show filepath context in bufferline This change adds functionality that computes the shortest unique filepath for each document in the editor to display as the title in the bufferline, along with the appropriate configuration options. --- book/src/editor.md | 27 +++++++++ helix-term/src/ui/editor.rs | 112 +++++++++++++++++++++++++++++++----- helix-view/src/editor.rs | 72 ++++++++++++++++++++++- 3 files changed, 195 insertions(+), 16 deletions(-) diff --git a/book/src/editor.md b/book/src/editor.md index 00db71d27..a9e8137ec 100644 --- a/book/src/editor.md +++ b/book/src/editor.md @@ -3,6 +3,7 @@ - [`[editor]` Section](#editor-section) - [`[editor.clipboard-provider]` Section](#editorclipboard-provider-section) - [`[editor.statusline]` Section](#editorstatusline-section) +- [`[editor.bufferline]` Section](#editorbufferline-section) - [`[editor.lsp]` Section](#editorlsp-section) - [`[editor.cursor-shape]` Section](#editorcursor-shape-section) - [`[editor.file-picker]` Section](#editorfile-picker-section) @@ -148,6 +149,32 @@ The following statusline elements can be configured: | `version-control` | The current branch name or detached commit hash of the opened workspace | | `register` | The current selected register | +### `[editor.bufferline]` Section + +For simplicity, `editor.bufferline` accepts a render mode, which will use +default settings for the rest of the configuration. + +```toml +[editor] +bufferline = "always" +``` + +To customize the behavior of the bufferline, the `[editor.bufferline]` section +must be used. + +| Key | Description | Default | +| --------- | ------------------------------------------------------------------------------------------------------------------- | ----------- | +| `show` | When to show the bufferline. Can be `always`, `never` or `multiple` (only shown if more than one buffer is in use). | `"never"` | +| `context` | Whether to provide additional filepath context. Can be `minimal` or `none`. | `"minimal"` | + +Example: + +```toml +[editor.bufferline] +show = "always" +context = "none" +``` + ### `[editor.lsp]` Section | Key | Description | Default | diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 9343d55d4..dca90e49e 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -29,9 +29,11 @@ use helix_view::{ graphics::{Color, CursorKind, Modifier, Rect, Style}, input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}, keyboard::{KeyCode, KeyModifiers}, - Document, Editor, Theme, View, + Document, DocumentId, Editor, Theme, View, +}; +use std::{ + collections::HashMap, ffi::OsString, mem::take, num::NonZeroUsize, ops, path::PathBuf, rc::Rc, }; -use std::{mem::take, num::NonZeroUsize, ops, path::PathBuf, rc::Rc}; use tui::{buffer::Buffer as Surface, text::Span}; @@ -560,7 +562,6 @@ impl EditorView { /// Render bufferline at the top pub fn render_bufferline(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 @@ -582,14 +583,28 @@ impl EditorView { let mut x = viewport.x; let current_doc = view!(editor).doc; + use helix_view::editor::BufferLineContextMode; + let fnames = match editor.config().bufferline.context.clone() { + BufferLineContextMode::None => { + let scratch = PathBuf::from(SCRATCH_BUFFER_NAME); // default filename to use for scratch buffer + HashMap::::from_iter(editor.documents().map(|doc| { + ( + doc.id(), + doc.path() + .unwrap_or(&scratch) + .file_name() + .unwrap_or_default() + .to_str() + .unwrap_or_default() + .to_owned(), + ) + })) + } + BufferLineContextMode::Minimal => expand_fname_contexts(editor, SCRATCH_BUFFER_NAME), + }; + for doc in editor.documents() { - let fname = doc - .path() - .unwrap_or(&scratch) - .file_name() - .unwrap_or_default() - .to_str() - .unwrap_or_default(); + let fname = fnames.get(&doc.id()).unwrap(); let style = if current_doc == doc.id() { bufferline_active @@ -1494,10 +1509,10 @@ impl Component for EditorView { 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, + use helix_view::editor::BufferLineRenderMode; + let use_bufferline = match config.bufferline.show { + BufferLineRenderMode::Always => true, + BufferLineRenderMode::Multiple => 1 < cx.editor.documents.len(), _ => false, }; @@ -1615,3 +1630,72 @@ fn canonicalize_key(key: &mut KeyEvent) { key.modifiers.remove(KeyModifiers::SHIFT) } } + +#[derive(Default)] +struct PathTrie { + parents: HashMap, + visits: u32, +} + +/// Returns a unique path ending for the current set of documents in the +/// editor. For example, documents `a/b` and `c/d` would resolve to `b` and `d` +/// respectively, while `a/b/c` and `a/d/c` would resolve to `b/c` and `d/c` +/// respectively. +fn expand_fname_contexts<'a>(editor: &'a Editor, scratch: &'a str) -> HashMap { + let mut trie = HashMap::new(); + + // Build out a reverse prefix trie for all documents + for doc in editor.documents() { + let Some(path) = doc.path() else { + continue; + }; + + let mut current_subtrie = &mut trie; + + for component in path.components().rev() { + let segment = component.as_os_str().to_os_string(); + let subtrie = current_subtrie + .entry(segment) + .or_insert_with(PathTrie::default); + + subtrie.visits += 1; + current_subtrie = &mut subtrie.parents; + } + } + + let mut fnames = HashMap::new(); + + // Navigate the built reverse prefix trie to find the smallest unique path + for doc in editor.documents() { + let Some(path) = doc.path() else { + fnames.insert(doc.id(), scratch.to_owned()); + continue; + }; + + let mut current_subtrie = ≜ + let mut built_path = vec![]; + + for component in path.components().rev() { + let segment = component.as_os_str().to_os_string(); + let subtrie = current_subtrie + .get(&segment) + .expect("should have contained segment"); + + built_path.insert(0, segment); + + if subtrie.visits == 1 { + fnames.insert( + doc.id(), + PathBuf::from_iter(built_path.iter()) + .to_string_lossy() + .into_owned(), + ); + break; + } + + current_subtrie = &subtrie.parents; + } + } + + fnames +} diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index bc811b88b..f865eeeaf 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -56,7 +56,11 @@ use helix_dap as dap; use helix_lsp::lsp; use helix_stdx::path::canonicalize; -use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer}; +use serde::{ + de::{self, IntoDeserializer}, + ser::SerializeMap, + Deserialize, Deserializer, Serialize, Serializer, +}; use arc_swap::{ access::{DynAccess, DynGuard}, @@ -162,6 +166,40 @@ where deserializer.deserialize_any(GutterVisitor) } +fn deserialize_bufferline_show_or_struct<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + struct BufferLineVisitor; + + impl<'de> serde::de::Visitor<'de> for BufferLineVisitor { + type Value = BufferLine; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + formatter, + "a bufferline render mode or a detailed bufferline configuration" + ) + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + Ok(BufferLineRenderMode::deserialize(v.into_deserializer())?.into()) + } + + fn visit_map(self, map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + BufferLine::deserialize(de::value::MapAccessDeserializer::new(map)) + } + } + + deserializer.deserialize_any(BufferLineVisitor) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", default, deny_unknown_fields)] pub struct GutterLineNumbersConfig { @@ -334,6 +372,7 @@ pub struct Config { #[serde(default)] pub whitespace: WhitespaceConfig, /// Persistently display open buffers along the top + #[serde(deserialize_with = "deserialize_bufferline_show_or_struct")] pub bufferline: BufferLine, /// Vertical indent width guides. pub indent_guides: IndentGuidesConfig, @@ -674,10 +713,27 @@ impl Default for CursorShapeConfig { } } +/// Bufferline configuration +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct BufferLine { + pub show: BufferLineRenderMode, + pub context: BufferLineContextMode, +} + +impl From for BufferLine { + fn from(show: BufferLineRenderMode) -> Self { + Self { + show, + ..Default::default() + } + } +} + /// bufferline render modes #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -pub enum BufferLine { +pub enum BufferLineRenderMode { /// Don't render bufferline #[default] Never, @@ -687,6 +743,18 @@ pub enum BufferLine { Multiple, } +/// Bufferline filename context modes +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum BufferLineContextMode { + /// Only show the filename + None, + + /// Expand filenames to the smallest unique path + #[default] + Minimal, +} + #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum LineNumber {