Limit editors' refresh runnables (#51299)

Kirill Bulatov created

Before, both rust-analyzer's LSPext tasks and tree-sitter tasks were
queried on the entire multi buffer range.

The PR moves all runnable-related logic into a submodule, and reworks
the logic to consider only the visible buffers.
Singleton buffers have their tasks resolved for the entire range still
(same as LSPext tasks work), multi buffers have their buffers' data
cached and reused.

Release Notes:

- Improved multi buffer's runnables resolution performance

Change summary

crates/editor/src/editor.rs       | 526 +-----------------
crates/editor/src/editor_tests.rs |  17 
crates/editor/src/element.rs      |   6 
crates/editor/src/runnables.rs    | 915 +++++++++++++++++++++++++++++++++
crates/editor/src/tasks.rs        | 110 ---
crates/tasks_ui/src/tasks_ui.rs   |   4 
6 files changed, 979 insertions(+), 599 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -35,13 +35,13 @@ mod lsp_ext;
 mod mouse_context_menu;
 pub mod movement;
 mod persistence;
+mod runnables;
 mod rust_analyzer_ext;
 pub mod scroll;
 mod selections_collection;
 pub mod semantic_tokens;
 mod split;
 pub mod split_editor_view;
-pub mod tasks;
 
 #[cfg(test)]
 mod code_completion_tests;
@@ -133,8 +133,8 @@ use language::{
     BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape,
     DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind,
     IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, LocalFile, OffsetRangeExt,
-    OutlineItem, Point, Runnable, Selection, SelectionGoal, TextObject, TransactionId,
-    TreeSitterOptions, WordsQuery,
+    OutlineItem, Point, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions,
+    WordsQuery,
     language_settings::{
         self, LanguageSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode,
         all_language_settings, language_settings,
@@ -158,7 +158,7 @@ use project::{
     BreakpointWithPosition, CodeAction, Completion, CompletionDisplayOptions, CompletionIntent,
     CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, InlayId,
     InvalidationStrategy, Location, LocationLink, LspAction, PrepareRenameResponse, Project,
-    ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind,
+    ProjectItem, ProjectPath, ProjectTransaction,
     debugger::{
         breakpoint_store::{
             Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState,
@@ -200,7 +200,7 @@ use std::{
     sync::Arc,
     time::{Duration, Instant},
 };
-use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables};
+use task::TaskVariables;
 use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _, ToPoint as _};
 use theme::{
     AccentColors, ActiveTheme, GlobalTheme, PlayerColor, StatusColors, SyntaxTheme, Theme,
@@ -231,6 +231,7 @@ use crate::{
         InlineValueCache,
         inlay_hints::{LspInlayHintData, inlay_hint_settings},
     },
+    runnables::{ResolvedTasks, RunnableData, RunnableTasks},
     scroll::{ScrollOffset, ScrollPixelOffset},
     selections_collection::resolve_selections_wrapping_blocks,
     semantic_tokens::SemanticTokenState,
@@ -857,37 +858,6 @@ impl BufferSerialization {
     }
 }
 
-#[derive(Clone, Debug)]
-struct RunnableTasks {
-    templates: Vec<(TaskSourceKind, TaskTemplate)>,
-    offset: multi_buffer::Anchor,
-    // We need the column at which the task context evaluation should take place (when we're spawning it via gutter).
-    column: u32,
-    // Values of all named captures, including those starting with '_'
-    extra_variables: HashMap<String, String>,
-    // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal.
-    context_range: Range<BufferOffset>,
-}
-
-impl RunnableTasks {
-    fn resolve<'a>(
-        &'a self,
-        cx: &'a task::TaskContext,
-    ) -> impl Iterator<Item = (TaskSourceKind, ResolvedTask)> + 'a {
-        self.templates.iter().filter_map(|(kind, template)| {
-            template
-                .resolve_task(&kind.to_id_base(), cx)
-                .map(|task| (kind.clone(), task))
-        })
-    }
-}
-
-#[derive(Clone)]
-pub struct ResolvedTasks {
-    templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>,
-    position: Anchor,
-}
-
 /// Addons allow storing per-editor state in other crates (e.g. Vim)
 pub trait Addon: 'static {
     fn extend_key_context(&self, _: &mut KeyContext, _: &App) {}
@@ -1295,8 +1265,7 @@ pub struct Editor {
     last_bounds: Option<Bounds<Pixels>>,
     last_position_map: Option<Rc<PositionMap>>,
     expect_bounds_change: Option<Bounds<Pixels>>,
-    tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>,
-    tasks_update_task: Option<Task<()>>,
+    runnables: RunnableData,
     breakpoint_store: Option<Entity<BreakpointStore>>,
     gutter_breakpoint_indicator: (Option<PhantomBreakpointIndicator>, Option<Task<()>>),
     pub(crate) gutter_diff_review_indicator: (Option<PhantomDiffReviewIndicator>, Option<Task<()>>),
@@ -2173,16 +2142,9 @@ impl Editor {
                         editor.registered_buffers.clear();
                         editor.register_visible_buffers(cx);
                         editor.invalidate_semantic_tokens(None);
+                        editor.refresh_runnables(window, cx);
                         editor.update_lsp_data(None, window, cx);
                         editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx);
-                        if editor.tasks_update_task.is_none() {
-                            editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
-                        }
-                    }
-                    project::Event::LanguageServerAdded(..) => {
-                        if editor.tasks_update_task.is_none() {
-                            editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
-                        }
                     }
                     project::Event::SnippetEdit(id, snippet_edits) => {
                         // todo(lw): Non singletons
@@ -2210,6 +2172,7 @@ impl Editor {
                         let buffer_id = *buffer_id;
                         if editor.buffer().read(cx).buffer(buffer_id).is_some() {
                             editor.register_buffer(buffer_id, cx);
+                            editor.refresh_runnables(window, cx);
                             editor.update_lsp_data(Some(buffer_id), window, cx);
                             editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
                             refresh_linked_ranges(editor, window, cx);
@@ -2288,7 +2251,7 @@ impl Editor {
                     &task_inventory,
                     window,
                     |editor, _, window, cx| {
-                        editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
+                        editor.refresh_runnables(window, cx);
                     },
                 ));
             };
@@ -2529,7 +2492,6 @@ impl Editor {
             }),
             blame: None,
             blame_subscription: None,
-            tasks: BTreeMap::default(),
 
             breakpoint_store,
             gutter_breakpoint_indicator: (None, None),
@@ -2565,7 +2527,7 @@ impl Editor {
                     ]
                 })
                 .unwrap_or_default(),
-            tasks_update_task: None,
+            runnables: RunnableData::new(),
             pull_diagnostics_task: Task::ready(()),
             colors: None,
             refresh_colors_task: Task::ready(()),
@@ -2632,7 +2594,6 @@ impl Editor {
                     cx.notify();
                 }));
         }
-        editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
         editor._subscriptions.extend(project_subscriptions);
 
         editor._subscriptions.push(cx.subscribe_in(
@@ -2668,6 +2629,7 @@ impl Editor {
                                     );
                                     if !editor.buffer().read(cx).is_singleton() {
                                         editor.update_lsp_data(None, window, cx);
+                                        editor.refresh_runnables(window, cx);
                                     }
                                 })
                                 .ok();
@@ -5791,18 +5753,11 @@ impl Editor {
         let display_snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx));
         let multi_buffer = self.buffer().read(cx);
         let multi_buffer_snapshot = multi_buffer.snapshot(cx);
-        let multi_buffer_visible_start = self
-            .scroll_manager
-            .native_anchor(&display_snapshot, cx)
-            .anchor
-            .to_point(&multi_buffer_snapshot);
-        let multi_buffer_visible_end = multi_buffer_snapshot.clip_point(
-            multi_buffer_visible_start
-                + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0),
-            Bias::Left,
-        );
         multi_buffer_snapshot
-            .range_to_buffer_ranges(multi_buffer_visible_start..=multi_buffer_visible_end)
+            .range_to_buffer_ranges(
+                self.multi_buffer_visible_range(&display_snapshot, cx)
+                    .to_inclusive(),
+            )
             .into_iter()
             .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty())
             .filter_map(|(buffer, excerpt_visible_range, excerpt_id)| {
@@ -6737,8 +6692,8 @@ impl Editor {
         };
         let buffer_id = buffer.read(cx).remote_id();
         let tasks = self
-            .tasks
-            .get(&(buffer_id, buffer_row))
+            .runnables
+            .runnables((buffer_id, buffer_row))
             .map(|t| Arc::new(t.to_owned()));
 
         if !self.focus_handle.is_focused(window) {
@@ -7789,24 +7744,13 @@ impl Editor {
             self.debounced_selection_highlight_complete = false;
         }
         if on_buffer_edit || query_changed {
-            let multi_buffer_visible_start = self
-                .scroll_manager
-                .native_anchor(&display_snapshot, cx)
-                .anchor
-                .to_point(&multi_buffer_snapshot);
-            let multi_buffer_visible_end = multi_buffer_snapshot.clip_point(
-                multi_buffer_visible_start
-                    + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0),
-                Bias::Left,
-            );
-            let multi_buffer_visible_range = multi_buffer_visible_start..multi_buffer_visible_end;
             self.quick_selection_highlight_task = Some((
                 query_range.clone(),
                 self.update_selection_occurrence_highlights(
                     snapshot.buffer.clone(),
                     query_text.clone(),
                     query_range.clone(),
-                    multi_buffer_visible_range,
+                    self.multi_buffer_visible_range(&display_snapshot, cx),
                     false,
                     window,
                     cx,
@@ -7841,6 +7785,27 @@ impl Editor {
         }
     }
 
+    pub fn multi_buffer_visible_range(
+        &self,
+        display_snapshot: &DisplaySnapshot,
+        cx: &App,
+    ) -> Range<Point> {
+        let visible_start = self
+            .scroll_manager
+            .native_anchor(display_snapshot, cx)
+            .anchor
+            .to_point(display_snapshot.buffer_snapshot())
+            .to_display_point(display_snapshot);
+
+        let mut target_end = visible_start;
+        *target_end.row_mut() += self.visible_line_count().unwrap_or(0.).ceil() as u32;
+
+        visible_start.to_point(display_snapshot)
+            ..display_snapshot
+                .clip_point(target_end, Bias::Right)
+                .to_point(display_snapshot)
+    }
+
     pub fn refresh_edit_prediction(
         &mut self,
         debounce: bool,
@@ -8809,19 +8774,6 @@ impl Editor {
         Some(self.edit_prediction_provider.as_ref()?.provider.clone())
     }
 
-    fn clear_tasks(&mut self) {
-        self.tasks.clear()
-    }
-
-    fn insert_tasks(&mut self, key: (BufferId, BufferRow), value: RunnableTasks) {
-        if self.tasks.insert(key, value).is_some() {
-            // 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"
-            )
-        }
-    }
-
     /// Get all display points of breakpoints that will be rendered within editor
     ///
     /// This function is used to handle overlaps between breakpoints and Code action/runner symbol.
@@ -9199,156 +9151,6 @@ impl Editor {
         })
     }
 
-    pub fn spawn_nearest_task(
-        &mut self,
-        action: &SpawnNearestTask,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let Some((workspace, _)) = self.workspace.clone() else {
-            return;
-        };
-        let Some(project) = self.project.clone() else {
-            return;
-        };
-
-        // Try to find a closest, enclosing node using tree-sitter that has a task
-        let Some((buffer, buffer_row, tasks)) = self
-            .find_enclosing_node_task(cx)
-            // Or find the task that's closest in row-distance.
-            .or_else(|| self.find_closest_task(cx))
-        else {
-            return;
-        };
-
-        let reveal_strategy = action.reveal;
-        let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx);
-        cx.spawn_in(window, async move |_, cx| {
-            let context = task_context.await?;
-            let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?;
-
-            let resolved = &mut resolved_task.resolved;
-            resolved.reveal = reveal_strategy;
-
-            workspace
-                .update_in(cx, |workspace, window, cx| {
-                    workspace.schedule_resolved_task(
-                        task_source_kind,
-                        resolved_task,
-                        false,
-                        window,
-                        cx,
-                    );
-                })
-                .ok()
-        })
-        .detach();
-    }
-
-    fn find_closest_task(
-        &mut self,
-        cx: &mut Context<Self>,
-    ) -> Option<(Entity<Buffer>, u32, Arc<RunnableTasks>)> {
-        let cursor_row = self
-            .selections
-            .newest_adjusted(&self.display_snapshot(cx))
-            .head()
-            .row;
-
-        let ((buffer_id, row), tasks) = self
-            .tasks
-            .iter()
-            .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?;
-
-        let buffer = self.buffer.read(cx).buffer(*buffer_id)?;
-        let tasks = Arc::new(tasks.to_owned());
-        Some((buffer, *row, tasks))
-    }
-
-    fn find_enclosing_node_task(
-        &mut self,
-        cx: &mut Context<Self>,
-    ) -> Option<(Entity<Buffer>, u32, Arc<RunnableTasks>)> {
-        let snapshot = self.buffer.read(cx).snapshot(cx);
-        let offset = self
-            .selections
-            .newest::<MultiBufferOffset>(&self.display_snapshot(cx))
-            .head();
-        let mut excerpt = snapshot.excerpt_containing(offset..offset)?;
-        let offset = excerpt.map_offset_to_buffer(offset);
-        let buffer_id = excerpt.buffer().remote_id();
-
-        let layer = excerpt.buffer().syntax_layer_at(offset)?;
-        let mut cursor = layer.node().walk();
-
-        while cursor.goto_first_child_for_byte(offset.0).is_some() {
-            if cursor.node().end_byte() == offset.0 {
-                cursor.goto_next_sibling();
-            }
-        }
-
-        // Ascend to the smallest ancestor that contains the range and has a task.
-        loop {
-            let node = cursor.node();
-            let node_range = node.byte_range();
-            let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row;
-
-            // Check if this node contains our offset
-            if node_range.start <= offset.0 && node_range.end >= offset.0 {
-                // If it contains offset, check for task
-                if let Some(tasks) = self.tasks.get(&(buffer_id, symbol_start_row)) {
-                    let buffer = self.buffer.read(cx).buffer(buffer_id)?;
-                    return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned())));
-                }
-            }
-
-            if !cursor.goto_parent() {
-                break;
-            }
-        }
-        None
-    }
-
-    fn render_run_indicator(
-        &self,
-        _style: &EditorStyle,
-        is_active: bool,
-        row: DisplayRow,
-        breakpoint: Option<(Anchor, Breakpoint, Option<BreakpointSessionState>)>,
-        cx: &mut Context<Self>,
-    ) -> IconButton {
-        let color = Color::Muted;
-        let position = breakpoint.as_ref().map(|(anchor, _, _)| *anchor);
-
-        IconButton::new(
-            ("run_indicator", row.0 as usize),
-            ui::IconName::PlayOutlined,
-        )
-        .shape(ui::IconButtonShape::Square)
-        .icon_size(IconSize::XSmall)
-        .icon_color(color)
-        .toggle_state(is_active)
-        .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| {
-            let quick_launch = match e {
-                ClickEvent::Keyboard(_) => true,
-                ClickEvent::Mouse(e) => e.down.button == MouseButton::Left,
-            };
-
-            window.focus(&editor.focus_handle(cx), cx);
-            editor.toggle_code_actions(
-                &ToggleCodeActions {
-                    deployed_from: Some(CodeActionSource::RunMenu(row)),
-                    quick_launch,
-                },
-                window,
-                cx,
-            );
-        }))
-        .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| {
-            editor.set_breakpoint_context_menu(row, position, event.position(), window, cx);
-        }))
-    }
-
     pub fn context_menu_visible(&self) -> bool {
         !self.edit_prediction_preview_is_active()
             && self
@@ -17153,241 +16955,6 @@ impl Editor {
         });
     }
 
-    fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
-        if !self.mode().is_full()
-            || !EditorSettings::get_global(cx).gutter.runnables
-            || !self.enable_runnables
-        {
-            self.clear_tasks();
-            return Task::ready(());
-        }
-        let project = self.project().map(Entity::downgrade);
-        let task_sources = self.lsp_task_sources(cx);
-        let multi_buffer = self.buffer.downgrade();
-        let lsp_data_enabled = self.lsp_data_enabled();
-        cx.spawn_in(window, async move |editor, cx| {
-            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
-            let Some(project) = project.and_then(|p| p.upgrade()) else {
-                return;
-            };
-            let Ok(display_snapshot) = editor.update(cx, |this, cx| {
-                this.display_map.update(cx, |map, cx| map.snapshot(cx))
-            }) else {
-                return;
-            };
-
-            let hide_runnables = project.update(cx, |project, _| project.is_via_collab());
-            if hide_runnables {
-                return;
-            }
-            let new_rows = cx
-                .background_spawn({
-                    let snapshot = display_snapshot.clone();
-                    async move {
-                        snapshot
-                            .buffer_snapshot()
-                            .runnable_ranges(Anchor::min()..Anchor::max())
-                            .collect()
-                    }
-                })
-                .await;
-            let lsp_tasks = if lsp_data_enabled {
-                let Ok(lsp_tasks) =
-                    cx.update(|_, cx| crate::lsp_tasks(project.clone(), &task_sources, None, cx))
-                else {
-                    return;
-                };
-                lsp_tasks.await
-            } else {
-                Vec::new()
-            };
-
-            let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| {
-                lsp_tasks
-                    .into_iter()
-                    .flat_map(|(kind, tasks)| {
-                        tasks.into_iter().filter_map(move |(location, task)| {
-                            Some((kind.clone(), location?, task))
-                        })
-                    })
-                    .fold(HashMap::default(), |mut acc, (kind, location, task)| {
-                        let buffer = location.target.buffer;
-                        let buffer_snapshot = buffer.read(cx).snapshot();
-                        let offset = display_snapshot.buffer_snapshot().excerpts().find_map(
-                            |(excerpt_id, snapshot, _)| {
-                                if snapshot.remote_id() == buffer_snapshot.remote_id() {
-                                    display_snapshot
-                                        .buffer_snapshot()
-                                        .anchor_in_excerpt(excerpt_id, location.target.range.start)
-                                } else {
-                                    None
-                                }
-                            },
-                        );
-                        if let Some(offset) = offset {
-                            let task_buffer_range =
-                                location.target.range.to_point(&buffer_snapshot);
-                            let context_buffer_range =
-                                task_buffer_range.to_offset(&buffer_snapshot);
-                            let context_range = BufferOffset(context_buffer_range.start)
-                                ..BufferOffset(context_buffer_range.end);
-
-                            acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row))
-                                .or_insert_with(|| RunnableTasks {
-                                    templates: Vec::new(),
-                                    offset,
-                                    column: task_buffer_range.start.column,
-                                    extra_variables: HashMap::default(),
-                                    context_range,
-                                })
-                                .templates
-                                .push((kind, task.original_task().clone()));
-                        }
-
-                        acc
-                    })
-            }) else {
-                return;
-            };
-
-            let Ok(prefer_lsp) = multi_buffer.update(cx, |buffer, cx| {
-                buffer.language_settings(cx).tasks.prefer_lsp
-            }) else {
-                return;
-            };
-
-            let rows = Self::runnable_rows(
-                project,
-                display_snapshot,
-                prefer_lsp && !lsp_tasks_by_rows.is_empty(),
-                new_rows,
-                cx.clone(),
-            )
-            .await;
-            editor
-                .update(cx, |editor, _| {
-                    editor.clear_tasks();
-                    for (key, mut value) in rows {
-                        if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&key) {
-                            value.templates.extend(lsp_tasks.templates);
-                        }
-
-                        editor.insert_tasks(key, value);
-                    }
-                    for (key, value) in lsp_tasks_by_rows {
-                        editor.insert_tasks(key, value);
-                    }
-                })
-                .ok();
-        })
-    }
-
-    fn runnable_rows(
-        project: Entity<Project>,
-        snapshot: DisplaySnapshot,
-        prefer_lsp: bool,
-        runnable_ranges: Vec<(Range<MultiBufferOffset>, language::RunnableRange)>,
-        cx: AsyncWindowContext,
-    ) -> Task<Vec<((BufferId, BufferRow), RunnableTasks)>> {
-        cx.spawn(async move |cx| {
-            let mut runnable_rows = Vec::with_capacity(runnable_ranges.len());
-            for (run_range, mut runnable) in runnable_ranges {
-                let Some(tasks) = cx
-                    .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx))
-                    .ok()
-                else {
-                    continue;
-                };
-                let mut tasks = tasks.await;
-
-                if prefer_lsp {
-                    tasks.retain(|(task_kind, _)| {
-                        !matches!(task_kind, TaskSourceKind::Language { .. })
-                    });
-                }
-                if tasks.is_empty() {
-                    continue;
-                }
-
-                let point = run_range.start.to_point(&snapshot.buffer_snapshot());
-                let Some(row) = snapshot
-                    .buffer_snapshot()
-                    .buffer_line_for_row(MultiBufferRow(point.row))
-                    .map(|(_, range)| range.start.row)
-                else {
-                    continue;
-                };
-
-                let context_range =
-                    BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end);
-                runnable_rows.push((
-                    (runnable.buffer_id, row),
-                    RunnableTasks {
-                        templates: tasks,
-                        offset: snapshot.buffer_snapshot().anchor_before(run_range.start),
-                        context_range,
-                        column: point.column,
-                        extra_variables: runnable.extra_captures,
-                    },
-                ));
-            }
-            runnable_rows
-        })
-    }
-
-    fn templates_with_tags(
-        project: &Entity<Project>,
-        runnable: &mut Runnable,
-        cx: &mut App,
-    ) -> Task<Vec<(TaskSourceKind, TaskTemplate)>> {
-        let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| {
-            let (worktree_id, file) = project
-                .buffer_for_id(runnable.buffer, cx)
-                .and_then(|buffer| buffer.read(cx).file())
-                .map(|file| (file.worktree_id(cx), file.clone()))
-                .unzip();
-
-            (
-                project.task_store().read(cx).task_inventory().cloned(),
-                worktree_id,
-                file,
-            )
-        });
-
-        let tags = mem::take(&mut runnable.tags);
-        let language = runnable.language.clone();
-        cx.spawn(async move |cx| {
-            let mut templates_with_tags = Vec::new();
-            if let Some(inventory) = inventory {
-                for RunnableTag(tag) in tags {
-                    let new_tasks = inventory.update(cx, |inventory, cx| {
-                        inventory.list_tasks(file.clone(), Some(language.clone()), worktree_id, cx)
-                    });
-                    templates_with_tags.extend(new_tasks.await.into_iter().filter(
-                        move |(_, template)| {
-                            template.tags.iter().any(|source_tag| source_tag == &tag)
-                        },
-                    ));
-                }
-            }
-            templates_with_tags.sort_by_key(|(kind, _)| kind.to_owned());
-
-            if let Some((leading_tag_source, _)) = templates_with_tags.first() {
-                // Strongest source wins; if we have worktree tag binding, prefer that to
-                // global and language bindings;
-                // if we have a global binding, prefer that to language binding.
-                let first_mismatch = templates_with_tags
-                    .iter()
-                    .position(|(tag_source, _)| tag_source != leading_tag_source);
-                if let Some(index) = first_mismatch {
-                    templates_with_tags.truncate(index);
-                }
-            }
-
-            templates_with_tags
-        })
-    }
-
     pub fn move_to_enclosing_bracket(
         &mut self,
         _: &MoveToEnclosingBracket,
@@ -24184,7 +23751,6 @@ impl Editor {
                 predecessor,
                 excerpts,
             } => {
-                self.tasks_update_task = Some(self.refresh_runnables(window, cx));
                 let buffer_id = buffer.read(cx).remote_id();
                 if self.buffer.read(cx).diff_for(buffer_id).is_none()
                     && let Some(project) = &self.project
@@ -24202,6 +23768,7 @@ impl Editor {
                     .invalidate_buffer(&buffer.read(cx).remote_id());
                 self.update_lsp_data(Some(buffer_id), window, cx);
                 self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
+                self.refresh_runnables(window, cx);
                 self.colorize_brackets(false, cx);
                 self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx);
                 cx.emit(EditorEvent::ExcerptsAdded {
@@ -24220,8 +23787,7 @@ impl Editor {
                 self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx);
                 for buffer_id in removed_buffer_ids {
                     self.registered_buffers.remove(buffer_id);
-                    self.tasks
-                        .retain(|(task_buffer_id, _), _| task_buffer_id != buffer_id);
+                    self.clear_runnables(Some(*buffer_id));
                     self.semantic_token_state.invalidate_buffer(buffer_id);
                     self.display_map.update(cx, |display_map, cx| {
                         display_map.invalidate_semantic_highlights(*buffer_id);
@@ -24263,10 +23829,12 @@ impl Editor {
                 }
                 self.colorize_brackets(false, cx);
                 self.update_lsp_data(None, window, cx);
+                self.refresh_runnables(window, cx);
                 cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() })
             }
             multi_buffer::Event::Reparsed(buffer_id) => {
-                self.tasks_update_task = Some(self.refresh_runnables(window, cx));
+                self.clear_runnables(Some(*buffer_id));
+                self.refresh_runnables(window, cx);
                 self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx);
                 self.colorize_brackets(true, cx);
                 jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx);
@@ -24274,7 +23842,7 @@ impl Editor {
                 cx.emit(EditorEvent::Reparsed(*buffer_id));
             }
             multi_buffer::Event::DiffHunksToggled => {
-                self.tasks_update_task = Some(self.refresh_runnables(window, cx));
+                self.refresh_runnables(window, cx);
             }
             multi_buffer::Event::LanguageChanged(buffer_id, is_fresh_language) => {
                 if !is_fresh_language {
@@ -24410,7 +23978,7 @@ impl Editor {
                 .unwrap_or(DiagnosticSeverity::Hint);
             self.set_max_diagnostics_severity(new_severity, cx);
         }
-        self.tasks_update_task = Some(self.refresh_runnables(window, cx));
+        self.refresh_runnables(window, cx);
         self.update_edit_prediction_settings(cx);
         self.refresh_edit_prediction(true, false, window, cx);
         self.refresh_inline_values(cx);

crates/editor/src/editor_tests.rs 🔗

@@ -5,6 +5,7 @@ use crate::{
     edit_prediction_tests::FakeEditPredictionDelegate,
     element::StickyHeader,
     linked_editing_ranges::LinkedEditingRanges,
+    runnables::RunnableTasks,
     scroll::scroll_amount::ScrollAmount,
     test::{
         assert_text_with_selections, build_editor, editor_content_with_blocks,
@@ -24403,20 +24404,24 @@ async fn test_find_enclosing_node_with_task(cx: &mut TestAppContext) {
 
     editor.update_in(cx, |editor, window, cx| {
         let snapshot = editor.buffer().read(cx).snapshot(cx);
-        editor.tasks.insert(
-            (buffer.read(cx).remote_id(), 3),
+        editor.runnables.insert(
+            buffer.read(cx).remote_id(),
+            3,
+            buffer.read(cx).version(),
             RunnableTasks {
-                templates: vec![],
+                templates: Vec::new(),
                 offset: snapshot.anchor_before(MultiBufferOffset(43)),
                 column: 0,
                 extra_variables: HashMap::default(),
                 context_range: BufferOffset(43)..BufferOffset(85),
             },
         );
-        editor.tasks.insert(
-            (buffer.read(cx).remote_id(), 8),
+        editor.runnables.insert(
+            buffer.read(cx).remote_id(),
+            8,
+            buffer.read(cx).version(),
             RunnableTasks {
-                templates: vec![],
+                templates: Vec::new(),
                 offset: snapshot.anchor_before(MultiBufferOffset(86)),
                 column: 0,
                 extra_variables: HashMap::default(),

crates/editor/src/element.rs 🔗

@@ -3275,9 +3275,9 @@ impl EditorElement {
                 snapshot.display_point_to_point(DisplayPoint::new(range.end, 0), Bias::Right);
 
             editor
-                .tasks
-                .iter()
-                .filter_map(|(_, tasks)| {
+                .runnables
+                .all_runnables()
+                .filter_map(|tasks| {
                     let multibuffer_point = tasks.offset.to_point(&snapshot.buffer_snapshot());
                     if multibuffer_point < offset_range_start
                         || multibuffer_point > offset_range_end

crates/editor/src/runnables.rs 🔗

@@ -0,0 +1,915 @@
+use std::{collections::BTreeMap, mem, ops::Range, sync::Arc};
+
+use clock::Global;
+use collections::HashMap;
+use gpui::{
+    App, AppContext as _, AsyncWindowContext, ClickEvent, Context, Entity, Focusable as _,
+    MouseButton, Task, Window,
+};
+use language::{Buffer, BufferRow, Runnable};
+use lsp::LanguageServerName;
+use multi_buffer::{
+    Anchor, BufferOffset, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot, ToPoint as _,
+};
+use project::{
+    Location, Project, TaskSourceKind,
+    debugger::breakpoint_store::{Breakpoint, BreakpointSessionState},
+    project_settings::ProjectSettings,
+};
+use settings::Settings as _;
+use smallvec::SmallVec;
+use task::{ResolvedTask, RunnableTag, TaskContext, TaskTemplate, TaskVariables, VariableName};
+use text::{BufferId, OffsetRangeExt as _, ToOffset as _, ToPoint as _};
+use ui::{Clickable as _, Color, IconButton, IconSize, Toggleable as _};
+
+use crate::{
+    CodeActionSource, Editor, EditorSettings, EditorStyle, RangeToAnchorExt, SpawnNearestTask,
+    ToggleCodeActions, UPDATE_DEBOUNCE, display_map::DisplayRow,
+};
+
+#[derive(Debug)]
+pub(super) struct RunnableData {
+    runnables: HashMap<BufferId, (Global, BTreeMap<BufferRow, RunnableTasks>)>,
+    runnables_update_task: Task<()>,
+}
+
+impl RunnableData {
+    pub fn new() -> Self {
+        Self {
+            runnables: HashMap::default(),
+            runnables_update_task: Task::ready(()),
+        }
+    }
+
+    pub fn runnables(
+        &self,
+        (buffer_id, buffer_row): (BufferId, BufferRow),
+    ) -> Option<&RunnableTasks> {
+        self.runnables.get(&buffer_id)?.1.get(&buffer_row)
+    }
+
+    pub fn all_runnables(&self) -> impl Iterator<Item = &RunnableTasks> {
+        self.runnables
+            .values()
+            .flat_map(|(_, tasks)| tasks.values())
+    }
+
+    pub fn has_cached(&self, buffer_id: BufferId, version: &Global) -> bool {
+        self.runnables
+            .get(&buffer_id)
+            .is_some_and(|(cached_version, _)| !version.changed_since(cached_version))
+    }
+
+    #[cfg(test)]
+    pub fn insert(
+        &mut self,
+        buffer_id: BufferId,
+        buffer_row: BufferRow,
+        version: Global,
+        tasks: RunnableTasks,
+    ) {
+        self.runnables
+            .entry(buffer_id)
+            .or_insert_with(|| (version, BTreeMap::default()))
+            .1
+            .insert(buffer_row, tasks);
+    }
+}
+
+#[derive(Clone, Debug)]
+pub struct RunnableTasks {
+    pub templates: Vec<(TaskSourceKind, TaskTemplate)>,
+    pub offset: multi_buffer::Anchor,
+    // We need the column at which the task context evaluation should take place (when we're spawning it via gutter).
+    pub column: u32,
+    // Values of all named captures, including those starting with '_'
+    pub extra_variables: HashMap<String, String>,
+    // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal.
+    pub context_range: Range<BufferOffset>,
+}
+
+impl RunnableTasks {
+    pub fn resolve<'a>(
+        &'a self,
+        cx: &'a task::TaskContext,
+    ) -> impl Iterator<Item = (TaskSourceKind, ResolvedTask)> + 'a {
+        self.templates.iter().filter_map(|(kind, template)| {
+            template
+                .resolve_task(&kind.to_id_base(), cx)
+                .map(|task| (kind.clone(), task))
+        })
+    }
+}
+
+#[derive(Clone)]
+pub struct ResolvedTasks {
+    pub templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>,
+    pub position: Anchor,
+}
+
+impl Editor {
+    pub fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if !self.mode().is_full()
+            || !EditorSettings::get_global(cx).gutter.runnables
+            || !self.enable_runnables
+        {
+            self.clear_runnables(None);
+            return;
+        }
+        if let Some(buffer) = self.buffer().read(cx).as_singleton() {
+            if self
+                .runnables
+                .has_cached(buffer.read(cx).remote_id(), &buffer.read(cx).version())
+            {
+                return;
+            }
+        }
+
+        let project = self.project().map(Entity::downgrade);
+        let lsp_task_sources = self.lsp_task_sources(true, true, cx);
+        let multi_buffer = self.buffer.downgrade();
+        self.runnables.runnables_update_task = cx.spawn_in(window, async move |editor, cx| {
+            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
+            let Some(project) = project.and_then(|p| p.upgrade()) else {
+                return;
+            };
+
+            let hide_runnables = project.update(cx, |project, _| project.is_via_collab());
+            if hide_runnables {
+                return;
+            }
+            let lsp_tasks = if lsp_task_sources.is_empty() {
+                Vec::new()
+            } else {
+                let Ok(lsp_tasks) = cx
+                    .update(|_, cx| crate::lsp_tasks(project.clone(), &lsp_task_sources, None, cx))
+                else {
+                    return;
+                };
+                lsp_tasks.await
+            };
+            let new_rows = {
+                let Some((multi_buffer_snapshot, multi_buffer_query_range)) = editor
+                    .update(cx, |editor, cx| {
+                        let multi_buffer = editor.buffer().read(cx);
+                        if multi_buffer.is_singleton() {
+                            Some((multi_buffer.snapshot(cx), Anchor::min()..Anchor::max()))
+                        } else {
+                            let display_snapshot =
+                                editor.display_map.update(cx, |map, cx| map.snapshot(cx));
+                            let multi_buffer_query_range =
+                                editor.multi_buffer_visible_range(&display_snapshot, cx);
+                            let multi_buffer_snapshot = display_snapshot.buffer();
+                            Some((
+                                multi_buffer_snapshot.clone(),
+                                multi_buffer_query_range.to_anchors(&multi_buffer_snapshot),
+                            ))
+                        }
+                    })
+                    .ok()
+                    .flatten()
+                else {
+                    return;
+                };
+                cx.background_spawn({
+                    async move {
+                        multi_buffer_snapshot
+                            .runnable_ranges(multi_buffer_query_range)
+                            .collect()
+                    }
+                })
+                .await
+            };
+
+            let Ok(multi_buffer_snapshot) =
+                editor.update(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx))
+            else {
+                return;
+            };
+            let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| {
+                lsp_tasks
+                    .into_iter()
+                    .flat_map(|(kind, tasks)| {
+                        tasks.into_iter().filter_map(move |(location, task)| {
+                            Some((kind.clone(), location?, task))
+                        })
+                    })
+                    .fold(HashMap::default(), |mut acc, (kind, location, task)| {
+                        let buffer = location.target.buffer;
+                        let buffer_snapshot = buffer.read(cx).snapshot();
+                        let offset = multi_buffer_snapshot.excerpts().find_map(
+                            |(excerpt_id, snapshot, _)| {
+                                if snapshot.remote_id() == buffer_snapshot.remote_id() {
+                                    multi_buffer_snapshot
+                                        .anchor_in_excerpt(excerpt_id, location.target.range.start)
+                                } else {
+                                    None
+                                }
+                            },
+                        );
+                        if let Some(offset) = offset {
+                            let task_buffer_range =
+                                location.target.range.to_point(&buffer_snapshot);
+                            let context_buffer_range =
+                                task_buffer_range.to_offset(&buffer_snapshot);
+                            let context_range = BufferOffset(context_buffer_range.start)
+                                ..BufferOffset(context_buffer_range.end);
+
+                            acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row))
+                                .or_insert_with(|| RunnableTasks {
+                                    templates: Vec::new(),
+                                    offset,
+                                    column: task_buffer_range.start.column,
+                                    extra_variables: HashMap::default(),
+                                    context_range,
+                                })
+                                .templates
+                                .push((kind, task.original_task().clone()));
+                        }
+
+                        acc
+                    })
+            }) else {
+                return;
+            };
+
+            let Ok(prefer_lsp) = multi_buffer.update(cx, |buffer, cx| {
+                buffer.language_settings(cx).tasks.prefer_lsp
+            }) else {
+                return;
+            };
+
+            let rows = Self::runnable_rows(
+                project,
+                multi_buffer_snapshot,
+                prefer_lsp && !lsp_tasks_by_rows.is_empty(),
+                new_rows,
+                cx.clone(),
+            )
+            .await;
+            editor
+                .update(cx, |editor, cx| {
+                    for ((buffer_id, row), mut new_tasks) in rows {
+                        let Some(buffer) = editor.buffer().read(cx).buffer(buffer_id) else {
+                            continue;
+                        };
+
+                        if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&(buffer_id, row)) {
+                            new_tasks.templates.extend(lsp_tasks.templates);
+                        }
+                        editor.insert_runnables(
+                            buffer_id,
+                            buffer.read(cx).version(),
+                            row,
+                            new_tasks,
+                        );
+                    }
+                    for ((buffer_id, row), new_tasks) in lsp_tasks_by_rows {
+                        let Some(buffer) = editor.buffer().read(cx).buffer(buffer_id) else {
+                            continue;
+                        };
+                        editor.insert_runnables(
+                            buffer_id,
+                            buffer.read(cx).version(),
+                            row,
+                            new_tasks,
+                        );
+                    }
+                })
+                .ok();
+        });
+    }
+
+    pub fn spawn_nearest_task(
+        &mut self,
+        action: &SpawnNearestTask,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some((workspace, _)) = self.workspace.clone() else {
+            return;
+        };
+        let Some(project) = self.project.clone() else {
+            return;
+        };
+
+        // Try to find a closest, enclosing node using tree-sitter that has a task
+        let Some((buffer, buffer_row, tasks)) = self
+            .find_enclosing_node_task(cx)
+            // Or find the task that's closest in row-distance.
+            .or_else(|| self.find_closest_task(cx))
+        else {
+            return;
+        };
+
+        let reveal_strategy = action.reveal;
+        let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx);
+        cx.spawn_in(window, async move |_, cx| {
+            let context = task_context.await?;
+            let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?;
+
+            let resolved = &mut resolved_task.resolved;
+            resolved.reveal = reveal_strategy;
+
+            workspace
+                .update_in(cx, |workspace, window, cx| {
+                    workspace.schedule_resolved_task(
+                        task_source_kind,
+                        resolved_task,
+                        false,
+                        window,
+                        cx,
+                    );
+                })
+                .ok()
+        })
+        .detach();
+    }
+
+    pub fn clear_runnables(&mut self, for_buffer: Option<BufferId>) {
+        if let Some(buffer_id) = for_buffer {
+            self.runnables.runnables.remove(&buffer_id);
+        } else {
+            self.runnables.runnables.clear();
+        }
+        self.runnables.runnables_update_task = Task::ready(());
+    }
+
+    pub fn task_context(&self, window: &mut Window, cx: &mut App) -> Task<Option<TaskContext>> {
+        let Some(project) = self.project.clone() else {
+            return Task::ready(None);
+        };
+        let (selection, buffer, editor_snapshot) = {
+            let selection = self.selections.newest_adjusted(&self.display_snapshot(cx));
+            let Some((buffer, _)) = self
+                .buffer()
+                .read(cx)
+                .point_to_buffer_offset(selection.start, cx)
+            else {
+                return Task::ready(None);
+            };
+            let snapshot = self.snapshot(window, cx);
+            (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,
+        };
+        let captured_variables = {
+            let mut variables = TaskVariables::default();
+            let buffer = location.buffer.read(cx);
+            let buffer_id = buffer.remote_id();
+            let snapshot = buffer.snapshot();
+            let starting_point = location.range.start.to_point(&snapshot);
+            let starting_offset = starting_point.to_offset(&snapshot);
+            for (_, tasks) in self
+                .runnables
+                .runnables
+                .get(&buffer_id)
+                .into_iter()
+                .flat_map(|(_, tasks)| tasks.range(0..starting_point.row + 1))
+            {
+                if !tasks
+                    .context_range
+                    .contains(&crate::BufferOffset(starting_offset))
+                {
+                    continue;
+                }
+                for (capture_name, value) in tasks.extra_variables.iter() {
+                    variables.insert(
+                        VariableName::Custom(capture_name.to_owned().into()),
+                        value.clone(),
+                    );
+                }
+            }
+            variables
+        };
+
+        project.update(cx, |project, cx| {
+            project.task_store().update(cx, |task_store, cx| {
+                task_store.task_context_for_location(captured_variables, location, cx)
+            })
+        })
+    }
+
+    pub fn lsp_task_sources(
+        &self,
+        visible_only: bool,
+        skip_cached: bool,
+        cx: &mut Context<Self>,
+    ) -> HashMap<LanguageServerName, Vec<BufferId>> {
+        if !self.lsp_data_enabled() {
+            return HashMap::default();
+        }
+        let buffers = if visible_only {
+            self.visible_excerpts(true, cx)
+                .into_values()
+                .map(|(buffer, _, _)| buffer)
+                .collect()
+        } else {
+            self.buffer().read(cx).all_buffers()
+        };
+
+        let lsp_settings = &ProjectSettings::get_global(cx).lsp;
+
+        buffers
+            .into_iter()
+            .filter_map(|buffer| {
+                let lsp_tasks_source = buffer
+                    .read(cx)
+                    .language()?
+                    .context_provider()?
+                    .lsp_task_source()?;
+                if lsp_settings
+                    .get(&lsp_tasks_source)
+                    .is_none_or(|s| s.enable_lsp_tasks)
+                {
+                    let buffer_id = buffer.read(cx).remote_id();
+                    if skip_cached
+                        && self
+                            .runnables
+                            .has_cached(buffer_id, &buffer.read(cx).version())
+                    {
+                        None
+                    } else {
+                        Some((lsp_tasks_source, buffer_id))
+                    }
+                } else {
+                    None
+                }
+            })
+            .fold(
+                HashMap::default(),
+                |mut acc, (lsp_task_source, buffer_id)| {
+                    acc.entry(lsp_task_source)
+                        .or_insert_with(Vec::new)
+                        .push(buffer_id);
+                    acc
+                },
+            )
+    }
+
+    pub fn find_enclosing_node_task(
+        &mut self,
+        cx: &mut Context<Self>,
+    ) -> Option<(Entity<Buffer>, u32, Arc<RunnableTasks>)> {
+        let snapshot = self.buffer.read(cx).snapshot(cx);
+        let offset = self
+            .selections
+            .newest::<MultiBufferOffset>(&self.display_snapshot(cx))
+            .head();
+        let mut excerpt = snapshot.excerpt_containing(offset..offset)?;
+        let offset = excerpt.map_offset_to_buffer(offset);
+        let buffer_id = excerpt.buffer().remote_id();
+
+        let layer = excerpt.buffer().syntax_layer_at(offset)?;
+        let mut cursor = layer.node().walk();
+
+        while cursor.goto_first_child_for_byte(offset.0).is_some() {
+            if cursor.node().end_byte() == offset.0 {
+                cursor.goto_next_sibling();
+            }
+        }
+
+        // Ascend to the smallest ancestor that contains the range and has a task.
+        loop {
+            let node = cursor.node();
+            let node_range = node.byte_range();
+            let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row;
+
+            // Check if this node contains our offset
+            if node_range.start <= offset.0 && node_range.end >= offset.0 {
+                // If it contains offset, check for task
+                if let Some(tasks) = self
+                    .runnables
+                    .runnables
+                    .get(&buffer_id)
+                    .and_then(|(_, tasks)| tasks.get(&symbol_start_row))
+                {
+                    let buffer = self.buffer.read(cx).buffer(buffer_id)?;
+                    return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned())));
+                }
+            }
+
+            if !cursor.goto_parent() {
+                break;
+            }
+        }
+        None
+    }
+
+    pub fn render_run_indicator(
+        &self,
+        _style: &EditorStyle,
+        is_active: bool,
+        row: DisplayRow,
+        breakpoint: Option<(Anchor, Breakpoint, Option<BreakpointSessionState>)>,
+        cx: &mut Context<Self>,
+    ) -> IconButton {
+        let color = Color::Muted;
+        let position = breakpoint.as_ref().map(|(anchor, _, _)| *anchor);
+
+        IconButton::new(
+            ("run_indicator", row.0 as usize),
+            ui::IconName::PlayOutlined,
+        )
+        .shape(ui::IconButtonShape::Square)
+        .icon_size(IconSize::XSmall)
+        .icon_color(color)
+        .toggle_state(is_active)
+        .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| {
+            let quick_launch = match e {
+                ClickEvent::Keyboard(_) => true,
+                ClickEvent::Mouse(e) => e.down.button == MouseButton::Left,
+            };
+
+            window.focus(&editor.focus_handle(cx), cx);
+            editor.toggle_code_actions(
+                &ToggleCodeActions {
+                    deployed_from: Some(CodeActionSource::RunMenu(row)),
+                    quick_launch,
+                },
+                window,
+                cx,
+            );
+        }))
+        .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| {
+            editor.set_breakpoint_context_menu(row, position, event.position(), window, cx);
+        }))
+    }
+
+    fn insert_runnables(
+        &mut self,
+        buffer: BufferId,
+        version: Global,
+        row: BufferRow,
+        new_tasks: RunnableTasks,
+    ) {
+        let (old_version, tasks) = self.runnables.runnables.entry(buffer).or_default();
+        if !old_version.changed_since(&version) {
+            *old_version = version;
+            tasks.insert(row, new_tasks);
+        }
+    }
+
+    fn runnable_rows(
+        project: Entity<Project>,
+        snapshot: MultiBufferSnapshot,
+        prefer_lsp: bool,
+        runnable_ranges: Vec<(Range<MultiBufferOffset>, language::RunnableRange)>,
+        cx: AsyncWindowContext,
+    ) -> Task<Vec<((BufferId, BufferRow), RunnableTasks)>> {
+        cx.spawn(async move |cx| {
+            let mut runnable_rows = Vec::with_capacity(runnable_ranges.len());
+            for (run_range, mut runnable) in runnable_ranges {
+                let Some(tasks) = cx
+                    .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx))
+                    .ok()
+                else {
+                    continue;
+                };
+                let mut tasks = tasks.await;
+
+                if prefer_lsp {
+                    tasks.retain(|(task_kind, _)| {
+                        !matches!(task_kind, TaskSourceKind::Language { .. })
+                    });
+                }
+                if tasks.is_empty() {
+                    continue;
+                }
+
+                let point = run_range.start.to_point(&snapshot);
+                let Some(row) = snapshot
+                    .buffer_line_for_row(MultiBufferRow(point.row))
+                    .map(|(_, range)| range.start.row)
+                else {
+                    continue;
+                };
+
+                let context_range =
+                    BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end);
+                runnable_rows.push((
+                    (runnable.buffer_id, row),
+                    RunnableTasks {
+                        templates: tasks,
+                        offset: snapshot.anchor_before(run_range.start),
+                        context_range,
+                        column: point.column,
+                        extra_variables: runnable.extra_captures,
+                    },
+                ));
+            }
+            runnable_rows
+        })
+    }
+
+    fn templates_with_tags(
+        project: &Entity<Project>,
+        runnable: &mut Runnable,
+        cx: &mut App,
+    ) -> Task<Vec<(TaskSourceKind, TaskTemplate)>> {
+        let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| {
+            let (worktree_id, file) = project
+                .buffer_for_id(runnable.buffer, cx)
+                .and_then(|buffer| buffer.read(cx).file())
+                .map(|file| (file.worktree_id(cx), file.clone()))
+                .unzip();
+
+            (
+                project.task_store().read(cx).task_inventory().cloned(),
+                worktree_id,
+                file,
+            )
+        });
+
+        let tags = mem::take(&mut runnable.tags);
+        let language = runnable.language.clone();
+        cx.spawn(async move |cx| {
+            let mut templates_with_tags = Vec::new();
+            if let Some(inventory) = inventory {
+                for RunnableTag(tag) in tags {
+                    let new_tasks = inventory.update(cx, |inventory, cx| {
+                        inventory.list_tasks(file.clone(), Some(language.clone()), worktree_id, cx)
+                    });
+                    templates_with_tags.extend(new_tasks.await.into_iter().filter(
+                        move |(_, template)| {
+                            template.tags.iter().any(|source_tag| source_tag == &tag)
+                        },
+                    ));
+                }
+            }
+            templates_with_tags.sort_by_key(|(kind, _)| kind.to_owned());
+
+            if let Some((leading_tag_source, _)) = templates_with_tags.first() {
+                // Strongest source wins; if we have worktree tag binding, prefer that to
+                // global and language bindings;
+                // if we have a global binding, prefer that to language binding.
+                let first_mismatch = templates_with_tags
+                    .iter()
+                    .position(|(tag_source, _)| tag_source != leading_tag_source);
+                if let Some(index) = first_mismatch {
+                    templates_with_tags.truncate(index);
+                }
+            }
+
+            templates_with_tags
+        })
+    }
+
+    fn find_closest_task(
+        &mut self,
+        cx: &mut Context<Self>,
+    ) -> Option<(Entity<Buffer>, u32, Arc<RunnableTasks>)> {
+        let cursor_row = self
+            .selections
+            .newest_adjusted(&self.display_snapshot(cx))
+            .head()
+            .row;
+
+        let ((buffer_id, row), tasks) = self
+            .runnables
+            .runnables
+            .iter()
+            .flat_map(|(buffer_id, (_, tasks))| {
+                tasks.iter().map(|(row, tasks)| ((*buffer_id, *row), tasks))
+            })
+            .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?;
+
+        let buffer = self.buffer.read(cx).buffer(buffer_id)?;
+        let tasks = Arc::new(tasks.to_owned());
+        Some((buffer, row, tasks))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{sync::Arc, time::Duration};
+
+    use gpui::{AppContext as _, Task, TestAppContext};
+    use indoc::indoc;
+    use language::ContextProvider;
+    use languages::rust_lang;
+    use multi_buffer::{MultiBuffer, PathKey};
+    use project::{FakeFs, Project};
+    use serde_json::json;
+    use task::{TaskTemplate, TaskTemplates};
+    use text::Point;
+    use util::path;
+
+    use crate::{
+        Editor, UPDATE_DEBOUNCE, editor_tests::init_test, scroll::scroll_amount::ScrollAmount,
+    };
+
+    struct TestRustContextProvider;
+
+    impl ContextProvider for TestRustContextProvider {
+        fn associated_tasks(
+            &self,
+            _: Option<Arc<dyn language::File>>,
+            _: &gpui::App,
+        ) -> Task<Option<TaskTemplates>> {
+            Task::ready(Some(TaskTemplates(vec![
+                TaskTemplate {
+                    label: "Run main".into(),
+                    command: "cargo".into(),
+                    args: vec!["run".into()],
+                    tags: vec!["rust-main".into()],
+                    ..TaskTemplate::default()
+                },
+                TaskTemplate {
+                    label: "Run test".into(),
+                    command: "cargo".into(),
+                    args: vec!["test".into()],
+                    tags: vec!["rust-test".into()],
+                    ..TaskTemplate::default()
+                },
+            ])))
+        }
+    }
+
+    fn rust_lang_with_task_context() -> Arc<language::Language> {
+        Arc::new(
+            Arc::try_unwrap(rust_lang())
+                .unwrap()
+                .with_context_provider(Some(Arc::new(TestRustContextProvider))),
+        )
+    }
+
+    fn collect_runnable_labels(
+        editor: &Editor,
+    ) -> Vec<(text::BufferId, language::BufferRow, Vec<String>)> {
+        let mut result = editor
+            .runnables
+            .runnables
+            .iter()
+            .flat_map(|(buffer_id, (_, tasks))| {
+                tasks.iter().map(move |(row, runnable_tasks)| {
+                    let mut labels: Vec<String> = runnable_tasks
+                        .templates
+                        .iter()
+                        .map(|(_, template)| template.label.clone())
+                        .collect();
+                    labels.sort();
+                    (*buffer_id, *row, labels)
+                })
+            })
+            .collect::<Vec<_>>();
+        result.sort_by_key(|(id, row, _)| (*id, *row));
+        result
+    }
+
+    #[gpui::test]
+    async fn test_multi_buffer_runnables_on_scroll(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        let padding_lines = 50;
+        let mut first_rs = String::from("fn main() {\n    println!(\"hello\");\n}\n");
+        for _ in 0..padding_lines {
+            first_rs.push_str("//\n");
+        }
+        let test_one_row = 3 + padding_lines as u32 + 1;
+        first_rs.push_str("#[test]\nfn test_one() {\n    assert!(true);\n}\n");
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                "first.rs": first_rs,
+                "second.rs": indoc! {"
+                    #[test]
+                    fn test_two() {
+                        assert!(true);
+                    }
+
+                    #[test]
+                    fn test_three() {
+                        assert!(true);
+                    }
+                "},
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+        language_registry.add(rust_lang_with_task_context());
+
+        let buffer_1 = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer(path!("/project/first.rs"), cx)
+            })
+            .await
+            .unwrap();
+        let buffer_2 = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer(path!("/project/second.rs"), cx)
+            })
+            .await
+            .unwrap();
+
+        let buffer_1_id = buffer_1.read_with(cx, |buffer, _| buffer.remote_id());
+        let buffer_2_id = buffer_2.read_with(cx, |buffer, _| buffer.remote_id());
+
+        let multi_buffer = cx.new(|cx| {
+            let mut multi_buffer = MultiBuffer::new(language::Capability::ReadWrite);
+            let end = buffer_1.read(cx).max_point();
+            multi_buffer.set_excerpts_for_path(
+                PathKey::sorted(0),
+                buffer_1.clone(),
+                [Point::new(0, 0)..end],
+                0,
+                cx,
+            );
+            multi_buffer.set_excerpts_for_path(
+                PathKey::sorted(1),
+                buffer_2.clone(),
+                [Point::new(0, 0)..Point::new(8, 1)],
+                0,
+                cx,
+            );
+            multi_buffer
+        });
+
+        let editor = cx.add_window(|window, cx| {
+            Editor::for_multibuffer(multi_buffer, Some(project.clone()), window, cx)
+        });
+        cx.executor().advance_clock(Duration::from_millis(500));
+        cx.executor().run_until_parked();
+
+        // Clear stale data from startup events, then refresh.
+        // first.rs is long enough that second.rs is below the ~47-line viewport.
+        editor
+            .update(cx, |editor, window, cx| {
+                editor.clear_runnables(None);
+                editor.refresh_runnables(window, cx);
+            })
+            .unwrap();
+        cx.executor().advance_clock(UPDATE_DEBOUNCE);
+        cx.executor().run_until_parked();
+        assert_eq!(
+            editor
+                .update(cx, |editor, _, _| collect_runnable_labels(editor))
+                .unwrap(),
+            vec![(buffer_1_id, 0, vec!["Run main".to_string()])],
+            "Only fn main from first.rs should be visible before scrolling"
+        );
+
+        // Scroll down to bring second.rs excerpts into view.
+        editor
+            .update(cx, |editor, window, cx| {
+                editor.scroll_screen(&ScrollAmount::Page(1.0), window, cx);
+            })
+            .unwrap();
+        cx.executor().advance_clock(Duration::from_millis(200));
+        cx.executor().run_until_parked();
+
+        let after_scroll = editor
+            .update(cx, |editor, _, _| collect_runnable_labels(editor))
+            .unwrap();
+        assert_eq!(
+            after_scroll,
+            vec![
+                (buffer_1_id, 0, vec!["Run main".to_string()]),
+                (buffer_1_id, test_one_row, vec!["Run test".to_string()]),
+                (buffer_2_id, 1, vec!["Run test".to_string()]),
+                (buffer_2_id, 6, vec!["Run test".to_string()]),
+            ],
+            "Tree-sitter should detect both #[test] fns in second.rs after scroll"
+        );
+
+        // Edit second.rs to invalidate its cache; first.rs data should persist.
+        buffer_2.update(cx, |buffer, cx| {
+            buffer.edit([(0..0, "// added comment\n")], None, cx);
+        });
+        editor
+            .update(cx, |editor, window, cx| {
+                editor.scroll_screen(&ScrollAmount::Page(-1.0), window, cx);
+            })
+            .unwrap();
+        cx.executor().advance_clock(Duration::from_millis(200));
+        cx.executor().run_until_parked();
+
+        assert_eq!(
+            editor
+                .update(cx, |editor, _, _| collect_runnable_labels(editor))
+                .unwrap(),
+            vec![
+                (buffer_1_id, 0, vec!["Run main".to_string()]),
+                (buffer_1_id, test_one_row, vec!["Run test".to_string()]),
+            ],
+            "first.rs runnables should survive an edit to second.rs"
+        );
+    }
+}

crates/editor/src/tasks.rs 🔗

@@ -1,110 +0,0 @@
-use crate::Editor;
-
-use collections::HashMap;
-use gpui::{App, Task, Window};
-use lsp::LanguageServerName;
-use project::{Location, project_settings::ProjectSettings};
-use settings::Settings as _;
-use task::{TaskContext, TaskVariables, VariableName};
-use text::{BufferId, ToOffset, ToPoint};
-
-impl Editor {
-    pub fn task_context(&self, window: &mut Window, cx: &mut App) -> Task<Option<TaskContext>> {
-        let Some(project) = self.project.clone() else {
-            return Task::ready(None);
-        };
-        let (selection, buffer, editor_snapshot) = {
-            let selection = self.selections.newest_adjusted(&self.display_snapshot(cx));
-            let Some((buffer, _)) = self
-                .buffer()
-                .read(cx)
-                .point_to_buffer_offset(selection.start, cx)
-            else {
-                return Task::ready(None);
-            };
-            let snapshot = self.snapshot(window, cx);
-            (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,
-        };
-        let captured_variables = {
-            let mut variables = TaskVariables::default();
-            let buffer = location.buffer.read(cx);
-            let buffer_id = buffer.remote_id();
-            let snapshot = buffer.snapshot();
-            let starting_point = location.range.start.to_point(&snapshot);
-            let starting_offset = starting_point.to_offset(&snapshot);
-            for (_, tasks) in self
-                .tasks
-                .range((buffer_id, 0)..(buffer_id, starting_point.row + 1))
-            {
-                if !tasks
-                    .context_range
-                    .contains(&crate::BufferOffset(starting_offset))
-                {
-                    continue;
-                }
-                for (capture_name, value) in tasks.extra_variables.iter() {
-                    variables.insert(
-                        VariableName::Custom(capture_name.to_owned().into()),
-                        value.clone(),
-                    );
-                }
-            }
-            variables
-        };
-
-        project.update(cx, |project, cx| {
-            project.task_store().update(cx, |task_store, cx| {
-                task_store.task_context_for_location(captured_variables, location, cx)
-            })
-        })
-    }
-
-    pub fn lsp_task_sources(&self, cx: &App) -> HashMap<LanguageServerName, Vec<BufferId>> {
-        let lsp_settings = &ProjectSettings::get_global(cx).lsp;
-
-        self.buffer()
-            .read(cx)
-            .all_buffers()
-            .into_iter()
-            .filter_map(|buffer| {
-                let lsp_tasks_source = buffer
-                    .read(cx)
-                    .language()?
-                    .context_provider()?
-                    .lsp_task_source()?;
-                if lsp_settings
-                    .get(&lsp_tasks_source)
-                    .is_none_or(|s| s.enable_lsp_tasks)
-                {
-                    let buffer_id = buffer.read(cx).remote_id();
-                    Some((lsp_tasks_source, buffer_id))
-                } else {
-                    None
-                }
-            })
-            .fold(
-                HashMap::default(),
-                |mut acc, (lsp_task_source, buffer_id)| {
-                    acc.entry(lsp_task_source)
-                        .or_insert_with(Vec::new)
-                        .push(buffer_id);
-                    acc
-                },
-            )
-    }
-}

crates/tasks_ui/src/tasks_ui.rs 🔗

@@ -316,7 +316,9 @@ pub fn task_contexts(
 
     let lsp_task_sources = active_editor
         .as_ref()
-        .map(|active_editor| active_editor.update(cx, |editor, cx| editor.lsp_task_sources(cx)))
+        .map(|active_editor| {
+            active_editor.update(cx, |editor, cx| editor.lsp_task_sources(false, false, cx))
+        })
         .unwrap_or_default();
 
     let latest_selection = active_editor.as_ref().map(|active_editor| {