Introduce dynamic tab titles for unsaved files based on buffer content (#32353)

Joseph T. Lyons created

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 |
|---------|---------|-----|
| <img width="227" alt="SCR-20250608-ouux"
src="https://github.com/user-attachments/assets/d02b1e50-5775-4252-86e6-6c9d3f6c72fb"
/> | <img width="230" alt="SCR-20250608-ousn"
src="https://github.com/user-attachments/assets/7c9c016b-642f-4a80-9bc1-8c9bdc7bbd32"
/> | <img width="242" alt="SCR-20250608-ovbg"
src="https://github.com/user-attachments/assets/c7f4be5c-5bba-4a2a-b477-1392ca938cd5"
/> |

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 |
|---------|---------|-----|
| <img width="233" alt="SCR-20250608-oviq"
src="https://github.com/user-attachments/assets/ccffecc6-0f46-4d1b-8739-740240bc067b"
/> | <img width="198" alt="SCR-20250608-ovkq"
src="https://github.com/user-attachments/assets/35c20149-f898-417b-aff3-dda22b8cc1f3"
/> | <img width="233" alt="SCR-20250608-ovns"
src="https://github.com/user-attachments/assets/2509e8f6-254b-4fcb-a0ea-e18e95bb685b"
/> |

Release Notes:

- Introduced dynamic tab titles for unsaved files based on buffer
content

Change summary

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(-)

Detailed changes

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

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::<String>()
+                    .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<Self>) {

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");
+}

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