From b15aef4310e86aa31c2ceab74184ec7e5627a2c5 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Sun, 8 Jun 2025 17:30:33 -0400 Subject: [PATCH] Introduce dynamic tab titles for unsaved files based on buffer content (#32353) https://github.com/user-attachments/assets/0bb08784-251c-4221-890a-2d6b3fb94e0f For new, unsaved files: - If a buffer has no content, or contains only whitespace, use `untitled` - If a buffer has content, take the first 40 chars of the first line | Sublime | VS Code | Zed | |---------|---------|-----| | SCR-20250608-ouux | SCR-20250608-ousn | SCR-20250608-ovbg | Note that this implementation also trims all leading whitespace, so that if the buffer has any non-whitespace content, we use it. VS Code and Sublime do not do this. | Sublime | VS Code | Zed | |---------|---------|-----| | SCR-20250608-oviq | SCR-20250608-ovkq | SCR-20250608-ovns | Release Notes: - Introduced dynamic tab titles for unsaved files based on buffer content --- crates/editor/src/editor.rs | 5 ++ crates/multi_buffer/src/multi_buffer.rs | 26 +++++++-- crates/multi_buffer/src/multi_buffer_tests.rs | 56 +++++++++++++++++++ crates/zed/src/zed.rs | 2 +- 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index e211496343..3c434a7455 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -18791,6 +18791,11 @@ impl Editor { cx.emit(EditorEvent::BufferEdited); cx.emit(SearchEvent::MatchesInvalidated); if *singleton_buffer_edited { + if let Some(buffer) = multibuffer.read(cx).as_singleton() { + if buffer.read(cx).file().is_none() { + cx.emit(EditorEvent::TitleChanged); + } + } if let Some(project) = &self.project { #[allow(clippy::mutable_key_type)] let languages_affected = multibuffer.update(cx, |multibuffer, cx| { diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index c7f22149d9..53b3b53de8 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -2600,13 +2600,27 @@ impl MultiBuffer { return title.into(); } - if let Some(buffer) = self.as_singleton() { - if let Some(file) = buffer.read(cx).file() { - return file.file_name(cx).to_string_lossy(); - } - } + self.as_singleton() + .and_then(|buffer| { + let buffer = buffer.read(cx); - "untitled".into() + if let Some(file) = buffer.file() { + return Some(file.file_name(cx).to_string_lossy()); + } + + let title = buffer + .snapshot() + .chars() + .skip_while(|ch| ch.is_whitespace()) + .take_while(|&ch| ch != '\n') + .take(40) + .collect::() + .trim_end() + .to_string(); + + (!title.is_empty()).then(|| title.into()) + }) + .unwrap_or("untitled".into()) } pub fn set_title(&mut self, title: String, cx: &mut Context) { diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index 435bfd56ba..65ea1189cb 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -3651,3 +3651,59 @@ fn assert_line_indents(snapshot: &MultiBufferSnapshot) { "reversed_line_indents({max_row})" ); } + +#[gpui::test] +fn test_new_empty_buffer_uses_untitled_title(cx: &mut App) { + let buffer = cx.new(|cx| Buffer::local("", cx)); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + + assert_eq!(multibuffer.read(cx).title(cx), "untitled"); +} + +#[gpui::test] +fn test_new_empty_buffer_uses_untitled_title_when_only_contains_whitespace(cx: &mut App) { + let buffer = cx.new(|cx| Buffer::local("\n ", cx)); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + + assert_eq!(multibuffer.read(cx).title(cx), "untitled"); +} + +#[gpui::test] +fn test_new_empty_buffer_takes_first_line_for_title(cx: &mut App) { + let buffer = cx.new(|cx| Buffer::local("Hello World\nSecond line", cx)); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + + assert_eq!(multibuffer.read(cx).title(cx), "Hello World"); +} + +#[gpui::test] +fn test_new_empty_buffer_takes_trimmed_first_line_for_title(cx: &mut App) { + let buffer = cx.new(|cx| Buffer::local("\nHello, World ", cx)); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + + assert_eq!(multibuffer.read(cx).title(cx), "Hello, World"); +} + +#[gpui::test] +fn test_new_empty_buffer_uses_truncated_first_line_for_title(cx: &mut App) { + let title_after = ["a", "b", "c", "d"] + .map(|letter| letter.repeat(10)) + .join(""); + let title = format!("{}{}", title_after, "e".repeat(10)); + let buffer = cx.new(|cx| Buffer::local(title, cx)); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + + assert_eq!(multibuffer.read(cx).title(cx), title_after); +} + +#[gpui::test] +fn test_new_empty_buffers_title_can_be_set(cx: &mut App) { + let buffer = cx.new(|cx| Buffer::local("Hello World", cx)); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + assert_eq!(multibuffer.read(cx).title(cx), "Hello World"); + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.set_title("Hey".into(), cx) + }); + assert_eq!(multibuffer.read(cx).title(cx), "Hey"); +} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 0c284661aa..22a03ea9bc 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -3027,7 +3027,7 @@ mod tests { }); cx.read(|cx| { assert!(editor.is_dirty(cx)); - assert_eq!(editor.read(cx).title(cx), "untitled"); + assert_eq!(editor.read(cx).title(cx), "hi"); }); // When the save completes, the buffer's title is updated and the language is assigned based