
New list (used tasks are above the separator line, sorted by the usage recency), then all language tasks, then project-local and global tasks are listed. Note that there are two test tasks (for `test_name_1` and `test_name_2` functions) that are created from the same task template: <img width="563" alt="Screenshot 2024-04-10 at 01 00 46" src="https://github.com/zed-industries/zed/assets/2690773/7455a82f-2af2-47bf-99bd-d9c5a36e64ab"> Tasks are deduplicated by labels, with the used tasks left in case of the conflict with the new tasks from the template: <img width="555" alt="Screenshot 2024-04-10 at 01 01 06" src="https://github.com/zed-industries/zed/assets/2690773/8f5a249e-abec-46ef-a991-08c6d0348648"> Regular recent tasks can be now removed too: <img width="565" alt="Screenshot 2024-04-10 at 01 00 55" src="https://github.com/zed-industries/zed/assets/2690773/0976b8fe-b5d7-4d2a-953d-1d8b1f216192"> When the caret is in the place where no function symbol could be retrieved, no cargo tests for function are listed in tasks: <img width="556" alt="image" src="https://github.com/zed-industries/zed/assets/2690773/df30feba-fe27-4645-8be9-02afc70f02da"> Part of https://github.com/zed-industries/zed/issues/10132 Reworks the task code to simplify it and enable proper task labels. * removes `trait Task`, renames `Definition` into `TaskTemplate` and use that instead of `Arc<dyn Task>` everywhere * implement more generic `TaskId` generation that depends on the `TaskContext` and `TaskTemplate` * remove `TaskId` out of the template and only create it after "resolving" the template into the `ResolvedTask`: this way, task templates, task state (`TaskContext`) and task "result" (resolved state) are clearly separated and are not mixed * implement the logic for filtering out non-related language tasks and tasks that have non-resolved Zed task variables * rework Zed template-vs-resolved-task display in modal: now all reruns and recently used tasks are resolved tasks with "fixed" context (unless configured otherwise in the task json) that are always shown, and Zed can add on top tasks with different context that are derived from the same template as the used, resolved tasks * sort the tasks list better, showing more specific and least recently used tasks higher * shows a separator between used and unused tasks, allow removing the used tasks same as the oneshot ones * remote the Oneshot task source as redundant: all oneshot tasks are now stored in the inventory's history * when reusing the tasks as query in the modal, paste the expanded task label now, show trimmed resolved label in the modal * adjusts Rust and Elixir task labels to be more descriptive and closer to bash scripts Release Notes: - Improved task modal ordering, run and deletion capabilities
589 lines
20 KiB
Rust
589 lines
20 KiB
Rust
use std::sync::Arc;
|
|
|
|
use crate::{active_item_selection_properties, schedule_resolved_task};
|
|
use fuzzy::{StringMatch, StringMatchCandidate};
|
|
use gpui::{
|
|
impl_actions, rems, AppContext, DismissEvent, EventEmitter, FocusableView, Global,
|
|
InteractiveElement, Model, ParentElement, Render, SharedString, Styled, Subscription, View,
|
|
ViewContext, VisualContext, WeakView,
|
|
};
|
|
use picker::{highlighted_match_with_paths::HighlightedText, Picker, PickerDelegate};
|
|
use project::{Inventory, TaskSourceKind};
|
|
use task::{ResolvedTask, TaskContext, TaskTemplate};
|
|
use ui::{
|
|
div, v_flex, ButtonCommon, ButtonSize, Clickable, Color, FluentBuilder as _, Icon, IconButton,
|
|
IconButtonShape, IconName, IconSize, ListItem, ListItemSpacing, RenderOnce, Selectable,
|
|
Tooltip, WindowContext,
|
|
};
|
|
use util::ResultExt;
|
|
use workspace::{ModalView, Workspace};
|
|
|
|
use serde::Deserialize;
|
|
|
|
/// Spawn a task with name or open tasks modal
|
|
#[derive(PartialEq, Clone, Deserialize, Default)]
|
|
pub struct Spawn {
|
|
#[serde(default)]
|
|
/// Name of the task to spawn.
|
|
/// If it is not set, a modal with a list of available tasks is opened instead.
|
|
/// Defaults to None.
|
|
pub task_name: Option<String>,
|
|
}
|
|
|
|
impl Spawn {
|
|
pub(crate) fn modal() -> Self {
|
|
Self { task_name: None }
|
|
}
|
|
}
|
|
/// Rerun last task
|
|
#[derive(PartialEq, Clone, Deserialize, Default)]
|
|
pub struct Rerun {
|
|
#[serde(default)]
|
|
/// Controls whether the task context is reevaluated prior to execution of a task.
|
|
/// If it is not, environment variables such as ZED_COLUMN, ZED_FILE are gonna be the same as in the last execution of a task
|
|
/// If it is, these variables will be updated to reflect current state of editor at the time task::Rerun is executed.
|
|
/// default: false
|
|
pub reevaluate_context: bool,
|
|
}
|
|
|
|
impl_actions!(task, [Rerun, Spawn]);
|
|
|
|
/// A modal used to spawn new tasks.
|
|
pub(crate) struct TasksModalDelegate {
|
|
inventory: Model<Inventory>,
|
|
candidates: Option<Vec<(TaskSourceKind, ResolvedTask)>>,
|
|
last_used_candidate_index: Option<usize>,
|
|
matches: Vec<StringMatch>,
|
|
selected_index: usize,
|
|
workspace: WeakView<Workspace>,
|
|
prompt: String,
|
|
task_context: TaskContext,
|
|
placeholder_text: Arc<str>,
|
|
}
|
|
|
|
impl TasksModalDelegate {
|
|
fn new(
|
|
inventory: Model<Inventory>,
|
|
task_context: TaskContext,
|
|
workspace: WeakView<Workspace>,
|
|
) -> Self {
|
|
Self {
|
|
inventory,
|
|
workspace,
|
|
candidates: None,
|
|
matches: Vec::new(),
|
|
last_used_candidate_index: None,
|
|
selected_index: 0,
|
|
prompt: String::default(),
|
|
task_context,
|
|
placeholder_text: Arc::from("Run a task..."),
|
|
}
|
|
}
|
|
|
|
fn spawn_oneshot(&mut self) -> Option<(TaskSourceKind, ResolvedTask)> {
|
|
if self.prompt.trim().is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let source_kind = TaskSourceKind::UserInput;
|
|
let id_base = source_kind.to_id_base();
|
|
let new_oneshot = TaskTemplate {
|
|
label: self.prompt.clone(),
|
|
command: self.prompt.clone(),
|
|
..TaskTemplate::default()
|
|
};
|
|
Some((
|
|
source_kind,
|
|
new_oneshot.resolve_task(&id_base, self.task_context.clone())?,
|
|
))
|
|
}
|
|
|
|
fn delete_previously_used(&mut self, ix: usize, cx: &mut AppContext) {
|
|
let Some(candidates) = self.candidates.as_mut() else {
|
|
return;
|
|
};
|
|
let Some(task) = candidates.get(ix).map(|(_, task)| task.clone()) else {
|
|
return;
|
|
};
|
|
// We remove this candidate manually instead of .taking() the candidates, as we already know the index;
|
|
// it doesn't make sense to requery the inventory for new candidates, as that's potentially costly and more often than not it should just return back
|
|
// the original list without a removed entry.
|
|
candidates.remove(ix);
|
|
self.inventory.update(cx, |inventory, _| {
|
|
inventory.delete_previously_used(&task.id);
|
|
});
|
|
}
|
|
}
|
|
|
|
pub(crate) struct TasksModal {
|
|
picker: View<Picker<TasksModalDelegate>>,
|
|
_subscription: Subscription,
|
|
}
|
|
|
|
impl TasksModal {
|
|
pub(crate) fn new(
|
|
inventory: Model<Inventory>,
|
|
task_context: TaskContext,
|
|
workspace: WeakView<Workspace>,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> Self {
|
|
let picker = cx.new_view(|cx| {
|
|
Picker::uniform_list(
|
|
TasksModalDelegate::new(inventory, task_context, workspace),
|
|
cx,
|
|
)
|
|
});
|
|
let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
|
|
cx.emit(DismissEvent);
|
|
});
|
|
Self {
|
|
picker,
|
|
_subscription,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Render for TasksModal {
|
|
fn render(&mut self, _: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
|
|
v_flex()
|
|
.key_context("TasksModal")
|
|
.w(rems(34.))
|
|
.child(self.picker.clone())
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<DismissEvent> for TasksModal {}
|
|
|
|
impl FocusableView for TasksModal {
|
|
fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
|
|
self.picker.read(cx).focus_handle(cx)
|
|
}
|
|
}
|
|
|
|
impl ModalView for TasksModal {}
|
|
|
|
impl PickerDelegate for TasksModalDelegate {
|
|
type ListItem = ListItem;
|
|
|
|
fn match_count(&self) -> usize {
|
|
self.matches.len()
|
|
}
|
|
|
|
fn selected_index(&self) -> usize {
|
|
self.selected_index
|
|
}
|
|
|
|
fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<picker::Picker<Self>>) {
|
|
self.selected_index = ix;
|
|
}
|
|
|
|
fn placeholder_text(&self, _: &mut WindowContext) -> Arc<str> {
|
|
self.placeholder_text.clone()
|
|
}
|
|
|
|
fn update_matches(
|
|
&mut self,
|
|
query: String,
|
|
cx: &mut ViewContext<picker::Picker<Self>>,
|
|
) -> gpui::Task<()> {
|
|
cx.spawn(move |picker, mut cx| async move {
|
|
let Some(candidates) = picker
|
|
.update(&mut cx, |picker, cx| {
|
|
let candidates = match &mut picker.delegate.candidates {
|
|
Some(candidates) => candidates,
|
|
None => {
|
|
let Ok((worktree, language)) =
|
|
picker.delegate.workspace.update(cx, |workspace, cx| {
|
|
active_item_selection_properties(workspace, cx)
|
|
})
|
|
else {
|
|
return Vec::new();
|
|
};
|
|
let (used, current) =
|
|
picker.delegate.inventory.update(cx, |inventory, cx| {
|
|
inventory.used_and_current_resolved_tasks(
|
|
language,
|
|
worktree,
|
|
picker.delegate.task_context.clone(),
|
|
cx,
|
|
)
|
|
});
|
|
picker.delegate.last_used_candidate_index = if used.is_empty() {
|
|
None
|
|
} else {
|
|
Some(used.len() - 1)
|
|
};
|
|
|
|
let mut new_candidates = used;
|
|
new_candidates.extend(current);
|
|
picker.delegate.candidates.insert(new_candidates)
|
|
}
|
|
};
|
|
candidates
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, (_, candidate))| StringMatchCandidate {
|
|
id: index,
|
|
char_bag: candidate.resolved_label.chars().collect(),
|
|
string: candidate
|
|
.resolved
|
|
.as_ref()
|
|
.map(|resolved| resolved.label.clone())
|
|
.unwrap_or_else(|| candidate.resolved_label.clone()),
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.ok()
|
|
else {
|
|
return;
|
|
};
|
|
let matches = fuzzy::match_strings(
|
|
&candidates,
|
|
&query,
|
|
true,
|
|
1000,
|
|
&Default::default(),
|
|
cx.background_executor().clone(),
|
|
)
|
|
.await;
|
|
picker
|
|
.update(&mut cx, |picker, _| {
|
|
let delegate = &mut picker.delegate;
|
|
delegate.matches = matches;
|
|
delegate.prompt = query;
|
|
|
|
if delegate.matches.is_empty() {
|
|
delegate.selected_index = 0;
|
|
} else {
|
|
delegate.selected_index =
|
|
delegate.selected_index.min(delegate.matches.len() - 1);
|
|
}
|
|
})
|
|
.log_err();
|
|
})
|
|
}
|
|
|
|
fn confirm(&mut self, omit_history_entry: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
|
|
let current_match_index = self.selected_index();
|
|
let task = self
|
|
.matches
|
|
.get(current_match_index)
|
|
.and_then(|current_match| {
|
|
let ix = current_match.candidate_id;
|
|
self.candidates
|
|
.as_ref()
|
|
.map(|candidates| candidates[ix].clone())
|
|
});
|
|
let Some((task_source_kind, task)) = task else {
|
|
return;
|
|
};
|
|
|
|
self.workspace
|
|
.update(cx, |workspace, cx| {
|
|
schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx);
|
|
})
|
|
.ok();
|
|
cx.emit(DismissEvent);
|
|
}
|
|
|
|
fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
|
|
cx.emit(DismissEvent);
|
|
}
|
|
|
|
fn render_match(
|
|
&self,
|
|
ix: usize,
|
|
selected: bool,
|
|
cx: &mut ViewContext<picker::Picker<Self>>,
|
|
) -> Option<Self::ListItem> {
|
|
let candidates = self.candidates.as_ref()?;
|
|
let hit = &self.matches[ix];
|
|
let (source_kind, _) = &candidates.get(hit.candidate_id)?;
|
|
|
|
let highlighted_location = HighlightedText {
|
|
text: hit.string.clone(),
|
|
highlight_positions: hit.positions.clone(),
|
|
char_count: hit.string.chars().count(),
|
|
};
|
|
let icon = match source_kind {
|
|
TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)),
|
|
TaskSourceKind::AbsPath { .. } => Some(Icon::new(IconName::Settings)),
|
|
TaskSourceKind::Worktree { .. } => Some(Icon::new(IconName::FileTree)),
|
|
TaskSourceKind::Language { name } => file_icons::FileIcons::get(cx)
|
|
.get_type_icon(&name.to_lowercase())
|
|
.map(|icon_path| Icon::from_path(icon_path)),
|
|
};
|
|
Some(
|
|
ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
|
|
.inset(true)
|
|
.spacing(ListItemSpacing::Sparse)
|
|
.map(|item| {
|
|
let item = if matches!(source_kind, TaskSourceKind::UserInput)
|
|
|| Some(ix) <= self.last_used_candidate_index
|
|
{
|
|
let task_index = hit.candidate_id;
|
|
let delete_button = div().child(
|
|
IconButton::new("delete", IconName::Close)
|
|
.shape(IconButtonShape::Square)
|
|
.icon_color(Color::Muted)
|
|
.size(ButtonSize::None)
|
|
.icon_size(IconSize::XSmall)
|
|
.on_click(cx.listener(move |picker, _event, cx| {
|
|
cx.stop_propagation();
|
|
cx.prevent_default();
|
|
|
|
picker.delegate.delete_previously_used(task_index, cx);
|
|
picker.delegate.last_used_candidate_index = picker
|
|
.delegate
|
|
.last_used_candidate_index
|
|
.unwrap_or(0)
|
|
.checked_sub(1);
|
|
picker.refresh(cx);
|
|
}))
|
|
.tooltip(|cx| {
|
|
Tooltip::text("Delete previously scheduled task", cx)
|
|
}),
|
|
);
|
|
item.end_hover_slot(delete_button)
|
|
} else {
|
|
item
|
|
};
|
|
if let Some(icon) = icon {
|
|
item.end_slot(icon)
|
|
} else {
|
|
item
|
|
}
|
|
})
|
|
.selected(selected)
|
|
.child(highlighted_location.render(cx)),
|
|
)
|
|
}
|
|
|
|
fn selected_as_query(&self) -> Option<String> {
|
|
use itertools::intersperse;
|
|
let task_index = self.matches.get(self.selected_index())?.candidate_id;
|
|
let tasks = self.candidates.as_ref()?;
|
|
let (_, task) = tasks.get(task_index)?;
|
|
task.resolved.as_ref().map(|spawn_in_terminal| {
|
|
let mut command = spawn_in_terminal.command.clone();
|
|
if !spawn_in_terminal.args.is_empty() {
|
|
command.push(' ');
|
|
command.extend(intersperse(spawn_in_terminal.args.clone(), " ".to_string()));
|
|
}
|
|
command
|
|
})
|
|
}
|
|
|
|
fn confirm_input(&mut self, omit_history_entry: bool, cx: &mut ViewContext<Picker<Self>>) {
|
|
let Some((task_source_kind, task)) = self.spawn_oneshot() else {
|
|
return;
|
|
};
|
|
self.workspace
|
|
.update(cx, |workspace, cx| {
|
|
schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx);
|
|
})
|
|
.ok();
|
|
cx.emit(DismissEvent);
|
|
}
|
|
|
|
fn separators_after_indices(&self) -> Vec<usize> {
|
|
if let Some(i) = self.last_used_candidate_index {
|
|
vec![i]
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO kb more tests on recent tasks from language templates
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use gpui::{TestAppContext, VisualTestContext};
|
|
use project::{FakeFs, Project};
|
|
use serde_json::json;
|
|
|
|
use crate::modal::Spawn;
|
|
|
|
use super::*;
|
|
|
|
#[gpui::test]
|
|
async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
|
|
crate::tests::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;
|
|
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(picker::UseSelectedQuery);
|
|
assert_eq!(
|
|
query(&tasks_picker, cx),
|
|
"echo 4",
|
|
"Query should be set to the selected task's command"
|
|
);
|
|
assert_eq!(
|
|
task_names(&tasks_picker, cx),
|
|
Vec::<String>::new(),
|
|
"No task should be listed"
|
|
);
|
|
cx.dispatch_action(picker::ConfirmInput { secondary: false });
|
|
|
|
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!["echo 4", "another one", "example task"],
|
|
"New oneshot 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!["echo 4"],
|
|
"New oneshot should match custom command query"
|
|
);
|
|
|
|
cx.dispatch_action(picker::ConfirmInput { secondary: false });
|
|
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, "another one", "example task"],
|
|
"Last recently used one show task should be listed first"
|
|
);
|
|
|
|
cx.dispatch_action(picker::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"
|
|
);
|
|
|
|
let query_str = "0";
|
|
cx.simulate_input(query_str);
|
|
assert_eq!(query(&tasks_picker, cx), "echo 40");
|
|
assert_eq!(
|
|
task_names(&tasks_picker, cx),
|
|
Vec::<String>::new(),
|
|
"New oneshot should not match any command query"
|
|
);
|
|
|
|
cx.dispatch_action(picker::ConfirmInput { secondary: true });
|
|
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!["echo 4", "another one", "example task"],
|
|
"No query should be added to the list, as it was submitted with secondary action (that maps to omit_history = true)"
|
|
);
|
|
|
|
cx.dispatch_action(Spawn {
|
|
task_name: Some("example task".to_string()),
|
|
});
|
|
let tasks_picker = workspace.update(cx, |workspace, cx| {
|
|
workspace
|
|
.active_modal::<TasksModal>(cx)
|
|
.unwrap()
|
|
.read(cx)
|
|
.picker
|
|
.clone()
|
|
});
|
|
assert_eq!(
|
|
task_names(&tasks_picker, cx),
|
|
vec!["echo 4", "another one", "example task"],
|
|
);
|
|
}
|
|
|
|
fn open_spawn_tasks(
|
|
workspace: &View<Workspace>,
|
|
cx: &mut VisualTestContext,
|
|
) -> View<Picker<TasksModalDelegate>> {
|
|
cx.dispatch_action(Spawn::default());
|
|
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<_>>()
|
|
})
|
|
}
|
|
}
|