tasks: Add spawn option by tag (#25650)

Artem Evsikov and Kirill Bulatov created

Closes #19497
Fixed conflicts from https://github.com/zed-industries/zed/pull/19498
Added tags to tasks selector

Release Notes:

- Added ability to spawn tasks by tag with key bindings
- Added tags to tasks selector


https://github.com/user-attachments/assets/0eefea21-ec4e-407c-9d4f-2a0a4a0f74df

---------

Co-authored-by: Kirill Bulatov <mail4score@gmail.com>

Change summary

assets/keymaps/default-linux.json  |  2 
assets/keymaps/default-macos.json  |  2 
assets/settings/initial_tasks.json |  4 +
crates/task/src/lib.rs             |  1 
crates/task/src/serde_helpers.rs   | 64 ++++++++++++++++++++++++++
crates/task/src/task_template.rs   | 12 ++--
crates/tasks_ui/src/modal.rs       | 38 ++++++++++++++-
crates/tasks_ui/src/tasks_ui.rs    | 76 ++++++++++++++++++++++---------
crates/zed_actions/src/lib.rs      |  6 ++
docs/src/tasks.md                  |  2 
10 files changed, 174 insertions(+), 33 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -482,6 +482,8 @@
       "alt-shift-r": ["task::Spawn", { "reveal_target": "center" }]
       // also possible to spawn tasks by name:
       // "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }]
+      // or by tag:
+      // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }],
     }
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -608,6 +608,8 @@
       "ctrl-alt-shift-r": ["task::Spawn", { "reveal_target": "center" }]
       // also possible to spawn tasks by name:
       // "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }]
+      // or by tag:
+      // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }],
     }
   },
   // Bindings from Sublime Text

assets/settings/initial_tasks.json 🔗

@@ -43,6 +43,8 @@
     //           "args": ["--login"]
     //         }
     //     }
-    "shell": "system"
+    "shell": "system",
+    // Represents the tags for inline runnable indicators, or spawning multiple tasks at once.
+    "tags": []
   }
 ]

crates/task/src/lib.rs 🔗

@@ -2,6 +2,7 @@
 #![deny(missing_docs)]
 
 mod debug_format;
+mod serde_helpers;
 pub mod static_source;
 mod task_template;
 mod vscode_format;

crates/task/src/serde_helpers.rs 🔗

@@ -0,0 +1,64 @@
+use schemars::{
+    SchemaGenerator,
+    schema::{ArrayValidation, InstanceType, Schema, SchemaObject, SingleOrVec, StringValidation},
+};
+use serde::de::{self, Deserializer, Visitor};
+use std::fmt;
+
+/// Generates a JSON schema for a non-empty string array.
+pub fn non_empty_string_vec_json_schema(_: &mut SchemaGenerator) -> Schema {
+    Schema::Object(SchemaObject {
+        instance_type: Some(InstanceType::Array.into()),
+        array: Some(Box::new(ArrayValidation {
+            unique_items: Some(true),
+            items: Some(SingleOrVec::Single(Box::new(Schema::Object(
+                SchemaObject {
+                    instance_type: Some(InstanceType::String.into()),
+                    string: Some(Box::new(StringValidation {
+                        min_length: Some(1), // Ensures string in the array is non-empty
+                        ..Default::default()
+                    })),
+                    ..Default::default()
+                },
+            )))),
+            ..Default::default()
+        })),
+        format: Some("vec-of-non-empty-strings".to_string()), // Use a custom format keyword
+        ..Default::default()
+    })
+}
+
+/// Deserializes a non-empty string array.
+pub fn non_empty_string_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    struct NonEmptyStringVecVisitor;
+
+    impl<'de> Visitor<'de> for NonEmptyStringVecVisitor {
+        type Value = Vec<String>;
+
+        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+            formatter.write_str("a list of non-empty strings")
+        }
+
+        fn visit_seq<V>(self, mut seq: V) -> Result<Vec<String>, V::Error>
+        where
+            V: de::SeqAccess<'de>,
+        {
+            let mut vec = Vec::new();
+            while let Some(value) = seq.next_element::<String>()? {
+                if value.is_empty() {
+                    return Err(de::Error::invalid_value(
+                        de::Unexpected::Str(&value),
+                        &"a non-empty string",
+                    ));
+                }
+                vec.push(value);
+            }
+            Ok(vec)
+        }
+    }
+
+    deserializer.deserialize_seq(NonEmptyStringVecVisitor)
+}

crates/task/src/task_template.rs 🔗

@@ -1,16 +1,16 @@
-use std::path::PathBuf;
-use util::serde::default_true;
-
 use anyhow::{Context, bail};
 use collections::{HashMap, HashSet};
 use schemars::{JsonSchema, r#gen::SchemaSettings};
 use serde::{Deserialize, Serialize};
 use sha2::{Digest, Sha256};
+use std::path::PathBuf;
+use util::serde::default_true;
 use util::{ResultExt, truncate_and_remove_front};
 
 use crate::{
     AttachConfig, ResolvedTask, RevealTarget, Shell, SpawnInTerminal, TCPHost, TaskContext, TaskId,
     VariableName, ZED_VARIABLE_NAME_PREFIX,
+    serde_helpers::{non_empty_string_vec, non_empty_string_vec_json_schema},
 };
 
 /// A template definition of a Zed task to run.
@@ -61,8 +61,10 @@ pub struct TaskTemplate {
     /// If this task should start a debugger or not
     #[serde(default, skip)]
     pub task_type: TaskType,
-    /// Represents the tags which this template attaches to. Adding this removes this task from other UI.
-    #[serde(default)]
+    /// Represents the tags which this template attaches to.
+    /// Adding this removes this task from other UI and gives you ability to run it by tag.
+    #[serde(default, deserialize_with = "non_empty_string_vec")]
+    #[schemars(schema_with = "non_empty_string_vec_json_schema")]
     pub tags: Vec<String>,
     /// Which shell to use when spawning the task.
     #[serde(default)]

crates/tasks_ui/src/modal.rs 🔗

@@ -15,10 +15,11 @@ use task::{
 };
 use ui::{
     ActiveTheme, Button, ButtonCommon, ButtonSize, Clickable, Color, FluentBuilder as _, Icon,
-    IconButton, IconButtonShape, IconName, IconSize, IntoElement, KeyBinding, LabelSize, ListItem,
-    ListItemSpacing, RenderOnce, Toggleable, Tooltip, div, h_flex, v_flex,
+    IconButton, IconButtonShape, IconName, IconSize, IntoElement, KeyBinding, Label, LabelSize,
+    ListItem, ListItemSpacing, RenderOnce, Toggleable, Tooltip, div, h_flex, v_flex,
 };
-use util::ResultExt;
+
+use util::{ResultExt, truncate_and_trailoff};
 use workspace::{ModalView, Workspace, tasks::schedule_resolved_task};
 pub use zed_actions::{Rerun, Spawn};
 
@@ -187,6 +188,8 @@ impl Focusable for TasksModal {
 
 impl ModalView for TasksModal {}
 
+const MAX_TAGS_LINE_LEN: usize = 30;
+
 impl PickerDelegate for TasksModalDelegate {
     type ListItem = ListItem;
 
@@ -398,6 +401,18 @@ impl PickerDelegate for TasksModalDelegate {
                 tooltip_label_text.push_str(&resolved.command_label);
             }
         }
+        if template.tags.len() > 0 {
+            tooltip_label_text.push('\n');
+            tooltip_label_text.push_str(
+                template
+                    .tags
+                    .iter()
+                    .map(|tag| format!("\n#{}", tag))
+                    .collect::<Vec<_>>()
+                    .join("")
+                    .as_str(),
+            );
+        }
         let tooltip_label = if tooltip_label_text.trim().is_empty() {
             None
         } else {
@@ -439,7 +454,22 @@ impl PickerDelegate for TasksModalDelegate {
             ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
                 .inset(true)
                 .start_slot::<Icon>(icon)
-                .end_slot::<AnyElement>(history_run_icon)
+                .end_slot::<AnyElement>(
+                    h_flex()
+                        .gap_1()
+                        .child(Label::new(truncate_and_trailoff(
+                            &template
+                                .tags
+                                .iter()
+                                .map(|tag| format!("#{}", tag))
+                                .collect::<Vec<_>>()
+                                .join(" "),
+                            MAX_TAGS_LINE_LEN,
+                        )))
+                        .flex_none()
+                        .child(history_run_icon.unwrap())
+                        .into_any_element(),
+                )
                 .spacing(ListItemSpacing::Sparse)
                 .when_some(tooltip_label, |list_item, item_label| {
                     list_item.tooltip(move |_, _| item_label.clone())

crates/tasks_ui/src/tasks_ui.rs 🔗

@@ -6,8 +6,10 @@ use editor::Editor;
 use feature_flags::{Debugger, FeatureFlagViewExt};
 use gpui::{App, AppContext as _, Context, Entity, Task, Window};
 use modal::{TaskOverrides, TasksModal};
-use project::{Location, TaskContexts, Worktree};
-use task::{RevealTarget, TaskContext, TaskId, TaskModal, TaskVariables, VariableName};
+use project::{Location, TaskContexts, TaskSourceKind, Worktree};
+use task::{
+    RevealTarget, TaskContext, TaskId, TaskModal, TaskTemplate, TaskVariables, VariableName,
+};
 use workspace::tasks::schedule_task;
 use workspace::{Workspace, tasks::schedule_resolved_task};
 
@@ -117,7 +119,25 @@ fn spawn_task_or_modal(
             let overrides = reveal_target.map(|reveal_target| TaskOverrides {
                 reveal_target: Some(reveal_target),
             });
-            spawn_task_with_name(task_name.clone(), overrides, window, cx).detach_and_log_err(cx)
+            let name = task_name.clone();
+            spawn_tasks_filtered(move |(_, task)| task.label.eq(&name), overrides, window, cx)
+                .detach_and_log_err(cx)
+        }
+        Spawn::ByTag {
+            task_tag,
+            reveal_target,
+        } => {
+            let overrides = reveal_target.map(|reveal_target| TaskOverrides {
+                reveal_target: Some(reveal_target),
+            });
+            let tag = task_tag.clone();
+            spawn_tasks_filtered(
+                move |(_, task)| task.tags.contains(&tag),
+                overrides,
+                window,
+                cx,
+            )
+            .detach_and_log_err(cx)
         }
         Spawn::ViaModal { reveal_target } => toggle_modal(
             workspace,
@@ -169,18 +189,21 @@ pub fn toggle_modal(
     }
 }
 
-fn spawn_task_with_name(
-    name: String,
+fn spawn_tasks_filtered<F>(
+    mut predicate: F,
     overrides: Option<TaskOverrides>,
     window: &mut Window,
     cx: &mut Context<Workspace>,
-) -> Task<anyhow::Result<()>> {
+) -> Task<anyhow::Result<()>>
+where
+    F: FnMut((&TaskSourceKind, &TaskTemplate)) -> bool + 'static,
+{
     cx.spawn_in(window, async move |workspace, cx| {
         let task_contexts = workspace.update_in(cx, |workspace, window, cx| {
             task_contexts(workspace, window, cx)
         })?;
         let task_contexts = task_contexts.await;
-        let tasks = workspace.update(cx, |workspace, cx| {
+        let mut tasks = workspace.update(cx, |workspace, cx| {
             let Some(task_inventory) = workspace
                 .project()
                 .read(cx)
@@ -208,24 +231,31 @@ fn spawn_task_with_name(
 
         let did_spawn = workspace
             .update(cx, |workspace, cx| {
-                let (task_source_kind, mut target_task) =
-                    tasks.into_iter().find(|(_, task)| task.label == name)?;
-                if let Some(overrides) = &overrides {
-                    if let Some(target_override) = overrides.reveal_target {
-                        target_task.reveal_target = target_override;
-                    }
-                }
                 let default_context = TaskContext::default();
                 let active_context = task_contexts.active_context().unwrap_or(&default_context);
-                schedule_task(
-                    workspace,
-                    task_source_kind,
-                    &target_task,
-                    active_context,
-                    false,
-                    cx,
-                );
-                Some(())
+
+                tasks.retain_mut(|(task_source_kind, target_task)| {
+                    if predicate((task_source_kind, target_task)) {
+                        if let Some(overrides) = &overrides {
+                            if let Some(target_override) = overrides.reveal_target {
+                                target_task.reveal_target = target_override;
+                            }
+                        }
+                        schedule_task(
+                            workspace,
+                            task_source_kind.clone(),
+                            target_task,
+                            active_context,
+                            false,
+                            cx,
+                        );
+                        true
+                    } else {
+                        false
+                    }
+                });
+
+                if tasks.is_empty() { None } else { Some(()) }
             })?
             .is_some();
         if !did_spawn {

crates/zed_actions/src/lib.rs 🔗

@@ -230,6 +230,12 @@ pub enum Spawn {
         #[serde(default)]
         reveal_target: Option<RevealTarget>,
     },
+    /// Spawns a task by the name given.
+    ByTag {
+        task_tag: String,
+        #[serde(default)]
+        reveal_target: Option<RevealTarget>,
+    },
     /// Spawns a task via modal's selection.
     ViaModal {
         /// Selected task's `reveal_target` property override.

docs/src/tasks.md 🔗

@@ -46,6 +46,8 @@ Zed supports ways to spawn (and rerun) commands using its integrated terminal to
     "show_summary": true,
     // Whether to show the command line in the output of the spawned task, defaults to `true`.
     "show_output": true
+    // Represents the tags for inline runnable indicators, or spawning multiple tasks at once.
+    "tags": []
   }
 ]
 ```