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",
Kirill Bulatov created
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(-)
@@ -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",
@@ -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",
@@ -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",
@@ -19,6 +19,7 @@ actions!(
SelectNext,
SelectFirst,
SelectLast,
- ShowContextMenu
+ ShowContextMenu,
+ UseSelectedQuery,
]
);
@@ -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| {
@@ -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;
@@ -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());
- });
- }
}
@@ -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"] }
@@ -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
+ })
+ }
}
@@ -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.