Add support for detecting tests in source files, and implement it for Rust (#11195)

Piotr Osiewicz and Mikayla created

Continuing work from #10873 

Release Notes:

- N/A

---------

Co-authored-by: Mikayla <mikayla@zed.dev>

Change summary

Cargo.lock                               |   3 
crates/collab/src/tests/editor_tests.rs  |   2 
crates/editor/Cargo.toml                 |   1 
crates/editor/src/actions.rs             |   8 
crates/editor/src/editor.rs              | 477 ++++++++++++++++++++++---
crates/editor/src/element.rs             | 179 +++++++-
crates/editor/src/mouse_context_menu.rs  |   2 
crates/editor/src/tasks.rs               | 118 ++++++
crates/language/src/buffer.rs            |  56 ++
crates/language/src/language.rs          |  43 ++
crates/language/src/language_registry.rs |   2 
crates/language/src/syntax_map.rs        |   6 
crates/languages/src/rust.rs             |   1 
crates/languages/src/rust/runnables.scm  |   7 
crates/multi_buffer/src/multi_buffer.rs  |  27 +
crates/project/src/project.rs            |  25 
crates/project/src/project_tests.rs      |  79 +--
crates/project/src/task_inventory.rs     | 221 +++--------
crates/task/Cargo.toml                   |   1 
crates/task/src/lib.rs                   |  19 
crates/task/src/static_source.rs         | 150 +++----
crates/task/src/task_template.rs         |   4 
crates/tasks_ui/Cargo.toml               |   1 
crates/tasks_ui/src/lib.rs               | 185 ---------
crates/tasks_ui/src/modal.rs             |   7 
crates/workspace/src/tasks.rs            |  83 ++++
crates/workspace/src/workspace.rs        |   1 
crates/zed/src/main.rs                   |  33 +
crates/zed/src/zed.rs                    |  11 
29 files changed, 1,147 insertions(+), 605 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3394,6 +3394,7 @@ dependencies = [
  "smol",
  "snippet",
  "sum_tree",
+ "task",
  "text",
  "theme",
  "time",
@@ -9875,6 +9876,7 @@ dependencies = [
  "futures 0.3.28",
  "gpui",
  "hex",
+ "parking_lot",
  "schemars",
  "serde",
  "serde_json_lenient",
@@ -9887,7 +9889,6 @@ dependencies = [
 name = "tasks_ui"
 version = "0.1.0"
 dependencies = [
- "anyhow",
  "editor",
  "file_icons",
  "fuzzy",

crates/collab/src/tests/editor_tests.rs 🔗

@@ -667,7 +667,7 @@ async fn test_collaborating_with_code_actions(
     editor_b.update(cx_b, |editor, cx| {
         editor.toggle_code_actions(
             &ToggleCodeActions {
-                deployed_from_indicator: false,
+                deployed_from_indicator: None,
             },
             cx,
         );

crates/editor/Cargo.toml 🔗

@@ -60,6 +60,7 @@ smallvec.workspace = true
 smol.workspace = true
 snippet.workspace = true
 sum_tree.workspace = true
+task.workspace = true
 text.workspace = true
 time.workspace = true
 time_format.workspace = true

crates/editor/src/actions.rs 🔗

@@ -53,7 +53,13 @@ pub struct SelectToEndOfLine {
 #[derive(PartialEq, Clone, Deserialize, Default)]
 pub struct ToggleCodeActions {
     #[serde(default)]
-    pub deployed_from_indicator: bool,
+    pub deployed_from_indicator: Option<u32>,
+}
+
+#[derive(PartialEq, Clone, Deserialize, Default)]
+pub struct ToggleTestRunner {
+    #[serde(default)]
+    pub deployed_from_row: Option<u32>,
 }
 
 #[derive(PartialEq, Clone, Deserialize, Default)]

crates/editor/src/editor.rs 🔗

@@ -34,6 +34,7 @@ mod persistence;
 mod rust_analyzer_ext;
 pub mod scroll;
 mod selections_collection;
+pub mod tasks;
 
 #[cfg(test)]
 mod editor_tests;
@@ -78,6 +79,7 @@ use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy};
 pub use inline_completion_provider::*;
 pub use items::MAX_TAB_TITLE_LEN;
 use itertools::Itertools;
+use language::Runnable;
 use language::{
     char_kind,
     language_settings::{self, all_language_settings, InlayHintSettings},
@@ -85,6 +87,7 @@ use language::{
     CursorShape, Diagnostic, Documentation, IndentKind, IndentSize, Language, OffsetRangeExt,
     Point, Selection, SelectionGoal, TransactionId,
 };
+use task::{ResolvedTask, TaskTemplate};
 
 use hover_links::{HoverLink, HoveredLinkState, InlayHighlight};
 use lsp::{DiagnosticSeverity, LanguageServerId};
@@ -99,7 +102,8 @@ use ordered_float::OrderedFloat;
 use parking_lot::{Mutex, RwLock};
 use project::project_settings::{GitGutterSetting, ProjectSettings};
 use project::{
-    CodeAction, Completion, FormatTrigger, Item, Location, Project, ProjectPath, ProjectTransaction,
+    CodeAction, Completion, FormatTrigger, Item, Location, Project, ProjectPath,
+    ProjectTransaction, TaskSourceKind, WorktreeId,
 };
 use rand::prelude::*;
 use rpc::{proto::*, ErrorExt};
@@ -395,6 +399,19 @@ impl Default for ScrollbarMarkerState {
     }
 }
 
+#[derive(Clone)]
+struct RunnableTasks {
+    templates: SmallVec<[(TaskSourceKind, TaskTemplate); 1]>,
+    // We need the column at which the task context evaluation should take place.
+    column: u32,
+}
+
+#[derive(Clone)]
+struct ResolvedTasks {
+    templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>,
+    position: text::Point,
+}
+
 /// Zed's primary text input `View`, allowing users to edit a [`MultiBuffer`]
 ///
 /// See the [module level documentation](self) for more information.
@@ -487,6 +504,7 @@ pub struct Editor {
     >,
     last_bounds: Option<Bounds<Pixels>>,
     expect_bounds_change: Option<Bounds<Pixels>>,
+    tasks: HashMap<u32, RunnableTasks>,
 }
 
 #[derive(Clone)]
@@ -1180,12 +1198,106 @@ impl CompletionsMenu {
 }
 
 #[derive(Clone)]
+struct CodeActionContents {
+    tasks: Option<Arc<ResolvedTasks>>,
+    actions: Option<Arc<[CodeAction]>>,
+}
+
+impl CodeActionContents {
+    fn len(&self) -> usize {
+        match (&self.tasks, &self.actions) {
+            (Some(tasks), Some(actions)) => actions.len() + tasks.templates.len(),
+            (Some(tasks), None) => tasks.templates.len(),
+            (None, Some(actions)) => actions.len(),
+            (None, None) => 0,
+        }
+    }
+
+    fn is_empty(&self) -> bool {
+        match (&self.tasks, &self.actions) {
+            (Some(tasks), Some(actions)) => actions.is_empty() && tasks.templates.is_empty(),
+            (Some(tasks), None) => tasks.templates.is_empty(),
+            (None, Some(actions)) => actions.is_empty(),
+            (None, None) => true,
+        }
+    }
+
+    fn iter(&self) -> impl Iterator<Item = CodeActionsItem> + '_ {
+        self.tasks
+            .iter()
+            .flat_map(|tasks| {
+                tasks
+                    .templates
+                    .iter()
+                    .map(|(kind, task)| CodeActionsItem::Task(kind.clone(), task.clone()))
+            })
+            .chain(self.actions.iter().flat_map(|actions| {
+                actions
+                    .iter()
+                    .map(|action| CodeActionsItem::CodeAction(action.clone()))
+            }))
+    }
+    fn get(&self, index: usize) -> Option<CodeActionsItem> {
+        match (&self.tasks, &self.actions) {
+            (Some(tasks), Some(actions)) => {
+                if index < tasks.templates.len() {
+                    tasks
+                        .templates
+                        .get(index)
+                        .cloned()
+                        .map(|(kind, task)| CodeActionsItem::Task(kind, task))
+                } else {
+                    actions
+                        .get(index - tasks.templates.len())
+                        .cloned()
+                        .map(CodeActionsItem::CodeAction)
+                }
+            }
+            (Some(tasks), None) => tasks
+                .templates
+                .get(index)
+                .cloned()
+                .map(|(kind, task)| CodeActionsItem::Task(kind, task)),
+            (None, Some(actions)) => actions.get(index).cloned().map(CodeActionsItem::CodeAction),
+            (None, None) => None,
+        }
+    }
+}
+
+#[allow(clippy::large_enum_variant)]
+#[derive(Clone)]
+enum CodeActionsItem {
+    Task(TaskSourceKind, ResolvedTask),
+    CodeAction(CodeAction),
+}
+
+impl CodeActionsItem {
+    fn as_task(&self) -> Option<&ResolvedTask> {
+        let Self::Task(_, task) = self else {
+            return None;
+        };
+        Some(task)
+    }
+    fn as_code_action(&self) -> Option<&CodeAction> {
+        let Self::CodeAction(action) = self else {
+            return None;
+        };
+        Some(action)
+    }
+    fn label(&self) -> String {
+        match self {
+            Self::CodeAction(action) => action.lsp_action.title.clone(),
+            Self::Task(_, task) => task.resolved_label.clone(),
+        }
+    }
+}
+
 struct CodeActionsMenu {
-    actions: Arc<[CodeAction]>,
+    actions: CodeActionContents,
     buffer: Model<Buffer>,
     selected_item: usize,
     scroll_handle: UniformListScrollHandle,
-    deployed_from_indicator: bool,
+    deployed_from_indicator: Option<u32>,
 }
 
 impl CodeActionsMenu {
@@ -1240,8 +1352,10 @@ impl CodeActionsMenu {
             "code_actions_menu",
             self.actions.len(),
             move |_this, range, cx| {
-                actions[range.clone()]
+                actions
                     .iter()
+                    .skip(range.start)
+                    .take(range.end - range.start)
                     .enumerate()
                     .map(|(ix, action)| {
                         let item_ix = range.start + ix;
@@ -1260,23 +1374,42 @@ impl CodeActionsMenu {
                                     .bg(colors.element_hover)
                                     .text_color(colors.text_accent)
                             })
-                            .on_mouse_down(
-                                MouseButton::Left,
-                                cx.listener(move |editor, _, cx| {
-                                    cx.stop_propagation();
-                                    if let Some(task) = editor.confirm_code_action(
-                                        &ConfirmCodeAction {
-                                            item_ix: Some(item_ix),
-                                        },
-                                        cx,
-                                    ) {
-                                        task.detach_and_log_err(cx)
-                                    }
-                                }),
-                            )
                             .whitespace_nowrap()
-                            // TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
-                            .child(SharedString::from(action.lsp_action.title.clone()))
+                            .when_some(action.as_code_action(), |this, action| {
+                                this.on_mouse_down(
+                                    MouseButton::Left,
+                                    cx.listener(move |editor, _, cx| {
+                                        cx.stop_propagation();
+                                        if let Some(task) = editor.confirm_code_action(
+                                            &ConfirmCodeAction {
+                                                item_ix: Some(item_ix),
+                                            },
+                                            cx,
+                                        ) {
+                                            task.detach_and_log_err(cx)
+                                        }
+                                    }),
+                                )
+                                // TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
+                                .child(SharedString::from(action.lsp_action.title.clone()))
+                            })
+                            .when_some(action.as_task(), |this, task| {
+                                this.on_mouse_down(
+                                    MouseButton::Left,
+                                    cx.listener(move |editor, _, cx| {
+                                        cx.stop_propagation();
+                                        if let Some(task) = editor.confirm_code_action(
+                                            &ConfirmCodeAction {
+                                                item_ix: Some(item_ix),
+                                            },
+                                            cx,
+                                        ) {
+                                            task.detach_and_log_err(cx)
+                                        }
+                                    }),
+                                )
+                                .child(SharedString::from(task.resolved_label.clone()))
+                            })
                     })
                     .collect()
             },
@@ -1291,16 +1424,20 @@ impl CodeActionsMenu {
             self.actions
                 .iter()
                 .enumerate()
-                .max_by_key(|(_, action)| action.lsp_action.title.chars().count())
+                .max_by_key(|(_, action)| match action {
+                    CodeActionsItem::Task(_, task) => task.resolved_label.chars().count(),
+                    CodeActionsItem::CodeAction(action) => action.lsp_action.title.chars().count(),
+                })
                 .map(|(ix, _)| ix),
         )
         .into_any_element();
 
-        let cursor_position = if self.deployed_from_indicator {
-            ContextMenuOrigin::GutterIndicator(cursor_position.row())
+        let cursor_position = if let Some(row) = self.deployed_from_indicator {
+            ContextMenuOrigin::GutterIndicator(row)
         } else {
             ContextMenuOrigin::EditorPoint(cursor_position)
         };
+
         (cursor_position, element)
     }
 }
@@ -1532,6 +1669,7 @@ impl Editor {
             git_blame_inline_enabled: ProjectSettings::get_global(cx).git.inline_blame_enabled(),
             blame: None,
             blame_subscription: None,
+            tasks: Default::default(),
             _subscriptions: vec![
                 cx.observe(&buffer, Self::on_buffer_changed),
                 cx.subscribe(&buffer, Self::on_buffer_event),
@@ -3687,38 +3825,131 @@ impl Editor {
 
     pub fn toggle_code_actions(&mut self, action: &ToggleCodeActions, cx: &mut ViewContext<Self>) {
         let mut context_menu = self.context_menu.write();
-        if matches!(context_menu.as_ref(), Some(ContextMenu::CodeActions(_))) {
-            *context_menu = None;
-            cx.notify();
-            return;
+        if let Some(ContextMenu::CodeActions(code_actions)) = context_menu.as_ref() {
+            if code_actions.deployed_from_indicator == action.deployed_from_indicator {
+                // Toggle if we're selecting the same one
+                *context_menu = None;
+                cx.notify();
+                return;
+            } else {
+                // Otherwise, clear it and start a new one
+                *context_menu = None;
+                cx.notify();
+            }
         }
         drop(context_menu);
 
         let deployed_from_indicator = action.deployed_from_indicator;
         let mut task = self.code_actions_task.take();
+        let action = action.clone();
         cx.spawn(|this, mut cx| async move {
             while let Some(prev_task) = task {
                 prev_task.await;
                 task = this.update(&mut cx, |this, _| this.code_actions_task.take())?;
             }
 
-            this.update(&mut cx, |this, cx| {
+            let spawned_test_task = this.update(&mut cx, |this, cx| {
                 if this.focus_handle.is_focused(cx) {
-                    if let Some((buffer, actions)) = this.available_code_actions.clone() {
-                        this.completion_tasks.clear();
-                        this.discard_inline_completion(cx);
-                        *this.context_menu.write() =
-                            Some(ContextMenu::CodeActions(CodeActionsMenu {
-                                buffer,
-                                actions,
-                                selected_item: Default::default(),
-                                scroll_handle: UniformListScrollHandle::default(),
-                                deployed_from_indicator,
-                            }));
-                        cx.notify();
+                    let row = action
+                        .deployed_from_indicator
+                        .unwrap_or_else(|| this.selections.newest::<Point>(cx).head().row);
+                    let tasks = this.tasks.get(&row).map(|t| Arc::new(t.to_owned()));
+                    let (buffer, code_actions) = this
+                        .available_code_actions
+                        .clone()
+                        .map(|(buffer, code_actions)| {
+                            let snapshot = buffer.read(cx).snapshot();
+                            let code_actions: Arc<[CodeAction]> = code_actions
+                                .into_iter()
+                                .filter(|action| {
+                                    text::ToPoint::to_point(&action.range.start, &snapshot).row
+                                        == row
+                                })
+                                .cloned()
+                                .collect();
+                            (buffer, code_actions)
+                        })
+                        .unzip();
+
+                    if tasks.is_none() && code_actions.is_none() {
+                        return None;
                     }
+                    let buffer = buffer.or_else(|| {
+                        let snapshot = this.snapshot(cx);
+                        let (buffer_snapshot, _) =
+                            snapshot.buffer_snapshot.buffer_line_for_row(row)?;
+                        let buffer_id = buffer_snapshot.remote_id();
+                        this.buffer().read(cx).buffer(buffer_id)
+                    });
+                    let Some(buffer) = buffer else {
+                        return None;
+                    };
+                    this.completion_tasks.clear();
+                    this.discard_inline_completion(cx);
+                    let task_context = tasks.as_ref().zip(this.workspace.clone()).and_then(
+                        |(tasks, (workspace, _))| {
+                            let position = Point::new(row, tasks.column);
+                            let range_start = buffer.read(cx).anchor_at(position, Bias::Right);
+                            let location = Location {
+                                buffer: buffer.clone(),
+                                range: range_start..range_start,
+                            };
+                            workspace
+                                .update(cx, |workspace, cx| {
+                                    tasks::task_context_for_location(workspace, location, cx)
+                                })
+                                .ok()
+                                .flatten()
+                        },
+                    );
+                    let tasks = tasks
+                        .zip(task_context.as_ref())
+                        .map(|(tasks, task_context)| {
+                            Arc::new(ResolvedTasks {
+                                templates: tasks
+                                    .templates
+                                    .iter()
+                                    .filter_map(|(kind, template)| {
+                                        template
+                                            .resolve_task(&kind.to_id_base(), &task_context)
+                                            .map(|task| (kind.clone(), task))
+                                    })
+                                    .collect(),
+                                position: Point::new(row, tasks.column),
+                            })
+                        });
+                    let spawn_straight_away = tasks
+                        .as_ref()
+                        .map_or(false, |tasks| tasks.templates.len() == 1)
+                        && code_actions
+                            .as_ref()
+                            .map_or(true, |actions| actions.is_empty());
+
+                    *this.context_menu.write() = Some(ContextMenu::CodeActions(CodeActionsMenu {
+                        buffer,
+                        actions: CodeActionContents {
+                            tasks,
+                            actions: code_actions,
+                        },
+                        selected_item: Default::default(),
+                        scroll_handle: UniformListScrollHandle::default(),
+                        deployed_from_indicator,
+                    }));
+                    if spawn_straight_away {
+                        if let Some(task) =
+                            this.confirm_code_action(&ConfirmCodeAction { item_ix: Some(0) }, cx)
+                        {
+                            cx.notify();
+                            return Some(task);
+                        }
+                    }
+                    cx.notify();
                 }
+                None
             })?;
+            if let Some(task) = spawned_test_task {
+                task.await?;
+            }
 
             Ok::<_, anyhow::Error>(())
         })
@@ -3736,23 +3967,47 @@ impl Editor {
             return None;
         };
         let action_ix = action.item_ix.unwrap_or(actions_menu.selected_item);
-        let action = actions_menu.actions.get(action_ix)?.clone();
-        let title = action.lsp_action.title.clone();
+        let action = actions_menu.actions.get(action_ix)?;
+        let title = action.label();
         let buffer = actions_menu.buffer;
         let workspace = self.workspace()?;
 
-        let apply_code_actions = workspace
-            .read(cx)
-            .project()
-            .clone()
-            .update(cx, |project, cx| {
-                project.apply_code_action(buffer, action, true, cx)
-            });
-        let workspace = workspace.downgrade();
-        Some(cx.spawn(|editor, cx| async move {
-            let project_transaction = apply_code_actions.await?;
-            Self::open_project_transaction(&editor, workspace, project_transaction, title, cx).await
-        }))
+        match action {
+            CodeActionsItem::Task(task_source_kind, resolved_task) => {
+                workspace.update(cx, |workspace, cx| {
+                    workspace::tasks::schedule_resolved_task(
+                        workspace,
+                        task_source_kind,
+                        resolved_task,
+                        false,
+                        cx,
+                    );
+
+                    None
+                })
+            }
+            CodeActionsItem::CodeAction(action) => {
+                let apply_code_actions = workspace
+                    .read(cx)
+                    .project()
+                    .clone()
+                    .update(cx, |project, cx| {
+                        project.apply_code_action(buffer, action, true, cx)
+                    });
+                let workspace = workspace.downgrade();
+                Some(cx.spawn(|editor, cx| async move {
+                    let project_transaction = apply_code_actions.await?;
+                    Self::open_project_transaction(
+                        &editor,
+                        workspace,
+                        project_transaction,
+                        title,
+                        cx,
+                    )
+                    .await
+                }))
+            }
+        }
     }
 
     async fn open_project_transaction(
@@ -4213,9 +4468,10 @@ impl Editor {
         Some(self.inline_completion_provider.as_ref()?.provider.clone())
     }
 
-    pub fn render_code_actions_indicator(
+    fn render_code_actions_indicator(
         &self,
         _style: &EditorStyle,
+        row: u32,
         is_active: bool,
         cx: &mut ViewContext<Self>,
     ) -> Option<IconButton> {
@@ -4226,10 +4482,10 @@ impl Editor {
                     .size(ui::ButtonSize::None)
                     .icon_color(Color::Muted)
                     .selected(is_active)
-                    .on_click(cx.listener(|editor, _e, cx| {
+                    .on_click(cx.listener(move |editor, _e, cx| {
                         editor.toggle_code_actions(
                             &ToggleCodeActions {
-                                deployed_from_indicator: true,
+                                deployed_from_indicator: Some(row),
                             },
                             cx,
                         );
@@ -4240,6 +4496,39 @@ impl Editor {
         }
     }
 
+    fn clear_tasks(&mut self) {
+        self.tasks.clear()
+    }
+
+    fn insert_tasks(&mut self, row: u32, tasks: RunnableTasks) {
+        if let Some(_) = self.tasks.insert(row, tasks) {
+            // This case should hopefully be rare, but just in case...
+            log::error!("multiple different run targets found on a single line, only the last target will be rendered")
+        }
+    }
+
+    fn render_run_indicator(
+        &self,
+        _style: &EditorStyle,
+        is_active: bool,
+        row: u32,
+        cx: &mut ViewContext<Self>,
+    ) -> IconButton {
+        IconButton::new("code_actions_indicator", ui::IconName::Play)
+            .icon_size(IconSize::XSmall)
+            .size(ui::ButtonSize::None)
+            .icon_color(Color::Muted)
+            .selected(is_active)
+            .on_click(cx.listener(move |editor, _e, cx| {
+                editor.toggle_code_actions(
+                    &ToggleCodeActions {
+                        deployed_from_indicator: Some(row),
+                    },
+                    cx,
+                );
+            }))
+    }
+
     pub fn render_fold_indicators(
         &mut self,
         fold_data: Vec<Option<(FoldStatus, u32, bool)>>,
@@ -7400,6 +7689,80 @@ impl Editor {
         self.select_larger_syntax_node_stack = stack;
     }
 
+    fn runnable_display_rows(
+        &self,
+        range: Range<Anchor>,
+        snapshot: &DisplaySnapshot,
+        cx: &WindowContext,
+    ) -> Vec<(u32, RunnableTasks)> {
+        if self
+            .project
+            .as_ref()
+            .map_or(false, |project| project.read(cx).is_remote())
+        {
+            // Do not display any test indicators in remote projects.
+            return vec![];
+        }
+        snapshot
+            .buffer_snapshot
+            .runnable_ranges(range)
+            .filter_map(|(multi_buffer_range, mut runnable)| {
+                let (tasks, _) = self.resolve_runnable(&mut runnable, cx);
+                if tasks.is_empty() {
+                    return None;
+                }
+                let point = multi_buffer_range.start.to_display_point(&snapshot);
+                Some((
+                    point.row(),
+                    RunnableTasks {
+                        templates: tasks,
+                        column: point.column(),
+                    },
+                ))
+            })
+            .collect()
+    }
+
+    fn resolve_runnable(
+        &self,
+        runnable: &mut Runnable,
+        cx: &WindowContext<'_>,
+    ) -> (
+        SmallVec<[(TaskSourceKind, TaskTemplate); 1]>,
+        Option<WorktreeId>,
+    ) {
+        let Some(project) = self.project.as_ref() else {
+            return Default::default();
+        };
+        let (inventory, worktree_id) = project.read_with(cx, |project, cx| {
+            let worktree_id = project
+                .buffer_for_id(runnable.buffer)
+                .and_then(|buffer| buffer.read(cx).file())
+                .map(|file| WorktreeId::from_usize(file.worktree_id()));
+
+            (project.task_inventory().clone(), worktree_id)
+        });
+
+        let inventory = inventory.read(cx);
+        let tags = mem::take(&mut runnable.tags);
+        (
+            SmallVec::from_iter(
+                tags.into_iter()
+                    .flat_map(|tag| {
+                        let tag = tag.0.clone();
+                        inventory
+                            .list_tasks(Some(runnable.language.clone()), worktree_id)
+                            .into_iter()
+                            .filter(move |(_, template)| {
+                                template.tags.iter().any(|source_tag| source_tag == &tag)
+                            })
+                    })
+                    .sorted_by_key(|(kind, _)| kind.to_owned()),
+            ),
+            worktree_id,
+        )
+    }
+
     pub fn move_to_enclosing_bracket(
         &mut self,
         _: &MoveToEnclosingBracket,

crates/editor/src/element.rs 🔗

@@ -12,10 +12,11 @@ use crate::{
     items::BufferSearchHighlights,
     mouse_context_menu::{self, MouseContextMenu},
     scroll::scroll_amount::ScrollAmount,
-    CursorShape, DisplayPoint, DocumentHighlightRead, DocumentHighlightWrite, Editor, EditorMode,
-    EditorSettings, EditorSnapshot, EditorStyle, ExpandExcerpts, GutterDimensions, HalfPageDown,
-    HalfPageUp, HoveredCursor, HunkToExpand, LineDown, LineUp, OpenExcerpts, PageDown, PageUp,
-    Point, SelectPhase, Selection, SoftWrap, ToPoint, CURSORS_VISIBLE_FOR, MAX_LINE_LEN,
+    CodeActionsMenu, CursorShape, DisplayPoint, DocumentHighlightRead, DocumentHighlightWrite,
+    Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, ExpandExcerpts,
+    GutterDimensions, HalfPageDown, HalfPageUp, HoveredCursor, HunkToExpand, LineDown, LineUp,
+    OpenExcerpts, PageDown, PageUp, Point, RunnableTasks, SelectPhase, Selection, SoftWrap,
+    ToPoint, CURSORS_VISIBLE_FOR, MAX_LINE_LEN,
 };
 use anyhow::Result;
 use client::ParticipantIndex;
@@ -1374,6 +1375,60 @@ impl EditorElement {
         Some(shaped_lines)
     }
 
+    fn layout_run_indicators(
+        &self,
+        task_lines: Vec<(u32, RunnableTasks)>,
+        line_height: Pixels,
+        scroll_pixel_position: gpui::Point<Pixels>,
+        gutter_dimensions: &GutterDimensions,
+        gutter_hitbox: &Hitbox,
+        cx: &mut WindowContext,
+    ) -> Vec<AnyElement> {
+        self.editor.update(cx, |editor, cx| {
+            editor.clear_tasks();
+
+            let active_task_indicator_row =
+                if let Some(crate::ContextMenu::CodeActions(CodeActionsMenu {
+                    deployed_from_indicator,
+                    actions,
+                    ..
+                })) = editor.context_menu.read().as_ref()
+                {
+                    actions
+                        .tasks
+                        .as_ref()
+                        .map(|tasks| tasks.position.row)
+                        .or_else(|| *deployed_from_indicator)
+                } else {
+                    None
+                };
+            task_lines
+                .into_iter()
+                .map(|(row, tasks)| {
+                    editor.insert_tasks(row, tasks);
+
+                    let button = editor.render_run_indicator(
+                        &self.style,
+                        Some(row) == active_task_indicator_row,
+                        row,
+                        cx,
+                    );
+
+                    let button = prepaint_gutter_button(
+                        button,
+                        row,
+                        line_height,
+                        gutter_dimensions,
+                        scroll_pixel_position,
+                        gutter_hitbox,
+                        cx,
+                    );
+                    button
+                })
+                .collect_vec()
+        })
+    }
+
     fn layout_code_actions_indicator(
         &self,
         line_height: Pixels,
@@ -1385,35 +1440,28 @@ impl EditorElement {
     ) -> Option<AnyElement> {
         let mut active = false;
         let mut button = None;
+        let row = newest_selection_head.row();
         self.editor.update(cx, |editor, cx| {
-            active = matches!(
-                editor.context_menu.read().as_ref(),
-                Some(crate::ContextMenu::CodeActions(_))
-            );
-            button = editor.render_code_actions_indicator(&self.style, active, cx);
+            if let Some(crate::ContextMenu::CodeActions(CodeActionsMenu {
+                deployed_from_indicator,
+                ..
+            })) = editor.context_menu.read().as_ref()
+            {
+                active = deployed_from_indicator.map_or(true, |indicator_row| indicator_row == row);
+            };
+            button = editor.render_code_actions_indicator(&self.style, row, active, cx);
         });
 
-        let mut button = button?.into_any_element();
-        let available_space = size(
-            AvailableSpace::MinContent,
-            AvailableSpace::Definite(line_height),
+        let button = prepaint_gutter_button(
+            button?,
+            row,
+            line_height,
+            gutter_dimensions,
+            scroll_pixel_position,
+            gutter_hitbox,
+            cx,
         );
-        let indicator_size = button.layout_as_root(available_space, cx);
-
-        let blame_width = gutter_dimensions
-            .git_blame_entries_width
-            .unwrap_or(Pixels::ZERO);
 
-        let mut x = blame_width;
-        let available_width = gutter_dimensions.margin + gutter_dimensions.left_padding
-            - indicator_size.width
-            - blame_width;
-        x += available_width / 2.;
-
-        let mut y = newest_selection_head.row() as f32 * line_height - scroll_pixel_position.y;
-        y += (line_height - indicator_size.height) / 2.;
-
-        button.prepaint_as_root(gutter_hitbox.origin + point(x, y), available_space, cx);
         Some(button)
     }
 
@@ -2351,6 +2399,10 @@ impl EditorElement {
                 }
             });
 
+            for test_indicators in layout.test_indicators.iter_mut() {
+                test_indicators.paint(cx);
+            }
+
             if let Some(indicator) = layout.code_actions_indicator.as_mut() {
                 indicator.paint(cx);
             }
@@ -3224,6 +3276,39 @@ impl EditorElement {
     }
 }
 
+fn prepaint_gutter_button(
+    button: IconButton,
+    row: u32,
+    line_height: Pixels,
+    gutter_dimensions: &GutterDimensions,
+    scroll_pixel_position: gpui::Point<Pixels>,
+    gutter_hitbox: &Hitbox,
+    cx: &mut WindowContext<'_>,
+) -> AnyElement {
+    let mut button = button.into_any_element();
+    let available_space = size(
+        AvailableSpace::MinContent,
+        AvailableSpace::Definite(line_height),
+    );
+    let indicator_size = button.layout_as_root(available_space, cx);
+
+    let blame_width = gutter_dimensions
+        .git_blame_entries_width
+        .unwrap_or(Pixels::ZERO);
+
+    let mut x = blame_width;
+    let available_width = gutter_dimensions.margin + gutter_dimensions.left_padding
+        - indicator_size.width
+        - blame_width;
+    x += available_width / 2.;
+
+    let mut y = row as f32 * line_height - scroll_pixel_position.y;
+    y += (line_height - indicator_size.height) / 2.;
+
+    button.prepaint_as_root(gutter_hitbox.origin + point(x, y), available_space, cx);
+    button
+}
+
 fn render_inline_blame_entry(
     blame: &gpui::Model<GitBlame>,
     blame_entry: BlameEntry,
@@ -3750,6 +3835,12 @@ impl Element for EditorElement {
                     cx,
                 );
 
+                let test_lines = self.editor.read(cx).runnable_display_rows(
+                    start_anchor..end_anchor,
+                    &snapshot.display_snapshot,
+                    cx,
+                );
+
                 let (selections, active_rows, newest_selection_head) = self.layout_selections(
                     start_anchor,
                     end_anchor,
@@ -3939,18 +4030,32 @@ impl Element for EditorElement {
                             cx,
                         );
                         if gutter_settings.code_actions {
-                            code_actions_indicator = self.layout_code_actions_indicator(
-                                line_height,
-                                newest_selection_head,
-                                scroll_pixel_position,
-                                &gutter_dimensions,
-                                &gutter_hitbox,
-                                cx,
-                            );
+                            let has_test_indicator = test_lines
+                                .iter()
+                                .any(|(line, _)| *line == newest_selection_head.row());
+                            if !has_test_indicator {
+                                code_actions_indicator = self.layout_code_actions_indicator(
+                                    line_height,
+                                    newest_selection_head,
+                                    scroll_pixel_position,
+                                    &gutter_dimensions,
+                                    &gutter_hitbox,
+                                    cx,
+                                );
+                            }
                         }
                     }
                 }
 
+                let test_indicators = self.layout_run_indicators(
+                    test_lines,
+                    line_height,
+                    scroll_pixel_position,
+                    &gutter_dimensions,
+                    &gutter_hitbox,
+                    cx,
+                );
+
                 if !context_menu_visible && !cx.has_active_drag() {
                     self.layout_hover_popovers(
                         &snapshot,
@@ -4051,6 +4156,7 @@ impl Element for EditorElement {
                     visible_cursors,
                     selections,
                     mouse_context_menu,
+                    test_indicators,
                     code_actions_indicator,
                     fold_indicators,
                     tab_invisible,
@@ -4170,6 +4276,7 @@ pub struct EditorLayout {
     selections: Vec<(PlayerColor, Vec<SelectionLayout>)>,
     max_row: u32,
     code_actions_indicator: Option<AnyElement>,
+    test_indicators: Vec<AnyElement>,
     fold_indicators: Vec<Option<AnyElement>>,
     mouse_context_menu: Option<AnyElement>,
     tab_invisible: ShapedLine,

crates/editor/src/mouse_context_menu.rs 🔗

@@ -79,7 +79,7 @@ pub fn deploy_context_menu(
                 .action(
                     "Code Actions",
                     Box::new(ToggleCodeActions {
-                        deployed_from_indicator: false,
+                        deployed_from_indicator: None,
                     }),
                 )
                 .separator()

crates/editor/src/tasks.rs 🔗

@@ -0,0 +1,118 @@
+use crate::Editor;
+
+use std::{path::Path, sync::Arc};
+
+use anyhow::Context;
+use gpui::WindowContext;
+use language::{BasicContextProvider, ContextProvider};
+use project::{Location, WorktreeId};
+use task::{TaskContext, TaskVariables};
+use util::ResultExt;
+use workspace::Workspace;
+
+pub(crate) fn task_context_for_location(
+    workspace: &Workspace,
+    location: Location,
+    cx: &mut WindowContext<'_>,
+) -> Option<TaskContext> {
+    let cwd = workspace::tasks::task_cwd(workspace, cx)
+        .log_err()
+        .flatten();
+
+    let buffer = location.buffer.clone();
+    let language_context_provider = buffer
+        .read(cx)
+        .language()
+        .and_then(|language| language.context_provider())
+        .unwrap_or_else(|| Arc::new(BasicContextProvider));
+
+    let worktree_abs_path = buffer
+        .read(cx)
+        .file()
+        .map(|file| WorktreeId::from_usize(file.worktree_id()))
+        .and_then(|worktree_id| {
+            workspace
+                .project()
+                .read(cx)
+                .worktree_for_id(worktree_id, cx)
+                .map(|worktree| worktree.read(cx).abs_path())
+        });
+    let task_variables = combine_task_variables(
+        worktree_abs_path.as_deref(),
+        location,
+        language_context_provider.as_ref(),
+        cx,
+    )
+    .log_err()?;
+    Some(TaskContext {
+        cwd,
+        task_variables,
+    })
+}
+
+pub(crate) fn task_context_with_editor(
+    workspace: &Workspace,
+    editor: &mut Editor,
+    cx: &mut WindowContext<'_>,
+) -> Option<TaskContext> {
+    let (selection, buffer, editor_snapshot) = {
+        let selection = editor.selections.newest::<usize>(cx);
+        let (buffer, _, _) = editor
+            .buffer()
+            .read(cx)
+            .point_to_buffer_offset(selection.start, cx)?;
+        let snapshot = editor.snapshot(cx);
+        Some((selection, buffer, snapshot))
+    }?;
+    let selection_range = selection.range();
+    let start = editor_snapshot
+        .display_snapshot
+        .buffer_snapshot
+        .anchor_after(selection_range.start)
+        .text_anchor;
+    let end = editor_snapshot
+        .display_snapshot
+        .buffer_snapshot
+        .anchor_after(selection_range.end)
+        .text_anchor;
+    let location = Location {
+        buffer,
+        range: start..end,
+    };
+    task_context_for_location(workspace, location, cx)
+}
+
+pub fn task_context(workspace: &Workspace, cx: &mut WindowContext<'_>) -> TaskContext {
+    let Some(editor) = workspace
+        .active_item(cx)
+        .and_then(|item| item.act_as::<Editor>(cx))
+    else {
+        return Default::default();
+    };
+    editor.update(cx, |editor, cx| {
+        task_context_with_editor(workspace, editor, cx).unwrap_or_default()
+    })
+}
+
+fn combine_task_variables(
+    worktree_abs_path: Option<&Path>,
+    location: Location,
+    context_provider: &dyn ContextProvider,
+    cx: &mut WindowContext<'_>,
+) -> anyhow::Result<TaskVariables> {
+    if context_provider.is_basic() {
+        context_provider
+            .build_context(worktree_abs_path, &location, cx)
+            .context("building basic provider context")
+    } else {
+        let mut basic_context = BasicContextProvider
+            .build_context(worktree_abs_path, &location, cx)
+            .context("building basic default context")?;
+        basic_context.extend(
+            context_provider
+                .build_context(worktree_abs_path, &location, cx)
+                .context("building provider context ")?,
+        );
+        Ok(basic_context)
+    }
+}

crates/language/src/buffer.rs 🔗

@@ -13,7 +13,7 @@ use crate::{
         SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxMapMatches,
         SyntaxSnapshot, ToTreeSitterPoint,
     },
-    LanguageScope, Outline,
+    LanguageScope, Outline, RunnableTag,
 };
 use anyhow::{anyhow, Context, Result};
 pub use clock::ReplicaId;
@@ -501,6 +501,13 @@ pub enum CharKind {
     Word,
 }
 
+/// A runnable is a set of data about a region that could be resolved into a task
+pub struct Runnable {
+    pub tags: SmallVec<[RunnableTag; 1]>,
+    pub language: Arc<Language>,
+    pub buffer: BufferId,
+}
+
 impl Buffer {
     /// Create a new buffer with the given base text.
     pub fn local<T: Into<String>>(base_text: T, cx: &mut ModelContext<Self>) -> Self {
@@ -2978,6 +2985,53 @@ impl BufferSnapshot {
         })
     }
 
+    pub fn runnable_ranges(
+        &self,
+        range: Range<Anchor>,
+    ) -> impl Iterator<Item = (Range<usize>, Runnable)> + '_ {
+        let offset_range = range.start.to_offset(self)..range.end.to_offset(self);
+
+        let mut syntax_matches = self.syntax.matches(offset_range, self, |grammar| {
+            grammar.runnable_config.as_ref().map(|config| &config.query)
+        });
+
+        let test_configs = syntax_matches
+            .grammars()
+            .iter()
+            .map(|grammar| grammar.runnable_config.as_ref())
+            .collect::<Vec<_>>();
+
+        iter::from_fn(move || {
+            let test_range = syntax_matches
+                .peek()
+                .and_then(|mat| {
+                    test_configs[mat.grammar_index].and_then(|test_configs| {
+                        let tags = SmallVec::from_iter(mat.captures.iter().filter_map(|capture| {
+                            test_configs.runnable_tags.get(&capture.index).cloned()
+                        }));
+
+                        if tags.is_empty() {
+                            return None;
+                        }
+
+                        Some((
+                            mat.captures
+                                .iter()
+                                .find(|capture| capture.index == test_configs.run_capture_ix)?,
+                            Runnable {
+                                tags,
+                                language: mat.language,
+                                buffer: self.remote_id(),
+                            },
+                        ))
+                    })
+                })
+                .map(|(mat, test_tags)| (mat.node.byte_range(), test_tags));
+            syntax_matches.advance();
+            test_range
+        })
+    }
+
     /// Returns selections for remote peers intersecting the given range.
     #[allow(clippy::type_complexity)]
     pub fn remote_selections_in_range(

crates/language/src/language.rs 🔗

@@ -56,6 +56,7 @@ use std::{
     },
 };
 use syntax_map::{QueryCursorHandle, SyntaxSnapshot};
+use task::RunnableTag;
 pub use task_context::{BasicContextProvider, ContextProvider, ContextProviderWithTasks};
 use theme::SyntaxTheme;
 use tree_sitter::{self, wasmtime, Query, QueryCursor, WasmStore};
@@ -836,6 +837,7 @@ pub struct Grammar {
     pub(crate) highlights_query: Option<Query>,
     pub(crate) brackets_config: Option<BracketConfig>,
     pub(crate) redactions_config: Option<RedactionConfig>,
+    pub(crate) runnable_config: Option<RunnableConfig>,
     pub(crate) indents_config: Option<IndentConfig>,
     pub outline_config: Option<OutlineConfig>,
     pub embedding_config: Option<EmbeddingConfig>,
@@ -882,6 +884,14 @@ struct RedactionConfig {
     pub redaction_capture_ix: u32,
 }
 
+struct RunnableConfig {
+    pub query: Query,
+    /// A mapping from captures indices to known test tags
+    pub runnable_tags: HashMap<u32, RunnableTag>,
+    /// index of the capture that corresponds to @run
+    pub run_capture_ix: u32,
+}
+
 struct OverrideConfig {
     query: Query,
     values: HashMap<u32, (String, LanguageConfigOverride)>,
@@ -923,6 +933,7 @@ impl Language {
                     injection_config: None,
                     override_config: None,
                     redactions_config: None,
+                    runnable_config: None,
                     error_query: Query::new(&ts_language, "(ERROR) @error").unwrap(),
                     ts_language,
                     highlight_map: Default::default(),
@@ -978,6 +989,11 @@ impl Language {
                 .with_redaction_query(query.as_ref())
                 .context("Error loading redaction query")?;
         }
+        if let Some(query) = queries.runnables {
+            self = self
+                .with_runnable_query(query.as_ref())
+                .context("Error loading tests query")?;
+        }
         Ok(self)
     }
 
@@ -989,6 +1005,33 @@ impl Language {
         Ok(self)
     }
 
+    pub fn with_runnable_query(mut self, source: &str) -> Result<Self> {
+        let grammar = self
+            .grammar_mut()
+            .ok_or_else(|| anyhow!("cannot mutate grammar"))?;
+
+        let query = Query::new(&grammar.ts_language, source)?;
+        let mut run_capture_index = None;
+        let mut runnable_tags = HashMap::default();
+        for (ix, name) in query.capture_names().iter().enumerate() {
+            if *name == "run" {
+                run_capture_index = Some(ix as u32);
+            } else if !name.starts_with('_') {
+                runnable_tags.insert(ix as u32, RunnableTag(name.to_string().into()));
+            }
+        }
+
+        if let Some(run_capture_ix) = run_capture_index {
+            grammar.runnable_config = Some(RunnableConfig {
+                query,
+                run_capture_ix,
+                runnable_tags,
+            });
+        }
+
+        Ok(self)
+    }
+
     pub fn with_outline_query(mut self, source: &str) -> Result<Self> {
         let grammar = self
             .grammar_mut()

crates/language/src/language_registry.rs 🔗

@@ -124,6 +124,7 @@ pub const QUERY_FILENAME_PREFIXES: &[(
     ("injections", |q| &mut q.injections),
     ("overrides", |q| &mut q.overrides),
     ("redactions", |q| &mut q.redactions),
+    ("runnables", |q| &mut q.runnables),
 ];
 
 /// Tree-sitter language queries for a given language.
@@ -137,6 +138,7 @@ pub struct LanguageQueries {
     pub injections: Option<Cow<'static, str>>,
     pub overrides: Option<Cow<'static, str>>,
     pub redactions: Option<Cow<'static, str>>,
+    pub runnables: Option<Cow<'static, str>>,
 }
 
 #[derive(Clone, Default)]

crates/language/src/syntax_map.rs 🔗

@@ -56,6 +56,7 @@ pub struct SyntaxMapCapture<'a> {
 
 #[derive(Debug)]
 pub struct SyntaxMapMatch<'a> {
+    pub language: Arc<Language>,
     pub depth: usize,
     pub pattern_index: usize,
     pub captures: &'a [QueryCapture<'a>],
@@ -71,6 +72,7 @@ struct SyntaxMapCapturesLayer<'a> {
 }
 
 struct SyntaxMapMatchesLayer<'a> {
+    language: Arc<Language>,
     depth: usize,
     next_pattern_index: usize,
     next_captures: Vec<QueryCapture<'a>>,
@@ -1016,6 +1018,7 @@ impl<'a> SyntaxMapMatches<'a> {
                     result.grammars.len() - 1
                 });
             let mut layer = SyntaxMapMatchesLayer {
+                language: layer.language.clone(),
                 depth: layer.depth,
                 grammar_index,
                 matches,
@@ -1048,10 +1051,13 @@ impl<'a> SyntaxMapMatches<'a> {
 
     pub fn peek(&self) -> Option<SyntaxMapMatch> {
         let layer = self.layers.first()?;
+
         if !layer.has_next {
             return None;
         }
+
         Some(SyntaxMapMatch {
+            language: layer.language.clone(),
             depth: layer.depth,
             grammar_index: layer.grammar_index,
             pattern_index: layer.next_pattern_index,

crates/languages/src/rust.rs 🔗

@@ -389,6 +389,7 @@ impl ContextProvider for RustContextProvider {
                     "--".into(),
                     "--nocapture".into(),
                 ],
+                tags: vec!["rust-test".to_owned()],
                 ..TaskTemplate::default()
             },
             TaskTemplate {

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -13,7 +13,7 @@ use language::{
     language_settings::{language_settings, LanguageSettings},
     AutoindentMode, Buffer, BufferChunks, BufferSnapshot, Capability, CharKind, Chunk, CursorShape,
     DiagnosticEntry, File, IndentSize, Language, LanguageScope, OffsetRangeExt, OffsetUtf16,
-    Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, ToOffset as _,
+    Outline, OutlineItem, Point, PointUtf16, Runnable, Selection, TextDimension, ToOffset as _,
     ToOffsetUtf16 as _, ToPoint as _, ToPointUtf16 as _, TransactionId, Unclipped,
 };
 use smallvec::SmallVec;
@@ -3165,6 +3165,31 @@ impl MultiBufferSnapshot {
             .flatten()
     }
 
+    pub fn runnable_ranges(
+        &self,
+        range: Range<Anchor>,
+    ) -> impl Iterator<Item = (Range<usize>, Runnable)> + '_ {
+        let range = range.start.to_offset(self)..range.end.to_offset(self);
+        self.excerpts_for_range(range.clone())
+            .flat_map(move |(excerpt, excerpt_offset)| {
+                let excerpt_buffer_start = excerpt.range.context.start.to_offset(&excerpt.buffer);
+
+                excerpt
+                    .buffer
+                    .runnable_ranges(excerpt.range.context.clone())
+                    .map(move |(mut match_range, runnable)| {
+                        // Re-base onto the excerpts coordinates in the multibuffer
+                        match_range.start =
+                            excerpt_offset + (match_range.start - excerpt_buffer_start);
+                        match_range.end = excerpt_offset + (match_range.end - excerpt_buffer_start);
+
+                        (match_range, runnable)
+                    })
+                    .skip_while(move |(match_range, _)| match_range.end < range.start)
+                    .take_while(move |(match_range, _)| match_range.start < range.end)
+            })
+    }
+
     pub fn diagnostics_update_count(&self) -> usize {
         self.diagnostics_update_count
     }

crates/project/src/project.rs 🔗

@@ -7655,17 +7655,15 @@ impl Project {
                     } else {
                         let fs = self.fs.clone();
                         let task_abs_path = abs_path.clone();
+                        let tasks_file_rx =
+                            watch_config_file(&cx.background_executor(), fs, task_abs_path);
                         task_inventory.add_source(
                             TaskSourceKind::Worktree {
                                 id: remote_worktree_id,
                                 abs_path,
                                 id_base: "local_tasks_for_worktree",
                             },
-                            |cx| {
-                                let tasks_file_rx =
-                                    watch_config_file(&cx.background_executor(), fs, task_abs_path);
-                                StaticSource::new(TrackedFile::new(tasks_file_rx, cx), cx)
-                            },
+                            StaticSource::new(TrackedFile::new(tasks_file_rx, cx)),
                             cx,
                         );
                     }
@@ -7677,23 +7675,20 @@ impl Project {
                     } else {
                         let fs = self.fs.clone();
                         let task_abs_path = abs_path.clone();
+                        let tasks_file_rx =
+                            watch_config_file(&cx.background_executor(), fs, task_abs_path);
                         task_inventory.add_source(
                             TaskSourceKind::Worktree {
                                 id: remote_worktree_id,
                                 abs_path,
                                 id_base: "local_vscode_tasks_for_worktree",
                             },
-                            |cx| {
-                                let tasks_file_rx =
-                                    watch_config_file(&cx.background_executor(), fs, task_abs_path);
-                                StaticSource::new(
-                                    TrackedFile::new_convertible::<task::VsCodeTaskFile>(
-                                        tasks_file_rx,
-                                        cx,
-                                    ),
+                            StaticSource::new(
+                                TrackedFile::new_convertible::<task::VsCodeTaskFile>(
+                                    tasks_file_rx,
                                     cx,
-                                )
-                            },
+                                ),
+                            ),
                             cx,
                         );
                     }

crates/project/src/project_tests.rs 🔗

@@ -14,7 +14,7 @@ use serde_json::json;
 #[cfg(not(windows))]
 use std::os;
 use std::task::Poll;
-use task::{TaskContext, TaskSource, TaskTemplate, TaskTemplates};
+use task::{TaskContext, TaskTemplate, TaskTemplates};
 use unindent::Unindent as _;
 use util::{assert_set_eq, paths::PathMatcher, test::temp_tree};
 use worktree::WorktreeModelHandle as _;
@@ -168,12 +168,11 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext)
 
         let all_tasks = project
             .update(cx, |project, cx| {
-                project.task_inventory().update(cx, |inventory, cx| {
+                project.task_inventory().update(cx, |inventory, _| {
                     let (mut old, new) = inventory.used_and_current_resolved_tasks(
                         None,
                         Some(workree_id),
                         &task_context,
-                        cx,
                     );
                     old.extend(new);
                     old
@@ -215,13 +214,9 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext)
 
     project.update(cx, |project, cx| {
         let inventory = project.task_inventory();
-        inventory.update(cx, |inventory, cx| {
-            let (mut old, new) = inventory.used_and_current_resolved_tasks(
-                None,
-                Some(workree_id),
-                &task_context,
-                cx,
-            );
+        inventory.update(cx, |inventory, _| {
+            let (mut old, new) =
+                inventory.used_and_current_resolved_tasks(None, Some(workree_id), &task_context);
             old.extend(new);
             let (_, resolved_task) = old
                 .into_iter()
@@ -231,41 +226,39 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext)
         })
     });
 
+    let tasks = serde_json::to_string(&TaskTemplates(vec![TaskTemplate {
+        label: "cargo check".to_string(),
+        command: "cargo".to_string(),
+        args: vec![
+            "check".to_string(),
+            "--all".to_string(),
+            "--all-targets".to_string(),
+        ],
+        env: HashMap::from_iter(Some((
+            "RUSTFLAGS".to_string(),
+            "-Zunstable-options".to_string(),
+        ))),
+        ..TaskTemplate::default()
+    }]))
+    .unwrap();
+    let (tx, rx) = futures::channel::mpsc::unbounded();
+
+    let templates = cx.update(|cx| TrackedFile::new(rx, cx));
+    tx.unbounded_send(tasks).unwrap();
+
+    let source = StaticSource::new(templates);
+    cx.run_until_parked();
+
     cx.update(|cx| {
         let all_tasks = project
             .update(cx, |project, cx| {
                 project.task_inventory().update(cx, |inventory, cx| {
                     inventory.remove_local_static_source(Path::new("/the-root/.zed/tasks.json"));
-                    inventory.add_source(
-                        global_task_source_kind.clone(),
-                        |cx| {
-                            cx.new_model(|_| {
-                                let source = TestTaskSource {
-                                    tasks: TaskTemplates(vec![TaskTemplate {
-                                        label: "cargo check".to_string(),
-                                        command: "cargo".to_string(),
-                                        args: vec![
-                                            "check".to_string(),
-                                            "--all".to_string(),
-                                            "--all-targets".to_string(),
-                                        ],
-                                        env: HashMap::from_iter(Some((
-                                            "RUSTFLAGS".to_string(),
-                                            "-Zunstable-options".to_string(),
-                                        ))),
-                                        ..TaskTemplate::default()
-                                    }]),
-                                };
-                                Box::new(source) as Box<_>
-                            })
-                        },
-                        cx,
-                    );
+                    inventory.add_source(global_task_source_kind.clone(), source, cx);
                     let (mut old, new) = inventory.used_and_current_resolved_tasks(
                         None,
                         Some(workree_id),
                         &task_context,
-                        cx,
                     );
                     old.extend(new);
                     old
@@ -317,20 +310,6 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext)
     });
 }
 
-struct TestTaskSource {
-    tasks: TaskTemplates,
-}
-
-impl TaskSource for TestTaskSource {
-    fn as_any(&mut self) -> &mut dyn std::any::Any {
-        self
-    }
-
-    fn tasks_to_schedule(&mut self, _: &mut ModelContext<Box<dyn TaskSource>>) -> TaskTemplates {
-        self.tasks.clone()
-    }
-}
-
 #[gpui::test]
 async fn test_managing_language_servers(cx: &mut gpui::TestAppContext) {
     init_test(cx);

crates/project/src/task_inventory.rs 🔗

@@ -1,17 +1,18 @@
 //! Project-wide storage of the tasks available, capable of updating itself from the sources set.
 
 use std::{
-    any::TypeId,
     cmp::{self, Reverse},
     path::{Path, PathBuf},
     sync::Arc,
 };
 
 use collections::{hash_map, HashMap, VecDeque};
-use gpui::{AppContext, Context, Model, ModelContext, Subscription};
+use gpui::{AppContext, Context, Model, ModelContext};
 use itertools::{Either, Itertools};
 use language::Language;
-use task::{ResolvedTask, TaskContext, TaskId, TaskSource, TaskTemplate, VariableName};
+use task::{
+    static_source::StaticSource, ResolvedTask, TaskContext, TaskId, TaskTemplate, VariableName,
+};
 use util::{post_inc, NumericPrefixWithSuffix};
 use worktree::WorktreeId;
 
@@ -22,14 +23,12 @@ pub struct Inventory {
 }
 
 struct SourceInInventory {
-    source: Model<Box<dyn TaskSource>>,
-    _subscription: Subscription,
-    type_id: TypeId,
+    source: StaticSource,
     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, Hash)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
 pub enum TaskSourceKind {
     /// bash-like commands spawned by users, not associated with any path
     UserInput,
@@ -95,7 +94,7 @@ impl Inventory {
     pub fn add_source(
         &mut self,
         kind: TaskSourceKind,
-        create_source: impl FnOnce(&mut ModelContext<Self>) -> Model<Box<dyn TaskSource>>,
+        source: StaticSource,
         cx: &mut ModelContext<Self>,
     ) {
         let abs_path = kind.abs_path();
@@ -106,16 +105,7 @@ impl Inventory {
             }
         }
 
-        let source = create_source(cx);
-        let type_id = source.read(cx).type_id();
-        let source = SourceInInventory {
-            _subscription: cx.observe(&source, |_, _, cx| {
-                cx.notify();
-            }),
-            source,
-            type_id,
-            kind,
-        };
+        let source = SourceInInventory { source, kind };
         self.sources.push(source);
         cx.notify();
     }
@@ -136,31 +126,12 @@ impl Inventory {
         self.sources.retain(|s| s.kind.worktree() != Some(worktree));
     }
 
-    pub fn source<T: TaskSource>(&self) -> Option<(Model<Box<dyn TaskSource>>, TaskSourceKind)> {
-        let target_type_id = std::any::TypeId::of::<T>();
-        self.sources.iter().find_map(
-            |SourceInInventory {
-                 type_id,
-                 source,
-                 kind,
-                 ..
-             }| {
-                if &target_type_id == type_id {
-                    Some((source.clone(), kind.clone()))
-                } else {
-                    None
-                }
-            },
-        )
-    }
-
     /// Pulls its task sources relevant to the worktree and the language given,
     /// returns all task templates with their source kinds, in no specific order.
     pub fn list_tasks(
         &self,
         language: Option<Arc<Language>>,
         worktree: Option<WorktreeId>,
-        cx: &mut AppContext,
     ) -> Vec<(TaskSourceKind, TaskTemplate)> {
         let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language {
             name: language.name(),
@@ -180,7 +151,7 @@ impl Inventory {
             .flat_map(|source| {
                 source
                     .source
-                    .update(cx, |source, cx| source.tasks_to_schedule(cx))
+                    .tasks_to_schedule()
                     .0
                     .into_iter()
                     .map(|task| (&source.kind, task))
@@ -199,7 +170,6 @@ impl Inventory {
         language: Option<Arc<Language>>,
         worktree: Option<WorktreeId>,
         task_context: &TaskContext,
-        cx: &mut AppContext,
     ) -> (
         Vec<(TaskSourceKind, ResolvedTask)>,
         Vec<(TaskSourceKind, ResolvedTask)>,
@@ -246,7 +216,7 @@ impl Inventory {
             .flat_map(|source| {
                 source
                     .source
-                    .update(cx, |source, cx| source.tasks_to_schedule(cx))
+                    .tasks_to_schedule()
                     .0
                     .into_iter()
                     .map(|task| (&source.kind, task))
@@ -387,9 +357,12 @@ fn task_variables_preference(task: &ResolvedTask) -> Reverse<usize> {
 
 #[cfg(test)]
 mod test_inventory {
-    use gpui::{AppContext, Context as _, Model, ModelContext, TestAppContext};
+    use gpui::{AppContext, Model, TestAppContext};
     use itertools::Itertools;
-    use task::{TaskContext, TaskId, TaskSource, TaskTemplate, TaskTemplates};
+    use task::{
+        static_source::{StaticSource, TrackedFile},
+        TaskContext, TaskTemplate, TaskTemplates,
+    };
     use worktree::WorktreeId;
 
     use crate::Inventory;
@@ -398,55 +371,28 @@ mod test_inventory {
 
     #[derive(Debug, Clone, PartialEq, Eq)]
     pub struct TestTask {
-        id: task::TaskId,
         name: String,
     }
 
-    pub struct StaticTestSource {
-        pub tasks: Vec<TestTask>,
-    }
-
-    impl StaticTestSource {
-        pub(super) fn new(
-            task_names: impl IntoIterator<Item = String>,
-            cx: &mut AppContext,
-        ) -> Model<Box<dyn TaskSource>> {
-            cx.new_model(|_| {
-                Box::new(Self {
-                    tasks: task_names
-                        .into_iter()
-                        .enumerate()
-                        .map(|(i, name)| TestTask {
-                            id: TaskId(format!("task_{i}_{name}")),
-                            name,
-                        })
-                        .collect(),
-                }) as Box<dyn TaskSource>
-            })
-        }
-    }
-
-    impl TaskSource for StaticTestSource {
-        fn tasks_to_schedule(
-            &mut self,
-            _cx: &mut ModelContext<Box<dyn TaskSource>>,
-        ) -> TaskTemplates {
-            TaskTemplates(
-                self.tasks
-                    .clone()
-                    .into_iter()
-                    .map(|task| TaskTemplate {
-                        label: task.name,
-                        command: "test command".to_string(),
-                        ..TaskTemplate::default()
-                    })
-                    .collect(),
-            )
-        }
-
-        fn as_any(&mut self) -> &mut dyn std::any::Any {
-            self
-        }
+    pub(super) fn static_test_source(
+        task_names: impl IntoIterator<Item = String>,
+        cx: &mut AppContext,
+    ) -> StaticSource {
+        let tasks = TaskTemplates(
+            task_names
+                .into_iter()
+                .map(|name| TaskTemplate {
+                    label: name,
+                    command: "test command".to_owned(),
+                    ..TaskTemplate::default()
+                })
+                .collect(),
+        );
+        let (tx, rx) = futures::channel::mpsc::unbounded();
+        let file = TrackedFile::new(rx, cx);
+        tx.unbounded_send(serde_json::to_string(&tasks).unwrap())
+            .unwrap();
+        StaticSource::new(file)
     }
 
     pub(super) fn task_template_names(
@@ -454,9 +400,9 @@ mod test_inventory {
         worktree: Option<WorktreeId>,
         cx: &mut TestAppContext,
     ) -> Vec<String> {
-        inventory.update(cx, |inventory, cx| {
+        inventory.update(cx, |inventory, _| {
             inventory
-                .list_tasks(None, worktree, cx)
+                .list_tasks(None, worktree)
                 .into_iter()
                 .map(|(_, task)| task.label)
                 .sorted()
@@ -469,13 +415,9 @@ mod test_inventory {
         worktree: Option<WorktreeId>,
         cx: &mut TestAppContext,
     ) -> Vec<String> {
-        inventory.update(cx, |inventory, cx| {
-            let (used, current) = inventory.used_and_current_resolved_tasks(
-                None,
-                worktree,
-                &TaskContext::default(),
-                cx,
-            );
+        inventory.update(cx, |inventory, _| {
+            let (used, current) =
+                inventory.used_and_current_resolved_tasks(None, worktree, &TaskContext::default());
             used.into_iter()
                 .chain(current)
                 .map(|(_, task)| task.original_task().label.clone())
@@ -488,9 +430,9 @@ mod test_inventory {
         task_name: &str,
         cx: &mut TestAppContext,
     ) {
-        inventory.update(cx, |inventory, cx| {
+        inventory.update(cx, |inventory, _| {
             let (task_source_kind, task) = inventory
-                .list_tasks(None, None, cx)
+                .list_tasks(None, None)
                 .into_iter()
                 .find(|(_, task)| task.label == task_name)
                 .unwrap_or_else(|| panic!("Failed to find task with name {task_name}"));
@@ -508,13 +450,9 @@ mod test_inventory {
         worktree: Option<WorktreeId>,
         cx: &mut TestAppContext,
     ) -> Vec<(TaskSourceKind, String)> {
-        inventory.update(cx, |inventory, cx| {
-            let (used, current) = inventory.used_and_current_resolved_tasks(
-                None,
-                worktree,
-                &TaskContext::default(),
-                cx,
-            );
+        inventory.update(cx, |inventory, _| {
+            let (used, current) =
+                inventory.used_and_current_resolved_tasks(None, worktree, &TaskContext::default());
             let mut all = used;
             all.extend(current);
             all.into_iter()
@@ -549,27 +487,25 @@ mod tests {
         inventory.update(cx, |inventory, cx| {
             inventory.add_source(
                 TaskSourceKind::UserInput,
-                |cx| StaticTestSource::new(vec!["3_task".to_string()], cx),
+                static_test_source(vec!["3_task".to_string()], cx),
                 cx,
             );
         });
         inventory.update(cx, |inventory, cx| {
             inventory.add_source(
                 TaskSourceKind::UserInput,
-                |cx| {
-                    StaticTestSource::new(
-                        vec![
-                            "1_task".to_string(),
-                            "2_task".to_string(),
-                            "1_a_task".to_string(),
-                        ],
-                        cx,
-                    )
-                },
+                static_test_source(
+                    vec![
+                        "1_task".to_string(),
+                        "2_task".to_string(),
+                        "1_a_task".to_string(),
+                    ],
+                    cx,
+                ),
                 cx,
             );
         });
-
+        cx.run_until_parked();
         let expected_initial_state = [
             "1_a_task".to_string(),
             "1_task".to_string(),
@@ -622,12 +558,11 @@ mod tests {
         inventory.update(cx, |inventory, cx| {
             inventory.add_source(
                 TaskSourceKind::UserInput,
-                |cx| {
-                    StaticTestSource::new(vec!["10_hello".to_string(), "11_hello".to_string()], cx)
-                },
+                static_test_source(vec!["10_hello".to_string(), "11_hello".to_string()], cx),
                 cx,
             );
         });
+        cx.run_until_parked();
         let expected_updated_state = [
             "10_hello".to_string(),
             "11_hello".to_string(),
@@ -680,15 +615,11 @@ mod tests {
         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,
-                    )
-                },
+                static_test_source(vec!["user_input".to_string(), common_name.to_string()], cx),
                 cx,
             );
             inventory.add_source(
@@ -696,12 +627,10 @@ mod tests {
                     id_base: "test source",
                     abs_path: path_1.to_path_buf(),
                 },
-                |cx| {
-                    StaticTestSource::new(
-                        vec!["static_source_1".to_string(), common_name.to_string()],
-                        cx,
-                    )
-                },
+                static_test_source(
+                    vec!["static_source_1".to_string(), common_name.to_string()],
+                    cx,
+                ),
                 cx,
             );
             inventory.add_source(
@@ -709,12 +638,10 @@ mod tests {
                     id_base: "test source",
                     abs_path: path_2.to_path_buf(),
                 },
-                |cx| {
-                    StaticTestSource::new(
-                        vec!["static_source_2".to_string(), common_name.to_string()],
-                        cx,
-                    )
-                },
+                static_test_source(
+                    vec!["static_source_2".to_string(), common_name.to_string()],
+                    cx,
+                ),
                 cx,
             );
             inventory.add_source(
@@ -723,12 +650,7 @@ mod tests {
                     abs_path: worktree_path_1.to_path_buf(),
                     id_base: "test_source",
                 },
-                |cx| {
-                    StaticTestSource::new(
-                        vec!["worktree_1".to_string(), common_name.to_string()],
-                        cx,
-                    )
-                },
+                static_test_source(vec!["worktree_1".to_string(), common_name.to_string()], cx),
                 cx,
             );
             inventory.add_source(
@@ -737,16 +659,11 @@ mod tests {
                     abs_path: worktree_path_2.to_path_buf(),
                     id_base: "test_source",
                 },
-                |cx| {
-                    StaticTestSource::new(
-                        vec!["worktree_2".to_string(), common_name.to_string()],
-                        cx,
-                    )
-                },
+                static_test_source(vec!["worktree_2".to_string(), common_name.to_string()], cx),
                 cx,
             );
         });
-
+        cx.run_until_parked();
         let worktree_independent_tasks = vec![
             (
                 TaskSourceKind::AbsPath {

crates/task/Cargo.toml 🔗

@@ -14,6 +14,7 @@ collections.workspace = true
 futures.workspace = true
 gpui.workspace = true
 hex.workspace = true
+parking_lot.workspace = true
 schemars.workspace = true
 serde.workspace = true
 serde_json_lenient.workspace = true

crates/task/src/lib.rs 🔗

@@ -6,9 +6,8 @@ mod task_template;
 mod vscode_format;
 
 use collections::{HashMap, HashSet};
-use gpui::ModelContext;
+use gpui::SharedString;
 use serde::Serialize;
-use std::any::Any;
 use std::borrow::Cow;
 use std::path::PathBuf;
 
@@ -103,6 +102,8 @@ pub enum VariableName {
     Column,
     /// Text from the latest selection.
     SelectedText,
+    /// The symbol selected by the symbol tagging system, specifically the @run capture in a runnables.scm
+    RunnableSymbol,
     /// Custom variable, provided by the plugin or other external source.
     /// Will be printed with `ZED_` prefix to avoid potential conflicts with other variables.
     Custom(Cow<'static, str>),
@@ -132,6 +133,7 @@ impl std::fmt::Display for VariableName {
             Self::Row => write!(f, "{ZED_VARIABLE_NAME_PREFIX}ROW"),
             Self::Column => write!(f, "{ZED_VARIABLE_NAME_PREFIX}COLUMN"),
             Self::SelectedText => write!(f, "{ZED_VARIABLE_NAME_PREFIX}SELECTED_TEXT"),
+            Self::RunnableSymbol => write!(f, "{ZED_VARIABLE_NAME_PREFIX}RUNNABLE_SYMBOL"),
             Self::Custom(s) => write!(f, "{ZED_VARIABLE_NAME_PREFIX}CUSTOM_{s}"),
         }
     }
@@ -169,13 +171,6 @@ pub struct TaskContext {
     pub task_variables: TaskVariables,
 }
 
-/// [`Source`] produces tasks that can be scheduled.
-///
-/// Implementations of this trait could be e.g. [`StaticSource`] that parses tasks from a .json files and provides process templates to be spawned;
-/// another one could be a language server providing lenses with tests or build server listing all targets for a given project.
-pub trait TaskSource: Any {
-    /// A way to erase the type of the source, processing and storing them generically.
-    fn as_any(&mut self) -> &mut dyn Any;
-    /// Collects all tasks available for scheduling.
-    fn tasks_to_schedule(&mut self, cx: &mut ModelContext<Box<dyn TaskSource>>) -> TaskTemplates;
-}
+/// This is a new type representing a 'tag' on a 'runnable symbol', typically a test of main() function, found via treesitter.
+#[derive(Clone, Debug)]
+pub struct RunnableTag(pub SharedString);

crates/task/src/static_source.rs 🔗

@@ -1,134 +1,110 @@
 //! 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::sync::Arc;
+
 use futures::StreamExt;
-use gpui::{AppContext, Context, Model, ModelContext, Subscription};
+use gpui::AppContext;
+use parking_lot::RwLock;
 use serde::Deserialize;
 use util::ResultExt;
 
-use crate::{TaskSource, TaskTemplates};
+use crate::TaskTemplates;
 use futures::channel::mpsc::UnboundedReceiver;
 
 /// The source of tasks defined in a tasks config file.
 pub struct StaticSource {
-    tasks: TaskTemplates,
-    _templates: Model<TrackedFile<TaskTemplates>>,
-    _subscription: Subscription,
+    tasks: TrackedFile<TaskTemplates>,
 }
 
 /// 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.
 pub struct TrackedFile<T> {
-    parsed_contents: T,
+    parsed_contents: Arc<RwLock<T>>,
 }
 
-impl<T: PartialEq + 'static> TrackedFile<T> {
+impl<T: PartialEq + 'static + Sync> TrackedFile<T> {
     /// Initializes new [`TrackedFile`] with a type that's deserializable.
-    pub fn new(mut tracker: UnboundedReceiver<String>, cx: &mut AppContext) -> Model<Self>
+    pub fn new(mut tracker: UnboundedReceiver<String>, cx: &mut AppContext) -> Self
     where
-        T: for<'a> Deserialize<'a> + Default,
+        T: for<'a> Deserialize<'a> + Default + Send,
     {
-        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();
+        let parsed_contents: Arc<RwLock<T>> = Arc::default();
+        cx.background_executor()
+            .spawn({
+                let parsed_contents = parsed_contents.clone();
+                async move {
+                    while let Some(new_contents) = tracker.next().await {
+                        if Arc::strong_count(&parsed_contents) == 1 {
+                            // We're no longer being observed. Stop polling.
+                            break;
+                        }
+                        if !new_contents.trim().is_empty() {
+                            let Some(new_contents) =
+                                serde_json_lenient::from_str::<T>(&new_contents).log_err()
+                            else {
+                                continue;
                             };
-                        })?;
+                            let mut contents = parsed_contents.write();
+                            *contents = new_contents;
+                        }
                     }
+                    anyhow::Ok(())
                 }
-                anyhow::Ok(())
             })
             .detach_and_log_err(cx);
-            Self {
-                parsed_contents: Default::default(),
-            }
-        })
+        Self { parsed_contents }
     }
 
     /// 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>
+    ) -> Self
     where
-        T: Default,
+        T: Default + Send,
     {
-        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::<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;
-                                cx.notify();
+        let parsed_contents: Arc<RwLock<T>> = Arc::default();
+        cx.background_executor()
+            .spawn({
+                let parsed_contents = parsed_contents.clone();
+                async move {
+                    while let Some(new_contents) = tracker.next().await {
+                        if Arc::strong_count(&parsed_contents) == 1 {
+                            // We're no longer being observed. Stop polling.
+                            break;
+                        }
+
+                        if !new_contents.trim().is_empty() {
+                            let Some(new_contents) =
+                                serde_json_lenient::from_str::<U>(&new_contents).log_err()
+                            else {
+                                continue;
+                            };
+                            let Some(new_contents) = new_contents.try_into().log_err() else {
+                                continue;
                             };
-                        })?;
+                            let mut contents = parsed_contents.write();
+                            *contents = new_contents;
+                        }
                     }
+                    anyhow::Ok(())
                 }
-                anyhow::Ok(())
             })
             .detach_and_log_err(cx);
-            Self {
-                parsed_contents: Default::default(),
-            }
-        })
-    }
-
-    fn get(&self) -> &T {
-        &self.parsed_contents
+        Self {
+            parsed_contents: Default::default(),
+        }
     }
 }
 
 impl StaticSource {
     /// Initializes the static source, reacting on tasks config changes.
-    pub fn new(
-        templates: Model<TrackedFile<TaskTemplates>>,
-        cx: &mut AppContext,
-    ) -> Model<Box<dyn TaskSource>> {
-        cx.new_model(|cx| {
-            let _subscription = cx.observe(
-                &templates,
-                move |source: &mut Box<(dyn TaskSource + 'static)>, new_templates, cx| {
-                    if let Some(static_source) = source.as_any().downcast_mut::<Self>() {
-                        static_source.tasks = new_templates.read(cx).get().clone();
-                        cx.notify();
-                    }
-                },
-            );
-            Box::new(Self {
-                tasks: TaskTemplates::default(),
-                _templates: templates,
-                _subscription,
-            })
-        })
-    }
-}
-
-impl TaskSource for StaticSource {
-    fn tasks_to_schedule(&mut self, _: &mut ModelContext<Box<dyn TaskSource>>) -> TaskTemplates {
-        self.tasks.clone()
+    pub fn new(tasks: TrackedFile<TaskTemplates>) -> Self {
+        Self { tasks }
     }
-
-    fn as_any(&mut self) -> &mut dyn std::any::Any {
-        self
+    /// Returns current list of tasks
+    pub fn tasks_to_schedule(&self) -> TaskTemplates {
+        self.tasks.parsed_contents.read().clone()
     }
 }

crates/task/src/task_template.rs 🔗

@@ -58,6 +58,10 @@ pub struct TaskTemplate {
     /// * `never` — avoid changing current terminal pane focus, but still add/reuse the task's tab there
     #[serde(default)]
     pub reveal: RevealStrategy,
+
+    /// Represents the tags which this template attaches to. Adding this removes this task from other UI.
+    #[serde(default)]
+    pub tags: Vec<String>,
 }
 
 /// What to do with the terminal pane and tab, after the command was started.

crates/tasks_ui/Cargo.toml 🔗

@@ -9,7 +9,6 @@ license = "GPL-3.0-or-later"
 workspace = true
 
 [dependencies]
-anyhow.workspace = true
 editor.workspace = true
 file_icons.workspace = true
 fuzzy.workspace = true

crates/tasks_ui/src/lib.rs 🔗

@@ -1,18 +1,13 @@
-use std::{
-    path::{Path, PathBuf},
-    sync::Arc,
-};
+use std::sync::Arc;
 
 use ::settings::Settings;
-use anyhow::Context;
-use editor::Editor;
+use editor::{tasks::task_context, Editor};
 use gpui::{AppContext, ViewContext, WindowContext};
-use language::{BasicContextProvider, ContextProvider, Language};
+use language::Language;
 use modal::TasksModal;
-use project::{Location, TaskSourceKind, WorktreeId};
-use task::{ResolvedTask, TaskContext, TaskTemplate, TaskVariables};
-use util::ResultExt;
-use workspace::Workspace;
+use project::WorktreeId;
+use workspace::tasks::schedule_task;
+use workspace::{tasks::schedule_resolved_task, Workspace};
 
 mod modal;
 mod settings;
@@ -97,9 +92,9 @@ fn spawn_task_with_name(name: String, cx: &mut ViewContext<Workspace>) {
             .update(&mut cx, |workspace, cx| {
                 let (worktree, language) = active_item_selection_properties(workspace, cx);
                 let tasks = workspace.project().update(cx, |project, cx| {
-                    project.task_inventory().update(cx, |inventory, cx| {
-                        inventory.list_tasks(language, worktree, cx)
-                    })
+                    project
+                        .task_inventory()
+                        .update(cx, |inventory, _| inventory.list_tasks(language, worktree))
                 });
                 let (task_source_kind, target_task) =
                     tasks.into_iter().find(|(_, task)| task.label == name)?;
@@ -152,168 +147,6 @@ fn active_item_selection_properties(
     (worktree_id, language)
 }
 
-fn task_context(workspace: &Workspace, cx: &mut WindowContext<'_>) -> TaskContext {
-    fn task_context_impl(workspace: &Workspace, cx: &mut WindowContext<'_>) -> Option<TaskContext> {
-        let cwd = task_cwd(workspace, cx).log_err().flatten();
-        let editor = workspace
-            .active_item(cx)
-            .and_then(|item| item.act_as::<Editor>(cx))?;
-
-        let (selection, buffer, editor_snapshot) = editor.update(cx, |editor, cx| {
-            let selection = editor.selections.newest::<usize>(cx);
-            let (buffer, _, _) = editor
-                .buffer()
-                .read(cx)
-                .point_to_buffer_offset(selection.start, cx)?;
-            let snapshot = editor.snapshot(cx);
-            Some((selection, buffer, snapshot))
-        })?;
-        let language_context_provider = buffer
-            .read(cx)
-            .language()
-            .and_then(|language| language.context_provider())
-            .unwrap_or_else(|| Arc::new(BasicContextProvider));
-        let selection_range = selection.range();
-        let start = editor_snapshot
-            .display_snapshot
-            .buffer_snapshot
-            .anchor_after(selection_range.start)
-            .text_anchor;
-        let end = editor_snapshot
-            .display_snapshot
-            .buffer_snapshot
-            .anchor_after(selection_range.end)
-            .text_anchor;
-        let worktree_abs_path = buffer
-            .read(cx)
-            .file()
-            .map(|file| WorktreeId::from_usize(file.worktree_id()))
-            .and_then(|worktree_id| {
-                workspace
-                    .project()
-                    .read(cx)
-                    .worktree_for_id(worktree_id, cx)
-                    .map(|worktree| worktree.read(cx).abs_path())
-            });
-        let location = Location {
-            buffer,
-            range: start..end,
-        };
-        let task_variables = combine_task_variables(
-            worktree_abs_path.as_deref(),
-            location,
-            language_context_provider.as_ref(),
-            cx,
-        )
-        .log_err()?;
-        Some(TaskContext {
-            cwd,
-            task_variables,
-        })
-    }
-
-    task_context_impl(workspace, cx).unwrap_or_default()
-}
-
-fn combine_task_variables(
-    worktree_abs_path: Option<&Path>,
-    location: Location,
-    context_provider: &dyn ContextProvider,
-    cx: &mut WindowContext<'_>,
-) -> anyhow::Result<TaskVariables> {
-    if context_provider.is_basic() {
-        context_provider
-            .build_context(worktree_abs_path, &location, cx)
-            .context("building basic provider context")
-    } else {
-        let mut basic_context = BasicContextProvider
-            .build_context(worktree_abs_path, &location, cx)
-            .context("building basic default context")?;
-        basic_context.extend(
-            context_provider
-                .build_context(worktree_abs_path, &location, cx)
-                .context("building provider context ")?,
-        );
-        Ok(basic_context)
-    }
-}
-
-fn schedule_task(
-    workspace: &Workspace,
-    task_source_kind: TaskSourceKind,
-    task_to_resolve: &TaskTemplate,
-    task_cx: &TaskContext,
-    omit_history: bool,
-    cx: &mut ViewContext<'_, Workspace>,
-) {
-    if let Some(spawn_in_terminal) =
-        task_to_resolve.resolve_task(&task_source_kind.to_id_base(), task_cx)
-    {
-        schedule_resolved_task(
-            workspace,
-            task_source_kind,
-            spawn_in_terminal,
-            omit_history,
-            cx,
-        );
-    }
-}
-
-fn schedule_resolved_task(
-    workspace: &Workspace,
-    task_source_kind: TaskSourceKind,
-    mut resolved_task: ResolvedTask,
-    omit_history: bool,
-    cx: &mut ViewContext<'_, Workspace>,
-) {
-    if let Some(spawn_in_terminal) = resolved_task.resolved.take() {
-        if !omit_history {
-            resolved_task.resolved = Some(spawn_in_terminal.clone());
-            workspace.project().update(cx, |project, cx| {
-                project.task_inventory().update(cx, |inventory, _| {
-                    inventory.task_scheduled(task_source_kind, resolved_task);
-                })
-            });
-        }
-        cx.emit(workspace::Event::SpawnTask(spawn_in_terminal));
-    }
-}
-
-fn task_cwd(workspace: &Workspace, cx: &mut WindowContext) -> anyhow::Result<Option<PathBuf>> {
-    let project = workspace.project().read(cx);
-    let available_worktrees = project
-        .worktrees()
-        .filter(|worktree| {
-            let worktree = worktree.read(cx);
-            worktree.is_visible()
-                && worktree.is_local()
-                && worktree.root_entry().map_or(false, |e| e.is_dir())
-        })
-        .collect::<Vec<_>>();
-    let cwd = match available_worktrees.len() {
-        0 => None,
-        1 => Some(available_worktrees[0].read(cx).abs_path()),
-        _ => {
-            let cwd_for_active_entry = project.active_entry().and_then(|entry_id| {
-                available_worktrees.into_iter().find_map(|worktree| {
-                    let worktree = worktree.read(cx);
-                    if worktree.contains_entry(entry_id) {
-                        Some(worktree.abs_path())
-                    } else {
-                        None
-                    }
-                })
-            });
-            anyhow::ensure!(
-                cwd_for_active_entry.is_some(),
-                "Cannot determine task cwd for multiple worktrees"
-            );
-            cwd_for_active_entry
-        }
-    };
-    Ok(cwd.map(|path| path.to_path_buf()))
-}
-
 #[cfg(test)]
 mod tests {
     use std::sync::Arc;

crates/tasks_ui/src/modal.rs 🔗

@@ -1,6 +1,6 @@
 use std::sync::Arc;
 
-use crate::{active_item_selection_properties, schedule_resolved_task};
+use crate::active_item_selection_properties;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
     impl_actions, rems, AppContext, DismissEvent, EventEmitter, FocusableView, InteractiveElement,
@@ -16,7 +16,7 @@ use ui::{
     Tooltip, WindowContext,
 };
 use util::ResultExt;
-use workspace::{ModalView, Workspace};
+use workspace::{tasks::schedule_resolved_task, ModalView, Workspace};
 
 use serde::Deserialize;
 
@@ -211,12 +211,11 @@ impl PickerDelegate for TasksModalDelegate {
                                 return Vec::new();
                             };
                             let (used, current) =
-                                picker.delegate.inventory.update(cx, |inventory, cx| {
+                                picker.delegate.inventory.update(cx, |inventory, _| {
                                     inventory.used_and_current_resolved_tasks(
                                         language,
                                         worktree,
                                         &picker.delegate.task_context,
-                                        cx,
                                     )
                                 });
                             picker.delegate.last_used_candidate_index = if used.is_empty() {

crates/workspace/src/tasks.rs 🔗

@@ -0,0 +1,83 @@
+use std::path::PathBuf;
+
+use project::TaskSourceKind;
+use task::{ResolvedTask, TaskContext, TaskTemplate};
+use ui::{ViewContext, WindowContext};
+
+use crate::Workspace;
+
+pub fn task_cwd(workspace: &Workspace, cx: &mut WindowContext) -> anyhow::Result<Option<PathBuf>> {
+    let project = workspace.project().read(cx);
+    let available_worktrees = project
+        .worktrees()
+        .filter(|worktree| {
+            let worktree = worktree.read(cx);
+            worktree.is_visible()
+                && worktree.is_local()
+                && worktree.root_entry().map_or(false, |e| e.is_dir())
+        })
+        .collect::<Vec<_>>();
+    let cwd = match available_worktrees.len() {
+        0 => None,
+        1 => Some(available_worktrees[0].read(cx).abs_path()),
+        _ => {
+            let cwd_for_active_entry = project.active_entry().and_then(|entry_id| {
+                available_worktrees.into_iter().find_map(|worktree| {
+                    let worktree = worktree.read(cx);
+                    if worktree.contains_entry(entry_id) {
+                        Some(worktree.abs_path())
+                    } else {
+                        None
+                    }
+                })
+            });
+            anyhow::ensure!(
+                cwd_for_active_entry.is_some(),
+                "Cannot determine task cwd for multiple worktrees"
+            );
+            cwd_for_active_entry
+        }
+    };
+    Ok(cwd.map(|path| path.to_path_buf()))
+}
+
+pub fn schedule_task(
+    workspace: &Workspace,
+    task_source_kind: TaskSourceKind,
+    task_to_resolve: &TaskTemplate,
+    task_cx: &TaskContext,
+    omit_history: bool,
+    cx: &mut ViewContext<'_, Workspace>,
+) {
+    if let Some(spawn_in_terminal) =
+        task_to_resolve.resolve_task(&task_source_kind.to_id_base(), task_cx)
+    {
+        schedule_resolved_task(
+            workspace,
+            task_source_kind,
+            spawn_in_terminal,
+            omit_history,
+            cx,
+        );
+    }
+}
+
+pub fn schedule_resolved_task(
+    workspace: &Workspace,
+    task_source_kind: TaskSourceKind,
+    mut resolved_task: ResolvedTask,
+    omit_history: bool,
+    cx: &mut ViewContext<'_, Workspace>,
+) {
+    if let Some(spawn_in_terminal) = resolved_task.resolved.take() {
+        if !omit_history {
+            resolved_task.resolved = Some(spawn_in_terminal.clone());
+            workspace.project().update(cx, |project, cx| {
+                project.task_inventory().update(cx, |inventory, _| {
+                    inventory.task_scheduled(task_source_kind, resolved_task);
+                })
+            });
+        }
+        cx.emit(crate::Event::SpawnTask(spawn_in_terminal));
+    }
+}

crates/workspace/src/workspace.rs 🔗

@@ -8,6 +8,7 @@ mod persistence;
 pub mod searchable;
 pub mod shared_screen;
 mod status_bar;
+pub mod tasks;
 mod toolbar;
 mod workspace_settings;
 

crates/zed/src/main.rs 🔗

@@ -297,7 +297,7 @@ fn init_ui(args: Args) {
 
         load_user_themes_in_background(fs.clone(), cx);
         watch_themes(fs.clone(), cx);
-
+        watch_languages(fs.clone(), languages.clone(), cx);
         watch_file_types(fs.clone(), cx);
 
         languages.set_theme(cx.theme().clone());
@@ -861,6 +861,37 @@ fn watch_themes(fs: Arc<dyn fs::Fs>, cx: &mut AppContext) {
     .detach()
 }
 
+#[cfg(debug_assertions)]
+fn watch_languages(fs: Arc<dyn fs::Fs>, languages: Arc<LanguageRegistry>, cx: &mut AppContext) {
+    use std::time::Duration;
+
+    let path = {
+        let p = Path::new("crates/languages/src");
+        let Ok(full_path) = p.canonicalize() else {
+            return;
+        };
+        full_path
+    };
+
+    cx.spawn(|_| async move {
+        let mut events = fs.watch(path.as_path(), Duration::from_millis(100)).await;
+        while let Some(event) = events.next().await {
+            let has_language_file = event.iter().any(|path| {
+                path.extension()
+                    .map(|ext| ext.to_string_lossy().as_ref() == "scm")
+                    .unwrap_or(false)
+            });
+            if has_language_file {
+                languages.reload();
+            }
+        }
+    })
+    .detach()
+}
+
+#[cfg(not(debug_assertions))]
+fn watch_languages(_fs: Arc<dyn fs::Fs>, _languages: Arc<LanguageRegistry>, _cx: &mut AppContext) {}
+
 #[cfg(debug_assertions)]
 fn watch_file_types(fs: Arc<dyn fs::Fs>, cx: &mut AppContext) {
     use std::time::Duration;

crates/zed/src/zed.rs 🔗

@@ -168,19 +168,14 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
             project.update(cx, |project, cx| {
                 let fs = app_state.fs.clone();
                 project.task_inventory().update(cx, |inventory, cx| {
+                    let tasks_file_rx =
+                        watch_config_file(&cx.background_executor(), fs, paths::TASKS.clone());
                     inventory.add_source(
                         TaskSourceKind::AbsPath {
                             id_base: "global_tasks",
                             abs_path: paths::TASKS.clone(),
                         },
-                        |cx| {
-                            let tasks_file_rx = watch_config_file(
-                                &cx.background_executor(),
-                                fs,
-                                paths::TASKS.clone(),
-                            );
-                            StaticSource::new(TrackedFile::new(tasks_file_rx, cx), cx)
-                        },
+                        StaticSource::new(TrackedFile::new(tasks_file_rx, cx)),
                         cx,
                     );
                 })