diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index d6a52c60c5..b1229313cd 100644 --- a/assets/keymaps/default-linux.json +++ b/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" }], } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 911cff5afb..a9f1ba0364 100644 --- a/assets/keymaps/default-macos.json +++ b/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 diff --git a/assets/settings/initial_tasks.json b/assets/settings/initial_tasks.json index a49b834020..6379af5a66 100644 --- a/assets/settings/initial_tasks.json +++ b/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": [] } ] diff --git a/crates/task/src/lib.rs b/crates/task/src/lib.rs index 57b64bf108..0ac044b154 100644 --- a/crates/task/src/lib.rs +++ b/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; diff --git a/crates/task/src/serde_helpers.rs b/crates/task/src/serde_helpers.rs new file mode 100644 index 0000000000..d7af919fbf --- /dev/null +++ b/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, D::Error> +where + D: Deserializer<'de>, +{ + struct NonEmptyStringVecVisitor; + + impl<'de> Visitor<'de> for NonEmptyStringVecVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a list of non-empty strings") + } + + fn visit_seq(self, mut seq: V) -> Result, V::Error> + where + V: de::SeqAccess<'de>, + { + let mut vec = Vec::new(); + while let Some(value) = seq.next_element::()? { + 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) +} diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs index 0957c2584a..f8f6e82676 100644 --- a/crates/task/src/task_template.rs +++ b/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, /// Which shell to use when spawning the task. #[serde(default)] diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index 257bb0bb8b..7d1175281d 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/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::>() + .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) - .end_slot::(history_run_icon) + .end_slot::( + h_flex() + .gap_1() + .child(Label::new(truncate_and_trailoff( + &template + .tags + .iter() + .map(|tag| format!("#{}", tag)) + .collect::>() + .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()) diff --git a/crates/tasks_ui/src/tasks_ui.rs b/crates/tasks_ui/src/tasks_ui.rs index ed84c73549..5b9ee19f9a 100644 --- a/crates/tasks_ui/src/tasks_ui.rs +++ b/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( + mut predicate: F, overrides: Option, window: &mut Window, cx: &mut Context, -) -> Task> { +) -> Task> +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 { diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 1e358c9bbd..9dee51ce31 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -230,6 +230,12 @@ pub enum Spawn { #[serde(default)] reveal_target: Option, }, + /// Spawns a task by the name given. + ByTag { + task_tag: String, + #[serde(default)] + reveal_target: Option, + }, /// Spawns a task via modal's selection. ViaModal { /// Selected task's `reveal_target` property override. diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 094b463a1c..c9d4e5e1aa 100644 --- a/docs/src/tasks.md +++ b/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": [] } ] ```