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

This commit is contained in:
Kirill Bulatov 2024-02-29 02:20:43 +02:00 committed by GitHub
parent 9bd5ebb74b
commit b7429bf29d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 340 additions and 116 deletions

3
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

@ -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| {

View File

@ -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;

View File

@ -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());
});
}
}

View File

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

View File

@ -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
})
}
}

View File

@ -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.