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, } 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, candidates: Option>, last_used_candidate_index: Option, matches: Vec, selected_index: usize, workspace: WeakView, prompt: String, task_context: TaskContext, placeholder_text: Arc, } impl TasksModalDelegate { fn new( inventory: Model, task_context: TaskContext, workspace: WeakView, ) -> 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>, _subscription: Subscription, } impl TasksModal { pub(crate) fn new( inventory: Model, task_context: TaskContext, workspace: WeakView, cx: &mut ViewContext, ) -> 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) -> impl gpui::prelude::IntoElement { v_flex() .key_context("TasksModal") .w(rems(34.)) .child(self.picker.clone()) } } impl EventEmitter 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>) { self.selected_index = ix; } fn placeholder_text(&self, _: &mut WindowContext) -> Arc { self.placeholder_text.clone() } fn update_matches( &mut self, query: String, cx: &mut ViewContext>, ) -> 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::>() }) .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>) { 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>) { cx.emit(DismissEvent); } fn render_match( &self, ix: usize, selected: bool, cx: &mut ViewContext>, ) -> Option { 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 { 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>) { 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 { 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::::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::::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::(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, cx: &mut VisualTestContext, ) -> View> { cx.dispatch_action(Spawn::default()); 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::>() }) } }