Added `menu::UseSelectedQuery` command that populates task modal query with the selected task name (#8572)

Kirill Bulatov created

Change summary

Cargo.lock                           |   3 
assets/keymaps/default-linux.json    |   1 
assets/keymaps/default-macos.json    |   1 
crates/menu/src/menu.rs              |   3 
crates/picker/src/picker.rs          |  12 +
crates/project/src/project.rs        |   2 
crates/project/src/task_inventory.rs | 244 ++++++++++++++++-------------
crates/tasks_ui/Cargo.toml           |   7 
crates/tasks_ui/src/modal.rs         | 181 ++++++++++++++++++++++
docs/src/tasks.md                    |   2 
10 files changed, 340 insertions(+), 116 deletions(-)

Detailed changes

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",

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",

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",

crates/menu/src/menu.rs 🔗

@@ -19,6 +19,7 @@ actions!(
         SelectNext,
         SelectFirst,
         SelectLast,
-        ShowContextMenu
+        ShowContextMenu,
+        UseSelectedQuery,
     ]
 );

crates/picker/src/picker.rs 🔗

@@ -32,6 +32,7 @@ pub struct Picker<D: PickerDelegate> {
 
 pub trait PickerDelegate: Sized + 'static {
     type ListItem: IntoElement;
+
     fn match_count(&self) -> usize;
     fn selected_index(&self) -> usize;
     fn separators_after_indices(&self) -> Vec<usize> {
@@ -57,6 +58,9 @@ pub trait PickerDelegate: Sized + 'static {
 
     fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>);
     fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>);
+    fn selected_as_query(&self) -> Option<String> {
+        None
+    }
 
     fn render_match(
         &self,
@@ -239,6 +243,13 @@ impl<D: PickerDelegate> Picker<D> {
         }
     }
 
+    fn use_selected_query(&mut self, _: &menu::UseSelectedQuery, cx: &mut ViewContext<Self>) {
+        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<Self>) {
         cx.stop_propagation();
         cx.prevent_default();
@@ -384,6 +395,7 @@ impl<D: PickerDelegate> Render for Picker<D> {
             .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| {

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;

crates/project/src/task_inventory.rs 🔗

@@ -54,7 +54,7 @@ impl TaskSourceKind {
 }
 
 impl Inventory {
-    pub(crate) fn new(cx: &mut AppContext) -> Model<Self> {
+    pub fn new(cx: &mut AppContext) -> Model<Self> {
         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<PathBuf>) -> Option<task::SpawnInTerminal> {
+            None
+        }
+    }
+
+    pub struct StaticTestSource {
+        pub tasks: Vec<TestTask>,
+    }
+
+    impl StaticTestSource {
+        pub fn new(
+            task_names: impl IntoIterator<Item = String>,
+            cx: &mut AppContext,
+        ) -> Model<Box<dyn TaskSource>> {
+            cx.new_model(|_| {
+                Box::new(Self {
+                    tasks: task_names
+                        .into_iter()
+                        .enumerate()
+                        .map(|(i, name)| TestTask {
+                            id: TaskId(format!("task_{i}_{name}")),
+                            name,
+                        })
+                        .collect(),
+                }) as Box<dyn TaskSource>
+            })
+        }
+    }
+
+    impl TaskSource for StaticTestSource {
+        fn tasks_for_path(
+            &mut self,
+            _path: Option<&Path>,
+            _cx: &mut ModelContext<Box<dyn TaskSource>>,
+        ) -> Vec<Arc<dyn Task>> {
+            self.tasks
+                .clone()
+                .into_iter()
+                .map(|task| Arc::new(task) as Arc<dyn Task>)
+                .collect()
+        }
+
+        fn as_any(&mut self) -> &mut dyn std::any::Any {
+            self
+        }
+    }
+
+    pub fn list_task_names(
+        inventory: &Model<Inventory>,
+        path: Option<&Path>,
+        worktree: Option<WorktreeId>,
+        lru: bool,
+        cx: &mut TestAppContext,
+    ) -> Vec<String> {
+        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<Inventory>,
+        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<Inventory>,
+        path: Option<&Path>,
+        worktree: Option<WorktreeId>,
+        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<PathBuf>) -> Option<task::SpawnInTerminal> {
-            None
-        }
-    }
-
-    struct StaticTestSource {
-        tasks: Vec<TestTask>,
-    }
-
-    impl StaticTestSource {
-        fn new(
-            task_names: impl IntoIterator<Item = String>,
-            cx: &mut AppContext,
-        ) -> Model<Box<dyn TaskSource>> {
-            cx.new_model(|_| {
-                Box::new(Self {
-                    tasks: task_names
-                        .into_iter()
-                        .enumerate()
-                        .map(|(i, name)| TestTask {
-                            id: TaskId(format!("task_{i}_{name}")),
-                            name,
-                        })
-                        .collect(),
-                }) as Box<dyn TaskSource>
-            })
-        }
-    }
-
-    impl TaskSource for StaticTestSource {
-        fn tasks_for_path(
-            &mut self,
-            // static task source does not depend on path input
-            _: Option<&Path>,
-            _cx: &mut ModelContext<Box<dyn TaskSource>>,
-        ) -> Vec<Arc<dyn Task>> {
-            self.tasks
-                .clone()
-                .into_iter()
-                .map(|task| Arc::new(task) as Arc<dyn Task>)
-                .collect()
-        }
-
-        fn as_any(&mut self) -> &mut dyn std::any::Any {
-            self
-        }
-    }
-
-    fn list_task_names(
-        inventory: &Model<Inventory>,
-        path: Option<&Path>,
-        worktree: Option<WorktreeId>,
-        lru: bool,
-        cx: &mut TestAppContext,
-    ) -> Vec<String> {
-        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<Inventory>,
-        path: Option<&Path>,
-        worktree: Option<WorktreeId>,
-        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<Inventory>, 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());
-        });
-    }
 }

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"] }

crates/tasks_ui/src/modal.rs 🔗

@@ -97,6 +97,7 @@ impl TasksModal {
 impl Render for TasksModal {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> 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<str> {
         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<String> {
+        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::<String>::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<Workspace>,
+        cx: &mut VisualTestContext,
+    ) -> View<Picker<TasksModalDelegate>> {
+        cx.dispatch_action(crate::modal::Spawn);
+        workspace.update(cx, |workspace, cx| {
+            workspace
+                .active_modal::<TasksModal>(cx)
+                .unwrap()
+                .read(cx)
+                .picker
+                .clone()
+        })
+    }
+
+    fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
+        spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
+    }
+
+    fn task_names(
+        spawn_tasks: &View<Picker<TasksModalDelegate>>,
+        cx: &mut VisualTestContext,
+    ) -> Vec<String> {
+        spawn_tasks.update(cx, |spawn_tasks, _| {
+            spawn_tasks
+                .delegate
+                .matches
+                .iter()
+                .map(|hit| hit.string.clone())
+                .collect::<Vec<_>>()
+        })
+    }
+
+    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
+        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
+        })
+    }
 }

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.