Detailed changes
@@ -619,6 +619,7 @@ impl GitBlame {
cx.emit(project::Event::Toast {
notification_id: "git-blame".into(),
message: notification,
+ link: None,
});
} else {
// If we weren't triggered by a user, we just log errors in the background, instead of sending
@@ -786,7 +787,8 @@ mod tests {
project::Event::Toast {
notification_id: "git-blame".into(),
message: "Failed to blame \"file.txt\": failed to get blame for \"file.txt\""
- .to_string()
+ .to_string(),
+ link: None
}
);
@@ -305,6 +305,13 @@ enum ProjectClientState {
},
}
+/// A link to display in a toast notification, useful to point to documentation.
+#[derive(PartialEq, Debug, Clone)]
+pub struct ToastLink {
+ pub label: &'static str,
+ pub url: &'static str,
+}
+
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
LanguageServerAdded(LanguageServerId, LanguageServerName, Option<WorktreeId>),
@@ -326,6 +333,8 @@ pub enum Event {
Toast {
notification_id: SharedString,
message: String,
+ /// Optional link to display as a button in the toast.
+ link: Option<ToastLink>,
},
HideToast {
notification_id: SharedString,
@@ -3215,6 +3224,7 @@ impl Project {
cx.emit(Event::Toast {
notification_id: "dap".into(),
message: message.clone(),
+ link: None,
});
}
}
@@ -3345,6 +3355,7 @@ impl Project {
LspStoreEvent::Notification(message) => cx.emit(Event::Toast {
notification_id: "lsp".into(),
message: message.clone(),
+ link: None,
}),
LspStoreEvent::SnippetEdit {
buffer_id,
@@ -3395,6 +3406,7 @@ impl Project {
let message = format!("Failed to set local settings in {path:?}:\n{message}");
cx.emit(Event::Toast {
notification_id: format!("local-settings-{path:?}").into(),
+ link: None,
message,
});
}
@@ -3408,6 +3420,10 @@ impl Project {
let message = format!("Failed to set local tasks in {path:?}:\n{message}");
cx.emit(Event::Toast {
notification_id: format!("local-tasks-{path:?}").into(),
+ link: Some(ToastLink {
+ label: "Open Tasks Documentation",
+ url: "https://zed.dev/docs/tasks",
+ }),
message,
});
}
@@ -3422,6 +3438,7 @@ impl Project {
format!("Failed to set local debug scenarios in {path:?}:\n{message}");
cx.emit(Event::Toast {
notification_id: format!("local-debug-scenarios-{path:?}").into(),
+ link: None,
message,
});
}
@@ -4888,6 +4905,7 @@ impl Project {
cx.emit(Event::Toast {
notification_id: envelope.payload.notification_id.into(),
message: envelope.payload.message,
+ link: None,
});
Ok(())
})
@@ -512,6 +512,7 @@ impl Inventory {
let new_resolved_tasks = worktree_tasks
.flat_map(|(kind, task)| {
let id_base = kind.to_id_base();
+
if let TaskSourceKind::Worktree { id, .. } = &kind {
None.or_else(|| {
let (_, _, item_context) =
@@ -660,8 +661,31 @@ impl Inventory {
});
}
};
+
+ let mut validation_errors = Vec::new();
let new_templates = raw_tasks.into_iter().filter_map(|raw_template| {
- serde_json::from_value::<TaskTemplate>(raw_template).log_err()
+ let template = serde_json::from_value::<TaskTemplate>(raw_template).log_err()?;
+
+ // Validate the variable names used in the `TaskTemplate`.
+ let unknown_variables = template.unknown_variables();
+ if !unknown_variables.is_empty() {
+ let variables_list = unknown_variables
+ .iter()
+ .map(|variable| format!("${variable}"))
+ .collect::<Vec<_>>()
+ .join(", ");
+
+ validation_errors.push(format!(
+ "Task '{}' uses unknown variables: {}",
+ template.label, variables_list
+ ));
+
+ // Skip this template, since it uses unknown variable names, but
+ // continue processing others.
+ return None;
+ }
+
+ Some(template)
});
let parsed_templates = &mut self.templates_from_settings;
@@ -710,6 +734,18 @@ impl Inventory {
}
}
+ if !validation_errors.is_empty() {
+ return Err(InvalidSettingsError::Tasks {
+ path: match &location {
+ TaskSettingsLocation::Global(path) => path.to_path_buf(),
+ TaskSettingsLocation::Worktree(location) => {
+ location.path.as_std_path().join(task_file_name())
+ }
+ },
+ message: validation_errors.join("\n"),
+ });
+ }
+
Ok(())
}
@@ -68,10 +68,12 @@ use settings::SettingsStore;
#[cfg(not(windows))]
use std::os;
use std::{
+ cell::RefCell,
env, mem,
num::NonZeroU32,
ops::Range,
path::{Path, PathBuf},
+ rc::Rc,
str::FromStr,
sync::{Arc, OnceLock},
task::Poll,
@@ -1112,6 +1114,64 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext)
);
}
+#[gpui::test]
+async fn test_invalid_local_tasks_shows_toast_with_doc_link(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+ TaskStore::init(None);
+
+ // We need to start with a valid `.zed/tasks.json` file as otherwise the
+ // event is emitted before we havd a chance to setup the event subscription.
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/dir"),
+ json!({
+ ".zed": {
+ "tasks.json": r#"[{ "label": "valid task", "command": "echo" }]"#,
+ },
+ "file.rs": ""
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+ let saw_toast = Rc::new(RefCell::new(false));
+
+ // Update the `.zed/tasks.json` file with an invalid variable, so we can
+ // later assert that the `Event::Toast` even is emitted.
+ fs.save(
+ path!("/dir/.zed/tasks.json").as_ref(),
+ &r#"[{ "label": "test $ZED_FOO", "command": "echo" }]"#.into(),
+ Default::default(),
+ )
+ .await
+ .unwrap();
+
+ project.update(cx, |_, cx| {
+ let saw_toast = saw_toast.clone();
+
+ cx.subscribe(&project, move |_, _, event: &Event, _| match event {
+ Event::Toast {
+ notification_id,
+ message,
+ link: Some(ToastLink { url, .. }),
+ } => {
+ assert!(notification_id.starts_with("local-tasks-"));
+ assert!(message.contains("ZED_FOO"));
+ assert_eq!(*url, "https://zed.dev/docs/tasks");
+ *saw_toast.borrow_mut() = true;
+ }
+ _ => {}
+ })
+ .detach();
+ });
+
+ cx.run_until_parked();
+ assert!(
+ *saw_toast.borrow(),
+ "Expected `Event::Toast` was never emitted"
+ );
+}
+
#[gpui::test]
async fn test_fallback_to_single_worktree_tasks(cx: &mut gpui::TestAppContext) {
init_test(cx);
@@ -1834,6 +1834,7 @@ impl ProjectPanel {
),
abs_path
),
+ link: None,
})
});
None
@@ -128,8 +128,6 @@ impl TaskTemplates {
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.
@@ -275,6 +273,53 @@ impl TaskTemplate {
},
})
}
+
+ /// Validates that all `$ZED_*` variables used in this template are known
+ /// variable names, returning a vector with all of the unique unknown
+ /// variables.
+ ///
+ /// Note that `$ZED_CUSTOM_*` variables are never considered to be invalid
+ /// since those are provided dynamically by extensions.
+ pub fn unknown_variables(&self) -> Vec<String> {
+ let mut variables = HashSet::default();
+
+ Self::collect_unknown_variables(&self.label, &mut variables);
+ Self::collect_unknown_variables(&self.command, &mut variables);
+
+ self.args
+ .iter()
+ .for_each(|arg| Self::collect_unknown_variables(arg, &mut variables));
+
+ self.env
+ .values()
+ .for_each(|value| Self::collect_unknown_variables(value, &mut variables));
+
+ if let Some(cwd) = &self.cwd {
+ Self::collect_unknown_variables(cwd, &mut variables);
+ }
+
+ variables.into_iter().collect()
+ }
+
+ fn collect_unknown_variables(template: &str, unknown: &mut HashSet<String>) {
+ shellexpand::env_with_context_no_errors(template, |variable| {
+ // It's possible that the variable has a default defined, which is
+ // separated by a `:`, for example, `${ZED_FILE:default_value} so we
+ // ensure that we're only looking at the variable name itself.
+ let colon_position = variable.find(':').unwrap_or(variable.len());
+ let variable_name = &variable[..colon_position];
+
+ if variable_name.starts_with(ZED_VARIABLE_NAME_PREFIX)
+ && let without_prefix = &variable_name[ZED_VARIABLE_NAME_PREFIX.len()..]
+ && !without_prefix.starts_with("CUSTOM_")
+ && variable_name.parse::<VariableName>().is_err()
+ {
+ unknown.insert(variable_name.to_string());
+ }
+
+ None::<&str>
+ });
+ }
}
const MAX_DISPLAY_VARIABLE_LENGTH: usize = 15;
@@ -327,7 +372,9 @@ fn substitute_all_template_variables_in_str<A: AsRef<str>>(
substituted_variables: &mut HashSet<VariableName>,
) -> Option<String> {
let substituted_string = shellexpand::env_with_context(template_str, |var| {
- // Colons denote a default value in case the variable is not set. We want to preserve that default, as otherwise shellexpand will substitute it for us.
+ // Colons denote a default value in case the variable is not set. We
+ // want to preserve that default, as otherwise shellexpand will
+ // substitute it for us.
let colon_position = var.find(':').unwrap_or(var.len());
let (variable_name, default) = var.split_at(colon_position);
if let Some(name) = task_variables.get(variable_name) {
@@ -346,15 +393,19 @@ fn substitute_all_template_variables_in_str<A: AsRef<str>>(
}
}
// This is an unknown variable.
- // We should not error out, as they may come from user environment (e.g. $PATH). That means that the variable substitution might not be perfect.
- // If there's a default, we need to return the string verbatim as otherwise shellexpand will apply that default for us.
+ // We should not error out, as they may come from user environment (e.g.
+ // $PATH). That means that the variable substitution might not be
+ // perfect. If there's a default, we need to return the string verbatim
+ // as otherwise shellexpand will apply that default for us.
if !default.is_empty() {
return Ok(Some(format!("${{{var}}}")));
}
+
// Else we can just return None and that variable will be left as is.
Ok(None)
})
.ok()?;
+
Some(substituted_string.into_owned())
}
@@ -374,6 +425,7 @@ fn substitute_all_template_variables_in_vec(
)?;
expanded.push(new_value);
}
+
Some(expanded)
}
@@ -424,6 +476,7 @@ fn substitute_all_template_variables_in_map(
)?;
new_map.insert(new_key, new_value);
}
+
Some(new_map)
}
@@ -696,8 +749,8 @@ mod tests {
project_env: HashMap::default(),
},
);
- assert_eq!(
- resolved_task_attempt, None,
+ assert!(
+ matches!(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})"
);
}
@@ -971,4 +1024,54 @@ mod tests {
"Should fail when ZED variable has no default and doesn't exist"
);
}
+
+ #[test]
+ fn test_unknown_variables() {
+ // Variable names starting with `ZED_` that are not valid should be
+ // reported.
+ let label = "test unknown variables".to_string();
+ let command = "$ZED_UNKNOWN".to_string();
+ let task = TaskTemplate {
+ label,
+ command,
+ ..TaskTemplate::default()
+ };
+
+ assert_eq!(task.unknown_variables(), vec!["ZED_UNKNOWN".to_string()]);
+
+ // Variable names starting with `ZED_CUSTOM_` should never be reported,
+ // as those are dynamically provided by extensions.
+ let label = "test custom variables".to_string();
+ let command = "$ZED_CUSTOM_UNKNOWN".to_string();
+ let task = TaskTemplate {
+ label,
+ command,
+ ..TaskTemplate::default()
+ };
+
+ assert!(task.unknown_variables().is_empty());
+
+ // Unknown variable names with defaults should still be reported,
+ // otherwise the default would always be silently used.
+ let label = "test custom variables".to_string();
+ let command = "${ZED_UNKNOWN:default_value}".to_string();
+ let task = TaskTemplate {
+ label,
+ command,
+ ..TaskTemplate::default()
+ };
+
+ assert_eq!(task.unknown_variables(), vec!["ZED_UNKNOWN".to_string()]);
+
+ // Valid variable names are not reported.
+ let label = "test custom variables".to_string();
+ let command = "$ZED_FILE".to_string();
+ let task = TaskTemplate {
+ label,
+ command,
+ ..TaskTemplate::default()
+ };
+
+ assert!(task.unknown_variables().is_empty());
+ }
}
@@ -124,7 +124,7 @@ impl TasksModalDelegate {
pub struct TasksModal {
pub picker: Entity<Picker<TasksModalDelegate>>,
- _subscription: [Subscription; 2],
+ _subscriptions: [Subscription; 2],
}
impl TasksModal {
@@ -139,13 +139,18 @@ impl TasksModal {
) -> Self {
let picker = cx.new(|cx| {
Picker::uniform_list(
- TasksModalDelegate::new(task_store, task_contexts, task_overrides, workspace),
+ TasksModalDelegate::new(
+ task_store.clone(),
+ task_contexts,
+ task_overrides,
+ workspace.clone(),
+ ),
window,
cx,
)
.modal(is_modal)
});
- let _subscription = [
+ let mut _subscriptions = [
cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| {
cx.emit(DismissEvent);
}),
@@ -155,9 +160,10 @@ impl TasksModal {
});
}),
];
+
Self {
picker,
- _subscription,
+ _subscriptions,
}
}
@@ -1346,10 +1346,20 @@ impl Workspace {
project::Event::Toast {
notification_id,
message,
+ link,
} => this.show_notification(
NotificationId::named(notification_id.clone()),
cx,
- |cx| cx.new(|cx| MessageNotification::new(message.clone(), cx)),
+ |cx| {
+ let mut notification = MessageNotification::new(message.clone(), cx);
+ if let Some(link) = link {
+ notification = notification
+ .more_info_message(link.label)
+ .more_info_url(link.url);
+ }
+
+ cx.new(|_| notification)
+ },
),
project::Event::HideToast { notification_id } => {