From 2b425aa102142932a88275341913e9e3d99bbbec Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 11 Mar 2026 17:05:22 +0200 Subject: [PATCH] Limit editors' refresh runnables (#51299) 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 --- 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(-) create mode 100644 crates/editor/src/runnables.rs delete mode 100644 crates/editor/src/tasks.rs diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a08ac3bbc466d159ce81a7aa3bebf82599914a0b..ca3dd81ab072d0e20389318515049793a8c827ef 100644 --- a/crates/editor/src/editor.rs +++ b/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, - // 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, -} - -impl RunnableTasks { - fn resolve<'a>( - &'a self, - cx: &'a task::TaskContext, - ) -> impl Iterator + '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>, last_position_map: Option>, expect_bounds_change: Option>, - tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>, - tasks_update_task: Option>, + runnables: RunnableData, breakpoint_store: Option>, gutter_breakpoint_indicator: (Option, Option>), pub(crate) gutter_diff_review_indicator: (Option, Option>), @@ -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 { + 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, - ) { - 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, - ) -> Option<(Entity, u32, Arc)> { - 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, - ) -> Option<(Entity, u32, Arc)> { - let snapshot = self.buffer.read(cx).snapshot(cx); - let offset = self - .selections - .newest::(&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)>, - cx: &mut Context, - ) -> 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) -> 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, - snapshot: DisplaySnapshot, - prefer_lsp: bool, - runnable_ranges: Vec<(Range, language::RunnableRange)>, - cx: AsyncWindowContext, - ) -> Task> { - 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, - runnable: &mut Runnable, - cx: &mut App, - ) -> Task> { - 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); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index d3da58733dd0a24622a6dcde87f638069e206cf4..fe71cb76f0f16dc7a928ccff725585c0e857c62e 100644 --- a/crates/editor/src/editor_tests.rs +++ b/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(), diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index b7207fce71bc71c5bdd5962ca3328030935238ca..3b1356525960654ea88c6cfa84115f1e67ac2e5b 100644 --- a/crates/editor/src/element.rs +++ b/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 diff --git a/crates/editor/src/runnables.rs b/crates/editor/src/runnables.rs new file mode 100644 index 0000000000000000000000000000000000000000..9fa6b89ec130e74f388c5e82b9b346197bb13abb --- /dev/null +++ b/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)>, + 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 { + 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, + // 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, +} + +impl RunnableTasks { + pub fn resolve<'a>( + &'a self, + cx: &'a task::TaskContext, + ) -> impl Iterator + '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) { + 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, + ) { + 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) { + 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> { + 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, + ) -> HashMap> { + 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, + ) -> Option<(Entity, u32, Arc)> { + let snapshot = self.buffer.read(cx).snapshot(cx); + let offset = self + .selections + .newest::(&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)>, + cx: &mut Context, + ) -> 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, + snapshot: MultiBufferSnapshot, + prefer_lsp: bool, + runnable_ranges: Vec<(Range, language::RunnableRange)>, + cx: AsyncWindowContext, + ) -> Task> { + 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, + runnable: &mut Runnable, + cx: &mut App, + ) -> Task> { + 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, + ) -> Option<(Entity, u32, Arc)> { + 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>, + _: &gpui::App, + ) -> Task> { + 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 { + 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)> { + let mut result = editor + .runnables + .runnables + .iter() + .flat_map(|(buffer_id, (_, tasks))| { + tasks.iter().map(move |(row, runnable_tasks)| { + let mut labels: Vec = runnable_tasks + .templates + .iter() + .map(|(_, template)| template.label.clone()) + .collect(); + labels.sort(); + (*buffer_id, *row, labels) + }) + }) + .collect::>(); + 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" + ); + } +} diff --git a/crates/editor/src/tasks.rs b/crates/editor/src/tasks.rs deleted file mode 100644 index e39880ddc1f575a7b12f40c5496c75c1f473c6e9..0000000000000000000000000000000000000000 --- a/crates/editor/src/tasks.rs +++ /dev/null @@ -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> { - 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> { - 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 - }, - ) - } -} diff --git a/crates/tasks_ui/src/tasks_ui.rs b/crates/tasks_ui/src/tasks_ui.rs index 29e6a9de7fab9b5421fe38fee0fd24fd43b12ccc..fdacef3b193beb8a656916edb61fbff1a200385b 100644 --- a/crates/tasks_ui/src/tasks_ui.rs +++ b/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| {