Add the ability for tasks to target the center pane (#22004)

Mikayla Maki created

Closes #20060
Closes #20720
Closes #19873
Closes #9445

Release Notes:

- Fixed a bug where tasks would be spawned with their working directory
set to a file in some cases
- Added the ability to spawn tasks in the center pane, when spawning
from a keybinding:

```json5
[
  {
    // Assuming you have a task labeled "echo hello"
    "ctrl--": [
      "task::Spawn",
      { "task_name": "echo hello", "target": "center" }
    ]
  }
]
```

Change summary

Cargo.lock                                 |   2 
crates/editor/src/editor.rs                |   2 
crates/project/src/task_inventory.rs       |   4 
crates/project/src/task_store.rs           |   2 
crates/project/src/terminals.rs            |   8 -
crates/task/Cargo.toml                     |   1 
crates/task/src/lib.rs                     |  16 ++
crates/task/src/task_template.rs           |  26 ++-
crates/tasks_ui/src/lib.rs                 |   7 
crates/tasks_ui/src/modal.rs               |   3 
crates/terminal_view/src/terminal_panel.rs | 160 ++++++++++++-----------
crates/terminal_view/src/terminal_view.rs  |  61 ++++++++
crates/workspace/Cargo.toml                |   1 
crates/workspace/src/tasks.rs              |  25 +++
crates/workspace/src/workspace.rs          |   4 
crates/worktree/src/worktree.rs            |   6 
crates/zed_actions/src/lib.rs              |  17 ++
docs/src/tasks.md                          |  24 +++
18 files changed, 263 insertions(+), 106 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -12635,6 +12635,7 @@ dependencies = [
  "sha2",
  "shellexpand 2.1.2",
  "util",
+ "zed_actions",
 ]
 
 [[package]]
@@ -15603,6 +15604,7 @@ dependencies = [
  "ui",
  "util",
  "uuid",
+ "zed_actions",
 ]
 
 [[package]]

crates/editor/src/editor.rs 🔗

@@ -523,7 +523,7 @@ impl RunnableTasks {
     ) -> impl Iterator<Item = (TaskSourceKind, ResolvedTask)> + 'a {
         self.templates.iter().filter_map(|(kind, template)| {
             template
-                .resolve_task(&kind.to_id_base(), cx)
+                .resolve_task(&kind.to_id_base(), Default::default(), cx)
                 .map(|task| (kind.clone(), task))
         })
     }

crates/project/src/task_inventory.rs 🔗

@@ -177,7 +177,7 @@ impl Inventory {
                 let id_base = kind.to_id_base();
                 Some((
                     kind,
-                    task.resolve_task(&id_base, task_context)?,
+                    task.resolve_task(&id_base, Default::default(), task_context)?,
                     not_used_score,
                 ))
             })
@@ -397,7 +397,7 @@ mod test_inventory {
             let id_base = task_source_kind.to_id_base();
             inventory.task_scheduled(
                 task_source_kind.clone(),
-                task.resolve_task(&id_base, &TaskContext::default())
+                task.resolve_task(&id_base, Default::default(), &TaskContext::default())
                     .unwrap_or_else(|| panic!("Failed to resolve task with name {task_name}")),
             );
         });

crates/project/src/task_store.rs 🔗

@@ -331,7 +331,7 @@ fn local_task_context_for_location(
     let worktree_id = location.buffer.read(cx).file().map(|f| f.worktree_id(cx));
     let worktree_abs_path = worktree_id
         .and_then(|worktree_id| worktree_store.read(cx).worktree_for_id(worktree_id, cx))
-        .map(|worktree| worktree.read(cx).abs_path());
+        .and_then(|worktree| worktree.read(cx).root_dir());
 
     cx.spawn(|mut cx| async move {
         let worktree_abs_path = worktree_abs_path.clone();

crates/project/src/terminals.rs 🔗

@@ -50,13 +50,7 @@ impl Project {
             .and_then(|entry_id| self.worktree_for_entry(entry_id, cx))
             .into_iter()
             .chain(self.worktrees(cx))
-            .find_map(|tree| {
-                let worktree = tree.read(cx);
-                worktree
-                    .root_entry()
-                    .filter(|entry| entry.is_dir())
-                    .map(|_| worktree.abs_path().clone())
-            });
+            .find_map(|tree| tree.read(cx).root_dir());
         worktree
     }
 

crates/task/Cargo.toml 🔗

@@ -21,6 +21,7 @@ serde_json_lenient.workspace = true
 sha2.workspace = true
 shellexpand.workspace = true
 util.workspace = true
+zed_actions.workspace = true
 
 [dev-dependencies]
 gpui = { workspace = true, features = ["test-support"] }

crates/task/src/lib.rs 🔗

@@ -18,11 +18,11 @@ pub use vscode_format::VsCodeTaskFile;
 
 /// Task identifier, unique within the application.
 /// Based on it, task reruns and terminal tabs are managed.
-#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize, Serialize)]
 pub struct TaskId(pub String);
 
 /// Contains all information needed by Zed to spawn a new terminal tab for the given task.
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct SpawnInTerminal {
     /// Id of the task to use when determining task tab affinity.
     pub id: TaskId,
@@ -57,6 +57,15 @@ pub struct SpawnInTerminal {
     pub show_command: bool,
 }
 
+/// An action for spawning a specific task
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct NewCenterTask {
+    /// The specification of the task to spawn.
+    pub action: SpawnInTerminal,
+}
+
+gpui::impl_actions!(tasks, [NewCenterTask]);
+
 /// A final form of the [`TaskTemplate`], that got resolved with a particualar [`TaskContext`] and now is ready to spawn the actual task.
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub struct ResolvedTask {
@@ -75,6 +84,9 @@ pub struct ResolvedTask {
     /// Further actions that need to take place after the resolved task is spawned,
     /// with all task variables resolved.
     pub resolved: Option<SpawnInTerminal>,
+
+    /// where to sawn the task in the UI, either in the terminal panel or in the center pane
+    pub target: zed_actions::TaskSpawnTarget,
 }
 
 impl ResolvedTask {

crates/task/src/task_template.rs 🔗

@@ -115,7 +115,12 @@ impl TaskTemplate {
     ///
     /// Every [`ResolvedTask`] gets a [`TaskId`], based on the `id_base` (to avoid collision with various task sources),
     /// and hashes of its template and [`TaskContext`], see [`ResolvedTask`] fields' documentation for more details.
-    pub fn resolve_task(&self, id_base: &str, cx: &TaskContext) -> Option<ResolvedTask> {
+    pub fn resolve_task(
+        &self,
+        id_base: &str,
+        target: zed_actions::TaskSpawnTarget,
+        cx: &TaskContext,
+    ) -> Option<ResolvedTask> {
         if self.label.trim().is_empty() || self.command.trim().is_empty() {
             return None;
         }
@@ -214,6 +219,7 @@ impl TaskTemplate {
         Some(ResolvedTask {
             id: id.clone(),
             substituted_variables,
+            target,
             original_task: self.clone(),
             resolved_label: full_label.clone(),
             resolved: Some(SpawnInTerminal {
@@ -382,7 +388,7 @@ mod tests {
             },
         ] {
             assert_eq!(
-                task_with_blank_property.resolve_task(TEST_ID_BASE, &TaskContext::default()),
+                task_with_blank_property.resolve_task(TEST_ID_BASE, Default::default(), &TaskContext::default()),
                 None,
                 "should not resolve task with blank label and/or command: {task_with_blank_property:?}"
             );
@@ -400,7 +406,7 @@ mod tests {
 
         let resolved_task = |task_template: &TaskTemplate, task_cx| {
             let resolved_task = task_template
-                .resolve_task(TEST_ID_BASE, task_cx)
+                .resolve_task(TEST_ID_BASE, Default::default(), task_cx)
                 .unwrap_or_else(|| panic!("failed to resolve task {task_without_cwd:?}"));
             assert_substituted_variables(&resolved_task, Vec::new());
             resolved_task
@@ -526,6 +532,7 @@ mod tests {
         for i in 0..15 {
             let resolved_task = task_with_all_variables.resolve_task(
                 TEST_ID_BASE,
+                Default::default(),
                 &TaskContext {
                     cwd: None,
                     task_variables: TaskVariables::from_iter(all_variables.clone()),
@@ -614,6 +621,7 @@ mod tests {
             let removed_variable = not_all_variables.remove(i);
             let resolved_task_attempt = task_with_all_variables.resolve_task(
                 TEST_ID_BASE,
+                Default::default(),
                 &TaskContext {
                     cwd: None,
                     task_variables: TaskVariables::from_iter(not_all_variables),
@@ -633,7 +641,7 @@ mod tests {
             ..Default::default()
         };
         let resolved_task = task
-            .resolve_task(TEST_ID_BASE, &TaskContext::default())
+            .resolve_task(TEST_ID_BASE, Default::default(), &TaskContext::default())
             .unwrap();
         assert_substituted_variables(&resolved_task, Vec::new());
         let resolved = resolved_task.resolved.unwrap();
@@ -651,7 +659,7 @@ mod tests {
             ..Default::default()
         };
         assert!(task
-            .resolve_task(TEST_ID_BASE, &TaskContext::default())
+            .resolve_task(TEST_ID_BASE, Default::default(), &TaskContext::default())
             .is_none());
     }
 
@@ -701,7 +709,7 @@ mod tests {
         .enumerate()
         {
             let resolved = symbol_dependent_task
-                .resolve_task(TEST_ID_BASE, &cx)
+                .resolve_task(TEST_ID_BASE, Default::default(), &cx)
                 .unwrap_or_else(|| panic!("Failed to resolve task {symbol_dependent_task:?}"));
             assert_eq!(
                 resolved.substituted_variables,
@@ -743,7 +751,9 @@ mod tests {
         context
             .task_variables
             .insert(VariableName::Symbol, "my-symbol".to_string());
-        assert!(faulty_go_test.resolve_task("base", &context).is_some());
+        assert!(faulty_go_test
+            .resolve_task("base", Default::default(), &context)
+            .is_some());
     }
 
     #[test]
@@ -802,7 +812,7 @@ mod tests {
         };
 
         let resolved = template
-            .resolve_task(TEST_ID_BASE, &context)
+            .resolve_task(TEST_ID_BASE, Default::default(), &context)
             .unwrap()
             .resolved
             .unwrap();

crates/tasks_ui/src/lib.rs 🔗

@@ -11,6 +11,7 @@ mod modal;
 mod settings;
 
 pub use modal::{Rerun, Spawn};
+use zed_actions::TaskSpawnTarget;
 
 pub fn init(cx: &mut AppContext) {
     settings::TaskSettings::register(cx);
@@ -53,6 +54,7 @@ pub fn init(cx: &mut AppContext) {
                                             task_source_kind,
                                             &original_task,
                                             &task_context,
+                                            Default::default(),
                                             false,
                                             cx,
                                         )
@@ -89,7 +91,8 @@ pub fn init(cx: &mut AppContext) {
 
 fn spawn_task_or_modal(workspace: &mut Workspace, action: &Spawn, cx: &mut ViewContext<Workspace>) {
     match &action.task_name {
-        Some(name) => spawn_task_with_name(name.clone(), cx).detach_and_log_err(cx),
+        Some(name) => spawn_task_with_name(name.clone(), action.target.unwrap_or_default(), cx)
+            .detach_and_log_err(cx),
         None => toggle_modal(workspace, cx).detach(),
     }
 }
@@ -119,6 +122,7 @@ fn toggle_modal(workspace: &mut Workspace, cx: &mut ViewContext<'_, Workspace>)
 
 fn spawn_task_with_name(
     name: String,
+    task_target: TaskSpawnTarget,
     cx: &mut ViewContext<Workspace>,
 ) -> AsyncTask<anyhow::Result<()>> {
     cx.spawn(|workspace, mut cx| async move {
@@ -160,6 +164,7 @@ fn spawn_task_with_name(
                     task_source_kind,
                     &target_task,
                     &task_context,
+                    task_target,
                     false,
                     cx,
                 );

crates/tasks_ui/src/modal.rs 🔗

@@ -68,7 +68,7 @@ impl TasksModalDelegate {
         };
         Some((
             source_kind,
-            new_oneshot.resolve_task(&id_base, &self.task_context)?,
+            new_oneshot.resolve_task(&id_base, Default::default(), &self.task_context)?,
         ))
     }
 
@@ -684,6 +684,7 @@ mod tests {
 
         cx.dispatch_action(Spawn {
             task_name: Some("example task".to_string()),
+            target: None,
         });
         let tasks_picker = workspace.update(cx, |workspace, cx| {
             workspace

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -255,7 +255,10 @@ impl TerminalPanel {
             terminal_panel
                 .update(&mut cx, |_, cx| {
                     cx.subscribe(&workspace, |terminal_panel, _, e, cx| {
-                        if let workspace::Event::SpawnTask(spawn_in_terminal) = e {
+                        if let workspace::Event::SpawnTask {
+                            action: spawn_in_terminal,
+                        } = e
+                        {
                             terminal_panel.spawn_task(spawn_in_terminal, cx);
                         };
                     })
@@ -450,83 +453,17 @@ impl TerminalPanel {
 
     fn spawn_task(&mut self, spawn_in_terminal: &SpawnInTerminal, cx: &mut ViewContext<Self>) {
         let mut spawn_task = spawn_in_terminal.clone();
-        // Set up shell args unconditionally, as tasks are always spawned inside of a shell.
-        let Some((shell, mut user_args)) = (match spawn_in_terminal.shell.clone() {
-            Shell::System => {
-                match self
-                    .workspace
-                    .update(cx, |workspace, cx| workspace.project().read(cx).is_local())
-                {
-                    Ok(local) => {
-                        if local {
-                            retrieve_system_shell().map(|shell| (shell, Vec::new()))
-                        } else {
-                            Some(("\"${SHELL:-sh}\"".to_string(), Vec::new()))
-                        }
-                    }
-                    Err(_no_window_e) => return,
-                }
-            }
-            Shell::Program(shell) => Some((shell, Vec::new())),
-            Shell::WithArguments { program, args, .. } => Some((program, args)),
-        }) else {
+        let Ok(is_local) = self
+            .workspace
+            .update(cx, |workspace, cx| workspace.project().read(cx).is_local())
+        else {
             return;
         };
-        #[cfg(target_os = "windows")]
-        let windows_shell_type = to_windows_shell_type(&shell);
-
-        #[cfg(not(target_os = "windows"))]
-        {
-            spawn_task.command_label = format!("{shell} -i -c '{}'", spawn_task.command_label);
-        }
-        #[cfg(target_os = "windows")]
-        {
-            use crate::terminal_panel::WindowsShellType;
-
-            match windows_shell_type {
-                WindowsShellType::Powershell => {
-                    spawn_task.command_label = format!("{shell} -C '{}'", spawn_task.command_label)
-                }
-                WindowsShellType::Cmd => {
-                    spawn_task.command_label = format!("{shell} /C '{}'", spawn_task.command_label)
-                }
-                WindowsShellType::Other => {
-                    spawn_task.command_label =
-                        format!("{shell} -i -c '{}'", spawn_task.command_label)
-                }
-            }
-        }
-
-        let task_command = std::mem::replace(&mut spawn_task.command, shell);
-        let task_args = std::mem::take(&mut spawn_task.args);
-        let combined_command = task_args
-            .into_iter()
-            .fold(task_command, |mut command, arg| {
-                command.push(' ');
-                #[cfg(not(target_os = "windows"))]
-                command.push_str(&arg);
-                #[cfg(target_os = "windows")]
-                command.push_str(&to_windows_shell_variable(windows_shell_type, arg));
-                command
-            });
-
-        #[cfg(not(target_os = "windows"))]
-        user_args.extend(["-i".to_owned(), "-c".to_owned(), combined_command]);
-        #[cfg(target_os = "windows")]
+        if let ControlFlow::Break(_) =
+            Self::fill_command(is_local, spawn_in_terminal, &mut spawn_task)
         {
-            use crate::terminal_panel::WindowsShellType;
-
-            match windows_shell_type {
-                WindowsShellType::Powershell => {
-                    user_args.extend(["-C".to_owned(), combined_command])
-                }
-                WindowsShellType::Cmd => user_args.extend(["/C".to_owned(), combined_command]),
-                WindowsShellType::Other => {
-                    user_args.extend(["-i".to_owned(), "-c".to_owned(), combined_command])
-                }
-            }
+            return;
         }
-        spawn_task.args = user_args;
         let spawn_task = spawn_task;
 
         let allow_concurrent_runs = spawn_in_terminal.allow_concurrent_runs;
@@ -602,6 +539,81 @@ impl TerminalPanel {
         .detach()
     }
 
+    pub fn fill_command(
+        is_local: bool,
+        spawn_in_terminal: &SpawnInTerminal,
+        spawn_task: &mut SpawnInTerminal,
+    ) -> ControlFlow<()> {
+        let Some((shell, mut user_args)) = (match spawn_in_terminal.shell.clone() {
+            Shell::System => {
+                if is_local {
+                    retrieve_system_shell().map(|shell| (shell, Vec::new()))
+                } else {
+                    Some(("\"${SHELL:-sh}\"".to_string(), Vec::new()))
+                }
+            }
+            Shell::Program(shell) => Some((shell, Vec::new())),
+            Shell::WithArguments { program, args, .. } => Some((program, args)),
+        }) else {
+            return ControlFlow::Break(());
+        };
+        #[cfg(target_os = "windows")]
+        let windows_shell_type = to_windows_shell_type(&shell);
+        #[cfg(not(target_os = "windows"))]
+        {
+            spawn_task.command_label = format!("{shell} -i -c '{}'", spawn_task.command_label);
+        }
+        #[cfg(target_os = "windows")]
+        {
+            use crate::terminal_panel::WindowsShellType;
+
+            match windows_shell_type {
+                WindowsShellType::Powershell => {
+                    spawn_task.command_label = format!("{shell} -C '{}'", spawn_task.command_label)
+                }
+                WindowsShellType::Cmd => {
+                    spawn_task.command_label = format!("{shell} /C '{}'", spawn_task.command_label)
+                }
+                WindowsShellType::Other => {
+                    spawn_task.command_label =
+                        format!("{shell} -i -c '{}'", spawn_task.command_label)
+                }
+            }
+        }
+        let task_command = std::mem::replace(&mut spawn_task.command, shell);
+        let task_args = std::mem::take(&mut spawn_task.args);
+        let combined_command = task_args
+            .into_iter()
+            .fold(task_command, |mut command, arg| {
+                command.push(' ');
+                #[cfg(not(target_os = "windows"))]
+                command.push_str(&arg);
+                #[cfg(target_os = "windows")]
+                command.push_str(&to_windows_shell_variable(windows_shell_type, arg));
+                command
+            });
+        #[cfg(not(target_os = "windows"))]
+        user_args.extend(["-i".to_owned(), "-c".to_owned(), combined_command]);
+        #[cfg(target_os = "windows")]
+        {
+            use crate::terminal_panel::WindowsShellType;
+
+            match windows_shell_type {
+                WindowsShellType::Powershell => {
+                    user_args.extend(["-C".to_owned(), combined_command])
+                }
+                WindowsShellType::Cmd => user_args.extend(["/C".to_owned(), combined_command]),
+                WindowsShellType::Other => {
+                    user_args.extend(["-i".to_owned(), "-c".to_owned(), combined_command])
+                }
+            }
+        }
+        spawn_task.args = user_args;
+        // Set up shell args unconditionally, as tasks are always spawned inside of a shell.
+
+        ControlFlow::Continue(())
+    }
+
     pub fn spawn_in_new_terminal(
         &mut self,
         spawn_task: SpawnInTerminal,

crates/terminal_view/src/terminal_view.rs 🔗

@@ -14,6 +14,7 @@ use gpui::{
 use language::Bias;
 use persistence::TERMINAL_DB;
 use project::{search::SearchQuery, terminals::TerminalKind, Fs, Metadata, Project};
+use task::{NewCenterTask, RevealStrategy};
 use terminal::{
     alacritty_terminal::{
         index::Point,
@@ -45,7 +46,7 @@ use zed_actions::InlineAssist;
 
 use std::{
     cmp,
-    ops::RangeInclusive,
+    ops::{ControlFlow, RangeInclusive},
     path::{Path, PathBuf},
     rc::Rc,
     sync::Arc,
@@ -78,8 +79,9 @@ pub fn init(cx: &mut AppContext) {
 
     register_serializable_item::<TerminalView>(cx);
 
-    cx.observe_new_views(|workspace: &mut Workspace, _| {
+    cx.observe_new_views(|workspace: &mut Workspace, _cx| {
         workspace.register_action(TerminalView::deploy);
+        workspace.register_action(TerminalView::deploy_center_task);
     })
     .detach();
 }
@@ -127,6 +129,61 @@ impl FocusableView for TerminalView {
 }
 
 impl TerminalView {
+    pub fn deploy_center_task(
+        workspace: &mut Workspace,
+        task: &NewCenterTask,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        let reveal_strategy: RevealStrategy = task.action.reveal;
+        let mut spawn_task = task.action.clone();
+
+        let is_local = workspace.project().read(cx).is_local();
+
+        if let ControlFlow::Break(_) =
+            TerminalPanel::fill_command(is_local, &task.action, &mut spawn_task)
+        {
+            return;
+        }
+
+        let kind = TerminalKind::Task(spawn_task);
+
+        let project = workspace.project().clone();
+        let database_id = workspace.database_id();
+        cx.spawn(|workspace, mut cx| async move {
+            let terminal = cx
+                .update(|cx| {
+                    let window = cx.window_handle();
+                    project.update(cx, |project, cx| project.create_terminal(kind, window, cx))
+                })?
+                .await?;
+
+            let terminal_view = cx.new_view(|cx| {
+                TerminalView::new(terminal.clone(), workspace.clone(), database_id, cx)
+            })?;
+
+            cx.update(|cx| {
+                let focus_item = match reveal_strategy {
+                    RevealStrategy::Always => true,
+                    RevealStrategy::Never | RevealStrategy::NoFocus => false,
+                };
+
+                workspace.update(cx, |workspace, cx| {
+                    workspace.add_item_to_active_pane(
+                        Box::new(terminal_view),
+                        None,
+                        focus_item,
+                        cx,
+                    );
+                })?;
+
+                anyhow::Ok(())
+            })??;
+
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+    }
+
     ///Create a new Terminal in the current working directory or the user's home directory
     pub fn deploy(
         workspace: &mut Workspace,

crates/workspace/Cargo.toml 🔗

@@ -61,6 +61,7 @@ ui.workspace = true
 util.workspace = true
 uuid.workspace = true
 strum.workspace = true
+zed_actions.workspace = true
 
 [dev-dependencies]
 call = { workspace = true, features = ["test-support"] }

crates/workspace/src/tasks.rs 🔗

@@ -1,15 +1,17 @@
 use project::TaskSourceKind;
 use remote::ConnectionState;
-use task::{ResolvedTask, TaskContext, TaskTemplate};
+use task::{NewCenterTask, ResolvedTask, TaskContext, TaskTemplate};
 use ui::ViewContext;
+use zed_actions::TaskSpawnTarget;
 
 use crate::Workspace;
 
 pub fn schedule_task(
-    workspace: &Workspace,
+    workspace: &mut Workspace,
     task_source_kind: TaskSourceKind,
     task_to_resolve: &TaskTemplate,
     task_cx: &TaskContext,
+    task_target: zed_actions::TaskSpawnTarget,
     omit_history: bool,
     cx: &mut ViewContext<'_, Workspace>,
 ) {
@@ -27,7 +29,7 @@ pub fn schedule_task(
     }
 
     if let Some(spawn_in_terminal) =
-        task_to_resolve.resolve_task(&task_source_kind.to_id_base(), task_cx)
+        task_to_resolve.resolve_task(&task_source_kind.to_id_base(), task_target, task_cx)
     {
         schedule_resolved_task(
             workspace,
@@ -40,12 +42,13 @@ pub fn schedule_task(
 }
 
 pub fn schedule_resolved_task(
-    workspace: &Workspace,
+    workspace: &mut Workspace,
     task_source_kind: TaskSourceKind,
     mut resolved_task: ResolvedTask,
     omit_history: bool,
     cx: &mut ViewContext<'_, Workspace>,
 ) {
+    let target = resolved_task.target;
     if let Some(spawn_in_terminal) = resolved_task.resolved.take() {
         if !omit_history {
             resolved_task.resolved = Some(spawn_in_terminal.clone());
@@ -59,6 +62,18 @@ pub fn schedule_resolved_task(
                 }
             });
         }
-        cx.emit(crate::Event::SpawnTask(Box::new(spawn_in_terminal)));
+
+        match target {
+            TaskSpawnTarget::Center => {
+                cx.dispatch_action(Box::new(NewCenterTask {
+                    action: spawn_in_terminal,
+                }));
+            }
+            TaskSpawnTarget::Dock => {
+                cx.emit(crate::Event::SpawnTask {
+                    action: Box::new(spawn_in_terminal),
+                });
+            }
+        }
     }
 }

crates/workspace/src/workspace.rs 🔗

@@ -686,7 +686,9 @@ pub enum Event {
     },
     ContactRequestedJoin(u64),
     WorkspaceCreated(WeakView<Workspace>),
-    SpawnTask(Box<SpawnInTerminal>),
+    SpawnTask {
+        action: Box<SpawnInTerminal>,
+    },
     OpenBundledFile {
         text: Cow<'static, str>,
         title: &'static str,

crates/worktree/src/worktree.rs 🔗

@@ -2533,6 +2533,12 @@ impl Snapshot {
         self.entry_for_path("")
     }
 
+    pub fn root_dir(&self) -> Option<Arc<Path>> {
+        self.root_entry()
+            .filter(|entry| entry.is_dir())
+            .map(|_| self.abs_path().clone())
+    }
+
     pub fn root_name(&self) -> &str {
         &self.root_name
     }

crates/zed_actions/src/lib.rs 🔗

@@ -90,6 +90,14 @@ pub struct OpenRecent {
 gpui::impl_actions!(projects, [OpenRecent]);
 gpui::actions!(projects, [OpenRemote]);
 
+#[derive(PartialEq, Eq, Clone, Copy, Deserialize, Default, Debug)]
+#[serde(rename_all = "snake_case")]
+pub enum TaskSpawnTarget {
+    Center,
+    #[default]
+    Dock,
+}
+
 /// Spawn a task with name or open tasks modal
 #[derive(PartialEq, Clone, Deserialize, Default)]
 pub struct Spawn {
@@ -98,11 +106,18 @@ pub struct Spawn {
     /// If it is not set, a modal with a list of available tasks is opened instead.
     /// Defaults to None.
     pub task_name: Option<String>,
+    /// Which part of the UI the task should be spawned in.
+    /// Defaults to Dock.
+    #[serde(default)]
+    pub target: Option<TaskSpawnTarget>,
 }
 
 impl Spawn {
     pub fn modal() -> Self {
-        Self { task_name: None }
+        Self {
+            task_name: None,
+            target: None,
+        }
     }
 }
 

docs/src/tasks.md 🔗

@@ -155,6 +155,30 @@ You can define your own keybindings for your tasks via additional argument to `t
 }
 ```
 
+Note that these tasks can also have a 'target' specified to control where the spawned task should show up.
+This could be useful for launching a terminal application that you want to use in the center area:
+
+```json
+// In tasks.json
+{
+  "label": "start lazygit",
+  "command": "lazygit -p $ZED_WORKTREE_ROOT"
+}
+```
+
+```json
+// In keymap.json
+{
+  "context": "Workspace",
+  "bindings": {
+    "alt-g": [
+      "task::Spawn",
+      { "task_name": "start lazygit", "target": "center" }
+    ]
+  }
+}
+```
+
 ## Binding runnable tags to task templates
 
 Zed supports overriding default action for inline runnable indicators via workspace-local and global `tasks.json` file with the following precedence hierarchy: