terminal_view: Add support for opening the terminal in the current file directory (#47739)

Ruben Fricke created

Closes #14863

Changes:
- Added `CurrentFileDirectory` variant to the `WorkingDirectory` enum.
- New terminals now open in the directory of the currently active file
when this option is set.
- Falls back to project directory, then first workspace directory if no
file is active.

Release Notes:

- Added `current_file_directory` option for terminal's
`working_directory` setting. Set `"working_directory":
current_file_directory"` to open new terminals in the directory of your
currently open file.

---

Still relatively new to Rust, so happy to receive any feedback! 😁

Change summary

assets/settings/default.json              | 13 ++-
crates/project/src/terminals.rs           | 14 ++++
crates/settings_content/src/terminal.rs   |  3 
crates/settings_ui/src/page_data.rs       |  4 +
crates/terminal_view/src/terminal_view.rs | 82 +++++++++++++++++++++++-
docs/src/reference/all-settings.md        | 18 ++++-
docs/src/terminal.md                      | 13 ++-
7 files changed, 127 insertions(+), 20 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -1596,15 +1596,18 @@
     // Default height when the terminal is docked to the bottom.
     "default_height": 320,
     // What working directory to use when launching the terminal.
-    // May take 4 values:
-    // 1. Use the current file's project directory. Fallback to the
+    // May take 5 values:
+    // 1. Use the current file's directory, falling back to the project
+    //    directory, then the first project in the workspace.
+    //      "working_directory": "current_file_directory"
+    // 2. Use the current file's project directory. Fallback to the
     //    first project directory strategy if unsuccessful
     //      "working_directory": "current_project_directory"
-    // 2. Use the first project in this workspace's directory
+    // 3. Use the first project in this workspace's directory
     //      "working_directory": "first_project_directory"
-    // 3. Always use this platform's home directory (if we can find it)
+    // 4. Always use this platform's home directory (if we can find it)
     //     "working_directory": "always_home"
-    // 4. Always use a specific directory. This value will be shell expanded.
+    // 5. Always use a specific directory. This value will be shell expanded.
     //    If this path is not a valid directory the terminal will default to
     //    this platform's home directory  (if we can find it)
     //      "working_directory": {

crates/project/src/terminals.rs 🔗

@@ -26,6 +26,20 @@ pub struct Terminals {
 }
 
 impl Project {
+    pub fn active_entry_directory(&self, cx: &App) -> Option<PathBuf> {
+        let entry_id = self.active_entry()?;
+        let worktree = self.worktree_for_entry(entry_id, cx)?;
+        let worktree = worktree.read(cx);
+        let entry = worktree.entry_for_id(entry_id)?;
+
+        let absolute_path = worktree.absolutize(entry.path.as_ref());
+        if entry.is_dir() {
+            Some(absolute_path)
+        } else {
+            absolute_path.parent().map(|p| p.to_path_buf())
+        }
+    }
+
     pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
         self.active_entry()
             .and_then(|entry_id| self.worktree_for_entry(entry_id, cx))

crates/settings_content/src/terminal.rs 🔗

@@ -219,6 +219,9 @@ pub enum Shell {
 #[strum_discriminants(derive(strum::VariantArray, strum::VariantNames, strum::FromRepr))]
 #[serde(rename_all = "snake_case")]
 pub enum WorkingDirectory {
+    /// Use the current file's directory, falling back to the project directory,
+    /// then the first project in the workspace.
+    CurrentFileDirectory,
     /// Use the current file's project directory. Fallback to the
     /// first project directory strategy if unsuccessful.
     CurrentProjectDirectory,

crates/settings_ui/src/page_data.rs 🔗

@@ -5762,6 +5762,9 @@ fn terminal_page() -> SettingsPage {
                                     .working_directory
                                     .get_or_insert_with(|| settings::WorkingDirectory::CurrentProjectDirectory);
                                 *settings_value = match value {
+                                    settings::WorkingDirectoryDiscriminants::CurrentFileDirectory => {
+                                        settings::WorkingDirectory::CurrentFileDirectory
+                                    },
                                     settings::WorkingDirectoryDiscriminants::CurrentProjectDirectory => {
                                         settings::WorkingDirectory::CurrentProjectDirectory
                                     }
@@ -5797,6 +5800,7 @@ fn terminal_page() -> SettingsPage {
                     fields: dynamic_variants::<settings::WorkingDirectory>()
                         .into_iter()
                         .map(|variant| match variant {
+                            settings::WorkingDirectoryDiscriminants::CurrentFileDirectory => vec![],
                             settings::WorkingDirectoryDiscriminants::CurrentProjectDirectory => vec![],
                             settings::WorkingDirectoryDiscriminants::FirstProjectDirectory => vec![],
                             settings::WorkingDirectoryDiscriminants::AlwaysHome => vec![],

crates/terminal_view/src/terminal_view.rs 🔗

@@ -1785,13 +1785,12 @@ impl SearchableItem for TerminalView {
 /// Falls back to home directory when no project directory is available.
 pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
     let directory = match &TerminalSettings::get_global(cx).working_directory {
-        WorkingDirectory::CurrentProjectDirectory => workspace
+        WorkingDirectory::CurrentFileDirectory => workspace
             .project()
             .read(cx)
-            .active_project_directory(cx)
-            .as_deref()
-            .map(Path::to_path_buf)
-            .or_else(|| first_project_directory(workspace, cx)),
+            .active_entry_directory(cx)
+            .or_else(|| current_project_directory(workspace, cx)),
+        WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx),
         WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
         WorkingDirectory::AlwaysHome => None,
         WorkingDirectory::Always { directory } => shellexpand::full(directory)
@@ -1801,6 +1800,17 @@ pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Opti
     };
     directory.or_else(dirs::home_dir)
 }
+
+fn current_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
+    workspace
+        .project()
+        .read(cx)
+        .active_project_directory(cx)
+        .as_deref()
+        .map(Path::to_path_buf)
+        .or_else(|| first_project_directory(workspace, cx))
+}
+
 ///Gets the first project's home directory, or the home directory
 fn first_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
     let worktree = workspace.worktrees(cx).next()?.read(cx);
@@ -1819,6 +1829,7 @@ mod tests {
     use gpui::TestAppContext;
     use project::{Entry, Project, ProjectPath, Worktree};
     use std::path::Path;
+    use util::paths::PathStyle;
     use util::rel_path::RelPath;
     use workspace::AppState;
 
@@ -1928,6 +1939,67 @@ mod tests {
         });
     }
 
+    // active_entry_directory: No active entry -> returns None (used by CurrentFileDirectory)
+    #[gpui::test]
+    async fn active_entry_directory_no_active_entry(cx: &mut TestAppContext) {
+        let (project, _workspace) = init_test(cx).await;
+
+        let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
+
+        cx.update(|cx| {
+            assert!(project.read(cx).active_entry().is_none());
+
+            let res = project.read(cx).active_entry_directory(cx);
+            assert_eq!(res, None);
+        });
+    }
+
+    // active_entry_directory: Active entry is file -> returns parent directory (used by CurrentFileDirectory)
+    #[gpui::test]
+    async fn active_entry_directory_active_file(cx: &mut TestAppContext) {
+        let (project, _workspace) = init_test(cx).await;
+
+        let (wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
+        let entry = cx
+            .update(|cx| {
+                wt.update(cx, |wt, cx| {
+                    wt.create_entry(
+                        RelPath::new(Path::new("src/main.rs"), PathStyle::local())
+                            .unwrap()
+                            .as_ref()
+                            .into(),
+                        false,
+                        None,
+                        cx,
+                    )
+                })
+            })
+            .await
+            .unwrap()
+            .into_included()
+            .unwrap();
+        insert_active_entry_for(wt, entry, project.clone(), cx);
+
+        cx.update(|cx| {
+            let res = project.read(cx).active_entry_directory(cx);
+            assert_eq!(res, Some(Path::new("/root/src").to_path_buf()));
+        });
+    }
+
+    // active_entry_directory: Active entry is directory -> returns that directory (used by CurrentFileDirectory)
+    #[gpui::test]
+    async fn active_entry_directory_active_dir(cx: &mut TestAppContext) {
+        let (project, _workspace) = init_test(cx).await;
+
+        let (wt, entry) = create_folder_wt(project.clone(), "/root/", cx).await;
+        insert_active_entry_for(wt, entry, project.clone(), cx);
+
+        cx.update(|cx| {
+            let res = project.read(cx).active_entry_directory(cx);
+            assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
+        });
+    }
+
     /// Creates a worktree with 1 file: /root.txt
     pub async fn init_test(cx: &mut TestAppContext) -> (Entity<Project>, Entity<Workspace>) {
         let params = cx.update(AppState::test);

docs/src/reference/all-settings.md 🔗

@@ -4112,7 +4112,17 @@ Example command to set the title: `echo -e "\e]2;New Title\007";`
 
 **Options**
 
-1. Use the current file's project directory. Fallback to the first project directory strategy if unsuccessful.
+1. Use the current file's directory, falling back to the project directory, then the first project in the workspace.
+
+```json [settings]
+{
+  "terminal": {
+    "working_directory": "current_file_directory"
+  }
+}
+```
+
+2. Use the current file's project directory. Fallback to the first project directory strategy if unsuccessful.
 
 ```json [settings]
 {
@@ -4122,7 +4132,7 @@ Example command to set the title: `echo -e "\e]2;New Title\007";`
 }
 ```
 
-2. Use the first project in this workspace's directory. Fallback to using this platform's home directory.
+3. Use the first project in this workspace's directory. Fallback to using this platform's home directory.
 
 ```json [settings]
 {
@@ -4132,7 +4142,7 @@ Example command to set the title: `echo -e "\e]2;New Title\007";`
 }
 ```
 
-3. Always use this platform's home directory if it can be found.
+4. Always use this platform's home directory if it can be found.
 
 ```json [settings]
 {
@@ -4142,7 +4152,7 @@ Example command to set the title: `echo -e "\e]2;New Title\007";`
 }
 ```
 
-4. Always use a specific directory. This value will be shell expanded. If this path is not a valid directory the terminal will default to this platform's home directory.
+5. Always use a specific directory. This value will be shell expanded. If this path is not a valid directory the terminal will default to this platform's home directory.
 
 ```json [settings]
 {

docs/src/terminal.md 🔗

@@ -58,12 +58,13 @@ To pass arguments to your shell:
 
 Control where new terminals start:
 
-| Value                                         | Behavior                                                        |
-| --------------------------------------------- | --------------------------------------------------------------- |
-| `"current_project_directory"`                 | Uses the project directory of the currently open file (default) |
-| `"first_project_directory"`                   | Uses the first project in your workspace                        |
-| `"always_home"`                               | Always starts in your home directory                            |
-| `{ "always": { "directory": "~/projects" } }` | Always starts in a specific directory                           |
+| Value                                         | Behavior                                                                                                          |
+| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
+| `"current_file_directory"`                    | Uses the current file's directory, falling back to the project directory, then the first project in the workspace |
+| `"current_project_directory"`                 | Uses the current file's project directory (default)                                                               |
+| `"first_project_directory"`                   | Uses the first project in your workspace                                                                          |
+| `"always_home"`                               | Always starts in your home directory                                                                              |
+| `{ "always": { "directory": "~/projects" } }` | Always starts in a specific directory                                                                             |
 
 ```json [settings]
 {