Allow .zed/tasks.json local configs (#8536)

Kirill Bulatov created

![image](https://github.com/zed-industries/zed/assets/2690773/e1511777-b4ca-469e-8636-1e513b615368)

Follow-up of
https://github.com/zed-industries/zed/issues/7108#issuecomment-1960746397

Makes more clear where each task came from, auto (re)load
.zed/config.json changes, properly filtering out other worktree tasks.

Release Notes:

- Added local task configurations

Change summary

crates/languages/src/json.rs                                 |   5 
crates/picker/src/highlighted_match_with_paths.rs            |  70 +
crates/picker/src/picker.rs                                  |   2 
crates/project/src/project.rs                                |  62 
crates/project/src/project_tests.rs                          |  46 
crates/project/src/task_inventory.rs                         | 387 +++++
crates/recent_projects/src/highlighted_workspace_location.rs | 130 --
crates/recent_projects/src/recent_projects.rs                | 118 +
crates/task/src/static_source.rs                             |  19 
crates/tasks_ui/src/modal.rs                                 |  74 
crates/util/src/paths.rs                                     |   1 
crates/zed/src/zed.rs                                        |  82 
12 files changed, 715 insertions(+), 281 deletions(-)

Detailed changes

crates/languages/src/json.rs 🔗

@@ -72,7 +72,10 @@ impl JsonLspAdapter {
                         "schema": KeymapFile::generate_json_schema(&action_names),
                     },
                     {
-                        "fileMatch": [schema_file_match(&paths::TASKS)],
+                        "fileMatch": [
+                            schema_file_match(&paths::TASKS),
+                            &*paths::LOCAL_TASKS_RELATIVE_PATH,
+                        ],
                         "schema": tasks_schema,
                     }
                 ]

crates/picker/src/highlighted_match_with_paths.rs 🔗

@@ -0,0 +1,70 @@
+use ui::{prelude::*, HighlightedLabel};
+
+#[derive(Clone)]
+pub struct HighlightedMatchWithPaths {
+    pub match_label: HighlightedText,
+    pub paths: Vec<HighlightedText>,
+}
+
+#[derive(Debug, Clone, IntoElement)]
+pub struct HighlightedText {
+    pub text: String,
+    pub highlight_positions: Vec<usize>,
+    pub char_count: usize,
+}
+
+impl HighlightedText {
+    pub fn join(components: impl Iterator<Item = Self>, separator: &str) -> Self {
+        let mut char_count = 0;
+        let separator_char_count = separator.chars().count();
+        let mut text = String::new();
+        let mut highlight_positions = Vec::new();
+        for component in components {
+            if char_count != 0 {
+                text.push_str(separator);
+                char_count += separator_char_count;
+            }
+
+            highlight_positions.extend(
+                component
+                    .highlight_positions
+                    .iter()
+                    .map(|position| position + char_count),
+            );
+            text.push_str(&component.text);
+            char_count += component.text.chars().count();
+        }
+
+        Self {
+            text,
+            highlight_positions,
+            char_count,
+        }
+    }
+}
+
+impl RenderOnce for HighlightedText {
+    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
+        HighlightedLabel::new(self.text, self.highlight_positions)
+    }
+}
+
+impl HighlightedMatchWithPaths {
+    pub fn render_paths_children(&mut self, element: Div) -> Div {
+        element.children(self.paths.clone().into_iter().map(|path| {
+            HighlightedLabel::new(path.text, path.highlight_positions)
+                .size(LabelSize::Small)
+                .color(Color::Muted)
+        }))
+    }
+}
+
+impl RenderOnce for HighlightedMatchWithPaths {
+    fn render(mut self, _: &mut WindowContext) -> impl IntoElement {
+        v_flex()
+            .child(self.match_label.clone())
+            .when(!self.paths.is_empty(), |this| {
+                self.render_paths_children(this)
+            })
+    }
+}

crates/picker/src/picker.rs 🔗

@@ -8,6 +8,8 @@ use std::{sync::Arc, time::Duration};
 use ui::{prelude::*, v_flex, Color, Divider, Label, ListItem, ListItemSpacing};
 use workspace::ModalView;
 
+pub mod highlighted_match_with_paths;
+
 enum ElementContainer {
     List(ListState),
     UniformList(UniformListScrollHandle),

crates/project/src/project.rs 🔗

@@ -59,7 +59,7 @@ use rand::prelude::*;
 use rpc::{ErrorCode, ErrorExt as _};
 use search::SearchQuery;
 use serde::Serialize;
-use settings::{Settings, SettingsStore};
+use settings::{watch_config_file, Settings, SettingsStore};
 use sha2::{Digest, Sha256};
 use similar::{ChangeTag, TextDiff};
 use smol::channel::{Receiver, Sender};
@@ -82,11 +82,15 @@ use std::{
     },
     time::{Duration, Instant},
 };
+use task::static_source::StaticSource;
 use terminals::Terminals;
 use text::{Anchor, BufferId};
 use util::{
-    debug_panic, defer, http::HttpClient, merge_json_value_into,
-    paths::LOCAL_SETTINGS_RELATIVE_PATH, post_inc, ResultExt, TryFutureExt as _,
+    debug_panic, defer,
+    http::HttpClient,
+    merge_json_value_into,
+    paths::{LOCAL_SETTINGS_RELATIVE_PATH, LOCAL_TASKS_RELATIVE_PATH},
+    post_inc, ResultExt, TryFutureExt as _,
 };
 
 pub use fs::*;
@@ -95,7 +99,7 @@ pub use language::Location;
 pub use prettier::FORMAT_SUFFIX as TEST_PRETTIER_FORMAT_SUFFIX;
 pub use project_core::project_settings;
 pub use project_core::worktree::{self, *};
-pub use task_inventory::Inventory;
+pub use task_inventory::{Inventory, TaskSourceKind};
 
 const MAX_SERVER_REINSTALL_ATTEMPT_COUNT: u64 = 4;
 const SERVER_REINSTALL_DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
@@ -6615,6 +6619,10 @@ impl Project {
         })
         .detach();
 
+        self.task_inventory().update(cx, |inventory, _| {
+            inventory.remove_worktree_sources(id_to_remove);
+        });
+
         self.worktrees.retain(|worktree| {
             if let Some(worktree) = worktree.upgrade() {
                 let id = worktree.read(cx).id();
@@ -6972,32 +6980,66 @@ impl Project {
         changes: &UpdatedEntriesSet,
         cx: &mut ModelContext<Self>,
     ) {
+        if worktree.read(cx).as_local().is_none() {
+            return;
+        }
         let project_id = self.remote_id();
         let worktree_id = worktree.entity_id();
-        let worktree = worktree.read(cx).as_local().unwrap();
-        let remote_worktree_id = worktree.id();
+        let remote_worktree_id = worktree.read(cx).id();
 
         let mut settings_contents = Vec::new();
         for (path, _, change) in changes.iter() {
-            if path.ends_with(&*LOCAL_SETTINGS_RELATIVE_PATH) {
+            let removed = change == &PathChange::Removed;
+            let abs_path = match worktree.read(cx).absolutize(path) {
+                Ok(abs_path) => abs_path,
+                Err(e) => {
+                    log::warn!("Cannot absolutize {path:?} received as {change:?} FS change: {e}");
+                    continue;
+                }
+            };
+
+            if abs_path.ends_with(&*LOCAL_SETTINGS_RELATIVE_PATH) {
                 let settings_dir = Arc::from(
                     path.ancestors()
                         .nth(LOCAL_SETTINGS_RELATIVE_PATH.components().count())
                         .unwrap(),
                 );
                 let fs = self.fs.clone();
-                let removed = *change == PathChange::Removed;
-                let abs_path = worktree.absolutize(path);
                 settings_contents.push(async move {
                     (
                         settings_dir,
                         if removed {
                             None
                         } else {
-                            Some(async move { fs.load(&abs_path?).await }.await)
+                            Some(async move { fs.load(&abs_path).await }.await)
                         },
                     )
                 });
+            } else if abs_path.ends_with(&*LOCAL_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_tasks_for_workspace_{remote_worktree_id}"),
+                                    tasks_file_rx,
+                                    cx,
+                                )
+                            },
+                            cx,
+                        );
+                    }
+                })
             }
         }
 

crates/project/src/project_tests.rs 🔗

@@ -95,14 +95,24 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext)
         "/the-root",
         json!({
             ".zed": {
-                "settings.json": r#"{ "tab_size": 8 }"#
+                "settings.json": r#"{ "tab_size": 8 }"#,
+                "tasks.json": r#"[{
+                    "label": "cargo check",
+                    "command": "cargo",
+                    "args": ["check", "--all"]
+                },]"#,
             },
             "a": {
                 "a.rs": "fn a() {\n    A\n}"
             },
             "b": {
                 ".zed": {
-                    "settings.json": r#"{ "tab_size": 2 }"#
+                    "settings.json": r#"{ "tab_size": 2 }"#,
+                    "tasks.json": r#"[{
+                        "label": "cargo check",
+                        "command": "cargo",
+                        "args": ["check"]
+                    },]"#,
                 },
                 "b.rs": "fn b() {\n  B\n}"
             }
@@ -140,6 +150,38 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext)
 
         assert_eq!(settings_a.tab_size.get(), 8);
         assert_eq!(settings_b.tab_size.get(), 2);
+
+        let workree_id = project.update(cx, |project, cx| {
+            project.worktrees().next().unwrap().read(cx).id()
+        });
+        let all_tasks = project
+            .update(cx, |project, cx| {
+                project.task_inventory().update(cx, |inventory, cx| {
+                    inventory.list_tasks(None, None, false, cx)
+                })
+            })
+            .into_iter()
+            .map(|(source_kind, task)| (source_kind, task.name().to_string()))
+            .collect::<Vec<_>>();
+        assert_eq!(
+            all_tasks,
+            vec![
+                (
+                    TaskSourceKind::Worktree {
+                        id: workree_id,
+                        abs_path: PathBuf::from("/the-root/.zed/tasks.json")
+                    },
+                    "cargo check".to_string()
+                ),
+                (
+                    TaskSourceKind::Worktree {
+                        id: workree_id,
+                        abs_path: PathBuf::from("/the-root/b/.zed/tasks.json")
+                    },
+                    "cargo check".to_string()
+                ),
+            ]
+        );
     });
 }
 

crates/project/src/task_inventory.rs 🔗

@@ -1,10 +1,15 @@
 //! Project-wide storage of the tasks available, capable of updating itself from the sources set.
 
-use std::{any::TypeId, path::Path, sync::Arc};
+use std::{
+    any::TypeId,
+    path::{Path, PathBuf},
+    sync::Arc,
+};
 
 use collections::{HashMap, VecDeque};
 use gpui::{AppContext, Context, Model, ModelContext, Subscription};
 use itertools::Itertools;
+use project_core::worktree::WorktreeId;
 use task::{Task, TaskId, TaskSource};
 use util::{post_inc, NumericPrefixWithSuffix};
 
@@ -18,6 +23,34 @@ struct SourceInInventory {
     source: Model<Box<dyn TaskSource>>,
     _subscription: Subscription,
     type_id: TypeId,
+    kind: TaskSourceKind,
+}
+
+/// Kind of a source the tasks are fetched from, used to display more source information in the UI.
+#[derive(Debug, Clone, PartialEq, Eq)]
+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),
+    /// Worktree-specific task definitions, e.g. dynamic tasks from open worktree file, or tasks from the worktree's .zed/task.json
+    Worktree { id: WorktreeId, abs_path: PathBuf },
+}
+
+impl TaskSourceKind {
+    fn abs_path(&self) -> Option<&Path> {
+        match self {
+            Self::AbsPath(abs_path) | Self::Worktree { abs_path, .. } => Some(abs_path),
+            Self::UserInput => None,
+        }
+    }
+
+    fn worktree(&self) -> Option<WorktreeId> {
+        match self {
+            Self::Worktree { id, .. } => Some(*id),
+            _ => None,
+        }
+    }
 }
 
 impl Inventory {
@@ -28,21 +61,53 @@ impl Inventory {
         })
     }
 
-    /// Registers a new tasks source, that would be fetched for available tasks.
-    pub fn add_source(&mut self, source: Model<Box<dyn TaskSource>>, cx: &mut ModelContext<Self>) {
-        let _subscription = cx.observe(&source, |_, _, cx| {
-            cx.notify();
-        });
+    /// If the task with the same path was not added yet,
+    /// registers a new tasks source to fetch for available tasks later.
+    /// Unless a source is removed, ignores future additions for the same path.
+    pub fn add_source(
+        &mut self,
+        kind: TaskSourceKind,
+        create_source: impl FnOnce(&mut ModelContext<Self>) -> Model<Box<dyn TaskSource>>,
+        cx: &mut ModelContext<Self>,
+    ) {
+        let abs_path = kind.abs_path();
+        if abs_path.is_some() {
+            if let Some(a) = self.sources.iter().find(|s| s.kind.abs_path() == abs_path) {
+                log::debug!("Source for path {abs_path:?} already exists, not adding. Old kind: {OLD_KIND:?}, new kind: {kind:?}", OLD_KIND = a.kind);
+                return;
+            }
+        }
+
+        let source = create_source(cx);
         let type_id = source.read(cx).type_id();
         let source = SourceInInventory {
+            _subscription: cx.observe(&source, |_, _, cx| {
+                cx.notify();
+            }),
             source,
-            _subscription,
             type_id,
+            kind,
         };
         self.sources.push(source);
         cx.notify();
     }
 
+    /// If present, removes the local static source entry that has the given path,
+    /// making corresponding task definitions unavailable in the fetch results.
+    ///
+    /// Now, entry for this path can be re-added again.
+    pub fn remove_local_static_source(&mut self, abs_path: &Path) {
+        self.sources.retain(|s| s.kind.abs_path() != Some(abs_path));
+    }
+
+    /// If present, removes the worktree source entry that has the given worktree id,
+    /// making corresponding task definitions unavailable in the fetch results.
+    ///
+    /// Now, entry for this path can be re-added again.
+    pub fn remove_worktree_sources(&mut self, worktree: WorktreeId) {
+        self.sources.retain(|s| s.kind.worktree() != Some(worktree));
+    }
+
     pub fn source<T: TaskSource>(&self) -> Option<Model<Box<dyn TaskSource>>> {
         let target_type_id = std::any::TypeId::of::<T>();
         self.sources.iter().find_map(
@@ -62,9 +127,10 @@ impl Inventory {
     pub fn list_tasks(
         &self,
         path: Option<&Path>,
+        worktree: Option<WorktreeId>,
         lru: bool,
         cx: &mut AppContext,
-    ) -> Vec<Arc<dyn Task>> {
+    ) -> Vec<(TaskSourceKind, Arc<dyn Task>)> {
         let mut lru_score = 0_u32;
         let tasks_by_usage = if lru {
             self.last_scheduled_tasks
@@ -78,18 +144,23 @@ impl Inventory {
             HashMap::default()
         };
         let not_used_score = post_inc(&mut lru_score);
-
         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_for_path(path, cx))
+                    .into_iter()
+                    .map(|task| (&source.kind, task))
             })
             .map(|task| {
                 let usages = if lru {
                     tasks_by_usage
-                        .get(&task.id())
+                        .get(&task.1.id())
                         .copied()
                         .unwrap_or(not_used_score)
                 } else {
@@ -97,16 +168,34 @@ impl Inventory {
                 };
                 (task, usages)
             })
-            .sorted_unstable_by(|(task_a, usages_a), (task_b, usages_b)| {
-                usages_a.cmp(usages_b).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(|(task, _)| task)
+            .sorted_unstable_by(
+                |((kind_a, task_a), usages_a), ((kind_b, task_b), usages_b)| {
+                    usages_a
+                        .cmp(usages_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.name())
+                                .cmp(&NumericPrefixWithSuffix::from_numeric_prefixed_str(
+                                    task_b.name(),
+                                ))
+                                .then(task_a.name().cmp(task_b.name()))
+                        })
+                },
+            )
+            .map(|((kind, task), _)| (kind.clone(), task))
             .collect()
     }
 
@@ -114,9 +203,10 @@ impl Inventory {
     pub fn last_scheduled_task(&self, cx: &mut AppContext) -> Option<Arc<dyn Task>> {
         self.last_scheduled_tasks.back().and_then(|id| {
             // TODO straighten the `Path` story to understand what has to be passed here: or it will break in the future.
-            self.list_tasks(None, false, cx)
+            self.list_tasks(None, None, false, cx)
                 .into_iter()
-                .find(|task| task.id() == id)
+                .find(|(_, task)| task.id() == id)
+                .map(|(_, task)| task)
         })
     }
 
@@ -140,30 +230,37 @@ 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 = list_task_names(&inventory, None, None, true, 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 = list_task_names(&inventory, None, None, false, cx);
         assert!(
             initial_tasks.is_empty(),
             "No tasks expected for empty inventory, but got {initial_tasks:?}"
         );
 
         inventory.update(cx, |inventory, cx| {
-            inventory.add_source(TestSource::new(vec!["3_task".to_string()], cx), cx);
+            inventory.add_source(
+                TaskSourceKind::UserInput,
+                |cx| StaticTestSource::new(vec!["3_task".to_string()], cx),
+                cx,
+            );
         });
         inventory.update(cx, |inventory, cx| {
             inventory.add_source(
-                TestSource::new(
-                    vec![
-                        "1_task".to_string(),
-                        "2_task".to_string(),
-                        "1_a_task".to_string(),
-                    ],
-                    cx,
-                ),
+                TaskSourceKind::UserInput,
+                |cx| {
+                    StaticTestSource::new(
+                        vec![
+                            "1_task".to_string(),
+                            "2_task".to_string(),
+                            "1_a_task".to_string(),
+                        ],
+                        cx,
+                    )
+                },
                 cx,
             );
         });
@@ -175,24 +272,24 @@ mod tests {
             "3_task".to_string(),
         ];
         assert_eq!(
-            list_task_names(&inventory, None, false, cx),
+            list_task_names(&inventory, None, None, false, cx),
             &expected_initial_state,
             "Task list without lru sorting, should be sorted alphanumerically"
         );
         assert_eq!(
-            list_task_names(&inventory, None, true, cx),
+            list_task_names(&inventory, None, None, true, 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),
+            list_task_names(&inventory, None, None, false, cx),
             &expected_initial_state,
             "Task list without lru sorting, should be sorted alphanumerically"
         );
         assert_eq!(
-            list_task_names(&inventory, None, true, cx),
+            list_task_names(&inventory, None, None, true, cx),
             vec![
                 "2_task".to_string(),
                 "1_a_task".to_string(),
@@ -206,12 +303,12 @@ 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),
+            list_task_names(&inventory, None, None, false, cx),
             &expected_initial_state,
             "Task list without lru sorting, should be sorted alphanumerically"
         );
         assert_eq!(
-            list_task_names(&inventory, None, true, cx),
+            list_task_names(&inventory, None, None, true, cx),
             vec![
                 "3_task".to_string(),
                 "1_task".to_string(),
@@ -222,7 +319,10 @@ mod tests {
 
         inventory.update(cx, |inventory, cx| {
             inventory.add_source(
-                TestSource::new(vec!["10_hello".to_string(), "11_hello".to_string()], cx),
+                TaskSourceKind::UserInput,
+                |cx| {
+                    StaticTestSource::new(vec!["10_hello".to_string(), "11_hello".to_string()], cx)
+                },
                 cx,
             );
         });
@@ -235,12 +335,12 @@ mod tests {
             "11_hello".to_string(),
         ];
         assert_eq!(
-            list_task_names(&inventory, None, false, cx),
+            list_task_names(&inventory, None, None, false, cx),
             &expected_updated_state,
             "Task list without lru sorting, should be sorted alphanumerically"
         );
         assert_eq!(
-            list_task_names(&inventory, None, true, cx),
+            list_task_names(&inventory, None, None, true, cx),
             vec![
                 "3_task".to_string(),
                 "1_task".to_string(),
@@ -253,12 +353,12 @@ mod tests {
 
         register_task_used(&inventory, "11_hello", cx);
         assert_eq!(
-            list_task_names(&inventory, None, false, cx),
+            list_task_names(&inventory, None, None, false, cx),
             &expected_updated_state,
             "Task list without lru sorting, should be sorted alphanumerically"
         );
         assert_eq!(
-            list_task_names(&inventory, None, true, cx),
+            list_task_names(&inventory, None, None, true, cx),
             vec![
                 "11_hello".to_string(),
                 "3_task".to_string(),
@@ -270,6 +370,169 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    fn test_inventory_static_task_filters(cx: &mut TestAppContext) {
+        let inventory_with_statics = cx.update(Inventory::new);
+        let common_name = "common_task_name";
+        let path_1 = Path::new("path_1");
+        let path_2 = Path::new("path_2");
+        let worktree_1 = WorktreeId::from_usize(1);
+        let worktree_path_1 = Path::new("worktree_path_1");
+        let worktree_2 = WorktreeId::from_usize(2);
+        let worktree_path_2 = Path::new("worktree_path_2");
+        inventory_with_statics.update(cx, |inventory, cx| {
+            inventory.add_source(
+                TaskSourceKind::UserInput,
+                |cx| {
+                    StaticTestSource::new(
+                        vec!["user_input".to_string(), common_name.to_string()],
+                        cx,
+                    )
+                },
+                cx,
+            );
+            inventory.add_source(
+                TaskSourceKind::AbsPath(path_1.to_path_buf()),
+                |cx| {
+                    StaticTestSource::new(
+                        vec!["static_source_1".to_string(), common_name.to_string()],
+                        cx,
+                    )
+                },
+                cx,
+            );
+            inventory.add_source(
+                TaskSourceKind::AbsPath(path_2.to_path_buf()),
+                |cx| {
+                    StaticTestSource::new(
+                        vec!["static_source_2".to_string(), common_name.to_string()],
+                        cx,
+                    )
+                },
+                cx,
+            );
+            inventory.add_source(
+                TaskSourceKind::Worktree {
+                    id: worktree_1,
+                    abs_path: worktree_path_1.to_path_buf(),
+                },
+                |cx| {
+                    StaticTestSource::new(
+                        vec!["worktree_1".to_string(), common_name.to_string()],
+                        cx,
+                    )
+                },
+                cx,
+            );
+            inventory.add_source(
+                TaskSourceKind::Worktree {
+                    id: worktree_2,
+                    abs_path: worktree_path_2.to_path_buf(),
+                },
+                |cx| {
+                    StaticTestSource::new(
+                        vec!["worktree_2".to_string(), common_name.to_string()],
+                        cx,
+                    )
+                },
+                cx,
+            );
+        });
+
+        let worktree_independent_tasks = vec![
+            (
+                TaskSourceKind::AbsPath(path_1.to_path_buf()),
+                common_name.to_string(),
+            ),
+            (
+                TaskSourceKind::AbsPath(path_1.to_path_buf()),
+                "static_source_1".to_string(),
+            ),
+            (
+                TaskSourceKind::AbsPath(path_2.to_path_buf()),
+                common_name.to_string(),
+            ),
+            (
+                TaskSourceKind::AbsPath(path_2.to_path_buf()),
+                "static_source_2".to_string(),
+            ),
+            (TaskSourceKind::UserInput, common_name.to_string()),
+            (TaskSourceKind::UserInput, "user_input".to_string()),
+        ];
+        let worktree_1_tasks = vec![
+            (
+                TaskSourceKind::Worktree {
+                    id: worktree_1,
+                    abs_path: worktree_path_1.to_path_buf(),
+                },
+                common_name.to_string(),
+            ),
+            (
+                TaskSourceKind::Worktree {
+                    id: worktree_1,
+                    abs_path: worktree_path_1.to_path_buf(),
+                },
+                "worktree_1".to_string(),
+            ),
+        ];
+        let worktree_2_tasks = vec![
+            (
+                TaskSourceKind::Worktree {
+                    id: worktree_2,
+                    abs_path: worktree_path_2.to_path_buf(),
+                },
+                common_name.to_string(),
+            ),
+            (
+                TaskSourceKind::Worktree {
+                    id: worktree_2,
+                    abs_path: worktree_path_2.to_path_buf(),
+                },
+                "worktree_2".to_string(),
+            ),
+        ];
+
+        let all_tasks = worktree_1_tasks
+            .iter()
+            .chain(worktree_2_tasks.iter())
+            // worktree-less tasks come later in the list
+            .chain(worktree_independent_tasks.iter())
+            .cloned()
+            .collect::<Vec<_>>();
+
+        for path in [
+            None,
+            Some(path_1),
+            Some(path_2),
+            Some(worktree_path_1),
+            Some(worktree_path_2),
+        ] {
+            assert_eq!(
+                list_tasks(&inventory_with_statics, path, None, false, cx),
+                all_tasks,
+                "Path {path:?} choice should not adjust static runnables"
+            );
+            assert_eq!(
+                list_tasks(&inventory_with_statics, path, Some(worktree_1), false, cx),
+                worktree_1_tasks
+                    .iter()
+                    .chain(worktree_independent_tasks.iter())
+                    .cloned()
+                    .collect::<Vec<_>>(),
+                "Path {path:?} choice should not adjust static runnables for worktree_1"
+            );
+            assert_eq!(
+                list_tasks(&inventory_with_statics, path, Some(worktree_2), false, cx),
+                worktree_2_tasks
+                    .iter()
+                    .chain(worktree_independent_tasks.iter())
+                    .cloned()
+                    .collect::<Vec<_>>(),
+                "Path {path:?} choice should not adjust static runnables for worktree_2"
+            );
+        }
+    }
+
     #[derive(Debug, Clone, PartialEq, Eq)]
     struct TestTask {
         id: TaskId,
@@ -294,11 +557,11 @@ mod tests {
         }
     }
 
-    struct TestSource {
+    struct StaticTestSource {
         tasks: Vec<TestTask>,
     }
 
-    impl TestSource {
+    impl StaticTestSource {
         fn new(
             task_names: impl IntoIterator<Item = String>,
             cx: &mut AppContext,
@@ -318,10 +581,11 @@ mod tests {
         }
     }
 
-    impl TaskSource for TestSource {
+    impl TaskSource for StaticTestSource {
         fn tasks_for_path(
             &mut self,
-            _path: Option<&Path>,
+            // static task source does not depend on path input
+            _: Option<&Path>,
             _cx: &mut ModelContext<Box<dyn TaskSource>>,
         ) -> Vec<Arc<dyn Task>> {
             self.tasks
@@ -339,24 +603,41 @@ mod tests {
     fn list_task_names(
         inventory: &Model<Inventory>,
         path: Option<&Path>,
+        worktree: Option<WorktreeId>,
         lru: bool,
         cx: &mut TestAppContext,
     ) -> Vec<String> {
         inventory.update(cx, |inventory, cx| {
             inventory
-                .list_tasks(path, lru, cx)
+                .list_tasks(path, worktree, lru, cx)
+                .into_iter()
+                .map(|(_, task)| task.name().to_string())
+                .collect()
+        })
+    }
+
+    fn list_tasks(
+        inventory: &Model<Inventory>,
+        path: Option<&Path>,
+        worktree: Option<WorktreeId>,
+        lru: bool,
+        cx: &mut TestAppContext,
+    ) -> Vec<(TaskSourceKind, String)> {
+        inventory.update(cx, |inventory, cx| {
+            inventory
+                .list_tasks(path, worktree, lru, cx)
                 .into_iter()
-                .map(|task| task.name().to_string())
+                .map(|(source_kind, task)| (source_kind, task.name().to_string()))
                 .collect()
         })
     }
 
     fn register_task_used(inventory: &Model<Inventory>, task_name: &str, cx: &mut TestAppContext) {
         inventory.update(cx, |inventory, cx| {
-            let task = inventory
-                .list_tasks(None, false, cx)
+            let (_, task) = inventory
+                .list_tasks(None, None, false, cx)
                 .into_iter()
-                .find(|task| task.name() == task_name)
+                .find(|(_, task)| task.name() == task_name)
                 .unwrap_or_else(|| panic!("Failed to find task with name {task_name}"));
             inventory.task_scheduled(task.id().clone());
         });

crates/recent_projects/src/highlighted_workspace_location.rs 🔗

@@ -1,130 +0,0 @@
-use std::path::Path;
-
-use fuzzy::StringMatch;
-use ui::{prelude::*, HighlightedLabel};
-use util::paths::PathExt;
-use workspace::WorkspaceLocation;
-
-#[derive(Clone, IntoElement)]
-pub struct HighlightedText {
-    pub text: String,
-    pub highlight_positions: Vec<usize>,
-    char_count: usize,
-}
-
-impl HighlightedText {
-    fn join(components: impl Iterator<Item = Self>, separator: &str) -> Self {
-        let mut char_count = 0;
-        let separator_char_count = separator.chars().count();
-        let mut text = String::new();
-        let mut highlight_positions = Vec::new();
-        for component in components {
-            if char_count != 0 {
-                text.push_str(separator);
-                char_count += separator_char_count;
-            }
-
-            highlight_positions.extend(
-                component
-                    .highlight_positions
-                    .iter()
-                    .map(|position| position + char_count),
-            );
-            text.push_str(&component.text);
-            char_count += component.text.chars().count();
-        }
-
-        Self {
-            text,
-            highlight_positions,
-            char_count,
-        }
-    }
-}
-
-impl RenderOnce for HighlightedText {
-    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
-        HighlightedLabel::new(self.text, self.highlight_positions)
-    }
-}
-
-#[derive(Clone)]
-pub struct HighlightedWorkspaceLocation {
-    pub names: HighlightedText,
-    pub paths: Vec<HighlightedText>,
-}
-
-impl HighlightedWorkspaceLocation {
-    pub fn new(string_match: &StringMatch, location: &WorkspaceLocation) -> Self {
-        let mut path_start_offset = 0;
-        let (names, paths): (Vec<_>, Vec<_>) = location
-            .paths()
-            .iter()
-            .map(|path| {
-                let path = path.compact();
-                let highlighted_text = Self::highlights_for_path(
-                    path.as_ref(),
-                    &string_match.positions,
-                    path_start_offset,
-                );
-
-                path_start_offset += highlighted_text.1.char_count;
-
-                highlighted_text
-            })
-            .unzip();
-
-        Self {
-            names: HighlightedText::join(names.into_iter().filter_map(|name| name), ", "),
-            paths,
-        }
-    }
-
-    // Compute the highlighted text for the name and path
-    fn highlights_for_path(
-        path: &Path,
-        match_positions: &Vec<usize>,
-        path_start_offset: usize,
-    ) -> (Option<HighlightedText>, HighlightedText) {
-        let path_string = path.to_string_lossy();
-        let path_char_count = path_string.chars().count();
-        // Get the subset of match highlight positions that line up with the given path.
-        // Also adjusts them to start at the path start
-        let path_positions = match_positions
-            .iter()
-            .copied()
-            .skip_while(|position| *position < path_start_offset)
-            .take_while(|position| *position < path_start_offset + path_char_count)
-            .map(|position| position - path_start_offset)
-            .collect::<Vec<_>>();
-
-        // Again subset the highlight positions to just those that line up with the file_name
-        // again adjusted to the start of the file_name
-        let file_name_text_and_positions = path.file_name().map(|file_name| {
-            let text = file_name.to_string_lossy();
-            let char_count = text.chars().count();
-            let file_name_start = path_char_count - char_count;
-            let highlight_positions = path_positions
-                .iter()
-                .copied()
-                .skip_while(|position| *position < file_name_start)
-                .take_while(|position| *position < file_name_start + char_count)
-                .map(|position| position - file_name_start)
-                .collect::<Vec<_>>();
-            HighlightedText {
-                text: text.to_string(),
-                highlight_positions,
-                char_count,
-            }
-        });
-
-        (
-            file_name_text_and_positions,
-            HighlightedText {
-                text: path_string.to_string(),
-                highlight_positions: path_positions,
-                char_count: path_char_count,
-            },
-        )
-    }
-}

crates/recent_projects/src/recent_projects.rs 🔗

@@ -1,15 +1,15 @@
-mod highlighted_workspace_location;
-
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
     AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Result,
     Subscription, Task, View, ViewContext, WeakView,
 };
-use highlighted_workspace_location::HighlightedWorkspaceLocation;
 use ordered_float::OrderedFloat;
-use picker::{Picker, PickerDelegate};
-use std::sync::Arc;
-use ui::{prelude::*, tooltip_container, HighlightedLabel, ListItem, ListItemSpacing, Tooltip};
+use picker::{
+    highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText},
+    Picker, PickerDelegate,
+};
+use std::{path::Path, sync::Arc};
+use ui::{prelude::*, tooltip_container, ListItem, ListItemSpacing, Tooltip};
 use util::paths::PathExt;
 use workspace::{ModalView, Workspace, WorkspaceId, WorkspaceLocation, WORKSPACE_DB};
 
@@ -245,32 +245,40 @@ impl PickerDelegate for RecentProjectsDelegate {
         selected: bool,
         cx: &mut ViewContext<Picker<Self>>,
     ) -> Option<Self::ListItem> {
-        let Some(r#match) = self.matches.get(ix) else {
+        let Some(hit) = self.matches.get(ix) else {
             return None;
         };
 
-        let (workspace_id, location) = &self.workspaces[r#match.candidate_id];
-        let highlighted_location: HighlightedWorkspaceLocation =
-            HighlightedWorkspaceLocation::new(&r#match, location);
-        let tooltip_highlighted_location = highlighted_location.clone();
-
+        let (workspace_id, location) = &self.workspaces[hit.candidate_id];
         let is_current_workspace = self.is_current_workspace(*workspace_id, cx);
+
+        let mut path_start_offset = 0;
+        let (match_labels, paths): (Vec<_>, Vec<_>) = location
+            .paths()
+            .iter()
+            .map(|path| {
+                let path = path.compact();
+                let highlighted_text =
+                    highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
+
+                path_start_offset += highlighted_text.1.char_count;
+                highlighted_text
+            })
+            .unzip();
+
+        let highlighted_match = HighlightedMatchWithPaths {
+            match_label: HighlightedText::join(
+                match_labels.into_iter().filter_map(|name| name),
+                ", ",
+            ),
+            paths: if self.render_paths { paths } else { Vec::new() },
+        };
         Some(
             ListItem::new(ix)
                 .inset(true)
                 .spacing(ListItemSpacing::Sparse)
                 .selected(selected)
-                .child(
-                    v_flex()
-                        .child(highlighted_location.names)
-                        .when(self.render_paths, |this| {
-                            this.children(highlighted_location.paths.into_iter().map(|path| {
-                                HighlightedLabel::new(path.text, path.highlight_positions)
-                                    .size(LabelSize::Small)
-                                    .color(Color::Muted)
-                            }))
-                        }),
-                )
+                .child(highlighted_match.clone().render(cx))
                 .when(!is_current_workspace, |el| {
                     let delete_button = div()
                         .child(
@@ -293,7 +301,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                     }
                 })
                 .tooltip(move |cx| {
-                    let tooltip_highlighted_location = tooltip_highlighted_location.clone();
+                    let tooltip_highlighted_location = highlighted_match.clone();
                     cx.new_view(move |_| MatchTooltip {
                         highlighted_location: tooltip_highlighted_location,
                     })
@@ -303,6 +311,54 @@ impl PickerDelegate for RecentProjectsDelegate {
     }
 }
 
+// Compute the highlighted text for the name and path
+fn highlights_for_path(
+    path: &Path,
+    match_positions: &Vec<usize>,
+    path_start_offset: usize,
+) -> (Option<HighlightedText>, HighlightedText) {
+    let path_string = path.to_string_lossy();
+    let path_char_count = path_string.chars().count();
+    // Get the subset of match highlight positions that line up with the given path.
+    // Also adjusts them to start at the path start
+    let path_positions = match_positions
+        .iter()
+        .copied()
+        .skip_while(|position| *position < path_start_offset)
+        .take_while(|position| *position < path_start_offset + path_char_count)
+        .map(|position| position - path_start_offset)
+        .collect::<Vec<_>>();
+
+    // Again subset the highlight positions to just those that line up with the file_name
+    // again adjusted to the start of the file_name
+    let file_name_text_and_positions = path.file_name().map(|file_name| {
+        let text = file_name.to_string_lossy();
+        let char_count = text.chars().count();
+        let file_name_start = path_char_count - char_count;
+        let highlight_positions = path_positions
+            .iter()
+            .copied()
+            .skip_while(|position| *position < file_name_start)
+            .take_while(|position| *position < file_name_start + char_count)
+            .map(|position| position - file_name_start)
+            .collect::<Vec<_>>();
+        HighlightedText {
+            text: text.to_string(),
+            highlight_positions,
+            char_count,
+        }
+    });
+
+    (
+        file_name_text_and_positions,
+        HighlightedText {
+            text: path_string.to_string(),
+            highlight_positions: path_positions,
+            char_count: path_char_count,
+        },
+    )
+}
+
 impl RecentProjectsDelegate {
     fn delete_recent_project(&self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
         if let Some(selected_match) = self.matches.get(ix) {
@@ -340,23 +396,13 @@ impl RecentProjectsDelegate {
     }
 }
 struct MatchTooltip {
-    highlighted_location: HighlightedWorkspaceLocation,
+    highlighted_location: HighlightedMatchWithPaths,
 }
 
 impl Render for MatchTooltip {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         tooltip_container(cx, |div, _| {
-            div.children(
-                self.highlighted_location
-                    .paths
-                    .clone()
-                    .into_iter()
-                    .map(|path| {
-                        HighlightedLabel::new(path.text, path.highlight_positions)
-                            .size(LabelSize::Small)
-                            .color(Color::Muted)
-                    }),
-            )
+            self.highlighted_location.render_paths_children(div)
         })
     }
 }

crates/task/src/static_source.rs 🔗

@@ -1,6 +1,7 @@
 //! 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,
     path::{Path, PathBuf},
     sync::Arc,
 };
@@ -22,15 +23,6 @@ struct StaticTask {
     definition: Definition,
 }
 
-impl StaticTask {
-    pub(super) fn new(id: usize, task_definition: Definition) -> Self {
-        Self {
-            id: TaskId(format!("static_{}_{}", task_definition.label, id)),
-            definition: task_definition,
-        }
-    }
-}
-
 impl Task for StaticTask {
     fn exec(&self, cwd: Option<PathBuf>) -> Option<SpawnInTerminal> {
         Some(SpawnInTerminal {
@@ -150,14 +142,16 @@ impl<T: for<'a> Deserialize<'a> + 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>>,
         tasks_file_tracker: UnboundedReceiver<String>,
         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(
                 &definitions,
-                |source: &mut Box<(dyn TaskSource + 'static)>, new_definitions, cx| {
+                move |source: &mut Box<(dyn TaskSource + 'static)>, new_definitions, cx| {
                     if let Some(static_source) = source.as_any().downcast_mut::<Self>() {
                         static_source.tasks = new_definitions
                             .read(cx)
@@ -166,7 +160,10 @@ impl StaticSource {
                             .clone()
                             .into_iter()
                             .enumerate()
-                            .map(|(id, definition)| StaticTask::new(id, definition))
+                            .map(|(i, definition)| StaticTask {
+                                id: TaskId(format!("static_{id_base}_{i}_{}", definition.label)),
+                                definition,
+                            })
                             .collect();
                         cx.notify();
                     }

crates/tasks_ui/src/modal.rs 🔗

@@ -1,4 +1,4 @@
-use std::sync::Arc;
+use std::{path::PathBuf, sync::Arc};
 
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
@@ -6,11 +6,14 @@ use gpui::{
     Model, ParentElement, Render, SharedString, Styled, Subscription, View, ViewContext,
     VisualContext, WeakView,
 };
-use picker::{Picker, PickerDelegate};
-use project::Inventory;
+use picker::{
+    highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText},
+    Picker, PickerDelegate,
+};
+use project::{Inventory, ProjectPath, TaskSourceKind};
 use task::{oneshot_source::OneshotSource, Task};
-use ui::{v_flex, HighlightedLabel, ListItem, ListItemSpacing, Selectable, WindowContext};
-use util::ResultExt;
+use ui::{v_flex, ListItem, ListItemSpacing, RenderOnce, Selectable, WindowContext};
+use util::{paths::PathExt, ResultExt};
 use workspace::{ModalView, Workspace};
 
 use crate::schedule_task;
@@ -20,7 +23,7 @@ actions!(task, [Spawn, Rerun]);
 /// A modal used to spawn new tasks.
 pub(crate) struct TasksModalDelegate {
     inventory: Model<Inventory>,
-    candidates: Vec<Arc<dyn Task>>,
+    candidates: Vec<(TaskSourceKind, Arc<dyn Task>)>,
     matches: Vec<StringMatch>,
     selected_index: usize,
     workspace: WeakView<Workspace>,
@@ -51,6 +54,21 @@ impl TasksModalDelegate {
                 )
             })
     }
+
+    fn active_item_path(
+        &mut self,
+        cx: &mut ViewContext<'_, Picker<Self>>,
+    ) -> Option<(PathBuf, ProjectPath)> {
+        let workspace = self.workspace.upgrade()?.read(cx);
+        let project = workspace.project().read(cx);
+        let active_item = workspace.active_item(cx)?;
+        active_item.project_path(cx).and_then(|project_path| {
+            project
+                .worktree_for_id(project_path.worktree_id, cx)
+                .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path))
+                .zip(Some(project_path))
+        })
+    }
 }
 
 pub(crate) struct TasksModal {
@@ -130,16 +148,22 @@ impl PickerDelegate for TasksModalDelegate {
         cx.spawn(move |picker, mut cx| async move {
             let Some(candidates) = picker
                 .update(&mut cx, |picker, cx| {
-                    picker.delegate.candidates = picker
-                        .delegate
-                        .inventory
-                        .update(cx, |inventory, cx| inventory.list_tasks(None, true, cx));
+                    let (path, worktree) = match picker.delegate.active_item_path(cx) {
+                        Some((abs_path, project_path)) => {
+                            (Some(abs_path), Some(project_path.worktree_id))
+                        }
+                        None => (None, None),
+                    };
+                    picker.delegate.candidates =
+                        picker.delegate.inventory.update(cx, |inventory, cx| {
+                            inventory.list_tasks(path.as_deref(), worktree, true, cx)
+                        });
                     picker
                         .delegate
                         .candidates
                         .iter()
                         .enumerate()
-                        .map(|(index, candidate)| StringMatchCandidate {
+                        .map(|(index, (_, candidate))| StringMatchCandidate {
                             id: index,
                             char_bag: candidate.name().chars().collect(),
                             string: candidate.name().into(),
@@ -178,7 +202,6 @@ impl PickerDelegate for TasksModalDelegate {
 
     fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
         let current_match_index = self.selected_index();
-
         let task = if secondary {
             if !self.prompt.trim().is_empty() {
                 self.spawn_oneshot(cx)
@@ -188,7 +211,7 @@ impl PickerDelegate for TasksModalDelegate {
         } else {
             self.matches.get(current_match_index).map(|current_match| {
                 let ix = current_match.candidate_id;
-                self.candidates[ix].clone()
+                self.candidates[ix].1.clone()
             })
         };
 
@@ -212,16 +235,35 @@ impl PickerDelegate for TasksModalDelegate {
         &self,
         ix: usize,
         selected: bool,
-        _cx: &mut ViewContext<picker::Picker<Self>>,
+        cx: &mut ViewContext<picker::Picker<Self>>,
     ) -> Option<Self::ListItem> {
         let hit = &self.matches[ix];
-        let highlights: Vec<_> = hit.positions.iter().copied().collect();
+        let (source_kind, _) = &self.candidates[hit.candidate_id];
+        let details = match source_kind {
+            TaskSourceKind::UserInput => "user input".to_string(),
+            TaskSourceKind::Worktree { abs_path, .. } | TaskSourceKind::AbsPath(abs_path) => {
+                abs_path.compact().to_string_lossy().to_string()
+            }
+        };
+
+        let highlighted_location = HighlightedMatchWithPaths {
+            match_label: HighlightedText {
+                text: hit.string.clone(),
+                highlight_positions: hit.positions.clone(),
+                char_count: hit.string.chars().count(),
+            },
+            paths: vec![HighlightedText {
+                char_count: details.chars().count(),
+                highlight_positions: Vec::new(),
+                text: details,
+            }],
+        };
         Some(
             ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
                 .inset(true)
                 .spacing(ListItemSpacing::Sparse)
                 .selected(selected)
-                .start_slot(HighlightedLabel::new(hit.string.clone(), highlights)),
+                .child(highlighted_location.render(cx)),
         )
     }
 }

crates/util/src/paths.rs 🔗

@@ -44,6 +44,7 @@ lazy_static::lazy_static! {
     pub static ref LOG: PathBuf = LOGS_DIR.join("Zed.log");
     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 TEMP_DIR: PathBuf = HOME.join(".cache").join("zed");
 }
 

crates/zed/src/zed.rs 🔗

@@ -14,24 +14,25 @@ use gpui::{
 pub use only_instance::*;
 pub use open_listener::*;
 
-use anyhow::{anyhow, Context as _};
+use anyhow::Context as _;
 use assets::Assets;
 use futures::{channel::mpsc, select_biased, StreamExt};
+use project::TaskSourceKind;
 use project_panel::ProjectPanel;
 use quick_action_bar::QuickActionBar;
 use release_channel::{AppCommitSha, ReleaseChannel};
 use rope::Rope;
 use search::project_search::ProjectSearchBar;
 use settings::{
-    initial_local_settings_content, watch_config_file, KeymapFile, Settings, SettingsStore,
-    DEFAULT_KEYMAP_PATH,
+    initial_local_settings_content, initial_tasks_content, watch_config_file, KeymapFile, Settings,
+    SettingsStore, DEFAULT_KEYMAP_PATH,
 };
 use std::{borrow::Cow, ops::Deref, path::Path, sync::Arc};
 use task::{oneshot_source::OneshotSource, static_source::StaticSource};
 use terminal_view::terminal_panel::{self, TerminalPanel};
 use util::{
     asset_str,
-    paths::{self, LOCAL_SETTINGS_RELATIVE_PATH},
+    paths::{self, LOCAL_SETTINGS_RELATIVE_PATH, LOCAL_TASKS_RELATIVE_PATH},
     ResultExt,
 };
 use uuid::Uuid;
@@ -59,6 +60,7 @@ actions!(
         OpenKeymap,
         OpenLicenses,
         OpenLocalSettings,
+        OpenLocalTasks,
         OpenLog,
         OpenTasks,
         OpenTelemetryLog,
@@ -155,18 +157,26 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
 
         let project = workspace.project().clone();
         if project.read(cx).is_local() {
-            let tasks_file_rx = watch_config_file(
-                &cx.background_executor(),
-                app_state.fs.clone(),
-                paths::TASKS.clone(),
-            );
-            let static_source = StaticSource::new(tasks_file_rx, cx);
-            let oneshot_source = OneshotSource::new(cx);
-
             project.update(cx, |project, cx| {
+                let fs = app_state.fs.clone();
                 project.task_inventory().update(cx, |inventory, cx| {
-                    inventory.add_source(oneshot_source, cx);
-                    inventory.add_source(static_source, cx);
+                    inventory.add_source(
+                        TaskSourceKind::UserInput,
+                        |cx| OneshotSource::new(cx),
+                        cx,
+                    );
+                    inventory.add_source(
+                        TaskSourceKind::AbsPath(paths::TASKS.clone()),
+                        |cx| {
+                            let tasks_file_rx = watch_config_file(
+                                &cx.background_executor(),
+                                fs,
+                                paths::TASKS.clone(),
+                            );
+                            StaticSource::new("global_tasks", tasks_file_rx, cx)
+                        },
+                        cx,
+                    );
                 })
             });
         }
@@ -283,6 +293,7 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
                 },
             )
             .register_action(open_local_settings_file)
+            .register_action(open_local_tasks_file)
             .register_action(
                 move |workspace: &mut Workspace,
                       _: &OpenDefaultKeymap,
@@ -602,6 +613,33 @@ fn open_local_settings_file(
     workspace: &mut Workspace,
     _: &OpenLocalSettings,
     cx: &mut ViewContext<Workspace>,
+) {
+    open_local_file(
+        workspace,
+        &LOCAL_SETTINGS_RELATIVE_PATH,
+        initial_local_settings_content(),
+        cx,
+    )
+}
+
+fn open_local_tasks_file(
+    workspace: &mut Workspace,
+    _: &OpenLocalTasks,
+    cx: &mut ViewContext<Workspace>,
+) {
+    open_local_file(
+        workspace,
+        &LOCAL_TASKS_RELATIVE_PATH,
+        initial_tasks_content(),
+        cx,
+    )
+}
+
+fn open_local_file(
+    workspace: &mut Workspace,
+    settings_relative_path: &'static Path,
+    initial_contents: Cow<'static, str>,
+    cx: &mut ViewContext<Workspace>,
 ) {
     let project = workspace.project().clone();
     let worktree = project
@@ -611,9 +649,7 @@ fn open_local_settings_file(
     if let Some(worktree) = worktree {
         let tree_id = worktree.read(cx).id();
         cx.spawn(|workspace, mut cx| async move {
-            let file_path = &*LOCAL_SETTINGS_RELATIVE_PATH;
-
-            if let Some(dir_path) = file_path.parent() {
+            if let Some(dir_path) = settings_relative_path.parent() {
                 if worktree.update(&mut cx, |tree, _| tree.entry_for_path(dir_path).is_none())? {
                     project
                         .update(&mut cx, |project, cx| {
@@ -624,10 +660,12 @@ fn open_local_settings_file(
                 }
             }
 
-            if worktree.update(&mut cx, |tree, _| tree.entry_for_path(file_path).is_none())? {
+            if worktree.update(&mut cx, |tree, _| {
+                tree.entry_for_path(settings_relative_path).is_none()
+            })? {
                 project
                     .update(&mut cx, |project, cx| {
-                        project.create_entry((tree_id, file_path), false, cx)
+                        project.create_entry((tree_id, settings_relative_path), false, cx)
                     })?
                     .await
                     .context("worktree was removed")?;
@@ -635,11 +673,11 @@ fn open_local_settings_file(
 
             let editor = workspace
                 .update(&mut cx, |workspace, cx| {
-                    workspace.open_path((tree_id, file_path), None, true, cx)
+                    workspace.open_path((tree_id, settings_relative_path), None, true, cx)
                 })?
                 .await?
                 .downcast::<Editor>()
-                .ok_or_else(|| anyhow!("unexpected item type"))?;
+                .context("unexpected item type: expected editor item")?;
 
             editor
                 .downgrade()
@@ -647,7 +685,7 @@ fn open_local_settings_file(
                     if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
                         if buffer.read(cx).is_empty() {
                             buffer.update(cx, |buffer, cx| {
-                                buffer.edit([(0..0, initial_local_settings_content())], None, cx)
+                                buffer.edit([(0..0, initial_contents)], None, cx)
                             });
                         }
                     }