Rework task modal (#10341)

New list (used tasks are above the separator line, sorted by the usage
recency), then all language tasks, then project-local and global tasks
are listed.
Note that there are two test tasks (for `test_name_1` and `test_name_2`
functions) that are created from the same task template:
<img width="563" alt="Screenshot 2024-04-10 at 01 00 46"
src="https://github.com/zed-industries/zed/assets/2690773/7455a82f-2af2-47bf-99bd-d9c5a36e64ab">

Tasks are deduplicated by labels, with the used tasks left in case of
the conflict with the new tasks from the template:
<img width="555" alt="Screenshot 2024-04-10 at 01 01 06"
src="https://github.com/zed-industries/zed/assets/2690773/8f5a249e-abec-46ef-a991-08c6d0348648">

Regular recent tasks can be now removed too:
<img width="565" alt="Screenshot 2024-04-10 at 01 00 55"
src="https://github.com/zed-industries/zed/assets/2690773/0976b8fe-b5d7-4d2a-953d-1d8b1f216192">

When the caret is in the place where no function symbol could be
retrieved, no cargo tests for function are listed in tasks:
<img width="556" alt="image"
src="https://github.com/zed-industries/zed/assets/2690773/df30feba-fe27-4645-8be9-02afc70f02da">


Part of https://github.com/zed-industries/zed/issues/10132
Reworks the task code to simplify it and enable proper task labels.

* removes `trait Task`, renames `Definition` into `TaskTemplate` and use
that instead of `Arc<dyn Task>` everywhere
* implement more generic `TaskId` generation that depends on the
`TaskContext` and `TaskTemplate`
* remove `TaskId` out of the template and only create it after
"resolving" the template into the `ResolvedTask`: this way, task
templates, task state (`TaskContext`) and task "result" (resolved state)
are clearly separated and are not mixed
* implement the logic for filtering out non-related language tasks and
tasks that have non-resolved Zed task variables
* rework Zed template-vs-resolved-task display in modal: now all reruns
and recently used tasks are resolved tasks with "fixed" context (unless
configured otherwise in the task json) that are always shown, and Zed
can add on top tasks with different context that are derived from the
same template as the used, resolved tasks
* sort the tasks list better, showing more specific and least recently
used tasks higher
* shows a separator between used and unused tasks, allow removing the
used tasks same as the oneshot ones
* remote the Oneshot task source as redundant: all oneshot tasks are now
stored in the inventory's history
* when reusing the tasks as query in the modal, paste the expanded task
label now, show trimmed resolved label in the modal
* adjusts Rust and Elixir task labels to be more descriptive and closer
to bash scripts

Release Notes:

- Improved task modal ordering, run and deletion capabilities
This commit is contained in:
Kirill Bulatov 2024-04-11 01:02:04 +02:00 committed by GitHub
parent b0eda77d73
commit d1ad96782c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1103 additions and 671 deletions

View File

@ -0,0 +1,7 @@
[
{
"label": "clippy",
"command": "cargo",
"args": ["xtask", "clippy"]
}
]

2
Cargo.lock generated
View File

@ -9615,9 +9615,11 @@ dependencies = [
"collections",
"futures 0.3.28",
"gpui",
"hex",
"schemars",
"serde",
"serde_json_lenient",
"sha2 0.10.7",
"shellexpand",
"subst",
"util",

View File

@ -701,7 +701,7 @@ impl AssistantPanel {
} else {
editor.highlight_background::<PendingInlineAssist>(
&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,
);
}

View File

@ -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<TaskDefinitions> {
fn associated_tasks(&self) -> Option<TaskTemplates> {
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<TaskDefinitions> {
Some(self.definitions.clone())
fn associated_tasks(&self) -> Option<TaskTemplates> {
Some(self.templates.clone())
}
fn build_context(&self, location: Location, cx: &mut AppContext) -> Result<TaskVariables> {

View File

@ -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()
},
]))
}

View File

@ -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": {

View File

@ -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<TaskDefinitions> {
Some(TaskDefinitions(vec![
Definition {
label: "Rust: Test current crate".to_owned(),
fn associated_tasks(&self) -> Option<TaskTemplates> {
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()
},
]))
}

View File

@ -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::<task::VsCodeTaskFile>(
tasks_file_rx,
cx,

View File

@ -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::<Vec<_>>();
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()
),

View File

@ -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<SourceInInventory>,
last_scheduled_tasks: VecDeque<(Arc<dyn Task>, 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<str> },
}
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<WorktreeId> {
pub fn worktree(&self) -> Option<WorktreeId> {
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<T: TaskSource>(&self) -> Option<Model<Box<dyn TaskSource>>> {
pub fn source<T: TaskSource>(&self) -> Option<(Model<Box<dyn TaskSource>>, TaskSourceKind)> {
let target_type_id = std::any::TypeId::of::<T>();
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<Arc<Language>>,
worktree: Option<WorktreeId>,
lru: bool,
cx: &mut AppContext,
) -> Vec<(TaskSourceKind, Arc<dyn Task>)> {
) -> 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<Arc<Language>>,
worktree: Option<WorktreeId>,
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::<Vec<_>>();
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<dyn Task>, 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<dyn Task>, 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<task::SpawnInTerminal> {
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<Item = String>,
cx: &mut AppContext,
) -> Model<Box<dyn TaskSource>> {
@ -302,12 +378,18 @@ pub mod test_inventory {
fn tasks_to_schedule(
&mut self,
_cx: &mut ModelContext<Box<dyn TaskSource>>,
) -> Vec<Arc<dyn Task>> {
self.tasks
.clone()
.into_iter()
.map(|task| Arc::new(task) as Arc<dyn Task>)
.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<Inventory>,
worktree: Option<WorktreeId>,
lru: bool,
cx: &mut TestAppContext,
) -> Vec<String> {
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<Inventory>,
worktree: Option<WorktreeId>,
cx: &mut TestAppContext,
) -> Vec<String> {
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<Inventory>,
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<Inventory>,
worktree: Option<WorktreeId>,
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::<Vec<_>>();
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::<Vec<_>>(),
);
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::<Vec<_>>(),
);
}

View File

@ -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

View File

@ -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<SpawnInTerminal>,
}
/// 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<VariableName, String>);
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<PathBuf>,
@ -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<SpawnInTerminal>;
}
/// [`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<Box<dyn TaskSource>>,
) -> Vec<Arc<dyn Task>>;
fn tasks_to_schedule(&mut self, cx: &mut ModelContext<Box<dyn TaskSource>>) -> TaskTemplates;
}

View File

@ -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<Arc<dyn Task>>,
}
#[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<SpawnInTerminal> {
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<Box<dyn TaskSource>> {
cx.new_model(|_| Box::new(Self { tasks: Vec::new() }) as Box<dyn TaskSource>)
}
/// Spawns a certain task based on the user prompt.
pub fn spawn(&mut self, prompt: String) -> Arc<dyn Task> {
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<Box<dyn TaskSource>>,
) -> Vec<Arc<dyn Task>> {
self.tasks.clone()
}
}

View File

@ -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<Self> {
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<Arc<dyn Task>> {
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<SpawnInTerminal> {
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<Arc<StaticTask>>,
_definitions: Model<TrackedFile<TaskDefinitions>>,
tasks: TaskTemplates,
_templates: Model<TrackedFile<TaskTemplates>>,
_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<String>,
/// Env overrides for the command, will be appended to the terminal's environment from the settings.
#[serde(default)]
pub env: HashMap<String, String>,
/// Current working directory to spawn the command into, defaults to current project root.
#[serde(default)]
pub cwd: Option<String>,
/// 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<Definition>);
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::<Self>();
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<T: PartialEq + 'static> TrackedFile<T> {
impl StaticSource {
/// Initializes the static source, reacting on tasks config changes.
pub fn new(
id_base: impl Into<Cow<'static, str>>,
definitions: Model<TrackedFile<TaskDefinitions>>,
templates: Model<TrackedFile<TaskTemplates>>,
cx: &mut AppContext,
) -> Model<Box<dyn TaskSource>> {
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::<Self>() {
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<Box<dyn TaskSource>>,
) -> Vec<Arc<dyn Task>> {
self.tasks
.iter()
.map(|task| task.clone() as Arc<dyn Task>)
.collect()
fn tasks_to_schedule(&mut self, _: &mut ModelContext<Box<dyn TaskSource>>) -> TaskTemplates {
self.tasks.clone()
}
fn as_any(&mut self) -> &mut dyn std::any::Any {

View File

@ -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<String>,
/// Env overrides for the command, will be appended to the terminal's environment from the settings.
#[serde(default)]
pub env: HashMap<String, String>,
/// Current working directory to spawn the command into, defaults to current project root.
#[serde(default)]
pub cwd: Option<String>,
/// 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<TaskTemplate>);
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::<Self>();
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<ResolvedTask> {
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<String, String>) -> HashMap<String, String> {
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<String> {
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<String, String>,
) -> Option<String> {
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<String>,
task_variables: &HashMap<String, String>,
) -> Option<Vec<String>> {
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<String, String>,
task_variables: &HashMap<String, String>,
) -> Option<HashMap<String, String>> {
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})");
}
}
}

View File

@ -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<Definition> {
fn to_zed_format(self, replacer: &EnvVariableReplacer) -> anyhow::Result<TaskTemplate> {
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<VsCodeTaskDefinition>,
}
impl TryFrom<VsCodeTaskFile> for TaskDefinitions {
impl TryFrom<VsCodeTaskFile> for TaskTemplates {
type Error = anyhow::Error;
fn try_from(value: VsCodeTaskFile) -> Result<Self, Self::Error> {
@ -143,12 +140,12 @@ impl TryFrom<VsCodeTaskFile> 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);
}
}

View File

@ -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<Workspace>) {
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<dyn Task>,
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()
});

View File

@ -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<Inventory>,
candidates: Option<Vec<(TaskSourceKind, Arc<dyn Task>)>>,
candidates: Option<Vec<(TaskSourceKind, ResolvedTask)>>,
last_used_candidate_index: Option<usize>,
matches: Vec<StringMatch>,
selected_index: usize,
workspace: WeakView<Workspace>,
@ -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<Arc<dyn Task>> {
fn spawn_oneshot(&mut self) -> Option<(TaskSourceKind, ResolvedTask)> {
if self.prompt.trim().is_empty() {
return None;
}
self.inventory
.update(cx, |inventory, _| inventory.source::<OneshotSource>())?
.update(cx, |oneshot_source, _| {
Some(
oneshot_source
.as_any()
.downcast_mut::<OneshotSource>()?
.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::<OneshotSource>()?;
let task_id = task.id();
oneshot_source.update(cx, |this, _| {
let oneshot_source = this.as_any().downcast_mut::<OneshotSource>()?;
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::<Vec<_>>()
})
@ -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<Self::ListItem> {
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<Picker<Self>>) {
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<usize> {
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"],
);
}

View File

@ -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;

View File

@ -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,

View File

@ -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<AppState>, 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,
);