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.
pull/13565/head
Isaac Corbrey 2025-05-16 13:19:58 -04:00
parent ab97585b69
commit bc0f718df5
3 changed files with 195 additions and 16 deletions

View File

@ -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 |

View File

@ -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;
for doc in editor.documents() {
let fname = doc
.path()
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::<DocumentId, String>::from_iter(editor.documents().map(|doc| {
(
doc.id(),
doc.path()
.unwrap_or(&scratch)
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default();
.unwrap_or_default()
.to_owned(),
)
}))
}
BufferLineContextMode::Minimal => expand_fname_contexts(editor, SCRATCH_BUFFER_NAME),
};
for doc in editor.documents() {
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<OsString, PathTrie>,
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<DocumentId, String> {
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 = &trie;
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
}

View File

@ -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<BufferLine, D::Error>
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<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(BufferLineRenderMode::deserialize(v.into_deserializer())?.into())
}
fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
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<BufferLineRenderMode> 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 {