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,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})");
}
}
}