diff --git a/.zed/tasks.json b/.zed/tasks.json index e69de29bb2..80465969e2 100644 --- a/.zed/tasks.json +++ b/.zed/tasks.json @@ -0,0 +1,7 @@ +[ + { + "label": "clippy", + "command": "cargo", + "args": ["xtask", "clippy"] + } +] diff --git a/Cargo.lock b/Cargo.lock index 40b2ff696a..c9e0837786 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9615,9 +9615,11 @@ dependencies = [ "collections", "futures 0.3.28", "gpui", + "hex", "schemars", "serde", "serde_json_lenient", + "sha2 0.10.7", "shellexpand", "subst", "util", diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 1a9284725d..5129fb3d8c 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -701,7 +701,7 @@ impl AssistantPanel { } else { editor.highlight_background::( &background_ranges, - |theme| theme.editor_active_line_background, // todo!("use the appropriate color") + |theme| theme.editor_active_line_background, // TODO use the appropriate color cx, ); } diff --git a/crates/language/src/task_context.rs b/crates/language/src/task_context.rs index 3106d303be..6b89af1ed3 100644 --- a/crates/language/src/task_context.rs +++ b/crates/language/src/task_context.rs @@ -2,7 +2,7 @@ use crate::Location; use anyhow::Result; use gpui::AppContext; -use task::{static_source::TaskDefinitions, TaskVariables, VariableName}; +use task::{TaskTemplates, TaskVariables, VariableName}; /// Language Contexts are used by Zed tasks to extract information about source file. pub trait ContextProvider: Send + Sync { @@ -10,7 +10,7 @@ pub trait ContextProvider: Send + Sync { Ok(TaskVariables::default()) } - fn associated_tasks(&self) -> Option { + fn associated_tasks(&self) -> Option { None } } @@ -45,18 +45,20 @@ impl ContextProvider for SymbolContextProvider { /// A ContextProvider that doesn't provide any task variables on it's own, though it has some associated tasks. pub struct ContextProviderWithTasks { - definitions: TaskDefinitions, + templates: TaskTemplates, } impl ContextProviderWithTasks { - pub fn new(definitions: TaskDefinitions) -> Self { - Self { definitions } + pub fn new(definitions: TaskTemplates) -> Self { + Self { + templates: definitions, + } } } impl ContextProvider for ContextProviderWithTasks { - fn associated_tasks(&self) -> Option { - Some(self.definitions.clone()) + fn associated_tasks(&self) -> Option { + Some(self.templates.clone()) } fn build_context(&self, location: Location, cx: &mut AppContext) -> Result { diff --git a/crates/languages/src/elixir.rs b/crates/languages/src/elixir.rs index ac8d55028d..1a215bf34a 100644 --- a/crates/languages/src/elixir.rs +++ b/crates/languages/src/elixir.rs @@ -20,10 +20,7 @@ use std::{ Arc, }, }; -use task::{ - static_source::{Definition, TaskDefinitions}, - VariableName, -}; +use task::{TaskTemplate, TaskTemplates, VariableName}; use util::{ fs::remove_matching, github::{latest_github_release, GitHubLspBinaryVersion}, @@ -554,27 +551,31 @@ fn label_for_symbol_elixir( pub(super) fn elixir_task_context() -> ContextProviderWithTasks { // Taken from https://gist.github.com/josevalim/2e4f60a14ccd52728e3256571259d493#gistcomment-4995881 - ContextProviderWithTasks::new(TaskDefinitions(vec![ - Definition { - label: "Elixir: test suite".to_owned(), + ContextProviderWithTasks::new(TaskTemplates(vec![ + TaskTemplate { + label: "mix test".to_owned(), command: "mix".to_owned(), args: vec!["test".to_owned()], - ..Definition::default() + ..TaskTemplate::default() }, - Definition { - label: "Elixir: failed tests suite".to_owned(), + TaskTemplate { + label: "mix test --failed".to_owned(), command: "mix".to_owned(), args: vec!["test".to_owned(), "--failed".to_owned()], - ..Definition::default() + ..TaskTemplate::default() }, - Definition { - label: "Elixir: test file".to_owned(), + TaskTemplate { + label: format!("mix test {}", VariableName::Symbol.template_value()), command: "mix".to_owned(), args: vec!["test".to_owned(), VariableName::Symbol.template_value()], - ..Definition::default() + ..TaskTemplate::default() }, - Definition { - label: "Elixir: test at current line".to_owned(), + TaskTemplate { + label: format!( + "mix test {}:{}", + VariableName::File.template_value(), + VariableName::Row.template_value() + ), command: "mix".to_owned(), args: vec![ "test".to_owned(), @@ -584,9 +585,9 @@ pub(super) fn elixir_task_context() -> ContextProviderWithTasks { VariableName::Row.template_value() ), ], - ..Definition::default() + ..TaskTemplate::default() }, - Definition { + TaskTemplate { label: "Elixir: break line".to_owned(), command: "iex".to_owned(), args: vec![ @@ -600,7 +601,7 @@ pub(super) fn elixir_task_context() -> ContextProviderWithTasks { VariableName::Row.template_value() ), ], - ..Definition::default() + ..TaskTemplate::default() }, ])) } diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index ee8371b3d5..442f0afff1 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -52,7 +52,7 @@ impl JsonLspAdapter { }, cx, ); - let tasks_schema = task::static_source::TaskDefinitions::generate_json_schema(); + let tasks_schema = task::TaskTemplates::generate_json_schema(); serde_json::json!({ "json": { "format": { diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index e090819268..e9901f51a6 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -17,10 +17,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use task::{ - static_source::{Definition, TaskDefinitions}, - TaskVariables, VariableName, -}; +use task::{TaskTemplate, TaskTemplates, TaskVariables, VariableName}; use util::{ fs::remove_matching, github::{latest_github_release, GitHubLspBinaryVersion}, @@ -355,20 +352,33 @@ impl ContextProvider for RustContextProvider { Ok(context) } - fn associated_tasks(&self) -> Option { - Some(TaskDefinitions(vec![ - Definition { - label: "Rust: Test current crate".to_owned(), + fn associated_tasks(&self) -> Option { + Some(TaskTemplates(vec![ + TaskTemplate { + label: format!( + "cargo check -p {}", + RUST_PACKAGE_TASK_VARIABLE.template_value(), + ), command: "cargo".into(), args: vec![ - "test".into(), + "check".into(), "-p".into(), RUST_PACKAGE_TASK_VARIABLE.template_value(), ], - ..Definition::default() + ..TaskTemplate::default() }, - Definition { - label: "Rust: Test current function".to_owned(), + TaskTemplate { + label: "cargo check --workspace --all-targets".into(), + command: "cargo".into(), + args: vec!["check".into(), "--workspace".into(), "--all-targets".into()], + ..TaskTemplate::default() + }, + TaskTemplate { + label: format!( + "cargo test -p {} {} -- --nocapture", + RUST_PACKAGE_TASK_VARIABLE.template_value(), + VariableName::Symbol.template_value(), + ), command: "cargo".into(), args: vec![ "test".into(), @@ -378,29 +388,32 @@ impl ContextProvider for RustContextProvider { "--".into(), "--nocapture".into(), ], - ..Definition::default() + ..TaskTemplate::default() }, - Definition { - label: "Rust: cargo run".into(), - command: "cargo".into(), - args: vec!["run".into()], - ..Definition::default() - }, - Definition { - label: "Rust: cargo check current crate".into(), + TaskTemplate { + label: format!( + "cargo test -p {}", + RUST_PACKAGE_TASK_VARIABLE.template_value() + ), command: "cargo".into(), args: vec![ - "check".into(), + "test".into(), "-p".into(), RUST_PACKAGE_TASK_VARIABLE.template_value(), ], - ..Definition::default() + ..TaskTemplate::default() }, - Definition { - label: "Rust: cargo check workspace".into(), + TaskTemplate { + label: "cargo run".into(), command: "cargo".into(), - args: vec!["check".into(), "--workspace".into()], - ..Definition::default() + args: vec!["run".into()], + ..TaskTemplate::default() + }, + TaskTemplate { + label: "cargo clean".into(), + command: "cargo".into(), + args: vec!["clean".into()], + ..TaskTemplate::default() }, ])) } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 12aa405a00..1b374c6dd8 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -112,8 +112,6 @@ pub use fs::*; pub use language::Location; #[cfg(any(test, feature = "test-support"))] pub use prettier::FORMAT_SUFFIX as TEST_PRETTIER_FORMAT_SUFFIX; -#[cfg(feature = "test-support")] -pub use task_inventory::test_inventory::*; pub use task_inventory::{Inventory, TaskSourceKind}; pub use worktree::{ DiagnosticSummary, Entry, EntryKind, File, LocalWorktree, PathChange, ProjectEntryId, @@ -7452,15 +7450,12 @@ impl Project { TaskSourceKind::Worktree { id: remote_worktree_id, abs_path, + id_base: "local_tasks_for_worktree", }, |cx| { let tasks_file_rx = watch_config_file(&cx.background_executor(), fs, task_abs_path); - StaticSource::new( - format!("local_tasks_for_workspace_{remote_worktree_id}"), - TrackedFile::new(tasks_file_rx, cx), - cx, - ) + StaticSource::new(TrackedFile::new(tasks_file_rx, cx), cx) }, cx, ); @@ -7477,14 +7472,12 @@ impl Project { TaskSourceKind::Worktree { id: remote_worktree_id, abs_path, + id_base: "local_vscode_tasks_for_worktree", }, |cx| { let tasks_file_rx = watch_config_file(&cx.background_executor(), fs, task_abs_path); StaticSource::new( - format!( - "local_vscode_tasks_for_workspace_{remote_worktree_id}" - ), TrackedFile::new_convertible::( tasks_file_rx, cx, diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index b473213de9..3f43631513 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -159,12 +159,12 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext) }); let all_tasks = project .update(cx, |project, cx| { - project.task_inventory().update(cx, |inventory, cx| { - inventory.list_tasks(None, None, false, cx) - }) + project + .task_inventory() + .update(cx, |inventory, cx| inventory.list_tasks(None, None, cx)) }) .into_iter() - .map(|(source_kind, task)| (source_kind, task.name().to_string())) + .map(|(source_kind, task)| (source_kind, task.label)) .collect::>(); assert_eq!( all_tasks, @@ -172,14 +172,16 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext) ( TaskSourceKind::Worktree { id: workree_id, - abs_path: PathBuf::from("/the-root/.zed/tasks.json") + abs_path: PathBuf::from("/the-root/.zed/tasks.json"), + id_base: "local_tasks_for_worktree", }, "cargo check".to_string() ), ( TaskSourceKind::Worktree { id: workree_id, - abs_path: PathBuf::from("/the-root/b/.zed/tasks.json") + abs_path: PathBuf::from("/the-root/b/.zed/tasks.json"), + id_base: "local_tasks_for_worktree", }, "cargo check".to_string() ), diff --git a/crates/project/src/task_inventory.rs b/crates/project/src/task_inventory.rs index 34a17c611e..c823964910 100644 --- a/crates/project/src/task_inventory.rs +++ b/crates/project/src/task_inventory.rs @@ -2,22 +2,23 @@ use std::{ any::TypeId, + cmp, path::{Path, PathBuf}, sync::Arc, }; use collections::{HashMap, VecDeque}; use gpui::{AppContext, Context, Model, ModelContext, Subscription}; -use itertools::Itertools; +use itertools::{Either, Itertools}; use language::Language; -use task::{static_source::tasks_for, Task, TaskContext, TaskSource}; +use task::{ResolvedTask, TaskContext, TaskId, TaskSource, TaskTemplate}; use util::{post_inc, NumericPrefixWithSuffix}; use worktree::WorktreeId; /// Inventory tracks available tasks for a given project. pub struct Inventory { sources: Vec, - last_scheduled_tasks: VecDeque<(Arc, TaskContext)>, + last_scheduled_tasks: VecDeque<(TaskSourceKind, ResolvedTask)>, } struct SourceInInventory { @@ -28,32 +29,56 @@ struct SourceInInventory { } /// Kind of a source the tasks are fetched from, used to display more source information in the UI. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum TaskSourceKind { /// bash-like commands spawned by users, not associated with any path UserInput, /// ~/.config/zed/task.json - like global files with task definitions, applicable to any path - AbsPath(PathBuf), + AbsPath { + id_base: &'static str, + abs_path: PathBuf, + }, /// Tasks from the worktree's .zed/task.json - Worktree { id: WorktreeId, abs_path: PathBuf }, + Worktree { + id: WorktreeId, + abs_path: PathBuf, + id_base: &'static str, + }, /// Languages-specific tasks coming from extensions. Language { name: Arc }, } impl TaskSourceKind { - fn abs_path(&self) -> Option<&Path> { + pub fn abs_path(&self) -> Option<&Path> { match self { - Self::AbsPath(abs_path) | Self::Worktree { abs_path, .. } => Some(abs_path), + Self::AbsPath { abs_path, .. } | Self::Worktree { abs_path, .. } => Some(abs_path), Self::UserInput | Self::Language { .. } => None, } } - fn worktree(&self) -> Option { + pub fn worktree(&self) -> Option { match self { Self::Worktree { id, .. } => Some(*id), _ => None, } } + + pub fn to_id_base(&self) -> String { + match self { + TaskSourceKind::UserInput => "oneshot".to_string(), + TaskSourceKind::AbsPath { id_base, abs_path } => { + format!("{id_base}_{}", abs_path.display()) + } + TaskSourceKind::Worktree { + id, + id_base, + abs_path, + } => { + format!("{id_base}_{id}_{}", abs_path.display()) + } + TaskSourceKind::Language { name } => format!("language_{name}"), + } + } } impl Inventory { @@ -111,14 +136,17 @@ impl Inventory { self.sources.retain(|s| s.kind.worktree() != Some(worktree)); } - pub fn source(&self) -> Option>> { + pub fn source(&self) -> Option<(Model>, TaskSourceKind)> { let target_type_id = std::any::TypeId::of::(); self.sources.iter().find_map( |SourceInInventory { - type_id, source, .. + type_id, + source, + kind, + .. }| { if &target_type_id == type_id { - Some(source.clone()) + Some((source.clone(), kind.clone())) } else { None } @@ -126,47 +154,23 @@ impl Inventory { ) } - /// Pulls its sources to list runnables for the editor given, or all runnables for no editor. + /// Pulls its task sources relevant to the worktree and the language given, + /// returns all task templates with their source kinds, in no specific order. pub fn list_tasks( &self, language: Option>, worktree: Option, - lru: bool, cx: &mut AppContext, - ) -> Vec<(TaskSourceKind, Arc)> { + ) -> Vec<(TaskSourceKind, TaskTemplate)> { let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language { name: language.name(), }); let language_tasks = language - .and_then(|language| { - let tasks = language.context_provider()?.associated_tasks()?; - Some((tasks, language)) - }) - .map(|(tasks, language)| { - let language_name = language.name(); - let id_base = format!("buffer_source_{language_name}"); - tasks_for(tasks, &id_base) - }) - .unwrap_or_default() + .and_then(|language| language.context_provider()?.associated_tasks()) .into_iter() + .flat_map(|tasks| tasks.0.into_iter()) .flat_map(|task| Some((task_source_kind.as_ref()?, task))); - let mut lru_score = 0_u32; - let tasks_by_usage = if lru { - self.last_scheduled_tasks.iter().rev().fold( - HashMap::default(), - |mut tasks, (task, context)| { - tasks - .entry(task.id().clone()) - .or_insert_with(|| (post_inc(&mut lru_score), Some(context))); - tasks - }, - ) - } else { - HashMap::default() - }; - let not_used_task_context = None; - let not_used_score = (post_inc(&mut lru_score), not_used_task_context); self.sources .iter() .filter(|source| { @@ -177,101 +181,173 @@ impl Inventory { source .source .update(cx, |source, cx| source.tasks_to_schedule(cx)) + .0 .into_iter() .map(|task| (&source.kind, task)) }) .chain(language_tasks) - .map(|task| { - let usages = if lru { - tasks_by_usage - .get(&task.1.id()) - .copied() - .unwrap_or(not_used_score) - } else { - not_used_score - }; - (task, usages) - }) - .sorted_unstable_by( - |((kind_a, task_a), usages_a), ((kind_b, task_b), usages_b)| { - usages_a - .0 - .cmp(&usages_b.0) - .then( - kind_a - .worktree() - .is_none() - .cmp(&kind_b.worktree().is_none()), - ) - .then(kind_a.worktree().cmp(&kind_b.worktree())) - .then( - kind_a - .abs_path() - .is_none() - .cmp(&kind_b.abs_path().is_none()), - ) - .then(kind_a.abs_path().cmp(&kind_b.abs_path())) - .then({ - NumericPrefixWithSuffix::from_numeric_prefixed_str(task_a.name()) - .cmp(&NumericPrefixWithSuffix::from_numeric_prefixed_str( - task_b.name(), - )) - .then(task_a.name().cmp(task_b.name())) - }) - }, - ) - .map(|((kind, task), _)| (kind.clone(), task)) + .map(|(task_source_kind, task)| (task_source_kind.clone(), task)) .collect() } + /// Pulls its task sources relevant to the worktree and the language given and resolves them with the [`TaskContext`] given. + /// Joins the new resolutions with the resolved tasks that were used (spawned) before, + /// orders them so that the most recently used come first, all equally used ones are ordered so that the most specific tasks come first. + /// Deduplicates the tasks by their labels and splits the ordered list into two: used tasks and the rest, newly resolved tasks. + pub fn used_and_current_resolved_tasks( + &self, + language: Option>, + worktree: Option, + task_context: TaskContext, + cx: &mut AppContext, + ) -> ( + Vec<(TaskSourceKind, ResolvedTask)>, + Vec<(TaskSourceKind, ResolvedTask)>, + ) { + let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language { + name: language.name(), + }); + let language_tasks = language + .and_then(|language| language.context_provider()?.associated_tasks()) + .into_iter() + .flat_map(|tasks| tasks.0.into_iter()) + .flat_map(|task| Some((task_source_kind.as_ref()?, task))); + + let mut lru_score = 0_u32; + let mut task_usage = self.last_scheduled_tasks.iter().rev().fold( + HashMap::default(), + |mut tasks, (task_source_kind, resolved_task)| { + tasks + .entry(&resolved_task.id) + .or_insert_with(|| (task_source_kind, resolved_task, post_inc(&mut lru_score))); + tasks + }, + ); + let not_used_score = post_inc(&mut lru_score); + let current_resolved_tasks = self + .sources + .iter() + .filter(|source| { + let source_worktree = source.kind.worktree(); + worktree.is_none() || source_worktree.is_none() || source_worktree == worktree + }) + .flat_map(|source| { + source + .source + .update(cx, |source, cx| source.tasks_to_schedule(cx)) + .0 + .into_iter() + .map(|task| (&source.kind, task)) + }) + .chain(language_tasks) + .filter_map(|(kind, task)| { + let id_base = kind.to_id_base(); + Some((kind, task.resolve_task(&id_base, task_context.clone())?)) + }) + .map(|(kind, task)| { + let lru_score = task_usage + .remove(&task.id) + .map(|(_, _, lru_score)| lru_score) + .unwrap_or(not_used_score); + (kind.clone(), task, lru_score) + }) + .collect::>(); + let previous_resolved_tasks = task_usage + .into_iter() + .map(|(_, (kind, task, lru_score))| (kind.clone(), task.clone(), lru_score)); + + previous_resolved_tasks + .chain(current_resolved_tasks) + .sorted_unstable_by(task_lru_comparator) + .unique_by(|(kind, task, _)| (kind.clone(), task.resolved_label.clone())) + .partition_map(|(kind, task, lru_index)| { + if lru_index < not_used_score { + Either::Left((kind, task)) + } else { + Either::Right((kind, task)) + } + }) + } + /// Returns the last scheduled task, if any of the sources contains one with the matching id. - pub fn last_scheduled_task(&self) -> Option<(Arc, TaskContext)> { + pub fn last_scheduled_task(&self) -> Option<(TaskSourceKind, ResolvedTask)> { self.last_scheduled_tasks.back().cloned() } /// Registers task "usage" as being scheduled – to be used for LRU sorting when listing all tasks. - pub fn task_scheduled(&mut self, task: Arc, task_context: TaskContext) { - self.last_scheduled_tasks.push_back((task, task_context)); + pub fn task_scheduled( + &mut self, + task_source_kind: TaskSourceKind, + resolved_task: ResolvedTask, + ) { + self.last_scheduled_tasks + .push_back((task_source_kind, resolved_task)); if self.last_scheduled_tasks.len() > 5_000 { self.last_scheduled_tasks.pop_front(); } } + + /// Deletes a resolved task from history, using its id. + /// A similar may still resurface in `used_and_current_resolved_tasks` when its [`TaskTemplate`] is resolved again. + pub fn delete_previously_used(&mut self, id: &TaskId) { + self.last_scheduled_tasks.retain(|(_, task)| &task.id != id); + } } -#[cfg(any(test, feature = "test-support"))] -pub mod test_inventory { - use std::sync::Arc; +fn task_lru_comparator( + (kind_a, task_a, lru_score_a): &(TaskSourceKind, ResolvedTask, u32), + (kind_b, task_b, lru_score_b): &(TaskSourceKind, ResolvedTask, u32), +) -> cmp::Ordering { + lru_score_a + .cmp(&lru_score_b) + .then(task_source_kind_preference(kind_a).cmp(&task_source_kind_preference(kind_b))) + .then( + kind_a + .worktree() + .is_none() + .cmp(&kind_b.worktree().is_none()), + ) + .then(kind_a.worktree().cmp(&kind_b.worktree())) + .then( + kind_a + .abs_path() + .is_none() + .cmp(&kind_b.abs_path().is_none()), + ) + .then(kind_a.abs_path().cmp(&kind_b.abs_path())) + .then({ + NumericPrefixWithSuffix::from_numeric_prefixed_str(&task_a.resolved_label) + .cmp(&NumericPrefixWithSuffix::from_numeric_prefixed_str( + &task_b.resolved_label, + )) + .then(task_a.resolved_label.cmp(&task_b.resolved_label)) + }) +} +fn task_source_kind_preference(kind: &TaskSourceKind) -> u32 { + match kind { + TaskSourceKind::Language { .. } => 1, + TaskSourceKind::UserInput => 2, + TaskSourceKind::Worktree { .. } => 3, + TaskSourceKind::AbsPath { .. } => 4, + } +} + +#[cfg(test)] +mod test_inventory { use gpui::{AppContext, Context as _, Model, ModelContext, TestAppContext}; - use task::{Task, TaskContext, TaskId, TaskSource}; + use itertools::Itertools; + use task::{TaskContext, TaskId, TaskSource, TaskTemplate, TaskTemplates}; use worktree::WorktreeId; use crate::Inventory; - use super::TaskSourceKind; + use super::{task_source_kind_preference, TaskSourceKind}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct TestTask { - pub id: task::TaskId, - pub name: String, - } - - impl Task for TestTask { - fn id(&self) -> &TaskId { - &self.id - } - - fn name(&self) -> &str { - &self.name - } - - fn cwd(&self) -> Option<&str> { - None - } - - fn prepare_exec(&self, _cwd: TaskContext) -> Option { - None - } + id: task::TaskId, + name: String, } pub struct StaticTestSource { @@ -279,7 +355,7 @@ pub mod test_inventory { } impl StaticTestSource { - pub fn new( + pub(super) fn new( task_names: impl IntoIterator, cx: &mut AppContext, ) -> Model> { @@ -302,12 +378,18 @@ pub mod test_inventory { fn tasks_to_schedule( &mut self, _cx: &mut ModelContext>, - ) -> Vec> { - self.tasks - .clone() - .into_iter() - .map(|task| Arc::new(task) as Arc) - .collect() + ) -> TaskTemplates { + TaskTemplates( + self.tasks + .clone() + .into_iter() + .map(|task| TaskTemplate { + label: task.name, + command: "test command".to_string(), + ..TaskTemplate::default() + }) + .collect(), + ) } fn as_any(&mut self) -> &mut dyn std::any::Any { @@ -315,47 +397,77 @@ pub mod test_inventory { } } - pub fn list_task_names( + pub(super) fn task_template_names( inventory: &Model, worktree: Option, - lru: bool, cx: &mut TestAppContext, ) -> Vec { inventory.update(cx, |inventory, cx| { inventory - .list_tasks(None, worktree, lru, cx) + .list_tasks(None, worktree, cx) .into_iter() - .map(|(_, task)| task.name().to_string()) + .map(|(_, task)| task.label) + .sorted() .collect() }) } - pub fn register_task_used( + pub(super) fn resolved_task_names( + inventory: &Model, + worktree: Option, + cx: &mut TestAppContext, + ) -> Vec { + inventory.update(cx, |inventory, cx| { + let (used, current) = inventory.used_and_current_resolved_tasks( + None, + worktree, + TaskContext::default(), + cx, + ); + used.into_iter() + .chain(current) + .map(|(_, task)| task.original_task.label) + .collect() + }) + } + + pub(super) fn register_task_used( inventory: &Model, task_name: &str, cx: &mut TestAppContext, ) { inventory.update(cx, |inventory, cx| { - let task = inventory - .list_tasks(None, None, false, cx) + let (task_source_kind, task) = inventory + .list_tasks(None, None, cx) .into_iter() - .find(|(_, task)| task.name() == task_name) + .find(|(_, task)| task.label == task_name) .unwrap_or_else(|| panic!("Failed to find task with name {task_name}")); - inventory.task_scheduled(task.1, TaskContext::default()); + let id_base = task_source_kind.to_id_base(); + inventory.task_scheduled( + task_source_kind.clone(), + task.resolve_task(&id_base, TaskContext::default()) + .unwrap_or_else(|| panic!("Failed to resolve task with name {task_name}")), + ); }); } - pub fn list_tasks( + pub(super) fn list_tasks( inventory: &Model, worktree: Option, - lru: bool, cx: &mut TestAppContext, ) -> Vec<(TaskSourceKind, String)> { inventory.update(cx, |inventory, cx| { - inventory - .list_tasks(None, worktree, lru, cx) - .into_iter() - .map(|(source_kind, task)| (source_kind, task.name().to_string())) + let (used, current) = inventory.used_and_current_resolved_tasks( + None, + worktree, + TaskContext::default(), + cx, + ); + let mut all = used; + all.extend(current); + all.into_iter() + .map(|(source_kind, task)| (source_kind, task.resolved_label)) + .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone())) .collect() }) } @@ -371,12 +483,12 @@ mod tests { #[gpui::test] fn test_task_list_sorting(cx: &mut TestAppContext) { let inventory = cx.update(Inventory::new); - let initial_tasks = list_task_names(&inventory, None, true, cx); + let initial_tasks = resolved_task_names(&inventory, None, cx); assert!( initial_tasks.is_empty(), "No tasks expected for empty inventory, but got {initial_tasks:?}" ); - let initial_tasks = list_task_names(&inventory, None, false, cx); + let initial_tasks = task_template_names(&inventory, None, cx); assert!( initial_tasks.is_empty(), "No tasks expected for empty inventory, but got {initial_tasks:?}" @@ -413,24 +525,22 @@ mod tests { "3_task".to_string(), ]; assert_eq!( - list_task_names(&inventory, None, false, cx), + task_template_names(&inventory, None, cx), &expected_initial_state, - "Task list without lru sorting, should be sorted alphanumerically" ); assert_eq!( - list_task_names(&inventory, None, true, cx), + resolved_task_names(&inventory, None, cx), &expected_initial_state, "Tasks with equal amount of usages should be sorted alphanumerically" ); register_task_used(&inventory, "2_task", cx); assert_eq!( - list_task_names(&inventory, None, false, cx), + task_template_names(&inventory, None, cx), &expected_initial_state, - "Task list without lru sorting, should be sorted alphanumerically" ); assert_eq!( - list_task_names(&inventory, None, true, cx), + resolved_task_names(&inventory, None, cx), vec![ "2_task".to_string(), "1_a_task".to_string(), @@ -444,12 +554,11 @@ mod tests { register_task_used(&inventory, "1_task", cx); register_task_used(&inventory, "3_task", cx); assert_eq!( - list_task_names(&inventory, None, false, cx), + task_template_names(&inventory, None, cx), &expected_initial_state, - "Task list without lru sorting, should be sorted alphanumerically" ); assert_eq!( - list_task_names(&inventory, None, true, cx), + resolved_task_names(&inventory, None, cx), vec![ "3_task".to_string(), "1_task".to_string(), @@ -468,20 +577,19 @@ mod tests { ); }); let expected_updated_state = [ + "10_hello".to_string(), + "11_hello".to_string(), "1_a_task".to_string(), "1_task".to_string(), "2_task".to_string(), "3_task".to_string(), - "10_hello".to_string(), - "11_hello".to_string(), ]; assert_eq!( - list_task_names(&inventory, None, false, cx), + task_template_names(&inventory, None, cx), &expected_updated_state, - "Task list without lru sorting, should be sorted alphanumerically" ); assert_eq!( - list_task_names(&inventory, None, true, cx), + resolved_task_names(&inventory, None, cx), vec![ "3_task".to_string(), "1_task".to_string(), @@ -494,12 +602,11 @@ mod tests { register_task_used(&inventory, "11_hello", cx); assert_eq!( - list_task_names(&inventory, None, false, cx), + task_template_names(&inventory, None, cx), &expected_updated_state, - "Task list without lru sorting, should be sorted alphanumerically" ); assert_eq!( - list_task_names(&inventory, None, true, cx), + resolved_task_names(&inventory, None, cx), vec![ "11_hello".to_string(), "3_task".to_string(), @@ -533,7 +640,10 @@ mod tests { cx, ); inventory.add_source( - TaskSourceKind::AbsPath(path_1.to_path_buf()), + TaskSourceKind::AbsPath { + id_base: "test source", + abs_path: path_1.to_path_buf(), + }, |cx| { StaticTestSource::new( vec!["static_source_1".to_string(), common_name.to_string()], @@ -543,7 +653,10 @@ mod tests { cx, ); inventory.add_source( - TaskSourceKind::AbsPath(path_2.to_path_buf()), + TaskSourceKind::AbsPath { + id_base: "test source", + abs_path: path_2.to_path_buf(), + }, |cx| { StaticTestSource::new( vec!["static_source_2".to_string(), common_name.to_string()], @@ -556,6 +669,7 @@ mod tests { TaskSourceKind::Worktree { id: worktree_1, abs_path: worktree_path_1.to_path_buf(), + id_base: "test_source", }, |cx| { StaticTestSource::new( @@ -569,6 +683,7 @@ mod tests { TaskSourceKind::Worktree { id: worktree_2, abs_path: worktree_path_2.to_path_buf(), + id_base: "test_source", }, |cx| { StaticTestSource::new( @@ -582,19 +697,31 @@ mod tests { let worktree_independent_tasks = vec![ ( - TaskSourceKind::AbsPath(path_1.to_path_buf()), + TaskSourceKind::AbsPath { + id_base: "test source", + abs_path: path_1.to_path_buf(), + }, common_name.to_string(), ), ( - TaskSourceKind::AbsPath(path_1.to_path_buf()), + TaskSourceKind::AbsPath { + id_base: "test source", + abs_path: path_1.to_path_buf(), + }, "static_source_1".to_string(), ), ( - TaskSourceKind::AbsPath(path_2.to_path_buf()), + TaskSourceKind::AbsPath { + id_base: "test source", + abs_path: path_2.to_path_buf(), + }, common_name.to_string(), ), ( - TaskSourceKind::AbsPath(path_2.to_path_buf()), + TaskSourceKind::AbsPath { + id_base: "test source", + abs_path: path_2.to_path_buf(), + }, "static_source_2".to_string(), ), (TaskSourceKind::UserInput, common_name.to_string()), @@ -605,6 +732,7 @@ mod tests { TaskSourceKind::Worktree { id: worktree_1, abs_path: worktree_path_1.to_path_buf(), + id_base: "test_source", }, common_name.to_string(), ), @@ -612,6 +740,7 @@ mod tests { TaskSourceKind::Worktree { id: worktree_1, abs_path: worktree_path_1.to_path_buf(), + id_base: "test_source", }, "worktree_1".to_string(), ), @@ -621,6 +750,7 @@ mod tests { TaskSourceKind::Worktree { id: worktree_2, abs_path: worktree_path_2.to_path_buf(), + id_base: "test_source", }, common_name.to_string(), ), @@ -628,6 +758,7 @@ mod tests { TaskSourceKind::Worktree { id: worktree_2, abs_path: worktree_path_2.to_path_buf(), + id_base: "test_source", }, "worktree_2".to_string(), ), @@ -639,26 +770,26 @@ mod tests { // worktree-less tasks come later in the list .chain(worktree_independent_tasks.iter()) .cloned() + .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone())) .collect::>(); + assert_eq!(list_tasks(&inventory_with_statics, None, cx), all_tasks); assert_eq!( - list_tasks(&inventory_with_statics, None, false, cx), - all_tasks, - ); - assert_eq!( - list_tasks(&inventory_with_statics, Some(worktree_1), false, cx), + list_tasks(&inventory_with_statics, Some(worktree_1), cx), worktree_1_tasks .iter() .chain(worktree_independent_tasks.iter()) .cloned() + .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone())) .collect::>(), ); assert_eq!( - list_tasks(&inventory_with_statics, Some(worktree_2), false, cx), + list_tasks(&inventory_with_statics, Some(worktree_2), cx), worktree_2_tasks .iter() .chain(worktree_independent_tasks.iter()) .cloned() + .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone())) .collect::>(), ); } diff --git a/crates/task/Cargo.toml b/crates/task/Cargo.toml index 40f566cba0..c7534b819a 100644 --- a/crates/task/Cargo.toml +++ b/crates/task/Cargo.toml @@ -13,9 +13,11 @@ anyhow.workspace = true collections.workspace = true futures.workspace = true gpui.workspace = true +hex.workspace = true schemars.workspace = true serde.workspace = true serde_json_lenient.workspace = true +sha2.workspace = true shellexpand.workspace = true subst = "0.3.0" util.workspace = true diff --git a/crates/task/src/lib.rs b/crates/task/src/lib.rs index 2064832b22..fe09b235bd 100644 --- a/crates/task/src/lib.rs +++ b/crates/task/src/lib.rs @@ -1,17 +1,18 @@ //! Baseline interface of Tasks in Zed: all tasks in Zed are intended to use those for implementing their own logic. #![deny(missing_docs)] -pub mod oneshot_source; pub mod static_source; +mod task_template; mod vscode_format; use collections::HashMap; use gpui::ModelContext; -use static_source::RevealStrategy; +use serde::Serialize; use std::any::Any; use std::borrow::Cow; use std::path::PathBuf; -use std::sync::Arc; + +pub use task_template::{RevealStrategy, TaskTemplate, TaskTemplates}; pub use vscode_format::VsCodeTaskFile; /// Task identifier, unique within the application. @@ -20,7 +21,7 @@ pub use vscode_format::VsCodeTaskFile; pub struct TaskId(pub String); /// Contains all information needed by Zed to spawn a new terminal tab for the given task. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SpawnInTerminal { /// Id of the task to use when determining task tab affinity. pub id: TaskId, @@ -42,8 +43,26 @@ pub struct SpawnInTerminal { pub reveal: RevealStrategy, } -/// Variables, available for use in [`TaskContext`] when a Zed's task gets turned into real command. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// 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 { + /// A way to distinguish tasks produced by the same template, but different contexts. + /// NOTE: Resolved tasks may have the same labels, commands and do the same things, + /// but still may have different ids if the context was different during the resolution. + /// Since the template has `env` field, for a generic task that may be a bash command, + /// so it's impossible to determine the id equality without more context in a generic case. + pub id: TaskId, + /// A template the task got resolved from. + pub original_task: TaskTemplate, + /// Full, unshortened label of the task after all resolutions are made. + pub resolved_label: String, + /// Further actions that need to take place after the resolved task is spawned, + /// with all task variables resolved. + pub resolved: Option, +} + +/// Variables, available for use in [`TaskContext`] when a Zed's [`TaskTemplate`] gets resolved into a [`ResolvedTask`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] pub enum VariableName { /// An absolute path of the currently opened file. File, @@ -74,22 +93,25 @@ impl VariableName { } } +/// A prefix that all [`VariableName`] variants are prefixed with when used in environment variables and similar template contexts. +pub const ZED_VARIABLE_NAME_PREFIX: &str = "ZED_"; + impl std::fmt::Display for VariableName { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { - Self::File => write!(f, "ZED_FILE"), - Self::WorktreeRoot => write!(f, "ZED_WORKTREE_ROOT"), - Self::Symbol => write!(f, "ZED_SYMBOL"), - Self::Row => write!(f, "ZED_ROW"), - Self::Column => write!(f, "ZED_COLUMN"), - Self::SelectedText => write!(f, "ZED_SELECTED_TEXT"), - Self::Custom(s) => write!(f, "ZED_{s}"), + Self::File => write!(f, "{ZED_VARIABLE_NAME_PREFIX}FILE"), + Self::WorktreeRoot => write!(f, "{ZED_VARIABLE_NAME_PREFIX}WORKTREE_ROOT"), + Self::Symbol => write!(f, "{ZED_VARIABLE_NAME_PREFIX}SYMBOL"), + Self::Row => write!(f, "{ZED_VARIABLE_NAME_PREFIX}ROW"), + Self::Column => write!(f, "{ZED_VARIABLE_NAME_PREFIX}COLUMN"), + Self::SelectedText => write!(f, "{ZED_VARIABLE_NAME_PREFIX}SELECTED_TEXT"), + Self::Custom(s) => write!(f, "{ZED_VARIABLE_NAME_PREFIX}CUSTOM_{s}"), } } } /// Container for predefined environment variables that describe state of Zed at the time the task was spawned. -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] pub struct TaskVariables(HashMap); impl TaskVariables { @@ -118,8 +140,9 @@ impl FromIterator<(VariableName, String)> for TaskVariables { } } -/// Keeps track of the file associated with a task and context of tasks execution (i.e. current file or current function) -#[derive(Clone, Debug, Default, PartialEq, Eq)] +/// Keeps track of the file associated with a task and context of tasks execution (i.e. current file or current function). +/// Keeps all Zed-related state inside, used to produce a resolved task out of its template. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] pub struct TaskContext { /// A path to a directory in which the task should be executed. pub cwd: Option, @@ -127,20 +150,6 @@ pub struct TaskContext { pub task_variables: TaskVariables, } -/// Represents a short lived recipe of a task, whose main purpose -/// is to get spawned. -pub trait Task { - /// Unique identifier of the task to spawn. - fn id(&self) -> &TaskId; - /// Human readable name of the task to display in the UI. - fn name(&self) -> &str; - /// Task's current working directory. If `None`, current project's root will be used. - fn cwd(&self) -> Option<&str>; - /// Sets up everything needed to spawn the task in the given directory (`cwd`). - /// If a task is intended to be spawned in the terminal, it should return the corresponding struct filled with the data necessary. - fn prepare_exec(&self, cx: TaskContext) -> Option; -} - /// [`Source`] produces tasks that can be scheduled. /// /// Implementations of this trait could be e.g. [`StaticSource`] that parses tasks from a .json files and provides process templates to be spawned; @@ -149,8 +158,5 @@ pub trait TaskSource: Any { /// A way to erase the type of the source, processing and storing them generically. fn as_any(&mut self) -> &mut dyn Any; /// Collects all tasks available for scheduling. - fn tasks_to_schedule( - &mut self, - cx: &mut ModelContext>, - ) -> Vec>; + fn tasks_to_schedule(&mut self, cx: &mut ModelContext>) -> TaskTemplates; } diff --git a/crates/task/src/oneshot_source.rs b/crates/task/src/oneshot_source.rs deleted file mode 100644 index cf2c73203d..0000000000 --- a/crates/task/src/oneshot_source.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! A source of tasks, based on ad-hoc user command prompt input. - -use std::sync::Arc; - -use crate::{ - static_source::RevealStrategy, SpawnInTerminal, Task, TaskContext, TaskId, TaskSource, -}; -use gpui::{AppContext, Context, Model}; - -/// A storage and source of tasks generated out of user command prompt inputs. -pub struct OneshotSource { - tasks: Vec>, -} - -#[derive(Clone)] -struct OneshotTask { - id: TaskId, -} - -impl OneshotTask { - fn new(prompt: String) -> Self { - Self { id: TaskId(prompt) } - } -} - -impl Task for OneshotTask { - fn id(&self) -> &TaskId { - &self.id - } - - fn name(&self) -> &str { - &self.id.0 - } - - fn cwd(&self) -> Option<&str> { - None - } - - fn prepare_exec(&self, cx: TaskContext) -> Option { - if self.id().0.is_empty() { - return None; - } - let TaskContext { - cwd, - task_variables, - } = cx; - Some(SpawnInTerminal { - id: self.id().clone(), - label: self.name().to_owned(), - command: self.id().0.clone(), - args: vec![], - cwd, - env: task_variables.into_env_variables(), - use_new_terminal: Default::default(), - allow_concurrent_runs: Default::default(), - reveal: RevealStrategy::default(), - }) - } -} - -impl OneshotSource { - /// Initializes the oneshot source, preparing to store user prompts. - pub fn new(cx: &mut AppContext) -> Model> { - cx.new_model(|_| Box::new(Self { tasks: Vec::new() }) as Box) - } - - /// Spawns a certain task based on the user prompt. - pub fn spawn(&mut self, prompt: String) -> Arc { - if let Some(task) = self.tasks.iter().find(|task| task.id().0 == prompt) { - // If we already have an oneshot task with that command, let's just reuse it. - task.clone() - } else { - let new_oneshot = Arc::new(OneshotTask::new(prompt)); - self.tasks.push(new_oneshot.clone()); - new_oneshot - } - } - /// Removes a task with a given ID from this source. - pub fn remove(&mut self, id: &TaskId) { - let position = self.tasks.iter().position(|task| task.id() == id); - if let Some(position) = position { - self.tasks.remove(position); - } - } -} - -impl TaskSource for OneshotSource { - fn as_any(&mut self) -> &mut dyn std::any::Any { - self - } - - fn tasks_to_schedule( - &mut self, - _cx: &mut gpui::ModelContext>, - ) -> Vec> { - self.tasks.clone() - } -} diff --git a/crates/task/src/static_source.rs b/crates/task/src/static_source.rs index cc169db313..393a4cfe4b 100644 --- a/crates/task/src/static_source.rs +++ b/crates/task/src/static_source.rs @@ -1,154 +1,20 @@ //! A source of tasks, based on a static configuration, deserialized from the tasks config file, and related infrastructure for tracking changes to the file. -use std::{borrow::Cow, sync::Arc}; - -use collections::HashMap; use futures::StreamExt; use gpui::{AppContext, Context, Model, ModelContext, Subscription}; -use schemars::{gen::SchemaSettings, JsonSchema}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use util::ResultExt; -use crate::{SpawnInTerminal, Task, TaskContext, TaskId, TaskSource}; +use crate::{TaskSource, TaskTemplates}; use futures::channel::mpsc::UnboundedReceiver; -/// A single config file entry with the deserialized task definition. -#[derive(Clone, Debug, PartialEq)] -struct StaticTask { - id: TaskId, - definition: Definition, -} - -impl StaticTask { - fn new(definition: Definition, (id_base, index_in_file): (&str, usize)) -> Arc { - Arc::new(Self { - id: TaskId(format!( - "static_{id_base}_{index_in_file}_{}", - definition.label - )), - definition, - }) - } -} - -/// TODO: doc -pub fn tasks_for(tasks: TaskDefinitions, id_base: &str) -> Vec> { - tasks - .0 - .into_iter() - .enumerate() - .map(|(index, task)| StaticTask::new(task, (id_base, index)) as Arc<_>) - .collect() -} - -impl Task for StaticTask { - fn prepare_exec(&self, cx: TaskContext) -> Option { - let TaskContext { - cwd, - task_variables, - } = cx; - let task_variables = task_variables.into_env_variables(); - let cwd = self - .definition - .cwd - .clone() - .and_then(|path| { - subst::substitute(&path, &task_variables) - .map(Into::into) - .ok() - }) - .or(cwd); - let mut definition_env = self.definition.env.clone(); - definition_env.extend(task_variables); - Some(SpawnInTerminal { - id: self.id.clone(), - cwd, - use_new_terminal: self.definition.use_new_terminal, - allow_concurrent_runs: self.definition.allow_concurrent_runs, - label: self.definition.label.clone(), - command: self.definition.command.clone(), - args: self.definition.args.clone(), - reveal: self.definition.reveal, - env: definition_env, - }) - } - - fn name(&self) -> &str { - &self.definition.label - } - - fn id(&self) -> &TaskId { - &self.id - } - - fn cwd(&self) -> Option<&str> { - self.definition.cwd.as_deref() - } -} - /// The source of tasks defined in a tasks config file. pub struct StaticSource { - tasks: Vec>, - _definitions: Model>, + tasks: TaskTemplates, + _templates: Model>, _subscription: Subscription, } -/// Static task definition from the tasks config file. -#[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub struct Definition { - /// Human readable name of the task to display in the UI. - pub label: String, - /// Executable command to spawn. - pub command: String, - /// Arguments to the command. - #[serde(default)] - pub args: Vec, - /// Env overrides for the command, will be appended to the terminal's environment from the settings. - #[serde(default)] - pub env: HashMap, - /// Current working directory to spawn the command into, defaults to current project root. - #[serde(default)] - pub cwd: Option, - /// Whether to use a new terminal tab or reuse the existing one to spawn the process. - #[serde(default)] - pub use_new_terminal: bool, - /// Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish. - #[serde(default)] - pub allow_concurrent_runs: bool, - /// 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) - /// * `never` — avoid changing current terminal pane focus, but still add/reuse the task's tab there - #[serde(default)] - pub reveal: RevealStrategy, -} - -/// What to do with the terminal pane and tab, after the command was started. -#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RevealStrategy { - /// Always show the terminal pane, add and focus the corresponding task's tab in it. - #[default] - Always, - /// Do not change terminal pane focus, but still add/reuse the task's tab there. - Never, -} - -/// A group of Tasks defined in a JSON file. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct TaskDefinitions(pub Vec); - -impl TaskDefinitions { - /// Generates JSON schema of Tasks JSON definition format. - pub fn generate_json_schema() -> serde_json_lenient::Value { - let schema = SchemaSettings::draft07() - .with(|settings| settings.option_add_null_type = false) - .into_generator() - .into_root_schema_for::(); - - serde_json_lenient::to_value(schema).unwrap() - } -} /// A Wrapper around deserializable T that keeps track of its contents /// via a provided channel. Once T value changes, the observers of [`TrackedFile`] are /// notified. @@ -235,32 +101,22 @@ impl TrackedFile { impl StaticSource { /// Initializes the static source, reacting on tasks config changes. pub fn new( - id_base: impl Into>, - definitions: Model>, + templates: Model>, cx: &mut AppContext, ) -> Model> { cx.new_model(|cx| { - let id_base = id_base.into(); let _subscription = cx.observe( - &definitions, - move |source: &mut Box<(dyn TaskSource + 'static)>, new_definitions, cx| { + &templates, + move |source: &mut Box<(dyn TaskSource + 'static)>, new_templates, cx| { if let Some(static_source) = source.as_any().downcast_mut::() { - static_source.tasks = new_definitions - .read(cx) - .get() - .0 - .clone() - .into_iter() - .enumerate() - .map(|(i, definition)| StaticTask::new(definition, (&id_base, i))) - .collect(); + static_source.tasks = new_templates.read(cx).get().clone(); cx.notify(); } }, ); Box::new(Self { - tasks: Vec::new(), - _definitions: definitions, + tasks: TaskTemplates::default(), + _templates: templates, _subscription, }) }) @@ -268,14 +124,8 @@ impl StaticSource { } impl TaskSource for StaticSource { - fn tasks_to_schedule( - &mut self, - _: &mut ModelContext>, - ) -> Vec> { - self.tasks - .iter() - .map(|task| task.clone() as Arc) - .collect() + fn tasks_to_schedule(&mut self, _: &mut ModelContext>) -> TaskTemplates { + self.tasks.clone() } fn as_any(&mut self) -> &mut dyn std::any::Any { diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs new file mode 100644 index 0000000000..d909dc3334 --- /dev/null +++ b/crates/task/src/task_template.rs @@ -0,0 +1,481 @@ +use std::path::PathBuf; + +use anyhow::Context; +use collections::HashMap; +use schemars::{gen::SchemaSettings, JsonSchema}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use util::{truncate_and_remove_front, ResultExt}; + +use crate::{ResolvedTask, SpawnInTerminal, TaskContext, TaskId, ZED_VARIABLE_NAME_PREFIX}; + +/// A template definition of a Zed task to run. +/// May use the [`VariableName`] to get the corresponding substitutions into its fields. +/// +/// Template itself is not ready to spawn a task, it needs to be resolved with a [`TaskContext`] first, that +/// contains all relevant Zed state in task variables. +/// A single template may produce different tasks (or none) for different contexts. +#[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct TaskTemplate { + /// Human readable name of the task to display in the UI. + pub label: String, + /// Executable command to spawn. + pub command: String, + /// Arguments to the command. + #[serde(default)] + pub args: Vec, + /// Env overrides for the command, will be appended to the terminal's environment from the settings. + #[serde(default)] + pub env: HashMap, + /// Current working directory to spawn the command into, defaults to current project root. + #[serde(default)] + pub cwd: Option, + /// Whether to use a new terminal tab or reuse the existing one to spawn the process. + #[serde(default)] + pub use_new_terminal: bool, + /// Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish. + #[serde(default)] + pub allow_concurrent_runs: bool, + /// 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) + /// * `never` — avoid changing current terminal pane focus, but still add/reuse the task's tab there + #[serde(default)] + pub reveal: RevealStrategy, +} + +/// What to do with the terminal pane and tab, after the command was started. +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RevealStrategy { + /// Always show the terminal pane, add and focus the corresponding task's tab in it. + #[default] + Always, + /// Do not change terminal pane focus, but still add/reuse the task's tab there. + Never, +} + +/// A group of Tasks defined in a JSON file. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct TaskTemplates(pub Vec); + +impl TaskTemplates { + /// Generates JSON schema of Tasks JSON template format. + pub fn generate_json_schema() -> serde_json_lenient::Value { + let schema = SchemaSettings::draft07() + .with(|settings| settings.option_add_null_type = false) + .into_generator() + .into_root_schema_for::(); + + serde_json_lenient::to_value(schema).unwrap() + } +} + +impl TaskTemplate { + /// Replaces all `VariableName` task variables in the task template string fields. + /// If any replacement fails or the new string substitutions still have [`ZED_VARIABLE_NAME_PREFIX`], + /// `None` is returned. + /// + /// 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 { + if self.label.trim().is_empty() || self.command.trim().is_empty() { + return None; + } + let TaskContext { + cwd, + task_variables, + } = cx; + let task_variables = task_variables.into_env_variables(); + let truncated_variables = truncate_variables(&task_variables); + let cwd = match self.cwd.as_deref() { + Some(cwd) => Some(substitute_all_template_variables_in_str( + cwd, + &task_variables, + )?), + None => None, + } + .map(PathBuf::from) + .or(cwd); + let shortened_label = + substitute_all_template_variables_in_str(&self.label, &truncated_variables)?; + let full_label = substitute_all_template_variables_in_str(&self.label, &task_variables)?; + let command = substitute_all_template_variables_in_str(&self.command, &task_variables)?; + let args = substitute_all_template_variables_in_vec(self.args.clone(), &task_variables)?; + let task_hash = to_hex_hash(self) + .context("hashing task template") + .log_err()?; + let variables_hash = to_hex_hash(&task_variables) + .context("hashing task variables") + .log_err()?; + let id = TaskId(format!("{id_base}_{task_hash}_{variables_hash}")); + let mut env = substitute_all_template_variables_in_map(self.env.clone(), &task_variables)?; + env.extend(task_variables); + Some(ResolvedTask { + id: id.clone(), + original_task: self.clone(), + resolved_label: full_label, + resolved: Some(SpawnInTerminal { + id, + cwd, + label: shortened_label, + command, + args, + env, + use_new_terminal: self.use_new_terminal, + allow_concurrent_runs: self.allow_concurrent_runs, + reveal: self.reveal, + }), + }) + } +} + +const MAX_DISPLAY_VARIABLE_LENGTH: usize = 15; + +fn truncate_variables(task_variables: &HashMap) -> HashMap { + task_variables + .iter() + .map(|(key, value)| { + ( + key.clone(), + truncate_and_remove_front(value, MAX_DISPLAY_VARIABLE_LENGTH), + ) + }) + .collect() +} + +fn to_hex_hash(object: impl Serialize) -> anyhow::Result { + let json = serde_json_lenient::to_string(&object).context("serializing the object")?; + let mut hasher = Sha256::new(); + hasher.update(json.as_bytes()); + Ok(hex::encode(hasher.finalize())) +} + +fn substitute_all_template_variables_in_str( + template_str: &str, + task_variables: &HashMap, +) -> Option { + let substituted_string = subst::substitute(&template_str, task_variables).ok()?; + if substituted_string.contains(ZED_VARIABLE_NAME_PREFIX) { + return None; + } + Some(substituted_string) +} + +fn substitute_all_template_variables_in_vec( + mut template_strs: Vec, + task_variables: &HashMap, +) -> Option> { + for template_str in &mut template_strs { + let substituted_string = subst::substitute(&template_str, task_variables).ok()?; + if substituted_string.contains(ZED_VARIABLE_NAME_PREFIX) { + return None; + } + *template_str = substituted_string + } + Some(template_strs) +} + +fn substitute_all_template_variables_in_map( + keys_and_values: HashMap, + task_variables: &HashMap, +) -> Option> { + keys_and_values + .into_iter() + .try_fold(HashMap::default(), |mut expanded_keys, (mut key, value)| { + match task_variables.get(&key) { + Some(variable_expansion) => key = variable_expansion.clone(), + None => { + if key.starts_with(ZED_VARIABLE_NAME_PREFIX) { + return Err(()); + } + } + } + expanded_keys.insert( + key, + subst::substitute(&value, task_variables) + .map_err(|_| ())? + .to_string(), + ); + Ok(expanded_keys) + }) + .ok() +} + +#[cfg(test)] +mod tests { + use std::{borrow::Cow, path::Path}; + + use crate::{TaskVariables, VariableName}; + + use super::*; + + const TEST_ID_BASE: &str = "test_base"; + + #[test] + fn test_resolving_templates_with_blank_command_and_label() { + let task_with_all_properties = TaskTemplate { + label: "test_label".to_string(), + command: "test_command".to_string(), + args: vec!["test_arg".to_string()], + env: HashMap::from_iter([("test_env_key".to_string(), "test_env_var".to_string())]), + ..TaskTemplate::default() + }; + + for task_with_blank_property in &[ + TaskTemplate { + label: "".to_string(), + ..task_with_all_properties.clone() + }, + TaskTemplate { + command: "".to_string(), + ..task_with_all_properties.clone() + }, + TaskTemplate { + label: "".to_string(), + command: "".to_string(), + ..task_with_all_properties.clone() + }, + ] { + assert_eq!( + task_with_blank_property.resolve_task(TEST_ID_BASE, TaskContext::default()), + None, + "should not resolve task with blank label and/or command: {task_with_blank_property:?}" + ); + } + } + + #[test] + fn test_template_cwd_resolution() { + let task_without_cwd = TaskTemplate { + cwd: None, + label: "test task".to_string(), + command: "echo 4".to_string(), + ..TaskTemplate::default() + }; + + let resolved_task = |task_template: &TaskTemplate, task_cx| { + let resolved_task = task_template + .resolve_task(TEST_ID_BASE, task_cx) + .unwrap_or_else(|| panic!("failed to resolve task {task_without_cwd:?}")); + resolved_task + .resolved + .clone() + .unwrap_or_else(|| { + panic!("failed to get resolve data for resolved task. Template: {task_without_cwd:?} Resolved: {resolved_task:?}") + }) + }; + + assert_eq!( + resolved_task( + &task_without_cwd, + TaskContext { + cwd: None, + task_variables: TaskVariables::default(), + } + ) + .cwd, + None, + "When neither task nor task context have cwd, it should be None" + ); + + let context_cwd = Path::new("a").join("b").join("c"); + assert_eq!( + resolved_task( + &task_without_cwd, + TaskContext { + cwd: Some(context_cwd.clone()), + task_variables: TaskVariables::default(), + } + ) + .cwd + .as_deref(), + Some(context_cwd.as_path()), + "TaskContext's cwd should be taken on resolve if task's cwd is None" + ); + + let task_cwd = Path::new("d").join("e").join("f"); + let mut task_with_cwd = task_without_cwd.clone(); + task_with_cwd.cwd = Some(task_cwd.display().to_string()); + let task_with_cwd = task_with_cwd; + + assert_eq!( + resolved_task( + &task_with_cwd, + TaskContext { + cwd: None, + task_variables: TaskVariables::default(), + } + ) + .cwd + .as_deref(), + Some(task_cwd.as_path()), + "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is None" + ); + + assert_eq!( + resolved_task( + &task_with_cwd, + TaskContext { + cwd: Some(context_cwd.clone()), + task_variables: TaskVariables::default(), + } + ) + .cwd + .as_deref(), + Some(task_cwd.as_path()), + "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is not None" + ); + } + + #[test] + fn test_template_variables_resolution() { + let custom_variable_1 = VariableName::Custom(Cow::Borrowed("custom_variable_1")); + let custom_variable_2 = VariableName::Custom(Cow::Borrowed("custom_variable_2")); + let long_value = "01".repeat(MAX_DISPLAY_VARIABLE_LENGTH * 2); + let all_variables = [ + (VariableName::Row, "1234".to_string()), + (VariableName::Column, "5678".to_string()), + (VariableName::File, "test_file".to_string()), + (VariableName::SelectedText, "test_selected_text".to_string()), + (VariableName::Symbol, long_value.clone()), + (VariableName::WorktreeRoot, "/test_root/".to_string()), + ( + custom_variable_1.clone(), + "test_custom_variable_1".to_string(), + ), + ( + custom_variable_2.clone(), + "test_custom_variable_2".to_string(), + ), + ]; + + let task_with_all_variables = TaskTemplate { + label: format!( + "test label for {} and {}", + VariableName::Row.template_value(), + VariableName::Symbol.template_value(), + ), + command: format!( + "echo {} {}", + VariableName::File.template_value(), + VariableName::Symbol.template_value(), + ), + args: vec![ + format!("arg1 {}", VariableName::SelectedText.template_value()), + format!("arg2 {}", VariableName::Column.template_value()), + format!("arg3 {}", VariableName::Symbol.template_value()), + ], + env: HashMap::from_iter([ + ("test_env_key".to_string(), "test_env_var".to_string()), + ( + "env_key_1".to_string(), + VariableName::WorktreeRoot.template_value(), + ), + ( + "env_key_2".to_string(), + format!( + "env_var_2_{}_{}", + custom_variable_1.template_value(), + custom_variable_2.template_value() + ), + ), + ( + "env_key_3".to_string(), + format!("env_var_3_{}", VariableName::Symbol.template_value()), + ), + ]), + ..TaskTemplate::default() + }; + + let mut first_resolved_id = None; + for i in 0..15 { + let resolved_task = task_with_all_variables.resolve_task( + TEST_ID_BASE, + TaskContext { + cwd: None, + task_variables: TaskVariables::from_iter(all_variables.clone()), + }, + ).unwrap_or_else(|| panic!("Should successfully resolve task {task_with_all_variables:?} with variables {all_variables:?}")); + + match &first_resolved_id { + None => first_resolved_id = Some(resolved_task.id), + Some(first_id) => assert_eq!( + &resolved_task.id, first_id, + "Step {i}, for the same task template and context, there should be the same resolved task id" + ), + } + + assert_eq!( + resolved_task.original_task, task_with_all_variables, + "Resolved task should store its template without changes" + ); + assert_eq!( + resolved_task.resolved_label, + format!("test label for 1234 and {long_value}"), + "Resolved task label should be substituted with variables and those should not be shortened" + ); + + let spawn_in_terminal = resolved_task + .resolved + .as_ref() + .expect("should have resolved a spawn in terminal task"); + assert_eq!( + spawn_in_terminal.label, + format!( + "test label for 1234 and …{}", + &long_value[..=MAX_DISPLAY_VARIABLE_LENGTH] + ), + "Human-readable label should have long substitutions trimmed" + ); + assert_eq!( + spawn_in_terminal.command, + format!("echo test_file {long_value}"), + "Command should be substituted with variables and those should not be shortened" + ); + assert_eq!( + spawn_in_terminal.args, + &[ + "arg1 test_selected_text", + "arg2 5678", + &format!("arg3 {long_value}") + ], + "Args should be substituted with variables and those should not be shortened" + ); + + assert_eq!( + spawn_in_terminal + .env + .get("test_env_key") + .map(|s| s.as_str()), + Some("test_env_var") + ); + assert_eq!( + spawn_in_terminal.env.get("env_key_1").map(|s| s.as_str()), + Some("/test_root/") + ); + assert_eq!( + spawn_in_terminal.env.get("env_key_2").map(|s| s.as_str()), + Some("env_var_2_test_custom_variable_1_test_custom_variable_2") + ); + assert_eq!( + spawn_in_terminal.env.get("env_key_3"), + Some(&format!("env_var_3_{long_value}")), + "Env vars should be substituted with variables and those should not be shortened" + ); + } + + for i in 0..all_variables.len() { + let mut not_all_variables = all_variables.to_vec(); + let removed_variable = not_all_variables.remove(i); + let resolved_task_attempt = task_with_all_variables.resolve_task( + TEST_ID_BASE, + TaskContext { + cwd: None, + task_variables: TaskVariables::from_iter(not_all_variables), + }, + ); + assert_eq!(resolved_task_attempt, None, "If any of the Zed task variables is not substituted, the task should not be resolved, but got some resolution without the variable {removed_variable:?} (index {i})"); + } + } +} diff --git a/crates/task/src/vscode_format.rs b/crates/task/src/vscode_format.rs index 86d29f7bae..fbba6d8e01 100644 --- a/crates/task/src/vscode_format.rs +++ b/crates/task/src/vscode_format.rs @@ -3,10 +3,7 @@ use collections::HashMap; use serde::Deserialize; use util::ResultExt; -use crate::{ - static_source::{Definition, TaskDefinitions}, - VariableName, -}; +use crate::{TaskTemplate, TaskTemplates, VariableName}; #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -87,7 +84,7 @@ impl EnvVariableReplacer { } impl VsCodeTaskDefinition { - fn to_zed_format(self, replacer: &EnvVariableReplacer) -> anyhow::Result { + fn to_zed_format(self, replacer: &EnvVariableReplacer) -> anyhow::Result { if self.other_attributes.contains_key("dependsOn") { bail!("Encountered unsupported `dependsOn` key during deserialization"); } @@ -107,7 +104,7 @@ impl VsCodeTaskDefinition { // Per VSC docs, only `command`, `args` and `options` support variable substitution. let command = replacer.replace(&command); let args = args.into_iter().map(|arg| replacer.replace(&arg)).collect(); - let mut ret = Definition { + let mut ret = TaskTemplate { label: self.label, command, args, @@ -127,7 +124,7 @@ pub struct VsCodeTaskFile { tasks: Vec, } -impl TryFrom for TaskDefinitions { +impl TryFrom for TaskTemplates { type Error = anyhow::Error; fn try_from(value: VsCodeTaskFile) -> Result { @@ -143,12 +140,12 @@ impl TryFrom for TaskDefinitions { VariableName::SelectedText.to_string(), ), ])); - let definitions = value + let templates = value .tasks .into_iter() .filter_map(|vscode_definition| vscode_definition.to_zed_format(&replacer).log_err()) .collect(); - Ok(Self(definitions)) + Ok(Self(templates)) } } @@ -157,9 +154,8 @@ mod tests { use std::collections::HashMap; use crate::{ - static_source::{Definition, TaskDefinitions}, vscode_format::{Command, VsCodeTaskDefinition}, - VsCodeTaskFile, + TaskTemplate, TaskTemplates, VsCodeTaskFile, }; use super::EnvVariableReplacer; @@ -257,13 +253,13 @@ mod tests { .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs)); let expected = vec![ - Definition { + TaskTemplate { label: "gulp: tests".to_string(), command: "npm".to_string(), args: vec!["run".to_string(), "build:tests:notypecheck".to_string()], ..Default::default() }, - Definition { + TaskTemplate { label: "tsc: watch ./src".to_string(), command: "node".to_string(), args: vec![ @@ -274,13 +270,13 @@ mod tests { ], ..Default::default() }, - Definition { + TaskTemplate { label: "npm: build:compiler".to_string(), command: "npm".to_string(), args: vec!["run".to_string(), "build:compiler".to_string()], ..Default::default() }, - Definition { + TaskTemplate { label: "npm: build:tests".to_string(), command: "npm".to_string(), args: vec!["run".to_string(), "build:tests:notypecheck".to_string()], @@ -288,7 +284,7 @@ mod tests { }, ]; - let tasks: TaskDefinitions = vscode_definitions.try_into().unwrap(); + let tasks: TaskTemplates = vscode_definitions.try_into().unwrap(); assert_eq!(tasks.0, expected); } @@ -360,36 +356,36 @@ mod tests { .zip(expected) .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs)); let expected = vec![ - Definition { + TaskTemplate { label: "Build Extension in Background".to_string(), command: "npm".to_string(), args: vec!["run".to_string(), "watch".to_string()], ..Default::default() }, - Definition { + TaskTemplate { label: "Build Extension".to_string(), command: "npm".to_string(), args: vec!["run".to_string(), "build".to_string()], ..Default::default() }, - Definition { + TaskTemplate { label: "Build Server".to_string(), command: "cargo build --package rust-analyzer".to_string(), ..Default::default() }, - Definition { + TaskTemplate { label: "Build Server (Release)".to_string(), command: "cargo build --release --package rust-analyzer".to_string(), ..Default::default() }, - Definition { + TaskTemplate { label: "Pretest".to_string(), command: "npm".to_string(), args: vec!["run".to_string(), "pretest".to_string()], ..Default::default() }, ]; - let tasks: TaskDefinitions = vscode_definitions.try_into().unwrap(); + let tasks: TaskTemplates = vscode_definitions.try_into().unwrap(); assert_eq!(tasks.0, expected); } } diff --git a/crates/tasks_ui/src/lib.rs b/crates/tasks_ui/src/lib.rs index 710869f855..b1969c6cb6 100644 --- a/crates/tasks_ui/src/lib.rs +++ b/crates/tasks_ui/src/lib.rs @@ -5,8 +5,8 @@ use editor::Editor; use gpui::{AppContext, ViewContext, WindowContext}; use language::{Language, Point}; use modal::{Spawn, TasksModal}; -use project::{Location, WorktreeId}; -use task::{Task, TaskContext, TaskVariables, VariableName}; +use project::{Location, TaskSourceKind, WorktreeId}; +use task::{ResolvedTask, TaskContext, TaskTemplate, TaskVariables, VariableName}; use util::ResultExt; use workspace::Workspace; @@ -23,18 +23,32 @@ pub fn init(cx: &mut AppContext) { workspace .register_action(spawn_task_or_modal) .register_action(move |workspace, action: &modal::Rerun, cx| { - if let Some((task, old_context)) = + if let Some((task_source_kind, last_scheduled_task)) = workspace.project().update(cx, |project, cx| { project.task_inventory().read(cx).last_scheduled_task() }) { - let task_context = if action.reevaluate_context { + if action.reevaluate_context { + let original_task = last_scheduled_task.original_task; let cwd = task_cwd(workspace, cx).log_err().flatten(); - task_context(workspace, cwd, cx) + let task_context = task_context(workspace, cwd, cx); + schedule_task( + workspace, + task_source_kind, + &original_task, + task_context, + false, + cx, + ) } else { - old_context - }; - schedule_task(workspace, &task, task_context, false, cx) + schedule_resolved_task( + workspace, + task_source_kind, + last_scheduled_task, + false, + cx, + ); + } }; }); }, @@ -64,13 +78,21 @@ fn spawn_task_with_name(name: String, cx: &mut ViewContext) { let (worktree, language) = active_item_selection_properties(workspace, cx); let tasks = workspace.project().update(cx, |project, cx| { project.task_inventory().update(cx, |inventory, cx| { - inventory.list_tasks(language, worktree, false, cx) + inventory.list_tasks(language, worktree, cx) }) }); - let (_, target_task) = tasks.into_iter().find(|(_, task)| task.name() == name)?; + let (task_source_kind, target_task) = + tasks.into_iter().find(|(_, task)| task.label == name)?; let cwd = task_cwd(workspace, cx).log_err().flatten(); let task_context = task_context(workspace, cwd, cx); - schedule_task(workspace, &target_task, task_context, false, cx); + schedule_task( + workspace, + task_source_kind, + &target_task, + task_context, + false, + cx, + ); Some(()) }) .ok() @@ -214,17 +236,38 @@ fn task_context( fn schedule_task( workspace: &Workspace, - task: &Arc, + task_source_kind: TaskSourceKind, + task_to_resolve: &TaskTemplate, task_cx: TaskContext, omit_history: bool, cx: &mut ViewContext<'_, Workspace>, ) { - let spawn_in_terminal = task.prepare_exec(task_cx.clone()); - if let Some(spawn_in_terminal) = spawn_in_terminal { + if let Some(spawn_in_terminal) = + task_to_resolve.resolve_task(&task_source_kind.to_id_base(), task_cx) + { + schedule_resolved_task( + workspace, + task_source_kind, + spawn_in_terminal, + omit_history, + cx, + ); + } +} + +fn schedule_resolved_task( + workspace: &Workspace, + task_source_kind: TaskSourceKind, + mut resolved_task: ResolvedTask, + omit_history: bool, + cx: &mut ViewContext<'_, Workspace>, +) { + if let Some(spawn_in_terminal) = resolved_task.resolved.take() { if !omit_history { + resolved_task.resolved = Some(spawn_in_terminal.clone()); workspace.project().update(cx, |project, cx| { project.task_inventory().update(cx, |inventory, _| { - inventory.task_scheduled(Arc::clone(task), task_cx); + inventory.task_scheduled(task_source_kind, resolved_task); }) }); } @@ -274,9 +317,9 @@ mod tests { use editor::Editor; use gpui::{Entity, TestAppContext}; use language::{Language, LanguageConfig, SymbolContextProvider}; - use project::{FakeFs, Project, TaskSourceKind}; + use project::{FakeFs, Project}; use serde_json::json; - use task::{oneshot_source::OneshotSource, TaskContext, TaskVariables, VariableName}; + use task::{TaskContext, TaskVariables, VariableName}; use ui::VisualContext; use workspace::{AppState, Workspace}; @@ -344,11 +387,6 @@ mod tests { .with_context_provider(Some(Arc::new(SymbolContextProvider))), ); let project = Project::test(fs, ["/dir".as_ref()], cx).await; - project.update(cx, |project, cx| { - project.task_inventory().update(cx, |inventory, cx| { - inventory.add_source(TaskSourceKind::UserInput, |cx| OneshotSource::new(cx), cx) - }) - }); let worktree_id = project.update(cx, |project, cx| { project.worktrees().next().unwrap().read(cx).id() }); diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index 09988d8f70..324be3fe94 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use crate::{active_item_selection_properties, schedule_task}; +use crate::{active_item_selection_properties, schedule_resolved_task}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ impl_actions, rems, AppContext, DismissEvent, EventEmitter, FocusableView, Global, @@ -9,7 +9,7 @@ use gpui::{ }; use picker::{highlighted_match_with_paths::HighlightedText, Picker, PickerDelegate}; use project::{Inventory, TaskSourceKind}; -use task::{oneshot_source::OneshotSource, Task, TaskContext}; +use task::{ResolvedTask, TaskContext, TaskTemplate}; use ui::{ div, v_flex, ButtonCommon, ButtonSize, Clickable, Color, FluentBuilder as _, Icon, IconButton, IconButtonShape, IconName, IconSize, ListItem, ListItemSpacing, RenderOnce, Selectable, @@ -51,7 +51,8 @@ impl_actions!(task, [Rerun, Spawn]); /// A modal used to spawn new tasks. pub(crate) struct TasksModalDelegate { inventory: Model, - candidates: Option)>>, + candidates: Option>, + last_used_candidate_index: Option, matches: Vec, selected_index: usize, workspace: WeakView, @@ -71,6 +72,7 @@ impl TasksModalDelegate { workspace, candidates: None, matches: Vec::new(), + last_used_candidate_index: None, selected_index: 0, prompt: String::default(), task_context, @@ -78,24 +80,25 @@ impl TasksModalDelegate { } } - fn spawn_oneshot(&mut self, cx: &mut AppContext) -> Option> { + fn spawn_oneshot(&mut self) -> Option<(TaskSourceKind, ResolvedTask)> { if self.prompt.trim().is_empty() { return None; } - self.inventory - .update(cx, |inventory, _| inventory.source::())? - .update(cx, |oneshot_source, _| { - Some( - oneshot_source - .as_any() - .downcast_mut::()? - .spawn(self.prompt.clone()), - ) - }) + let source_kind = TaskSourceKind::UserInput; + let id_base = source_kind.to_id_base(); + let new_oneshot = TaskTemplate { + label: self.prompt.clone(), + command: self.prompt.clone(), + ..TaskTemplate::default() + }; + Some(( + source_kind, + new_oneshot.resolve_task(&id_base, self.task_context.clone())?, + )) } - fn delete_oneshot(&mut self, ix: usize, cx: &mut AppContext) { + fn delete_previously_used(&mut self, ix: usize, cx: &mut AppContext) { let Some(candidates) = self.candidates.as_mut() else { return; }; @@ -106,16 +109,8 @@ impl TasksModalDelegate { // it doesn't make sense to requery the inventory for new candidates, as that's potentially costly and more often than not it should just return back // the original list without a removed entry. candidates.remove(ix); - self.inventory.update(cx, |inventory, cx| { - let oneshot_source = inventory.source::()?; - let task_id = task.id(); - - oneshot_source.update(cx, |this, _| { - let oneshot_source = this.as_any().downcast_mut::()?; - oneshot_source.remove(task_id); - Some(()) - }); - Some(()) + self.inventory.update(cx, |inventory, _| { + inventory.delete_previously_used(&task.id); }); } } @@ -194,26 +189,47 @@ impl PickerDelegate for TasksModalDelegate { cx.spawn(move |picker, mut cx| async move { let Some(candidates) = picker .update(&mut cx, |picker, cx| { - let candidates = picker.delegate.candidates.get_or_insert_with(|| { - let Ok((worktree, language)) = - picker.delegate.workspace.update(cx, |workspace, cx| { - active_item_selection_properties(workspace, cx) - }) - else { - return Vec::new(); - }; - picker.delegate.inventory.update(cx, |inventory, cx| { - inventory.list_tasks(language, worktree, true, cx) - }) - }); + let candidates = match &mut picker.delegate.candidates { + Some(candidates) => candidates, + None => { + let Ok((worktree, language)) = + picker.delegate.workspace.update(cx, |workspace, cx| { + active_item_selection_properties(workspace, cx) + }) + else { + return Vec::new(); + }; + let (used, current) = + picker.delegate.inventory.update(cx, |inventory, cx| { + inventory.used_and_current_resolved_tasks( + language, + worktree, + picker.delegate.task_context.clone(), + cx, + ) + }); + picker.delegate.last_used_candidate_index = if used.is_empty() { + None + } else { + Some(used.len() - 1) + }; + let mut new_candidates = used; + new_candidates.extend(current); + picker.delegate.candidates.insert(new_candidates) + } + }; candidates .iter() .enumerate() .map(|(index, (_, candidate))| StringMatchCandidate { id: index, - char_bag: candidate.name().chars().collect(), - string: candidate.name().into(), + char_bag: candidate.resolved_label.chars().collect(), + string: candidate + .resolved + .as_ref() + .map(|resolved| resolved.label.clone()) + .unwrap_or_else(|| candidate.resolved_label.clone()), }) .collect::>() }) @@ -256,21 +272,15 @@ impl PickerDelegate for TasksModalDelegate { let ix = current_match.candidate_id; self.candidates .as_ref() - .map(|candidates| candidates[ix].1.clone()) + .map(|candidates| candidates[ix].clone()) }); - let Some(task) = task else { + let Some((task_source_kind, task)) = task else { return; }; self.workspace .update(cx, |workspace, cx| { - schedule_task( - workspace, - &task, - self.task_context.clone(), - omit_history_entry, - cx, - ); + schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx); }) .ok(); cx.emit(DismissEvent); @@ -288,16 +298,13 @@ impl PickerDelegate for TasksModalDelegate { ) -> Option { let candidates = self.candidates.as_ref()?; let hit = &self.matches[ix]; - let (source_kind, _) = &candidates[hit.candidate_id]; + let (source_kind, _) = &candidates.get(hit.candidate_id)?; let highlighted_location = HighlightedText { text: hit.string.clone(), highlight_positions: hit.positions.clone(), char_count: hit.string.chars().count(), }; - let base_item = ListItem::new(SharedString::from(format!("tasks-modal-{ix}"))) - .inset(true) - .spacing(ListItemSpacing::Sparse); let icon = match source_kind { TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)), TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)), @@ -307,9 +314,13 @@ impl PickerDelegate for TasksModalDelegate { .map(|icon_path| Icon::from_path(icon_path)), }; Some( - base_item + ListItem::new(SharedString::from(format!("tasks-modal-{ix}"))) + .inset(true) + .spacing(ListItemSpacing::Sparse) .map(|item| { - let item = if matches!(source_kind, TaskSourceKind::UserInput) { + let item = if matches!(source_kind, TaskSourceKind::UserInput) + || Some(ix) <= self.last_used_candidate_index + { let task_index = hit.candidate_id; let delete_button = div().child( IconButton::new("delete", IconName::Close) @@ -317,14 +328,21 @@ impl PickerDelegate for TasksModalDelegate { .icon_color(Color::Muted) .size(ButtonSize::None) .icon_size(IconSize::XSmall) - .on_click(cx.listener(move |this, _event, cx| { + .on_click(cx.listener(move |picker, _event, cx| { cx.stop_propagation(); cx.prevent_default(); - this.delegate.delete_oneshot(task_index, cx); - this.refresh(cx); + picker.delegate.delete_previously_used(task_index, cx); + picker.delegate.last_used_candidate_index = picker + .delegate + .last_used_candidate_index + .unwrap_or(0) + .checked_sub(1); + picker.refresh(cx); })) - .tooltip(|cx| Tooltip::text("Delete an one-shot task", cx)), + .tooltip(|cx| { + Tooltip::text("Delete previously scheduled task", cx) + }), ); item.end_hover_slot(delete_button) } else { @@ -346,35 +364,38 @@ impl PickerDelegate for TasksModalDelegate { let task_index = self.matches.get(self.selected_index())?.candidate_id; let tasks = self.candidates.as_ref()?; let (_, task) = tasks.get(task_index)?; - let mut spawn_prompt = task.prepare_exec(self.task_context.clone())?; - if !spawn_prompt.args.is_empty() { - spawn_prompt.command.push(' '); - spawn_prompt - .command - .extend(intersperse(spawn_prompt.args, " ".to_string())); - } - Some(spawn_prompt.command) + task.resolved.as_ref().map(|spawn_in_terminal| { + let mut command = spawn_in_terminal.command.clone(); + if !spawn_in_terminal.args.is_empty() { + command.push(' '); + command.extend(intersperse(spawn_in_terminal.args.clone(), " ".to_string())); + } + command + }) } fn confirm_input(&mut self, omit_history_entry: bool, cx: &mut ViewContext>) { - let Some(task) = self.spawn_oneshot(cx) else { + let Some((task_source_kind, task)) = self.spawn_oneshot() else { return; }; self.workspace .update(cx, |workspace, cx| { - schedule_task( - workspace, - &task, - self.task_context.clone(), - omit_history_entry, - cx, - ); + schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx); }) .ok(); cx.emit(DismissEvent); } + + fn separators_after_indices(&self) -> Vec { + if let Some(i) = self.last_used_candidate_index { + vec![i] + } else { + Vec::new() + } + } } +// TODO kb more tests on recent tasks from language templates #[cfg(test)] mod tests { use gpui::{TestAppContext, VisualTestContext}; @@ -412,12 +433,6 @@ mod tests { .await; let project = Project::test(fs, ["/dir".as_ref()], cx).await; - project.update(cx, |project, cx| { - project.task_inventory().update(cx, |inventory, cx| { - inventory.add_source(TaskSourceKind::UserInput, |cx| OneshotSource::new(cx), cx) - }) - }); - let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); let tasks_picker = open_spawn_tasks(&workspace, cx); @@ -518,8 +533,8 @@ mod tests { ); assert_eq!( task_names(&tasks_picker, cx), - vec!["echo 4", "another one", "example task", "echo 40"], - "Last recently used one show task should be listed last, as it is a fire-and-forget task" + vec!["echo 4", "another one", "example task"], + "No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)" ); cx.dispatch_action(Spawn { @@ -535,7 +550,7 @@ mod tests { }); assert_eq!( task_names(&tasks_picker, cx), - vec!["echo 4", "another one", "example task", "echo 40"], + vec!["echo 4", "another one", "example task"], ); } diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 82291c752f..8bb4eed227 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -39,7 +39,7 @@ use pty_info::PtyProcessInfo; use serde::{Deserialize, Serialize}; use settings::Settings; use smol::channel::{Receiver, Sender}; -use task::{static_source::RevealStrategy, TaskId}; +use task::{RevealStrategy, TaskId}; use terminal_settings::{AlternateScroll, Shell, TerminalBlink, TerminalSettings}; use theme::{ActiveTheme, Theme}; use util::truncate_and_trailoff; diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 89a1882071..801d6d1ed0 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -14,7 +14,7 @@ use project::{Fs, ProjectEntryId}; use search::{buffer_search::DivRegistrar, BufferSearchBar}; use serde::{Deserialize, Serialize}; use settings::Settings; -use task::{static_source::RevealStrategy, SpawnInTerminal, TaskId}; +use task::{RevealStrategy, SpawnInTerminal, TaskId}; use terminal::{ terminal_settings::{Shell, TerminalDockPosition, TerminalSettings}, SpawnTask, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index d120b11cc3..1954e33f94 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -29,10 +29,7 @@ use settings::{ SettingsStore, DEFAULT_KEYMAP_PATH, }; use std::{borrow::Cow, ops::Deref, path::Path, sync::Arc}; -use task::{ - oneshot_source::OneshotSource, - static_source::{StaticSource, TrackedFile}, -}; +use task::static_source::{StaticSource, TrackedFile}; use theme::ActiveTheme; use workspace::notifications::NotificationId; @@ -163,23 +160,17 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { let fs = app_state.fs.clone(); project.task_inventory().update(cx, |inventory, cx| { inventory.add_source( - TaskSourceKind::UserInput, - |cx| OneshotSource::new(cx), - cx, - ); - inventory.add_source( - TaskSourceKind::AbsPath(paths::TASKS.clone()), + TaskSourceKind::AbsPath { + id_base: "global_tasks", + abs_path: paths::TASKS.clone(), + }, |cx| { let tasks_file_rx = watch_config_file( &cx.background_executor(), fs, paths::TASKS.clone(), ); - StaticSource::new( - "global_tasks", - TrackedFile::new(tasks_file_rx, cx), - cx, - ) + StaticSource::new(TrackedFile::new(tasks_file_rx, cx), cx) }, cx, );