From 293e080f03b3b5f5d0624f7aeca4cd0256e5bc39 Mon Sep 17 00:00:00 2001 From: Thorsten Ball Date: Thu, 31 Oct 2024 14:25:57 +0100 Subject: [PATCH] tasks: Add `editor: Spawn Nearest Task` action (#19901) This spawns the runnable task that that's closest to the cursor. One thing missing right now is that it doesn't find tasks that are attached to non-outline symbols, such as subtests in Go. Release Notes: - Added a new reveal option for tasks: `"no_focus"`. If used, the tasks terminal panel will be opened and shown, but not focused. - Added a new `editor: spawn nearest task` action that spawns the task with a run indicator icon nearest to the cursor. It can be configured to also use a `reveal` strategy. Example: ```json { "context": "EmptyPane || SharedScreen || vim_mode == normal", "bindings": { ", r t": ["editor::SpawnNearestTask", { "reveal": "no_focus" }], } } ``` Demo: https://github.com/user-attachments/assets/0d1818f0-7ae4-4200-8c3e-0ed47550c298 --------- Co-authored-by: Bennet --- assets/settings/initial_tasks.json | 1 + crates/editor/src/actions.rs | 8 + crates/editor/src/editor.rs | 173 +++++++++++++++++---- crates/editor/src/editor_tests.rs | 83 ++++++++++ crates/editor/src/element.rs | 3 +- crates/task/src/task_template.rs | 2 + crates/terminal_view/src/terminal_panel.rs | 26 +++- docs/src/tasks.md | 1 + 8 files changed, 259 insertions(+), 38 deletions(-) diff --git a/assets/settings/initial_tasks.json b/assets/settings/initial_tasks.json index 72f1da0173..31808ac632 100644 --- a/assets/settings/initial_tasks.json +++ b/assets/settings/initial_tasks.json @@ -16,6 +16,7 @@ "allow_concurrent_runs": false, // What to do with the terminal pane and tab, after the command was started: // * `always` — always show the terminal pane, add and focus the corresponding task's tab in it (default) + // * `no_focus` — always show the terminal pane, add/reuse the task's tab there, but don't focus it // * `never` — avoid changing current terminal pane focus, but still add/reuse the task's tab there "reveal": "always", // What to do with the terminal pane and tab, after the command had finished: diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 474655700c..3d932b931a 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -159,6 +159,13 @@ pub struct DeleteToPreviousWordStart { pub struct FoldAtLevel { pub level: u32, } + +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct SpawnNearestTask { + #[serde(default)] + pub reveal: task::RevealStrategy, +} + impl_actions!( editor, [ @@ -184,6 +191,7 @@ impl_actions!( SelectToBeginningOfLine, SelectToEndOfLine, SelectUpByLines, + SpawnNearestTask, ShowCompletions, ToggleCodeActions, ToggleComments, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 16ba3d8b8b..27bccb40b3 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -502,6 +502,19 @@ struct RunnableTasks { 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)] struct ResolvedTasks { templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>, @@ -4723,29 +4736,7 @@ impl Editor { .as_ref() .zip(editor.project.clone()) .map(|(tasks, project)| { - let position = Point::new(buffer_row, tasks.column); - let range_start = buffer.read(cx).anchor_at(position, Bias::Right); - let location = Location { - buffer: buffer.clone(), - range: range_start..range_start, - }; - // Fill in the environmental variables from the tree-sitter captures - let mut captured_task_variables = TaskVariables::default(); - for (capture_name, value) in tasks.extra_variables.clone() { - captured_task_variables.insert( - task::VariableName::Custom(capture_name.into()), - value.clone(), - ); - } - project.update(cx, |project, cx| { - project.task_store().update(cx, |task_store, cx| { - task_store.task_context_for_location( - captured_task_variables, - location, - cx, - ) - }) - }) + Self::build_tasks_context(&project, &buffer, buffer_row, tasks, cx) }); Some(cx.spawn(|editor, mut cx| async move { @@ -4756,15 +4747,7 @@ impl Editor { let resolved_tasks = tasks.zip(task_context).map(|(tasks, task_context)| { Arc::new(ResolvedTasks { - templates: tasks - .templates - .iter() - .filter_map(|(kind, template)| { - template - .resolve_task(&kind.to_id_base(), &task_context) - .map(|task| (kind.clone(), task)) - }) - .collect(), + templates: tasks.resolve(&task_context).collect(), position: snapshot.buffer_snapshot.anchor_before(Point::new( multibuffer_point.row, tasks.column, @@ -5470,6 +5453,132 @@ impl Editor { } } + fn build_tasks_context( + project: &Model, + buffer: &Model, + buffer_row: u32, + tasks: &Arc, + cx: &mut ViewContext, + ) -> Task> { + let position = Point::new(buffer_row, tasks.column); + let range_start = buffer.read(cx).anchor_at(position, Bias::Right); + let location = Location { + buffer: buffer.clone(), + range: range_start..range_start, + }; + // Fill in the environmental variables from the tree-sitter captures + let mut captured_task_variables = TaskVariables::default(); + for (capture_name, value) in tasks.extra_variables.clone() { + captured_task_variables.insert( + task::VariableName::Custom(capture_name.into()), + value.clone(), + ); + } + project.update(cx, |project, cx| { + project.task_store().update(cx, |task_store, cx| { + task_store.task_context_for_location(captured_task_variables, location, cx) + }) + }) + } + + pub fn spawn_nearest_task(&mut self, action: &SpawnNearestTask, cx: &mut ViewContext) { + 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(|_, mut cx| async move { + let context = task_context.await?; + let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?; + + let resolved = resolved_task.resolved.as_mut()?; + resolved.reveal = reveal_strategy; + + workspace + .update(&mut cx, |workspace, cx| { + workspace::tasks::schedule_resolved_task( + workspace, + task_source_kind, + resolved_task, + false, + cx, + ); + }) + .ok() + }) + .detach(); + } + + fn find_closest_task( + &mut self, + cx: &mut ViewContext, + ) -> Option<(Model, u32, Arc)> { + let cursor_row = self.selections.newest_adjusted(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 ViewContext, + ) -> Option<(Model, u32, Arc)> { + let snapshot = self.buffer.read(cx).snapshot(cx); + let offset = self.selections.newest::(cx).head(); + let excerpt = snapshot.excerpt_containing(offset..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).is_some() { + if cursor.node().end_byte() == offset { + 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 && node_range.end >= offset { + // 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, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 02583889bc..92d62fac7f 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -13330,6 +13330,89 @@ async fn test_goto_definition_with_find_all_references_fallback(cx: &mut gpui::T }); } +#[gpui::test] +async fn test_find_enclosing_node_with_task(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let text = r#" + #[cfg(test)] + mod tests() { + #[test] + fn runnable_1() { + let a = 1; + } + + #[test] + fn runnable_2() { + let a = 1; + let b = 2; + } + } + "# + .unindent(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file("/file.rs", Default::default()).await; + + let project = Project::test(fs, ["/a".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let buffer = cx.new_model(|cx| Buffer::local(text, cx).with_language(language, cx)); + let multi_buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + + let editor = cx.new_view(|cx| { + Editor::new( + EditorMode::Full, + multi_buffer, + Some(project.clone()), + true, + cx, + ) + }); + + editor.update(cx, |editor, cx| { + editor.tasks.insert( + (buffer.read(cx).remote_id(), 3), + RunnableTasks { + templates: vec![], + offset: MultiBufferOffset(43), + column: 0, + extra_variables: HashMap::default(), + context_range: BufferOffset(43)..BufferOffset(85), + }, + ); + editor.tasks.insert( + (buffer.read(cx).remote_id(), 8), + RunnableTasks { + templates: vec![], + offset: MultiBufferOffset(86), + column: 0, + extra_variables: HashMap::default(), + context_range: BufferOffset(86)..BufferOffset(191), + }, + ); + + // Test finding task when cursor is inside function body + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(4, 5)..Point::new(4, 5)]) + }); + let (_, row, _) = editor.find_enclosing_node_task(cx).unwrap(); + assert_eq!(row, 3, "Should find task for cursor inside runnable_1"); + + // Test finding task when cursor is on function name + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(8, 4)..Point::new(8, 4)]) + }); + let (_, row, _) = editor.find_enclosing_node_task(cx).unwrap(); + assert_eq!(row, 8, "Should find task when cursor is on function name"); + }); +} + fn empty_range(row: usize, column: usize) -> Range { let point = DisplayPoint::new(DisplayRow(row as u32), column as u32); point..point diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index ac4d5d2340..a3d634378a 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -449,7 +449,8 @@ impl EditorElement { register_action(view, cx, Editor::apply_all_diff_hunks); register_action(view, cx, Editor::apply_selected_diff_hunks); register_action(view, cx, Editor::open_active_item_in_terminal); - register_action(view, cx, Editor::reload_file) + register_action(view, cx, Editor::reload_file); + register_action(view, cx, Editor::spawn_nearest_task); } fn register_key_listeners(&self, cx: &mut WindowContext, layout: &EditorLayout) { diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs index 2bf40f52ae..b72a0d25f8 100644 --- a/crates/task/src/task_template.rs +++ b/crates/task/src/task_template.rs @@ -66,6 +66,8 @@ pub enum RevealStrategy { /// Always show the terminal pane, add and focus the corresponding task's tab in it. #[default] Always, + /// Always show the terminal pane, add the task's tab in it, but don't focus it. + NoFocus, /// Do not change terminal pane focus, but still add/reuse the task's tab there. Never, } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 582d0d78c3..6d64ac1a48 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -575,9 +575,9 @@ impl TerminalPanel { .collect() } - fn activate_terminal_view(&self, item_index: usize, cx: &mut WindowContext) { + fn activate_terminal_view(&self, item_index: usize, focus: bool, cx: &mut WindowContext) { self.pane.update(cx, |pane, cx| { - pane.activate_item(item_index, true, true, cx) + pane.activate_item(item_index, true, focus, cx) }) } @@ -616,8 +616,14 @@ impl TerminalPanel { pane.add_item(terminal_view, true, focus, None, cx); }); - if reveal_strategy == RevealStrategy::Always { - workspace.focus_panel::(cx); + match reveal_strategy { + RevealStrategy::Always => { + workspace.focus_panel::(cx); + } + RevealStrategy::NoFocus => { + workspace.open_panel::(cx); + } + RevealStrategy::Never => {} } Ok(terminal) })?; @@ -698,7 +704,7 @@ impl TerminalPanel { match reveal { RevealStrategy::Always => { - self.activate_terminal_view(terminal_item_index, cx); + self.activate_terminal_view(terminal_item_index, true, cx); let task_workspace = self.workspace.clone(); cx.spawn(|_, mut cx| async move { task_workspace @@ -707,6 +713,16 @@ impl TerminalPanel { }) .detach(); } + RevealStrategy::NoFocus => { + self.activate_terminal_view(terminal_item_index, false, cx); + let task_workspace = self.workspace.clone(); + cx.spawn(|_, mut cx| async move { + task_workspace + .update(&mut cx, |workspace, cx| workspace.open_panel::(cx)) + .ok() + }) + .detach(); + } RevealStrategy::Never => {} } diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 42a1ac4c38..3f81aefc39 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -18,6 +18,7 @@ Zed supports ways to spawn (and rerun) commands using its integrated terminal to "allow_concurrent_runs": false, // What to do with the terminal pane and tab, after the command was started: // * `always` — always show the terminal pane, add and focus the corresponding task's tab in it (default) + // * `no_focus` — always show the terminal pane, add/reuse the task's tab there, but don't focus it // * `never` — avoid changing current terminal pane focus, but still add/reuse the task's tab there "reveal": "always", // What to do with the terminal pane and tab, after the command had finished: