diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index d297b5fa98..0834d7eabb 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -177,7 +177,7 @@ impl Diff { }; format!( "Diff: {}\n```\n{}\n```\n", - path.unwrap_or("untitled".into()), + path.unwrap_or(MultiBuffer::DEFAULT_TITLE.into()), buffer_text ) } @@ -260,7 +260,7 @@ impl PendingDiff { let path = new_buffer .file() .map(|file| file.path().display(file.path_style(cx))) - .unwrap_or("untitled".into()) + .unwrap_or(MultiBuffer::DEFAULT_TITLE.into()) .into(); let replica_id = new_buffer.replica_id(); diff --git a/crates/agent_ui/src/diagnostics.rs b/crates/agent_ui/src/diagnostics.rs index 5a2423cae6..75c21d68b7 100644 --- a/crates/agent_ui/src/diagnostics.rs +++ b/crates/agent_ui/src/diagnostics.rs @@ -1,6 +1,7 @@ use anyhow::Result; use gpui::{App, AppContext as _, Entity, Task}; use language::{Anchor, BufferSnapshot, DiagnosticEntryRef, DiagnosticSeverity, ToOffset}; +use multi_buffer::MultiBuffer; use project::{DiagnosticSummary, Project}; use rope::Point; use std::{fmt::Write, ops::RangeInclusive, path::Path}; @@ -22,7 +23,7 @@ pub fn codeblock_fence_for_path( write!(text, "{path}").unwrap(); } else { - write!(text, "untitled").unwrap(); + write!(text, "{}", MultiBuffer::DEFAULT_TITLE).unwrap(); } if let Some(row_range) = row_range { diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index 0dc4c1aaf2..e9d3686085 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -1253,11 +1253,10 @@ impl RatePredictionsModal { }; let file = completion.buffer.read(cx).file(); - let file_name = file - .as_ref() - .map_or(SharedString::new_static("untitled"), |file| { - file.file_name(cx).to_string().into() - }); + let file_name = file.as_ref().map_or( + SharedString::new_static(MultiBuffer::DEFAULT_TITLE), + |file| file.file_name(cx).to_string().into(), + ); let file_path = file.map(|file| file.path().as_unix_str().to_string()); ListItem::new(completion.id.clone()) diff --git a/crates/editor/src/edit_prediction.rs b/crates/editor/src/edit_prediction.rs index 43e9b2ff37..5d110eb3ce 100644 --- a/crates/editor/src/edit_prediction.rs +++ b/crates/editor/src/edit_prediction.rs @@ -2295,7 +2295,7 @@ impl Editor { let file_name = snapshot .file() .map(|file| SharedString::new(file.file_name(cx))) - .unwrap_or(SharedString::new_static("untitled")); + .unwrap_or(SharedString::new_static(MultiBuffer::DEFAULT_TITLE)); h_flex() .id("ep-jump-outside-popover") @@ -2429,7 +2429,7 @@ impl Editor { let file_name = snapshot .file() .map(|file| file.file_name(cx)) - .unwrap_or("untitled"); + .unwrap_or(MultiBuffer::DEFAULT_TITLE); Some( h_flex() .px_2() diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 73a0c3ebfd..9656732972 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -10896,7 +10896,7 @@ impl Editor { if multibuffer.is_singleton() { multibuffer.title(cx).to_string() } else { - "untitled".to_string() + MultiBuffer::DEFAULT_TITLE.to_string() } }) }); diff --git a/crates/editor/src/element/header.rs b/crates/editor/src/element/header.rs index a6611b0c90..0ba76a1376 100644 --- a/crates/editor/src/element/header.rs +++ b/crates/editor/src/element/header.rs @@ -12,7 +12,7 @@ use gpui::{ linear_color_stop, linear_gradient, point, px, size, }; use language::language_settings::ShowWhitespaceSetting; -use multi_buffer::{Anchor, ExcerptBoundaryInfo}; +use multi_buffer::{Anchor, ExcerptBoundaryInfo, MultiBuffer}; use project::Entry; use settings::{RelativeLineNumbers, Settings}; use smallvec::SmallVec; @@ -821,7 +821,7 @@ pub(crate) fn render_buffer_header( |path_header| { let filename = filename .map(SharedString::from) - .unwrap_or_else(|| "untitled".into()); + .unwrap_or_else(|| MultiBuffer::DEFAULT_TITLE.into()); let full_path = match parent_path.as_deref() { Some(parent) if !parent.is_empty() => { diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index f61546e289..4e97c8c3fe 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -18,8 +18,8 @@ use gpui::{ IntoElement, ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point, }; use language::{ - Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, Point, - SelectionGoal, proto::serialize_anchor as serialize_text_anchor, + Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, PLAIN_TEXT, + Point, SelectionGoal, proto::serialize_anchor as serialize_text_anchor, }; use lsp::DiagnosticSeverity; use multi_buffer::{BufferOffset, MultiBufferOffset, PathKey}; @@ -717,7 +717,22 @@ impl Item for Editor { } fn suggested_filename(&self, cx: &App) -> SharedString { - self.buffer.read(cx).title(cx).to_string().into() + let multi_buffer = self.buffer.read(cx); + let title = multi_buffer.title(cx); + if let Some(buffer) = multi_buffer.as_singleton() { + let buffer = buffer.read(cx); + if buffer.file().is_none() + && let Some(language) = buffer.language() + && *language != *PLAIN_TEXT + && let Some(suffix) = language.path_suffixes().first() + && !suffix.is_empty() + && !title.ends_with(&format!(".{suffix}")) + { + return format!("{title}.{suffix}").into(); + } + } + + title.to_string().into() } fn tab_icon(&self, _: &Window, cx: &App) -> Option { @@ -2386,6 +2401,95 @@ mod tests { } } + #[gpui::test] + async fn test_suggested_filename_uses_language_extension_for_untitled_buffer( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx, |_| {}); + + let buffer = cx.update(|cx| { + cx.new(|cx| Buffer::local("", cx).with_language(languages::rust_lang(), cx)) + }); + let (editor, cx) = + cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx)); + + editor.read_with(cx, |editor, cx| { + assert_eq!(editor.suggested_filename(cx).as_ref(), "untitled.rs"); + }); + } + + #[gpui::test] + async fn test_suggested_filename_appends_extension_to_content_title( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx, |_| {}); + + let buffer = cx.update(|cx| { + cx.new(|cx| { + Buffer::local("sadsdsads\nmore text", cx).with_language(languages::rust_lang(), cx) + }) + }); + let (editor, cx) = + cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx)); + + editor.read_with(cx, |editor, cx| { + assert_eq!(editor.tab_content_text(0, cx).as_ref(), "sadsdsads"); + assert_eq!(editor.suggested_filename(cx).as_ref(), "sadsdsads.rs"); + }); + } + + #[gpui::test] + async fn test_suggested_filename_does_not_duplicate_extension(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let buffer = cx.update(|cx| { + cx.new(|cx| { + Buffer::local("main.rs\nfn main() {}", cx).with_language(languages::rust_lang(), cx) + }) + }); + let (editor, cx) = + cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx)); + + editor.read_with(cx, |editor, cx| { + assert_eq!(editor.suggested_filename(cx).as_ref(), "main.rs"); + }); + } + + #[gpui::test] + async fn test_suggested_filename_keeps_content_title_for_plain_text( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx, |_| {}); + + let buffer = cx.update(|cx| { + cx.new(|cx| { + Buffer::local("shopping list\nmilk", cx) + .with_language(language::PLAIN_TEXT.clone(), cx) + }) + }); + let (editor, cx) = + cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx)); + + editor.read_with(cx, |editor, cx| { + assert_eq!(editor.suggested_filename(cx).as_ref(), "shopping list"); + }); + } + + #[gpui::test] + async fn test_suggested_filename_keeps_content_title_without_language( + cx: &mut gpui::TestAppContext, + ) { + init_test(cx, |_| {}); + + let buffer = cx.update(|cx| cx.new(|cx| Buffer::local("shopping list\nmilk", cx))); + let (editor, cx) = + cx.add_window_view(|window, cx| Editor::for_buffer(buffer, None, window, cx)); + + editor.read_with(cx, |editor, cx| { + assert_eq!(editor.suggested_filename(cx).as_ref(), "shopping list"); + }); + } + async fn deserialize_editor( item_id: ItemId, workspace_id: WorkspaceId, diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index b046380b28..a8159566da 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -236,7 +236,7 @@ impl Item for FileDiffView { .to_string(), ) }) - .unwrap_or_else(|| "untitled".into()) + .unwrap_or_else(|| MultiBuffer::DEFAULT_TITLE.into()) }; let old_filename = title_text(&self.old_buffer); let new_filename = title_text(&self.new_buffer); @@ -250,7 +250,7 @@ impl Item for FileDiffView { .read(cx) .file() .map(|file| file.full_path(cx).compact().to_string_lossy().into_owned()) - .unwrap_or_else(|| "untitled".into()) + .unwrap_or_else(|| MultiBuffer::DEFAULT_TITLE.into()) }; let old_path = path(&self.old_buffer); let new_path = path(&self.new_buffer); diff --git a/crates/git_ui/src/multi_diff_view.rs b/crates/git_ui/src/multi_diff_view.rs index 7bf7682938..47bf5a2b72 100644 --- a/crates/git_ui/src/multi_diff_view.rs +++ b/crates/git_ui/src/multi_diff_view.rs @@ -94,7 +94,7 @@ fn register_entry( RelPath::new(rel, PathStyle::local()) .map(|r| r.into_owned().into()) .unwrap_or_else(|_| { - RelPath::new(Path::new("untitled"), PathStyle::Posix) + RelPath::new(Path::new(MultiBuffer::DEFAULT_TITLE), PathStyle::Posix) .unwrap() .into_owned() .into() @@ -108,7 +108,7 @@ fn register_entry( .and_then(|s| RelPath::new(Path::new(s), PathStyle::Posix).ok()) .map(|r| r.into_owned().into()) .unwrap_or_else(|| { - RelPath::new(Path::new("untitled"), PathStyle::Posix) + RelPath::new(Path::new(MultiBuffer::DEFAULT_TITLE), PathStyle::Posix) .unwrap() .into_owned() .into() diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 991ebf2125..c9cf6b5f53 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -218,7 +218,7 @@ impl TextDiffView { .file() .map(|f| f.full_path(cx).compact().to_string_lossy().into_owned()) }) - .unwrap_or("untitled".into()); + .unwrap_or(MultiBuffer::DEFAULT_TITLE.into()); let selection_location_path = selection_location_text .map(|text| format!("{} @ {}", path, text)) diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 805ffc7355..e233f393b2 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -2114,6 +2114,9 @@ impl MultiBuffer { self.title.as_deref() } + /// The title used for buffers not backed by a file and with no title of their own. + pub const DEFAULT_TITLE: &str = "untitled"; + pub fn title<'a>(&'a self, cx: &'a App) -> Cow<'a, str> { if let Some(title) = self.title.as_ref() { return title.into(); @@ -2131,7 +2134,7 @@ impl MultiBuffer { } }; - "untitled".into() + Self::DEFAULT_TITLE.into() } fn buffer_content_title(&self, buffer: &Buffer) -> Option> {