Suggest unsaved buffer content text as the default filename (#35707)

Igal Tabachnik created

Closes #24672

This PR complements a feature added earlier by @JosephTLyons (in
https://github.com/zed-industries/zed/pull/32353) where the text is
considered as the tab title in a new buffer. It piggybacks off that
change and sets the title as the suggested filename in the save dialog
(completely mirroring the same functionality in VSCode):

![2025-08-05 11 50
28](https://github.com/user-attachments/assets/49ad9e4a-5559-44b0-a4b0-ae19890e478e)

Release Notes:

- Text entered in a new untitled buffer is considered as the default
filename when saving

Change summary

crates/editor/src/items.rs                   |  4 +++
crates/gpui/src/app.rs                       |  3 +
crates/gpui/src/platform.rs                  |  6 +++
crates/gpui/src/platform/linux/platform.rs   | 29 ++++++++++++++-------
crates/gpui/src/platform/mac/platform.rs     | 12 ++++++++
crates/gpui/src/platform/test/platform.rs    |  1 
crates/gpui/src/platform/windows/platform.rs | 20 ++++++++++++--
crates/workspace/src/item.rs                 | 11 ++++++++
crates/workspace/src/pane.rs                 |  4 ++
crates/workspace/src/workspace.rs            |  3 +
10 files changed, 75 insertions(+), 18 deletions(-)

Detailed changes

crates/editor/src/items.rs 🔗

@@ -654,6 +654,10 @@ impl Item for Editor {
         }
     }
 
+    fn suggested_filename(&self, cx: &App) -> SharedString {
+        self.buffer.read(cx).title(cx).to_string().into()
+    }
+
     fn tab_icon(&self, _: &Window, cx: &App) -> Option<Icon> {
         ItemSettings::get_global(cx)
             .file_icons

crates/gpui/src/app.rs 🔗

@@ -816,8 +816,9 @@ impl App {
     pub fn prompt_for_new_path(
         &self,
         directory: &Path,
+        suggested_name: Option<&str>,
     ) -> oneshot::Receiver<Result<Option<PathBuf>>> {
-        self.platform.prompt_for_new_path(directory)
+        self.platform.prompt_for_new_path(directory, suggested_name)
     }
 
     /// Reveals the specified path at the platform level, such as in Finder on macOS.

crates/gpui/src/platform.rs 🔗

@@ -220,7 +220,11 @@ pub(crate) trait Platform: 'static {
         &self,
         options: PathPromptOptions,
     ) -> oneshot::Receiver<Result<Option<Vec<PathBuf>>>>;
-    fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver<Result<Option<PathBuf>>>;
+    fn prompt_for_new_path(
+        &self,
+        directory: &Path,
+        suggested_name: Option<&str>,
+    ) -> oneshot::Receiver<Result<Option<PathBuf>>>;
     fn can_select_mixed_files_and_dirs(&self) -> bool;
     fn reveal_path(&self, path: &Path);
     fn open_with_system(&self, path: &Path);

crates/gpui/src/platform/linux/platform.rs 🔗

@@ -327,26 +327,35 @@ impl<P: LinuxClient + 'static> Platform for P {
         done_rx
     }
 
-    fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver<Result<Option<PathBuf>>> {
+    fn prompt_for_new_path(
+        &self,
+        directory: &Path,
+        suggested_name: Option<&str>,
+    ) -> oneshot::Receiver<Result<Option<PathBuf>>> {
         let (done_tx, done_rx) = oneshot::channel();
 
         #[cfg(not(any(feature = "wayland", feature = "x11")))]
-        let _ = (done_tx.send(Ok(None)), directory);
+        let _ = (done_tx.send(Ok(None)), directory, suggested_name);
 
         #[cfg(any(feature = "wayland", feature = "x11"))]
         self.foreground_executor()
             .spawn({
                 let directory = directory.to_owned();
+                let suggested_name = suggested_name.map(|s| s.to_owned());
 
                 async move {
-                    let request = match ashpd::desktop::file_chooser::SaveFileRequest::default()
-                        .modal(true)
-                        .title("Save File")
-                        .current_folder(directory)
-                        .expect("pathbuf should not be nul terminated")
-                        .send()
-                        .await
-                    {
+                    let mut request_builder =
+                        ashpd::desktop::file_chooser::SaveFileRequest::default()
+                            .modal(true)
+                            .title("Save File")
+                            .current_folder(directory)
+                            .expect("pathbuf should not be nul terminated");
+
+                    if let Some(suggested_name) = suggested_name {
+                        request_builder = request_builder.current_name(suggested_name.as_str());
+                    }
+
+                    let request = match request_builder.send().await {
                         Ok(request) => request,
                         Err(err) => {
                             let result = match err {

crates/gpui/src/platform/mac/platform.rs 🔗

@@ -737,8 +737,13 @@ impl Platform for MacPlatform {
         done_rx
     }
 
-    fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver<Result<Option<PathBuf>>> {
+    fn prompt_for_new_path(
+        &self,
+        directory: &Path,
+        suggested_name: Option<&str>,
+    ) -> oneshot::Receiver<Result<Option<PathBuf>>> {
         let directory = directory.to_owned();
+        let suggested_name = suggested_name.map(|s| s.to_owned());
         let (done_tx, done_rx) = oneshot::channel();
         self.foreground_executor()
             .spawn(async move {
@@ -748,6 +753,11 @@ impl Platform for MacPlatform {
                     let url = NSURL::fileURLWithPath_isDirectory_(nil, path, true.to_objc());
                     panel.setDirectoryURL(url);
 
+                    if let Some(suggested_name) = suggested_name {
+                        let name_string = ns_string(&suggested_name);
+                        let _: () = msg_send![panel, setNameFieldStringValue: name_string];
+                    }
+
                     let done_tx = Cell::new(Some(done_tx));
                     let block = ConcreteBlock::new(move |response: NSModalResponse| {
                         let mut result = None;

crates/gpui/src/platform/test/platform.rs 🔗

@@ -336,6 +336,7 @@ impl Platform for TestPlatform {
     fn prompt_for_new_path(
         &self,
         directory: &std::path::Path,
+        _suggested_name: Option<&str>,
     ) -> oneshot::Receiver<Result<Option<std::path::PathBuf>>> {
         let (tx, rx) = oneshot::channel();
         self.background_executor()

crates/gpui/src/platform/windows/platform.rs 🔗

@@ -490,13 +490,18 @@ impl Platform for WindowsPlatform {
         rx
     }
 
-    fn prompt_for_new_path(&self, directory: &Path) -> Receiver<Result<Option<PathBuf>>> {
+    fn prompt_for_new_path(
+        &self,
+        directory: &Path,
+        suggested_name: Option<&str>,
+    ) -> Receiver<Result<Option<PathBuf>>> {
         let directory = directory.to_owned();
+        let suggested_name = suggested_name.map(|s| s.to_owned());
         let (tx, rx) = oneshot::channel();
         let window = self.find_current_active_window();
         self.foreground_executor()
             .spawn(async move {
-                let _ = tx.send(file_save_dialog(directory, window));
+                let _ = tx.send(file_save_dialog(directory, suggested_name, window));
             })
             .detach();
 
@@ -804,7 +809,11 @@ fn file_open_dialog(
     Ok(Some(paths))
 }
 
-fn file_save_dialog(directory: PathBuf, window: Option<HWND>) -> Result<Option<PathBuf>> {
+fn file_save_dialog(
+    directory: PathBuf,
+    suggested_name: Option<String>,
+    window: Option<HWND>,
+) -> Result<Option<PathBuf>> {
     let dialog: IFileSaveDialog = unsafe { CoCreateInstance(&FileSaveDialog, None, CLSCTX_ALL)? };
     if !directory.to_string_lossy().is_empty() {
         if let Some(full_path) = directory.canonicalize().log_err() {
@@ -815,6 +824,11 @@ fn file_save_dialog(directory: PathBuf, window: Option<HWND>) -> Result<Option<P
             unsafe { dialog.SetFolder(&path_item).log_err() };
         }
     }
+
+    if let Some(suggested_name) = suggested_name {
+        unsafe { dialog.SetFileName(&HSTRING::from(suggested_name)).log_err() };
+    }
+
     unsafe {
         dialog.SetFileTypes(&[Common::COMDLG_FILTERSPEC {
             pszName: windows::core::w!("All files"),

crates/workspace/src/item.rs 🔗

@@ -270,6 +270,12 @@ pub trait Item: Focusable + EventEmitter<Self::Event> + Render + Sized {
     /// Returns the textual contents of the tab.
     fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString;
 
+    /// Returns the suggested filename for saving this item.
+    /// By default, returns the tab content text.
+    fn suggested_filename(&self, cx: &App) -> SharedString {
+        self.tab_content_text(0, cx)
+    }
+
     fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
         None
     }
@@ -497,6 +503,7 @@ pub trait ItemHandle: 'static + Send {
     ) -> gpui::Subscription;
     fn tab_content(&self, params: TabContentParams, window: &Window, cx: &App) -> AnyElement;
     fn tab_content_text(&self, detail: usize, cx: &App) -> SharedString;
+    fn suggested_filename(&self, cx: &App) -> SharedString;
     fn tab_icon(&self, window: &Window, cx: &App) -> Option<Icon>;
     fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString>;
     fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent>;
@@ -631,6 +638,10 @@ impl<T: Item> ItemHandle for Entity<T> {
         self.read(cx).tab_content_text(detail, cx)
     }
 
+    fn suggested_filename(&self, cx: &App) -> SharedString {
+        self.read(cx).suggested_filename(cx)
+    }
+
     fn tab_icon(&self, window: &Window, cx: &App) -> Option<Icon> {
         self.read(cx).tab_icon(window, cx)
     }

crates/workspace/src/pane.rs 🔗

@@ -2062,6 +2062,8 @@ impl Pane {
                 })?
                 .await?;
             } else if can_save_as && is_singleton {
+                let suggested_name =
+                    cx.update(|_window, cx| item.suggested_filename(cx).to_string())?;
                 let new_path = pane.update_in(cx, |pane, window, cx| {
                     pane.activate_item(item_ix, true, true, window, cx);
                     pane.workspace.update(cx, |workspace, cx| {
@@ -2073,7 +2075,7 @@ impl Pane {
                         } else {
                             DirectoryLister::Project(workspace.project().clone())
                         };
-                        workspace.prompt_for_new_path(lister, window, cx)
+                        workspace.prompt_for_new_path(lister, Some(suggested_name), window, cx)
                     })
                 })??;
                 let Some(new_path) = new_path.await.ok().flatten().into_iter().flatten().next()

crates/workspace/src/workspace.rs 🔗

@@ -2067,6 +2067,7 @@ impl Workspace {
     pub fn prompt_for_new_path(
         &mut self,
         lister: DirectoryLister,
+        suggested_name: Option<String>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> oneshot::Receiver<Option<Vec<PathBuf>>> {
@@ -2094,7 +2095,7 @@ impl Workspace {
                     })
                     .or_else(std::env::home_dir)
                     .unwrap_or_else(|| PathBuf::from(""));
-                cx.prompt_for_new_path(&relative_to)
+                cx.prompt_for_new_path(&relative_to, suggested_name.as_deref())
             })?;
             let abs_path = match abs_path.await? {
                 Ok(path) => path,