VS Code -> Zed tasks converter (#9538)

Anthony Eid , Piotr Osiewicz , and Piotr Osiewicz created

We can convert shell, npm and gulp tasks to a Zed format. Additionally, we convert a subset of task variables that VsCode supports.

Release notes:

- Zed can now load tasks in Visual Studio Code task format

---------

Co-authored-by: Piotr Osiewicz <piotr@zed.dev>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>

Change summary

.zed/tasks.json                          |   0 
Cargo.lock                               |   1 
crates/project/src/project.rs            |  38 ++
crates/task/Cargo.toml                   |   1 
crates/task/src/lib.rs                   |   2 
crates/task/src/static_source.rs         |  62 +++
crates/task/src/vscode_format.rs         | 386 ++++++++++++++++++++++++++
crates/task/test_data/rust-analyzer.json |  67 ++++
crates/task/test_data/typescript.json    |  51 +++
crates/util/src/paths.rs                 |   1 
crates/zed/src/zed.rs                    |  11 
11 files changed, 604 insertions(+), 16 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -9410,6 +9410,7 @@ dependencies = [
  "schemars",
  "serde",
  "serde_json_lenient",
+ "shellexpand",
  "subst",
  "util",
 ]

crates/project/src/project.rs 🔗

@@ -87,14 +87,16 @@ use std::{
     },
     time::{Duration, Instant},
 };
-use task::static_source::StaticSource;
+use task::static_source::{StaticSource, TrackedFile};
 use terminals::Terminals;
 use text::{Anchor, BufferId};
 use util::{
     debug_panic, defer,
     http::HttpClient,
     merge_json_value_into,
-    paths::{LOCAL_SETTINGS_RELATIVE_PATH, LOCAL_TASKS_RELATIVE_PATH},
+    paths::{
+        LOCAL_SETTINGS_RELATIVE_PATH, LOCAL_TASKS_RELATIVE_PATH, LOCAL_VSCODE_TASKS_RELATIVE_PATH,
+    },
     post_inc, ResultExt, TryFutureExt as _,
 };
 use worktree::{Snapshot, Traversal};
@@ -7108,7 +7110,37 @@ impl Project {
                                     watch_config_file(&cx.background_executor(), fs, task_abs_path);
                                 StaticSource::new(
                                     format!("local_tasks_for_workspace_{remote_worktree_id}"),
-                                    tasks_file_rx,
+                                    TrackedFile::new(tasks_file_rx, cx),
+                                    cx,
+                                )
+                            },
+                            cx,
+                        );
+                    }
+                })
+            } else if abs_path.ends_with(&*LOCAL_VSCODE_TASKS_RELATIVE_PATH) {
+                self.task_inventory().update(cx, |task_inventory, cx| {
+                    if removed {
+                        task_inventory.remove_local_static_source(&abs_path);
+                    } else {
+                        let fs = self.fs.clone();
+                        let task_abs_path = abs_path.clone();
+                        task_inventory.add_source(
+                            TaskSourceKind::Worktree {
+                                id: remote_worktree_id,
+                                abs_path,
+                            },
+                            |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,
+                                    ),
                                     cx,
                                 )
                             },

crates/task/Cargo.toml 🔗

@@ -16,6 +16,7 @@ gpui.workspace = true
 schemars.workspace = true
 serde.workspace = true
 serde_json_lenient.workspace = true
+shellexpand.workspace = true
 subst = "0.3.0"
 util.workspace = true
 

crates/task/src/lib.rs 🔗

@@ -3,6 +3,7 @@
 
 pub mod oneshot_source;
 pub mod static_source;
+mod vscode_format;
 
 use collections::HashMap;
 use gpui::ModelContext;
@@ -10,6 +11,7 @@ use static_source::RevealStrategy;
 use std::any::Any;
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
+pub use vscode_format::VsCodeTaskFile;
 
 /// Task identifier, unique within the application.
 /// Based on it, task reruns and terminal tabs are managed.

crates/task/src/static_source.rs 🔗

@@ -64,7 +64,7 @@ pub struct StaticSource {
 }
 
 /// Static task definition from the tasks config file.
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub(crate) struct Definition {
     /// Human readable name of the task to display in the UI.
@@ -106,7 +106,7 @@ pub enum RevealStrategy {
 
 /// A group of Tasks defined in a JSON file.
 #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
-pub struct DefinitionProvider(Vec<Definition>);
+pub struct DefinitionProvider(pub(crate) Vec<Definition>);
 
 impl DefinitionProvider {
     /// Generates JSON schema of Tasks JSON definition format.
@@ -122,25 +122,64 @@ impl DefinitionProvider {
 /// 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.
-struct TrackedFile<T> {
+pub struct TrackedFile<T> {
     parsed_contents: T,
 }
 
-impl<T: for<'a> Deserialize<'a> + PartialEq + 'static> TrackedFile<T> {
-    fn new(
-        parsed_contents: T,
+impl<T: PartialEq + 'static> TrackedFile<T> {
+    /// Initializes new [`TrackedFile`] with a type that's deserializable.
+    pub fn new(mut tracker: UnboundedReceiver<String>, cx: &mut AppContext) -> Model<Self>
+    where
+        T: for<'a> Deserialize<'a> + Default,
+    {
+        cx.new_model(move |cx| {
+            cx.spawn(|tracked_file, mut cx| async move {
+                while let Some(new_contents) = tracker.next().await {
+                    if !new_contents.trim().is_empty() {
+                        // String -> T (ZedTaskFormat)
+                        // String -> U (VsCodeFormat) -> Into::into T
+                        let Some(new_contents) =
+                            serde_json_lenient::from_str(&new_contents).log_err()
+                        else {
+                            continue;
+                        };
+                        tracked_file.update(&mut cx, |tracked_file: &mut TrackedFile<T>, cx| {
+                            if tracked_file.parsed_contents != new_contents {
+                                tracked_file.parsed_contents = new_contents;
+                                cx.notify();
+                            };
+                        })?;
+                    }
+                }
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+            Self {
+                parsed_contents: Default::default(),
+            }
+        })
+    }
+
+    /// Initializes new [`TrackedFile`] with a type that's convertible from another deserializable type.
+    pub fn new_convertible<U: for<'a> Deserialize<'a> + TryInto<T, Error = anyhow::Error>>(
         mut tracker: UnboundedReceiver<String>,
         cx: &mut AppContext,
-    ) -> Model<Self> {
+    ) -> Model<Self>
+    where
+        T: Default,
+    {
         cx.new_model(move |cx| {
             cx.spawn(|tracked_file, mut cx| async move {
                 while let Some(new_contents) = tracker.next().await {
                     if !new_contents.trim().is_empty() {
                         let Some(new_contents) =
-                            serde_json_lenient::from_str(&new_contents).log_err()
+                            serde_json_lenient::from_str::<U>(&new_contents).log_err()
                         else {
                             continue;
                         };
+                        let Some(new_contents) = new_contents.try_into().log_err() else {
+                            continue;
+                        };
                         tracked_file.update(&mut cx, |tracked_file: &mut TrackedFile<T>, cx| {
                             if tracked_file.parsed_contents != new_contents {
                                 tracked_file.parsed_contents = new_contents;
@@ -152,7 +191,9 @@ impl<T: for<'a> Deserialize<'a> + PartialEq + 'static> TrackedFile<T> {
                 anyhow::Ok(())
             })
             .detach_and_log_err(cx);
-            Self { parsed_contents }
+            Self {
+                parsed_contents: Default::default(),
+            }
         })
     }
 
@@ -165,10 +206,9 @@ impl StaticSource {
     /// Initializes the static source, reacting on tasks config changes.
     pub fn new(
         id_base: impl Into<Cow<'static, str>>,
-        tasks_file_tracker: UnboundedReceiver<String>,
+        definitions: Model<TrackedFile<DefinitionProvider>>,
         cx: &mut AppContext,
     ) -> Model<Box<dyn TaskSource>> {
-        let definitions = TrackedFile::new(DefinitionProvider::default(), tasks_file_tracker, cx);
         cx.new_model(|cx| {
             let id_base = id_base.into();
             let _subscription = cx.observe(

crates/task/src/vscode_format.rs 🔗

@@ -0,0 +1,386 @@
+use anyhow::bail;
+use collections::HashMap;
+use serde::Deserialize;
+use util::ResultExt;
+
+use crate::static_source::{Definition, DefinitionProvider};
+
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct TaskOptions {
+    cwd: Option<String>,
+    #[serde(default)]
+    env: HashMap<String, String>,
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct VsCodeTaskDefinition {
+    label: String,
+    #[serde(flatten)]
+    command: Option<Command>,
+    #[serde(flatten)]
+    other_attributes: HashMap<String, serde_json_lenient::Value>,
+    options: Option<TaskOptions>,
+}
+
+#[derive(Clone, Deserialize, PartialEq, Debug)]
+#[serde(tag = "type")]
+#[serde(rename_all = "camelCase")]
+enum Command {
+    Npm {
+        script: String,
+    },
+    Shell {
+        command: String,
+        #[serde(default)]
+        args: Vec<String>,
+    },
+    Gulp {
+        task: String,
+    },
+}
+
+type VsCodeEnvVariable = String;
+type ZedEnvVariable = String;
+
+struct EnvVariableReplacer {
+    variables: HashMap<VsCodeEnvVariable, ZedEnvVariable>,
+}
+
+impl EnvVariableReplacer {
+    fn new(variables: HashMap<VsCodeEnvVariable, ZedEnvVariable>) -> Self {
+        Self { variables }
+    }
+    // Replaces occurrences of VsCode-specific environment variables with Zed equivalents.
+    fn replace(&self, input: &str) -> String {
+        shellexpand::env_with_context_no_errors(&input, |var: &str| {
+            // 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);
+            let append_previous_default = |ret: &mut String| {
+                if !default.is_empty() {
+                    ret.push_str(default);
+                }
+            };
+            if let Some(substitution) = self.variables.get(variable_name) {
+                // Got a VSCode->Zed hit, perform a substitution
+                let mut name = format!("${{{substitution}");
+                append_previous_default(&mut name);
+                name.push_str("}");
+                return Some(name);
+            }
+            // 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.
+            if !default.is_empty() {
+                return Some(format!("${{{var}}}"));
+            }
+            // Else we can just return None and that variable will be left as is.
+            None
+        })
+        .into_owned()
+    }
+}
+
+impl VsCodeTaskDefinition {
+    fn to_zed_format(self, replacer: &EnvVariableReplacer) -> anyhow::Result<Definition> {
+        if self.other_attributes.contains_key("dependsOn") {
+            bail!("Encountered unsupported `dependsOn` key during deserialization");
+        }
+        // `type` might not be set in e.g. tasks that use `dependsOn`; we still want to deserialize the whole object though (hence command is an Option),
+        // as that way we can provide more specific description of why deserialization failed.
+        // E.g. if the command is missing due to `dependsOn` presence, we can check other_attributes first before doing this (and provide nice error message)
+        // catch-all if on value.command presence.
+        let Some(command) = self.command else {
+            bail!("Missing `type` field in task");
+        };
+
+        let (command, args) = match command {
+            Command::Npm { script } => ("npm".to_owned(), vec!["run".to_string(), script]),
+            Command::Shell { command, args } => (command, args),
+            Command::Gulp { task } => ("gulp".to_owned(), vec![task]),
+        };
+        // 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 {
+            label: self.label,
+            command,
+            args,
+            ..Default::default()
+        };
+        if let Some(options) = self.options {
+            ret.cwd = options.cwd.map(|cwd| replacer.replace(&cwd));
+            ret.env = options.env;
+        }
+        Ok(ret)
+    }
+}
+
+/// [`VsCodeTaskFile`] is a superset of Code's task definition format.
+#[derive(Debug, Deserialize, PartialEq)]
+pub struct VsCodeTaskFile {
+    tasks: Vec<VsCodeTaskDefinition>,
+}
+
+impl TryFrom<VsCodeTaskFile> for DefinitionProvider {
+    type Error = anyhow::Error;
+
+    fn try_from(value: VsCodeTaskFile) -> Result<Self, Self::Error> {
+        let replacer = EnvVariableReplacer::new(HashMap::from_iter([
+            ("workspaceFolder".to_owned(), "ZED_WORKTREE_ROOT".to_owned()),
+            ("file".to_owned(), "ZED_FILE".to_owned()),
+            ("lineNumber".to_owned(), "ZED_ROW".to_owned()),
+            ("selectedText".to_owned(), "ZED_SELECTED_TEXT".to_owned()),
+        ]));
+        let definitions = value
+            .tasks
+            .into_iter()
+            .filter_map(|vscode_definition| vscode_definition.to_zed_format(&replacer).log_err())
+            .collect();
+        Ok(Self(definitions))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::HashMap;
+
+    use crate::{
+        static_source::{Definition, DefinitionProvider},
+        vscode_format::{Command, VsCodeTaskDefinition},
+        VsCodeTaskFile,
+    };
+
+    use super::EnvVariableReplacer;
+
+    fn compare_without_other_attributes(lhs: VsCodeTaskDefinition, rhs: VsCodeTaskDefinition) {
+        assert_eq!(
+            VsCodeTaskDefinition {
+                other_attributes: Default::default(),
+                ..lhs
+            },
+            VsCodeTaskDefinition {
+                other_attributes: Default::default(),
+                ..rhs
+            },
+        );
+    }
+
+    #[test]
+    fn test_variable_substitution() {
+        let replacer = EnvVariableReplacer::new(Default::default());
+        assert_eq!(replacer.replace("Food"), "Food");
+        // Unknown variables are left in tact.
+        assert_eq!(
+            replacer.replace("$PATH is an environment variable"),
+            "$PATH is an environment variable"
+        );
+        assert_eq!(replacer.replace("${PATH}"), "${PATH}");
+        assert_eq!(replacer.replace("${PATH:food}"), "${PATH:food}");
+        // And now, the actual replacing
+        let replacer = EnvVariableReplacer::new(HashMap::from_iter([(
+            "PATH".to_owned(),
+            "ZED_PATH".to_owned(),
+        )]));
+        assert_eq!(replacer.replace("Food"), "Food");
+        assert_eq!(
+            replacer.replace("$PATH is an environment variable"),
+            "${ZED_PATH} is an environment variable"
+        );
+        assert_eq!(replacer.replace("${PATH}"), "${ZED_PATH}");
+        assert_eq!(replacer.replace("${PATH:food}"), "${ZED_PATH:food}");
+    }
+
+    #[test]
+    fn can_deserialize_ts_tasks() {
+        static TYPESCRIPT_TASKS: &'static str = include_str!("../test_data/typescript.json");
+        let vscode_definitions: VsCodeTaskFile =
+            serde_json_lenient::from_str(&TYPESCRIPT_TASKS).unwrap();
+
+        let expected = vec![
+            VsCodeTaskDefinition {
+                label: "gulp: tests".to_string(),
+                command: Some(Command::Npm {
+                    script: "build:tests:notypecheck".to_string(),
+                }),
+                other_attributes: Default::default(),
+                options: None,
+            },
+            VsCodeTaskDefinition {
+                label: "tsc: watch ./src".to_string(),
+                command: Some(Command::Shell {
+                    command: "node".to_string(),
+                    args: vec![
+                        "${workspaceFolder}/node_modules/typescript/lib/tsc.js".to_string(),
+                        "--build".to_string(),
+                        "${workspaceFolder}/src".to_string(),
+                        "--watch".to_string(),
+                    ],
+                }),
+                other_attributes: Default::default(),
+                options: None,
+            },
+            VsCodeTaskDefinition {
+                label: "npm: build:compiler".to_string(),
+                command: Some(Command::Npm {
+                    script: "build:compiler".to_string(),
+                }),
+                other_attributes: Default::default(),
+                options: None,
+            },
+            VsCodeTaskDefinition {
+                label: "npm: build:tests".to_string(),
+                command: Some(Command::Npm {
+                    script: "build:tests:notypecheck".to_string(),
+                }),
+                other_attributes: Default::default(),
+                options: None,
+            },
+        ];
+
+        assert_eq!(vscode_definitions.tasks.len(), expected.len());
+        vscode_definitions
+            .tasks
+            .iter()
+            .zip(expected)
+            .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs));
+
+        let expected = vec![
+            Definition {
+                label: "gulp: tests".to_string(),
+                command: "npm".to_string(),
+                args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],
+                ..Default::default()
+            },
+            Definition {
+                label: "tsc: watch ./src".to_string(),
+                command: "node".to_string(),
+                args: vec![
+                    "${ZED_WORKTREE_ROOT}/node_modules/typescript/lib/tsc.js".to_string(),
+                    "--build".to_string(),
+                    "${ZED_WORKTREE_ROOT}/src".to_string(),
+                    "--watch".to_string(),
+                ],
+                ..Default::default()
+            },
+            Definition {
+                label: "npm: build:compiler".to_string(),
+                command: "npm".to_string(),
+                args: vec!["run".to_string(), "build:compiler".to_string()],
+                ..Default::default()
+            },
+            Definition {
+                label: "npm: build:tests".to_string(),
+                command: "npm".to_string(),
+                args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],
+                ..Default::default()
+            },
+        ];
+
+        let tasks: DefinitionProvider = vscode_definitions.try_into().unwrap();
+        assert_eq!(tasks.0, expected);
+    }
+
+    #[test]
+    fn can_deserialize_rust_analyzer_tasks() {
+        static RUST_ANALYZER_TASKS: &'static str = include_str!("../test_data/rust-analyzer.json");
+        let vscode_definitions: VsCodeTaskFile =
+            serde_json_lenient::from_str(&RUST_ANALYZER_TASKS).unwrap();
+        let expected = vec![
+            VsCodeTaskDefinition {
+                label: "Build Extension in Background".to_string(),
+                command: Some(Command::Npm {
+                    script: "watch".to_string(),
+                }),
+                options: None,
+                other_attributes: Default::default(),
+            },
+            VsCodeTaskDefinition {
+                label: "Build Extension".to_string(),
+                command: Some(Command::Npm {
+                    script: "build".to_string(),
+                }),
+                options: None,
+                other_attributes: Default::default(),
+            },
+            VsCodeTaskDefinition {
+                label: "Build Server".to_string(),
+                command: Some(Command::Shell {
+                    command: "cargo build --package rust-analyzer".to_string(),
+                    args: Default::default(),
+                }),
+                options: None,
+                other_attributes: Default::default(),
+            },
+            VsCodeTaskDefinition {
+                label: "Build Server (Release)".to_string(),
+                command: Some(Command::Shell {
+                    command: "cargo build --release --package rust-analyzer".to_string(),
+                    args: Default::default(),
+                }),
+                options: None,
+                other_attributes: Default::default(),
+            },
+            VsCodeTaskDefinition {
+                label: "Pretest".to_string(),
+                command: Some(Command::Npm {
+                    script: "pretest".to_string(),
+                }),
+                options: None,
+                other_attributes: Default::default(),
+            },
+            VsCodeTaskDefinition {
+                label: "Build Server and Extension".to_string(),
+                command: None,
+                options: None,
+                other_attributes: Default::default(),
+            },
+            VsCodeTaskDefinition {
+                label: "Build Server (Release) and Extension".to_string(),
+                command: None,
+                options: None,
+                other_attributes: Default::default(),
+            },
+        ];
+        assert_eq!(vscode_definitions.tasks.len(), expected.len());
+        vscode_definitions
+            .tasks
+            .iter()
+            .zip(expected)
+            .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs));
+        let expected = vec![
+            Definition {
+                label: "Build Extension in Background".to_string(),
+                command: "npm".to_string(),
+                args: vec!["run".to_string(), "watch".to_string()],
+                ..Default::default()
+            },
+            Definition {
+                label: "Build Extension".to_string(),
+                command: "npm".to_string(),
+                args: vec!["run".to_string(), "build".to_string()],
+                ..Default::default()
+            },
+            Definition {
+                label: "Build Server".to_string(),
+                command: "cargo build --package rust-analyzer".to_string(),
+                ..Default::default()
+            },
+            Definition {
+                label: "Build Server (Release)".to_string(),
+                command: "cargo build --release --package rust-analyzer".to_string(),
+                ..Default::default()
+            },
+            Definition {
+                label: "Pretest".to_string(),
+                command: "npm".to_string(),
+                args: vec!["run".to_string(), "pretest".to_string()],
+                ..Default::default()
+            },
+        ];
+        let tasks: DefinitionProvider = vscode_definitions.try_into().unwrap();
+        assert_eq!(tasks.0, expected);
+    }
+}

crates/task/test_data/rust-analyzer.json 🔗

@@ -0,0 +1,67 @@
+// See https://go.microsoft.com/fwlink/?LinkId=733558
+// for the documentation about the tasks.json format
+{
+  "version": "2.0.0",
+  "tasks": [
+    {
+      "label": "Build Extension in Background",
+      "group": "build",
+      "type": "npm",
+      "script": "watch",
+      "path": "editors/code/",
+      "problemMatcher": {
+        "base": "$tsc-watch",
+        "fileLocation": ["relative", "${workspaceFolder}/editors/code/"]
+      },
+      "isBackground": true
+    },
+    {
+      "label": "Build Extension",
+      "group": "build",
+      "type": "npm",
+      "script": "build",
+      "path": "editors/code/",
+      "problemMatcher": {
+        "base": "$tsc",
+        "fileLocation": ["relative", "${workspaceFolder}/editors/code/"]
+      }
+    },
+    {
+      "label": "Build Server",
+      "group": "build",
+      "type": "shell",
+      "command": "cargo build --package rust-analyzer",
+      "problemMatcher": "$rustc"
+    },
+    {
+      "label": "Build Server (Release)",
+      "group": "build",
+      "type": "shell",
+      "command": "cargo build --release --package rust-analyzer",
+      "problemMatcher": "$rustc"
+    },
+    {
+      "label": "Pretest",
+      "group": "build",
+      "isBackground": false,
+      "type": "npm",
+      "script": "pretest",
+      "path": "editors/code/",
+      "problemMatcher": {
+        "base": "$tsc",
+        "fileLocation": ["relative", "${workspaceFolder}/editors/code/"]
+      }
+    },
+
+    {
+      "label": "Build Server and Extension",
+      "dependsOn": ["Build Server", "Build Extension"],
+      "problemMatcher": "$rustc"
+    },
+    {
+      "label": "Build Server (Release) and Extension",
+      "dependsOn": ["Build Server (Release)", "Build Extension"],
+      "problemMatcher": "$rustc"
+    }
+  ]
+}

crates/task/test_data/typescript.json 🔗

@@ -0,0 +1,51 @@
+{
+  // See https://go.microsoft.com/fwlink/?LinkId=733558
+  // for the documentation about the tasks.json format
+  "version": "2.0.0",
+  "tasks": [
+    {
+      // Kept for backwards compat for old launch.json files so it's
+      // less annoying if moving up to the new build or going back to
+      // the old build.
+      //
+      // This is first because the actual "npm: build:tests" task
+      // below has the same script value, and VS Code ignores labels
+      // and deduplicates them.
+      // https://github.com/microsoft/vscode/issues/93001
+      "label": "gulp: tests",
+      "type": "npm",
+      "script": "build:tests:notypecheck",
+      "group": "build",
+      "hide": true,
+      "problemMatcher": ["$tsc"]
+    },
+    {
+      "label": "tsc: watch ./src",
+      "type": "shell",
+      "command": "node",
+      "args": [
+        "${workspaceFolder}/node_modules/typescript/lib/tsc.js",
+        "--build",
+        "${workspaceFolder}/src",
+        "--watch"
+      ],
+      "group": "build",
+      "isBackground": true,
+      "problemMatcher": ["$tsc-watch"]
+    },
+    {
+      "label": "npm: build:compiler",
+      "type": "npm",
+      "script": "build:compiler",
+      "group": "build",
+      "problemMatcher": ["$tsc"]
+    },
+    {
+      "label": "npm: build:tests",
+      "type": "npm",
+      "script": "build:tests:notypecheck",
+      "group": "build",
+      "problemMatcher": ["$tsc"]
+    }
+  ]
+}

crates/util/src/paths.rs 🔗

@@ -63,6 +63,7 @@ lazy_static::lazy_static! {
     pub static ref OLD_LOG: PathBuf = LOGS_DIR.join("Zed.log.old");
     pub static ref LOCAL_SETTINGS_RELATIVE_PATH: &'static Path = Path::new(".zed/settings.json");
     pub static ref LOCAL_TASKS_RELATIVE_PATH: &'static Path = Path::new(".zed/tasks.json");
+    pub static ref LOCAL_VSCODE_TASKS_RELATIVE_PATH: &'static Path = Path::new(".vscode/tasks.json");
     pub static ref TEMP_DIR: PathBuf = if cfg!(target_os = "widows") {
         dirs::data_local_dir()
             .expect("failed to determine LocalAppData directory")

crates/zed/src/zed.rs 🔗

@@ -29,7 +29,10 @@ use settings::{
     SettingsStore, DEFAULT_KEYMAP_PATH,
 };
 use std::{borrow::Cow, ops::Deref, path::Path, sync::Arc};
-use task::{oneshot_source::OneshotSource, static_source::StaticSource};
+use task::{
+    oneshot_source::OneshotSource,
+    static_source::{StaticSource, TrackedFile},
+};
 use terminal_view::terminal_panel::{self, TerminalPanel};
 use util::{
     asset_str,
@@ -166,7 +169,11 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
                                 fs,
                                 paths::TASKS.clone(),
                             );
-                            StaticSource::new("global_tasks", tasks_file_rx, cx)
+                            StaticSource::new(
+                                "global_tasks",
+                                TrackedFile::new(tasks_file_rx, cx),
+                                cx,
+                            )
                         },
                         cx,
                     );