Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,11 @@ watch = false
# Accepted env values: 1 = true, 0 = false (other values ignored)
# Default: true
# code-line-numbers = true

# Maximum tab title length (in characters)
# Controls the tab title of the terminal: "leaf: <filename>".
# When the filename is long, it is truncated: "leaf: prefix...ext".
# Priority: LEAF_TAB_TITLE_LENGTH > this setting > default (-1)
# Minimum accepted: 20. Any other value falls back to no truncation.
# Default: -1 (no truncation)
# tab-title-length = -1
14 changes: 14 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ pub(crate) struct App {
pub(super) file_mode: bool,
pub(super) code_line_numbers: bool,
max_width: Option<usize>,
tab_title_max_filename_len: Option<usize>,
mouse_capture: bool,
}

Expand Down Expand Up @@ -318,6 +319,7 @@ impl App {
file_mode: false,
code_line_numbers: true,
max_width: None,
tab_title_max_filename_len: None,
mouse_capture: true,
};
app.store_current_theme_preview();
Expand All @@ -341,6 +343,14 @@ impl App {
self.max_width = max_width;
}

pub(crate) fn set_tab_title_max_filename_len(&mut self, value: Option<usize>) {
self.tab_title_max_filename_len = value;
}

pub(crate) fn tab_title_max_filename_len(&self) -> Option<usize> {
self.tab_title_max_filename_len
}

pub(crate) fn is_mouse_capture_enabled(&self) -> bool {
self.mouse_capture
}
Expand Down Expand Up @@ -468,6 +478,10 @@ impl App {
&self.filename
}

pub(crate) fn title_filename(&self) -> Option<&str> {
self.has_content().then_some(self.filename.as_str())
}

#[cfg(test)]
pub(crate) fn line(&self, idx: usize) -> Option<&Line<'static>> {
self.lines.get(idx)
Expand Down
25 changes: 24 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::{
};

use anyhow::Context;
use serde::Deserialize;
use serde::{Deserialize, Deserializer};

use crate::theme::{resolve_theme_selection, CustomThemeConfig};

Expand All @@ -21,11 +21,24 @@ pub(crate) struct LeafConfig {
pub(crate) extras: Vec<String>,
#[serde(rename = "code-line-numbers")]
pub(crate) code_line_numbers: Option<bool>,
#[serde(
rename = "tab-title-length",
deserialize_with = "deserialize_lenient_i32"
)]
pub(crate) tab_title_length: Option<i32>,
pub(crate) themes: BTreeMap<String, CustomThemeConfig>,
#[serde(skip)]
pub(crate) config_dir: Option<PathBuf>,
}

fn deserialize_lenient_i32<'de, D>(deserializer: D) -> Result<Option<i32>, D::Error>
where
D: Deserializer<'de>,
{
let value = toml::Value::deserialize(deserializer)?;
Ok(value.as_integer().and_then(|n| i32::try_from(n).ok()))
}

#[derive(Default)]
pub(crate) struct CliOverrides {
pub(crate) width: Option<usize>,
Expand Down Expand Up @@ -74,6 +87,16 @@ pub(crate) fn load_config(overrides: &CliOverrides) -> (LeafConfig, Option<Strin
}
}

let leaf_tab_title_env_overrides = std::env::var("LEAF_TAB_TITLE_LENGTH")
.is_ok_and(|v| v.parse::<i32>().is_ok_and(crate::is_valid_tab_title_length));
if !leaf_tab_title_env_overrides {
if let Some(n) = config.tab_title_length {
if (0..20).contains(&n) {
warnings.push("tab-title-length is invalid, no truncation applied".to_string());
}
}
}

let warning = if warnings.is_empty() {
None
} else {
Expand Down
31 changes: 29 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,31 @@ fn resolve_code_line_numbers(config_value: Option<bool>) -> bool {
config_value.unwrap_or(true)
}

const LEAF_TAB_PREFIX_LEN: usize = 6;

pub(crate) fn is_valid_tab_title_length(n: i32) -> bool {
n == -1 || n >= 20
}

fn resolve_tab_title_max_filename_len(config_value: Option<i32>) -> Option<usize> {
if let Ok(val) = std::env::var("LEAF_TAB_TITLE_LENGTH") {
if let Ok(n) = val.parse::<i32>() {
if is_valid_tab_title_length(n) {
return tab_title_n_to_max_filename_len(n);
}
}
}
tab_title_n_to_max_filename_len(config_value.unwrap_or(-1))
}

pub(crate) fn tab_title_n_to_max_filename_len(n: i32) -> Option<usize> {
if n >= 20 {
Some((n as usize).saturating_sub(LEAF_TAB_PREFIX_LEN))
} else {
None
}
}

fn append_config_warning(warning: &mut Option<String>, next: Option<String>) {
let Some(next) = next else {
return;
Expand Down Expand Up @@ -188,6 +213,8 @@ fn main() -> Result<()> {
let watch_from_config = user_config.watch.unwrap_or(false);
let max_width = resolve_configured_width(cli_width, user_config.width);
let code_line_numbers = resolve_code_line_numbers(user_config.code_line_numbers);
let tab_title_max_filename_len =
resolve_tab_title_max_filename_len(user_config.tab_title_length);

if let Some(ref mut spec) = inline_spec {
if spec.width.is_none() {
Expand Down Expand Up @@ -363,6 +390,7 @@ fn main() -> Result<()> {
app.set_last_content_hash(last_content_hash);
app.set_watch_from_config(watch_from_config);
app.set_max_width(max_width);
app.set_tab_title_max_filename_len(tab_title_max_filename_len);
app.set_extras(user_config.extras);
app.set_file_mode(file_mode);
app.set_editor_config(Some(resolved_editor));
Expand All @@ -387,8 +415,7 @@ fn main() -> Result<()> {
);

let mut stdout = io::stdout();
print!("\x1b]0;leaf\x07");
let _ = io::stdout().flush();
terminal::set_tab_title(app.title_filename(), app.tab_title_max_filename_len());
runtime::debug_log(debug_input, "terminal enter start");
let mut session = TerminalSession::enter(&mut stdout)?;
runtime::debug_log(debug_input, "terminal enter done");
Expand Down
5 changes: 2 additions & 3 deletions src/render/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,15 @@ pub(crate) fn status_goto_line_section(app: &App) -> Option<Vec<Span<'static>>>
.fg(theme.ui.status_error_fg)
.bg(theme.ui.status_error_bg),
)
} else if let Some(target) = app.goto_line_target() {
} else {
let target = app.goto_line_target()?;
let logical = app.line_number_at(target);
Span::styled(
format!(" :{} ", logical),
Style::default()
.fg(theme.ui.status_success_fg)
.bg(theme.ui.status_success_bg),
)
} else {
return None;
};
Some(vec![span])
}
Expand Down
10 changes: 10 additions & 0 deletions src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ pub(crate) fn run(
const PICKER_LOAD_POLL_INTERVAL: Duration = Duration::from_millis(50);
let mut needs_redraw = !initial_draw_done;
let mut pending_resize: Option<Instant> = None;
let mut last_title_filename: Option<String> = app.title_filename().map(str::to_string);
sync_render_width(terminal, app, ss, themes)?;

loop {
Expand All @@ -97,6 +98,15 @@ pub(crate) fn run(
needs_redraw = true;
}

let current_title_filename = app.title_filename();
if current_title_filename != last_title_filename.as_deref() {
crate::terminal::set_tab_title(
current_title_filename,
app.tab_title_max_filename_len(),
);
last_title_filename = current_title_filename.map(str::to_string);
}

if needs_redraw {
terminal.draw(|f| ui(f, app))?;
needs_redraw = false;
Expand Down
97 changes: 96 additions & 1 deletion src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,40 @@ use crossterm::{
},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use std::io::{self, Write};
use std::path::Path;

fn format_tab_title_filename(filename: &str, max_len: usize) -> String {
if filename.len() <= max_len || filename.chars().count() <= max_len {
return filename.to_string();
}
let ext = Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let keep = max_len
.saturating_sub(3)
.saturating_sub(ext.chars().count());
let prefix: String = filename.chars().take(keep).collect();
format!("{prefix}...{ext}")
}

pub(crate) fn set_tab_title(filename: Option<&str>, max_filename_len: Option<usize>) {
let mut stdout = io::stdout();
match (filename, max_filename_len) {
(Some(name), Some(m)) if !name.is_empty() => {
let display = format_tab_title_filename(name, m);
let _ = write!(stdout, "\x1b]0;leaf: {display}\x07");
}
(Some(name), None) if !name.is_empty() => {
let _ = write!(stdout, "\x1b]0;leaf: {name}\x07");
}
_ => {
let _ = write!(stdout, "\x1b]0;leaf\x07");
}
}
let _ = stdout.flush();
}

pub(crate) struct TerminalSession {
raw_enabled: bool,
Expand Down Expand Up @@ -160,3 +193,65 @@ pub(crate) fn finish_with_restore(
(Ok(()), Ok(())) => Ok(()),
}
}

#[cfg(test)]
mod tests {
use super::*;

const MAX: usize = 15;

#[test]
fn format_tab_title_filename_short_unchanged() {
assert_eq!(format_tab_title_filename("readme.md", MAX), "readme.md");
assert_eq!(
format_tab_title_filename("notes-2026.md", MAX),
"notes-2026.md"
);
assert_eq!(format_tab_title_filename("script.rs", MAX), "script.rs");
assert_eq!(format_tab_title_filename("stdin", MAX), "stdin");
assert_eq!(format_tab_title_filename("README", MAX), "README");
assert_eq!(format_tab_title_filename(".gitignore", MAX), ".gitignore");
assert_eq!(format_tab_title_filename(".env", MAX), ".env");
}

#[test]
fn format_tab_title_filename_at_limit_unchanged() {
let name = "abcdefghijklmno";
assert_eq!(name.chars().count(), MAX);
assert_eq!(format_tab_title_filename(name, MAX), name);
}

#[test]
fn format_tab_title_filename_truncates_with_extension() {
assert_eq!(
format_tab_title_filename("chapitre-1-introduction.md", MAX),
"chapitre-1...md"
);
assert_eq!(
format_tab_title_filename("verylongfilename.markdown", MAX),
"very...markdown"
);
}

#[test]
fn format_tab_title_filename_md_output_is_exactly_max_len() {
let out = format_tab_title_filename("chapitre-1-introduction.md", MAX);
assert_eq!(out.chars().count(), MAX);
}

#[test]
fn format_tab_title_filename_truncates_without_extension() {
assert_eq!(
format_tab_title_filename("verylongfilenamewithoutextension", MAX),
"verylongfile..."
);
}

#[test]
fn format_tab_title_filename_utf8_boundary_safe() {
let name = "résumé-tres-long-fichier.md";
let out = format_tab_title_filename(name, MAX);
assert!(out.ends_with("...md"));
assert!(out.chars().count() <= MAX);
}
}
48 changes: 48 additions & 0 deletions src/tests/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,54 @@ heading_1 = "#fabd2f"
);
}

#[test]
fn tab_title_n_to_max_filename_len_returns_none_for_disabled() {
assert_eq!(tab_title_n_to_max_filename_len(-1), None);
assert_eq!(tab_title_n_to_max_filename_len(-42), None);
assert_eq!(tab_title_n_to_max_filename_len(0), None);
assert_eq!(tab_title_n_to_max_filename_len(19), None);
}

#[test]
fn tab_title_n_to_max_filename_len_subtracts_prefix_when_valid() {
assert_eq!(tab_title_n_to_max_filename_len(20), Some(14));
assert_eq!(tab_title_n_to_max_filename_len(21), Some(15));
assert_eq!(tab_title_n_to_max_filename_len(50), Some(44));
}

#[test]
fn parse_tab_title_length_valid_int() {
let toml = r#"tab-title-length = -1"#;
let config: LeafConfig = toml::from_str(toml).unwrap();
assert_eq!(config.tab_title_length, Some(-1));

let toml = r#"tab-title-length = 30"#;
let config: LeafConfig = toml::from_str(toml).unwrap();
assert_eq!(config.tab_title_length, Some(30));
}

#[test]
fn parse_tab_title_length_string_falls_back_silently() {
let toml = r#"
theme = "forest"
tab-title-length = "abc"
"#;
let config: LeafConfig = toml::from_str(toml).unwrap();
assert_eq!(config.tab_title_length, None);
assert_eq!(config.theme.as_deref(), Some("forest"));
}

#[test]
fn parse_tab_title_length_bool_falls_back_silently() {
let toml = r#"
theme = "arctic"
tab-title-length = true
"#;
let config: LeafConfig = toml::from_str(toml).unwrap();
assert_eq!(config.tab_title_length, None);
assert_eq!(config.theme.as_deref(), Some("arctic"));
}

#[test]
fn config_path_returns_some() {
let path = config_path();
Expand Down
Loading