tasks: Add `editor: Spawn Nearest Task` action (#19901)

Thorsten Ball and Bennet created

This spawns the runnable task that that's closest to the cursor.

One thing missing right now is that it doesn't find tasks that are
attached to non-outline symbols, such as subtests in Go.

Release Notes:

- Added a new reveal option for tasks: `"no_focus"`. If used, the tasks
terminal panel will be opened and shown, but not focused.
- Added a new `editor: spawn nearest task` action that spawns the task
with a run indicator icon nearest to the cursor. It can be configured to
also use a `reveal` strategy. Example:
```json
{
  "context": "EmptyPane || SharedScreen || vim_mode == normal",
  "bindings": {
    ", r t": ["editor::SpawnNearestTask", { "reveal": "no_focus" }],
  }
}
```


Demo:



https://github.com/user-attachments/assets/0d1818f0-7ae4-4200-8c3e-0ed47550c298

---------

Co-authored-by: Bennet <bennet@zed.dev>

Change summary

assets/settings/initial_tasks.json         |   1 
crates/editor/src/actions.rs               |   8 +
crates/editor/src/editor.rs                | 173 +++++++++++++++++++----
crates/editor/src/editor_tests.rs          |  83 +++++++++++
crates/editor/src/element.rs               |   3 
crates/task/src/task_template.rs           |   2 
crates/terminal_view/src/terminal_panel.rs |  26 ++
docs/src/tasks.md                          |   1 
8 files changed, 259 insertions(+), 38 deletions(-)

Detailed changes

assets/settings/initial_tasks.json 🔗

@@ -16,6 +16,7 @@
     "allow_concurrent_runs": false,
     // What to do with the terminal pane and tab, after the command was started:
     // * `always` — always show the terminal pane, add and focus the corresponding task's tab in it (default)
+    // * `no_focus` — always show the terminal pane, add/reuse the task's tab there, but don't focus it
     // * `never` — avoid changing current terminal pane focus, but still add/reuse the task's tab there
     "reveal": "always",
     // What to do with the terminal pane and tab, after the command had finished:

crates/editor/src/actions.rs 🔗

@@ -159,6 +159,13 @@ pub struct DeleteToPreviousWordStart {
 pub struct FoldAtLevel {
     pub level: u32,
 }
+
+#[derive(PartialEq, Clone, Deserialize, Default)]
+pub struct SpawnNearestTask {
+    #[serde(default)]
+    pub reveal: task::RevealStrategy,
+}
+
 impl_actions!(
     editor,
     [
@@ -184,6 +191,7 @@ impl_actions!(
         SelectToBeginningOfLine,
         SelectToEndOfLine,
         SelectUpByLines,
+        SpawnNearestTask,
         ShowCompletions,
         ToggleCodeActions,
         ToggleComments,

crates/editor/src/editor.rs 🔗

@@ -502,6 +502,19 @@ struct RunnableTasks {
     context_range: Range<BufferOffset>,
 }
 
+impl RunnableTasks {
+    fn resolve<'a>(
+        &'a self,
+        cx: &'a task::TaskContext,
+    ) -> impl Iterator<Item = (TaskSourceKind, ResolvedTask)> + 'a {
+        self.templates.iter().filter_map(|(kind, template)| {
+            template
+                .resolve_task(&kind.to_id_base(), cx)
+                .map(|task| (kind.clone(), task))
+        })
+    }
+}
+
 #[derive(Clone)]
 struct ResolvedTasks {
     templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>,
@@ -4723,29 +4736,7 @@ impl Editor {
                             .as_ref()
                             .zip(editor.project.clone())
                             .map(|(tasks, project)| {
-                                let position = Point::new(buffer_row, tasks.column);
-                                let range_start = buffer.read(cx).anchor_at(position, Bias::Right);
-                                let location = Location {
-                                    buffer: buffer.clone(),
-                                    range: range_start..range_start,
-                                };
-                                // Fill in the environmental variables from the tree-sitter captures
-                                let mut captured_task_variables = TaskVariables::default();
-                                for (capture_name, value) in tasks.extra_variables.clone() {
-                                    captured_task_variables.insert(
-                                        task::VariableName::Custom(capture_name.into()),
-                                        value.clone(),
-                                    );
-                                }
-                                project.update(cx, |project, cx| {
-                                    project.task_store().update(cx, |task_store, cx| {
-                                        task_store.task_context_for_location(
-                                            captured_task_variables,
-                                            location,
-                                            cx,
-                                        )
-                                    })
-                                })
+                                Self::build_tasks_context(&project, &buffer, buffer_row, tasks, cx)
                             });
 
                     Some(cx.spawn(|editor, mut cx| async move {
@@ -4756,15 +4747,7 @@ impl Editor {
                         let resolved_tasks =
                             tasks.zip(task_context).map(|(tasks, task_context)| {
                                 Arc::new(ResolvedTasks {
-                                    templates: tasks
-                                        .templates
-                                        .iter()
-                                        .filter_map(|(kind, template)| {
-                                            template
-                                                .resolve_task(&kind.to_id_base(), &task_context)
-                                                .map(|task| (kind.clone(), task))
-                                        })
-                                        .collect(),
+                                    templates: tasks.resolve(&task_context).collect(),
                                     position: snapshot.buffer_snapshot.anchor_before(Point::new(
                                         multibuffer_point.row,
                                         tasks.column,
@@ -5470,6 +5453,132 @@ impl Editor {
         }
     }
 
+    fn build_tasks_context(
+        project: &Model<Project>,
+        buffer: &Model<Buffer>,
+        buffer_row: u32,
+        tasks: &Arc<RunnableTasks>,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Option<task::TaskContext>> {
+        let position = Point::new(buffer_row, tasks.column);
+        let range_start = buffer.read(cx).anchor_at(position, Bias::Right);
+        let location = Location {
+            buffer: buffer.clone(),
+            range: range_start..range_start,
+        };
+        // Fill in the environmental variables from the tree-sitter captures
+        let mut captured_task_variables = TaskVariables::default();
+        for (capture_name, value) in tasks.extra_variables.clone() {
+            captured_task_variables.insert(
+                task::VariableName::Custom(capture_name.into()),
+                value.clone(),
+            );
+        }
+        project.update(cx, |project, cx| {
+            project.task_store().update(cx, |task_store, cx| {
+                task_store.task_context_for_location(captured_task_variables, location, cx)
+            })
+        })
+    }
+
+    pub fn spawn_nearest_task(&mut self, action: &SpawnNearestTask, cx: &mut ViewContext<Self>) {
+        let Some((workspace, _)) = self.workspace.clone() else {
+            return;
+        };
+        let Some(project) = self.project.clone() else {
+            return;
+        };
+
+        // Try to find a closest, enclosing node using tree-sitter that has a
+        // task
+        let Some((buffer, buffer_row, tasks)) = self
+            .find_enclosing_node_task(cx)
+            // Or find the task that's closest in row-distance.
+            .or_else(|| self.find_closest_task(cx))
+        else {
+            return;
+        };
+
+        let reveal_strategy = action.reveal;
+        let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx);
+        cx.spawn(|_, mut cx| async move {
+            let context = task_context.await?;
+            let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?;
+
+            let resolved = resolved_task.resolved.as_mut()?;
+            resolved.reveal = reveal_strategy;
+
+            workspace
+                .update(&mut cx, |workspace, cx| {
+                    workspace::tasks::schedule_resolved_task(
+                        workspace,
+                        task_source_kind,
+                        resolved_task,
+                        false,
+                        cx,
+                    );
+                })
+                .ok()
+        })
+        .detach();
+    }
+
+    fn find_closest_task(
+        &mut self,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<(Model<Buffer>, u32, Arc<RunnableTasks>)> {
+        let cursor_row = self.selections.newest_adjusted(cx).head().row;
+
+        let ((buffer_id, row), tasks) = self
+            .tasks
+            .iter()
+            .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?;
+
+        let buffer = self.buffer.read(cx).buffer(*buffer_id)?;
+        let tasks = Arc::new(tasks.to_owned());
+        Some((buffer, *row, tasks))
+    }
+
+    fn find_enclosing_node_task(
+        &mut self,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<(Model<Buffer>, u32, Arc<RunnableTasks>)> {
+        let snapshot = self.buffer.read(cx).snapshot(cx);
+        let offset = self.selections.newest::<usize>(cx).head();
+        let excerpt = snapshot.excerpt_containing(offset..offset)?;
+        let buffer_id = excerpt.buffer().remote_id();
+
+        let layer = excerpt.buffer().syntax_layer_at(offset)?;
+        let mut cursor = layer.node().walk();
+
+        while cursor.goto_first_child_for_byte(offset).is_some() {
+            if cursor.node().end_byte() == offset {
+                cursor.goto_next_sibling();
+            }
+        }
+
+        // Ascend to the smallest ancestor that contains the range and has a task.
+        loop {
+            let node = cursor.node();
+            let node_range = node.byte_range();
+            let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row;
+
+            // Check if this node contains our offset
+            if node_range.start <= offset && node_range.end >= offset {
+                // If it contains offset, check for task
+                if let Some(tasks) = self.tasks.get(&(buffer_id, symbol_start_row)) {
+                    let buffer = self.buffer.read(cx).buffer(buffer_id)?;
+                    return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned())));
+                }
+            }
+
+            if !cursor.goto_parent() {
+                break;
+            }
+        }
+        None
+    }
+
     fn render_run_indicator(
         &self,
         _style: &EditorStyle,

crates/editor/src/editor_tests.rs 🔗

@@ -13330,6 +13330,89 @@ async fn test_goto_definition_with_find_all_references_fallback(cx: &mut gpui::T
     });
 }
 
+#[gpui::test]
+async fn test_find_enclosing_node_with_task(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
+    let language = Arc::new(Language::new(
+        LanguageConfig::default(),
+        Some(tree_sitter_rust::LANGUAGE.into()),
+    ));
+
+    let text = r#"
+        #[cfg(test)]
+        mod tests() {
+            #[test]
+            fn runnable_1() {
+                let a = 1;
+            }
+
+            #[test]
+            fn runnable_2() {
+                let a = 1;
+                let b = 2;
+            }
+        }
+    "#
+    .unindent();
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_file("/file.rs", Default::default()).await;
+
+    let project = Project::test(fs, ["/a".as_ref()], cx).await;
+    let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+    let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx);
+    let buffer = cx.new_model(|cx| Buffer::local(text, cx).with_language(language, cx));
+    let multi_buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer.clone(), cx));
+
+    let editor = cx.new_view(|cx| {
+        Editor::new(
+            EditorMode::Full,
+            multi_buffer,
+            Some(project.clone()),
+            true,
+            cx,
+        )
+    });
+
+    editor.update(cx, |editor, cx| {
+        editor.tasks.insert(
+            (buffer.read(cx).remote_id(), 3),
+            RunnableTasks {
+                templates: vec![],
+                offset: MultiBufferOffset(43),
+                column: 0,
+                extra_variables: HashMap::default(),
+                context_range: BufferOffset(43)..BufferOffset(85),
+            },
+        );
+        editor.tasks.insert(
+            (buffer.read(cx).remote_id(), 8),
+            RunnableTasks {
+                templates: vec![],
+                offset: MultiBufferOffset(86),
+                column: 0,
+                extra_variables: HashMap::default(),
+                context_range: BufferOffset(86)..BufferOffset(191),
+            },
+        );
+
+        // Test finding task when cursor is inside function body
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([Point::new(4, 5)..Point::new(4, 5)])
+        });
+        let (_, row, _) = editor.find_enclosing_node_task(cx).unwrap();
+        assert_eq!(row, 3, "Should find task for cursor inside runnable_1");
+
+        // Test finding task when cursor is on function name
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([Point::new(8, 4)..Point::new(8, 4)])
+        });
+        let (_, row, _) = editor.find_enclosing_node_task(cx).unwrap();
+        assert_eq!(row, 8, "Should find task when cursor is on function name");
+    });
+}
+
 fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
     let point = DisplayPoint::new(DisplayRow(row as u32), column as u32);
     point..point

crates/editor/src/element.rs 🔗

@@ -449,7 +449,8 @@ impl EditorElement {
         register_action(view, cx, Editor::apply_all_diff_hunks);
         register_action(view, cx, Editor::apply_selected_diff_hunks);
         register_action(view, cx, Editor::open_active_item_in_terminal);
-        register_action(view, cx, Editor::reload_file)
+        register_action(view, cx, Editor::reload_file);
+        register_action(view, cx, Editor::spawn_nearest_task);
     }
 
     fn register_key_listeners(&self, cx: &mut WindowContext, layout: &EditorLayout) {

crates/task/src/task_template.rs 🔗

@@ -66,6 +66,8 @@ pub enum RevealStrategy {
     /// Always show the terminal pane, add and focus the corresponding task's tab in it.
     #[default]
     Always,
+    /// Always show the terminal pane, add the task's tab in it, but don't focus it.
+    NoFocus,
     /// Do not change terminal pane focus, but still add/reuse the task's tab there.
     Never,
 }

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -575,9 +575,9 @@ impl TerminalPanel {
             .collect()
     }
 
-    fn activate_terminal_view(&self, item_index: usize, cx: &mut WindowContext) {
+    fn activate_terminal_view(&self, item_index: usize, focus: bool, cx: &mut WindowContext) {
         self.pane.update(cx, |pane, cx| {
-            pane.activate_item(item_index, true, true, cx)
+            pane.activate_item(item_index, true, focus, cx)
         })
     }
 
@@ -616,8 +616,14 @@ impl TerminalPanel {
                     pane.add_item(terminal_view, true, focus, None, cx);
                 });
 
-                if reveal_strategy == RevealStrategy::Always {
-                    workspace.focus_panel::<Self>(cx);
+                match reveal_strategy {
+                    RevealStrategy::Always => {
+                        workspace.focus_panel::<Self>(cx);
+                    }
+                    RevealStrategy::NoFocus => {
+                        workspace.open_panel::<Self>(cx);
+                    }
+                    RevealStrategy::Never => {}
                 }
                 Ok(terminal)
             })?;
@@ -698,7 +704,7 @@ impl TerminalPanel {
 
         match reveal {
             RevealStrategy::Always => {
-                self.activate_terminal_view(terminal_item_index, cx);
+                self.activate_terminal_view(terminal_item_index, true, cx);
                 let task_workspace = self.workspace.clone();
                 cx.spawn(|_, mut cx| async move {
                     task_workspace
@@ -707,6 +713,16 @@ impl TerminalPanel {
                 })
                 .detach();
             }
+            RevealStrategy::NoFocus => {
+                self.activate_terminal_view(terminal_item_index, false, cx);
+                let task_workspace = self.workspace.clone();
+                cx.spawn(|_, mut cx| async move {
+                    task_workspace
+                        .update(&mut cx, |workspace, cx| workspace.open_panel::<Self>(cx))
+                        .ok()
+                })
+                .detach();
+            }
             RevealStrategy::Never => {}
         }
 

docs/src/tasks.md 🔗

@@ -18,6 +18,7 @@ Zed supports ways to spawn (and rerun) commands using its integrated terminal to
     "allow_concurrent_runs": false,
     // What to do with the terminal pane and tab, after the command was started:
     // * `always` — always show the terminal pane, add and focus the corresponding task's tab in it (default)
+    // * `no_focus` — always show the terminal pane, add/reuse the task's tab there, but don't focus it
     // * `never` — avoid changing current terminal pane focus, but still add/reuse the task's tab there
     "reveal": "always",
     // What to do with the terminal pane and tab, after the command had finished: