diff --git a/Cargo.lock b/Cargo.lock index d4486db94c..dc7126a608 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9140,12 +9140,15 @@ name = "tasks_ui" version = "0.1.0" dependencies = [ "anyhow", + "editor", "fuzzy", "gpui", + "language", "menu", "picker", "project", "serde", + "serde_json", "task", "ui", "util", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index feeb0f081b..f55e637655 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -16,6 +16,7 @@ "ctrl-enter": "menu::SecondaryConfirm", "escape": "menu::Cancel", "ctrl-c": "menu::Cancel", + "shift-enter": "menu::UseSelectedQuery", "ctrl-shift-w": "workspace::CloseWindow", "shift-escape": "workspace::ToggleZoom", "ctrl-o": "workspace::Open", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index f12eff73e8..a24a6e44ed 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -17,6 +17,7 @@ "cmd-enter": "menu::SecondaryConfirm", "escape": "menu::Cancel", "ctrl-c": "menu::Cancel", + "shift-enter": "menu::UseSelectedQuery", "cmd-shift-w": "workspace::CloseWindow", "shift-escape": "workspace::ToggleZoom", "cmd-o": "workspace::Open", diff --git a/crates/menu/src/menu.rs b/crates/menu/src/menu.rs index a0e8abfabd..69bd0860db 100644 --- a/crates/menu/src/menu.rs +++ b/crates/menu/src/menu.rs @@ -19,6 +19,7 @@ actions!( SelectNext, SelectFirst, SelectLast, - ShowContextMenu + ShowContextMenu, + UseSelectedQuery, ] ); diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index fdf16be66f..033f6c7661 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -32,6 +32,7 @@ pub struct Picker { pub trait PickerDelegate: Sized + 'static { type ListItem: IntoElement; + fn match_count(&self) -> usize; fn selected_index(&self) -> usize; fn separators_after_indices(&self) -> Vec { @@ -57,6 +58,9 @@ pub trait PickerDelegate: Sized + 'static { fn confirm(&mut self, secondary: bool, cx: &mut ViewContext>); fn dismissed(&mut self, cx: &mut ViewContext>); + fn selected_as_query(&self) -> Option { + None + } fn render_match( &self, @@ -239,6 +243,13 @@ impl Picker { } } + fn use_selected_query(&mut self, _: &menu::UseSelectedQuery, cx: &mut ViewContext) { + if let Some(new_query) = self.delegate.selected_as_query() { + self.set_query(new_query, cx); + cx.stop_propagation(); + } + } + fn handle_click(&mut self, ix: usize, secondary: bool, cx: &mut ViewContext) { cx.stop_propagation(); cx.prevent_default(); @@ -384,6 +395,7 @@ impl Render for Picker { .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::secondary_confirm)) + .on_action(cx.listener(Self::use_selected_query)) .child(picker_editor) .child(Divider::horizontal()) .when(self.delegate.match_count() > 0, |el| { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 9206b52c4a..9faf058ac6 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -99,6 +99,8 @@ pub use language::Location; pub use prettier::FORMAT_SUFFIX as TEST_PRETTIER_FORMAT_SUFFIX; pub use project_core::project_settings; pub use project_core::worktree::{self, *}; +#[cfg(feature = "test-support")] +pub use task_inventory::test_inventory::*; pub use task_inventory::{Inventory, TaskSourceKind}; const MAX_SERVER_REINSTALL_ATTEMPT_COUNT: u64 = 4; diff --git a/crates/project/src/task_inventory.rs b/crates/project/src/task_inventory.rs index 90d1850e8e..2c4b08c15c 100644 --- a/crates/project/src/task_inventory.rs +++ b/crates/project/src/task_inventory.rs @@ -54,7 +54,7 @@ impl TaskSourceKind { } impl Inventory { - pub(crate) fn new(cx: &mut AppContext) -> Model { + pub fn new(cx: &mut AppContext) -> Model { cx.new_model(|_| Self { sources: Vec::new(), last_scheduled_tasks: VecDeque::new(), @@ -219,12 +219,140 @@ impl Inventory { } } +#[cfg(feature = "test-support")] +pub mod test_inventory { + use std::{ + path::{Path, PathBuf}, + sync::Arc, + }; + + use gpui::{AppContext, Context as _, Model, ModelContext, TestAppContext}; + use project_core::worktree::WorktreeId; + use task::{Task, TaskId, TaskSource}; + + use crate::Inventory; + + use super::TaskSourceKind; + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct TestTask { + pub id: task::TaskId, + pub name: String, + } + + impl Task for TestTask { + fn id(&self) -> &TaskId { + &self.id + } + + fn name(&self) -> &str { + &self.name + } + + fn cwd(&self) -> Option<&Path> { + None + } + + fn exec(&self, _cwd: Option) -> Option { + None + } + } + + pub struct StaticTestSource { + pub tasks: Vec, + } + + impl StaticTestSource { + pub fn new( + task_names: impl IntoIterator, + cx: &mut AppContext, + ) -> Model> { + cx.new_model(|_| { + Box::new(Self { + tasks: task_names + .into_iter() + .enumerate() + .map(|(i, name)| TestTask { + id: TaskId(format!("task_{i}_{name}")), + name, + }) + .collect(), + }) as Box + }) + } + } + + impl TaskSource for StaticTestSource { + fn tasks_for_path( + &mut self, + _path: Option<&Path>, + _cx: &mut ModelContext>, + ) -> Vec> { + self.tasks + .clone() + .into_iter() + .map(|task| Arc::new(task) as Arc) + .collect() + } + + fn as_any(&mut self) -> &mut dyn std::any::Any { + self + } + } + + pub fn list_task_names( + inventory: &Model, + path: Option<&Path>, + worktree: Option, + lru: bool, + cx: &mut TestAppContext, + ) -> Vec { + inventory.update(cx, |inventory, cx| { + inventory + .list_tasks(path, worktree, lru, cx) + .into_iter() + .map(|(_, task)| task.name().to_string()) + .collect() + }) + } + + pub fn register_task_used( + inventory: &Model, + task_name: &str, + cx: &mut TestAppContext, + ) { + inventory.update(cx, |inventory, cx| { + let task = inventory + .list_tasks(None, None, false, cx) + .into_iter() + .find(|(_, task)| task.name() == task_name) + .unwrap_or_else(|| panic!("Failed to find task with name {task_name}")); + inventory.task_scheduled(task.1.id().clone()); + }); + } + + pub fn list_tasks( + inventory: &Model, + path: Option<&Path>, + worktree: Option, + lru: bool, + cx: &mut TestAppContext, + ) -> Vec<(TaskSourceKind, String)> { + inventory.update(cx, |inventory, cx| { + inventory + .list_tasks(path, worktree, lru, cx) + .into_iter() + .map(|(source_kind, task)| (source_kind, task.name().to_string())) + .collect() + }) + } +} + #[cfg(test)] mod tests { - use std::path::PathBuf; - use gpui::TestAppContext; + use super::test_inventory::*; use super::*; #[gpui::test] @@ -532,114 +660,4 @@ mod tests { ); } } - - #[derive(Debug, Clone, PartialEq, Eq)] - struct TestTask { - id: TaskId, - name: String, - } - - impl Task for TestTask { - fn id(&self) -> &TaskId { - &self.id - } - - fn name(&self) -> &str { - &self.name - } - - fn cwd(&self) -> Option<&Path> { - None - } - - fn exec(&self, _cwd: Option) -> Option { - None - } - } - - struct StaticTestSource { - tasks: Vec, - } - - impl StaticTestSource { - fn new( - task_names: impl IntoIterator, - cx: &mut AppContext, - ) -> Model> { - cx.new_model(|_| { - Box::new(Self { - tasks: task_names - .into_iter() - .enumerate() - .map(|(i, name)| TestTask { - id: TaskId(format!("task_{i}_{name}")), - name, - }) - .collect(), - }) as Box - }) - } - } - - impl TaskSource for StaticTestSource { - fn tasks_for_path( - &mut self, - // static task source does not depend on path input - _: Option<&Path>, - _cx: &mut ModelContext>, - ) -> Vec> { - self.tasks - .clone() - .into_iter() - .map(|task| Arc::new(task) as Arc) - .collect() - } - - fn as_any(&mut self) -> &mut dyn std::any::Any { - self - } - } - - fn list_task_names( - inventory: &Model, - path: Option<&Path>, - worktree: Option, - lru: bool, - cx: &mut TestAppContext, - ) -> Vec { - inventory.update(cx, |inventory, cx| { - inventory - .list_tasks(path, worktree, lru, cx) - .into_iter() - .map(|(_, task)| task.name().to_string()) - .collect() - }) - } - - fn list_tasks( - inventory: &Model, - path: Option<&Path>, - worktree: Option, - lru: bool, - cx: &mut TestAppContext, - ) -> Vec<(TaskSourceKind, String)> { - inventory.update(cx, |inventory, cx| { - inventory - .list_tasks(path, worktree, lru, cx) - .into_iter() - .map(|(source_kind, task)| (source_kind, task.name().to_string())) - .collect() - }) - } - - fn register_task_used(inventory: &Model, task_name: &str, cx: &mut TestAppContext) { - inventory.update(cx, |inventory, cx| { - let (_, task) = inventory - .list_tasks(None, None, false, cx) - .into_iter() - .find(|(_, task)| task.name() == task_name) - .unwrap_or_else(|| panic!("Failed to find task with name {task_name}")); - inventory.task_scheduled(task.id().clone()); - }); - } } diff --git a/crates/tasks_ui/Cargo.toml b/crates/tasks_ui/Cargo.toml index c4faf4d3b5..6c350ff931 100644 --- a/crates/tasks_ui/Cargo.toml +++ b/crates/tasks_ui/Cargo.toml @@ -17,3 +17,10 @@ serde.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true + +[dev-dependencies] +editor = { workspace = true, features = ["test-support"] } +language = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +serde_json.workspace = true +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index dcc740a8dd..6431145e08 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -97,6 +97,7 @@ impl TasksModal { impl Render for TasksModal { fn render(&mut self, cx: &mut ViewContext) -> impl gpui::prelude::IntoElement { v_flex() + .key_context("TasksModal") .w(rems(34.)) .child(self.picker.clone()) .on_mouse_down_out(cx.listener(|modal, _, cx| { @@ -134,9 +135,10 @@ impl PickerDelegate for TasksModalDelegate { fn placeholder_text(&self, cx: &mut WindowContext) -> Arc { Arc::from(format!( - "{} runs the selected task, {} spawns a bash-like task from the prompt", - cx.keystroke_text_for(&menu::Confirm), + "{} use task name as prompt, {} spawns a bash-like task from the prompt, {} runs the selected task", + cx.keystroke_text_for(&menu::UseSelectedQuery), cx.keystroke_text_for(&menu::SecondaryConfirm), + cx.keystroke_text_for(&menu::Confirm), )) } @@ -266,4 +268,179 @@ impl PickerDelegate for TasksModalDelegate { .child(highlighted_location.render(cx)), ) } + + fn selected_as_query(&self) -> Option { + Some(self.matches.get(self.selected_index())?.string.clone()) + } +} + +#[cfg(test)] +mod tests { + use gpui::{TestAppContext, VisualTestContext}; + use project::{FakeFs, Project}; + use serde_json::json; + use workspace::AppState; + + use super::*; + + #[gpui::test] + async fn test_name(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/dir", + json!({ + ".zed": { + "tasks.json": r#"[ + { + "label": "example task", + "command": "echo", + "args": ["4"] + }, + { + "label": "another one", + "command": "echo", + "args": ["55"] + }, + ]"#, + }, + "a.ts": "a" + }), + ) + .await; + + let project = Project::test(fs, ["/dir".as_ref()], cx).await; + project.update(cx, |project, cx| { + project.task_inventory().update(cx, |inventory, cx| { + inventory.add_source(TaskSourceKind::UserInput, |cx| OneshotSource::new(cx), cx) + }) + }); + + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); + + let tasks_picker = open_spawn_tasks(&workspace, cx); + assert_eq!( + query(&tasks_picker, cx), + "", + "Initial query should be empty" + ); + assert_eq!( + task_names(&tasks_picker, cx), + vec!["another one", "example task"], + "Initial tasks should be listed in alphabetical order" + ); + + let query_str = "tas"; + cx.simulate_input(query_str); + assert_eq!(query(&tasks_picker, cx), query_str); + assert_eq!( + task_names(&tasks_picker, cx), + vec!["example task"], + "Only one task should match the query {query_str}" + ); + + cx.dispatch_action(menu::UseSelectedQuery); + assert_eq!( + query(&tasks_picker, cx), + "example task", + "Query should be set to the selected task's name" + ); + assert_eq!( + task_names(&tasks_picker, cx), + vec!["example task"], + "No other tasks should be listed" + ); + cx.dispatch_action(menu::Confirm); + + let tasks_picker = open_spawn_tasks(&workspace, cx); + assert_eq!( + query(&tasks_picker, cx), + "", + "Query should be reset after confirming" + ); + assert_eq!( + task_names(&tasks_picker, cx), + vec!["example task", "another one"], + "Last recently used task should be listed first" + ); + + let query_str = "echo 4"; + cx.simulate_input(query_str); + assert_eq!(query(&tasks_picker, cx), query_str); + assert_eq!( + task_names(&tasks_picker, cx), + Vec::::new(), + "No tasks should match custom command query" + ); + + cx.dispatch_action(menu::SecondaryConfirm); + let tasks_picker = open_spawn_tasks(&workspace, cx); + assert_eq!( + query(&tasks_picker, cx), + "", + "Query should be reset after confirming" + ); + assert_eq!( + task_names(&tasks_picker, cx), + vec![query_str, "example task", "another one"], + "Last recently used one show task should be listed first" + ); + + cx.dispatch_action(menu::UseSelectedQuery); + assert_eq!( + query(&tasks_picker, cx), + query_str, + "Query should be set to the custom task's name" + ); + assert_eq!( + task_names(&tasks_picker, cx), + vec![query_str], + "Only custom task should be listed" + ); + } + + fn open_spawn_tasks( + workspace: &View, + cx: &mut VisualTestContext, + ) -> View> { + cx.dispatch_action(crate::modal::Spawn); + workspace.update(cx, |workspace, cx| { + workspace + .active_modal::(cx) + .unwrap() + .read(cx) + .picker + .clone() + }) + } + + fn query(spawn_tasks: &View>, cx: &mut VisualTestContext) -> String { + spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx)) + } + + fn task_names( + spawn_tasks: &View>, + cx: &mut VisualTestContext, + ) -> Vec { + spawn_tasks.update(cx, |spawn_tasks, _| { + spawn_tasks + .delegate + .matches + .iter() + .map(|hit| hit.string.clone()) + .collect::>() + }) + } + + fn init_test(cx: &mut TestAppContext) -> Arc { + cx.update(|cx| { + let state = AppState::test(cx); + language::init(cx); + crate::init(cx); + editor::init(cx); + workspace::init_settings(cx); + Project::init_settings(cx); + state + }) + } } diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 688551ec85..bfe6f8f983 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -4,6 +4,8 @@ Zed supports ways to spawn (and rerun) commands using its integrated terminal to Currently, two kinds of tasks are supported, but more will be added in the future. +All tasks are are sorted in LRU order and their names can be used (with `menu::UseSelectedQuery`, `shift-enter` by default) as an input text for quicker oneshot task edit-spawn cycle. + ## Static tasks Tasks, defined in a config file (`tasks.json` in the Zed config directory) that do not depend on the current editor or its content.