1use std::{path::PathBuf, sync::Arc};
2
3use fuzzy::{StringMatch, StringMatchCandidate};
4use gpui::{
5 actions, impl_actions, rems, AppContext, DismissEvent, EventEmitter, FocusableView,
6 InteractiveElement, Model, ParentElement, Render, SharedString, Styled, Subscription, View,
7 ViewContext, VisualContext, WeakView,
8};
9use picker::{
10 highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText},
11 Picker, PickerDelegate,
12};
13use project::{Inventory, ProjectPath, TaskSourceKind};
14use task::{oneshot_source::OneshotSource, Task, TaskContext};
15use ui::{v_flex, ListItem, ListItemSpacing, RenderOnce, Selectable, WindowContext};
16use util::{paths::PathExt, ResultExt};
17use workspace::{ModalView, Workspace};
18
19use crate::schedule_task;
20use serde::Deserialize;
21actions!(task, [Spawn]);
22
23/// Rerun last task
24#[derive(PartialEq, Clone, Deserialize, Default)]
25pub struct Rerun {
26 #[serde(default)]
27 /// Controls whether the task context is reevaluated prior to execution of a task.
28 /// 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
29 /// If it is, these variables will be updated to reflect current state of editor at the time task::Rerun is executed.
30 /// default: false
31 pub reevaluate_context: bool,
32}
33
34impl_actions!(task, [Rerun]);
35
36/// A modal used to spawn new tasks.
37pub(crate) struct TasksModalDelegate {
38 inventory: Model<Inventory>,
39 candidates: Vec<(TaskSourceKind, Arc<dyn Task>)>,
40 matches: Vec<StringMatch>,
41 selected_index: usize,
42 workspace: WeakView<Workspace>,
43 prompt: String,
44 task_context: TaskContext,
45}
46
47impl TasksModalDelegate {
48 fn new(
49 inventory: Model<Inventory>,
50 task_context: TaskContext,
51 workspace: WeakView<Workspace>,
52 ) -> Self {
53 Self {
54 inventory,
55 workspace,
56 candidates: Vec::new(),
57 matches: Vec::new(),
58 selected_index: 0,
59 prompt: String::default(),
60 task_context,
61 }
62 }
63
64 fn spawn_oneshot(&mut self, cx: &mut AppContext) -> Option<Arc<dyn Task>> {
65 self.inventory
66 .update(cx, |inventory, _| inventory.source::<OneshotSource>())?
67 .update(cx, |oneshot_source, _| {
68 Some(
69 oneshot_source
70 .as_any()
71 .downcast_mut::<OneshotSource>()?
72 .spawn(self.prompt.clone()),
73 )
74 })
75 }
76
77 fn active_item_path(
78 &mut self,
79 cx: &mut ViewContext<'_, Picker<Self>>,
80 ) -> Option<(PathBuf, ProjectPath)> {
81 let workspace = self.workspace.upgrade()?.read(cx);
82 let project = workspace.project().read(cx);
83 let active_item = workspace.active_item(cx)?;
84 active_item.project_path(cx).and_then(|project_path| {
85 project
86 .worktree_for_id(project_path.worktree_id, cx)
87 .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path))
88 .zip(Some(project_path))
89 })
90 }
91}
92
93pub(crate) struct TasksModal {
94 picker: View<Picker<TasksModalDelegate>>,
95 _subscription: Subscription,
96}
97
98impl TasksModal {
99 pub(crate) fn new(
100 inventory: Model<Inventory>,
101 task_context: TaskContext,
102 workspace: WeakView<Workspace>,
103 cx: &mut ViewContext<Self>,
104 ) -> Self {
105 let picker = cx.new_view(|cx| {
106 Picker::uniform_list(
107 TasksModalDelegate::new(inventory, task_context, workspace),
108 cx,
109 )
110 });
111 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
112 cx.emit(DismissEvent);
113 });
114 Self {
115 picker,
116 _subscription,
117 }
118 }
119}
120
121impl Render for TasksModal {
122 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl gpui::prelude::IntoElement {
123 v_flex()
124 .key_context("TasksModal")
125 .w(rems(34.))
126 .child(self.picker.clone())
127 .on_mouse_down_out(cx.listener(|modal, _, cx| {
128 modal.picker.update(cx, |picker, cx| {
129 picker.cancel(&Default::default(), cx);
130 })
131 }))
132 }
133}
134
135impl EventEmitter<DismissEvent> for TasksModal {}
136
137impl FocusableView for TasksModal {
138 fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
139 self.picker.read(cx).focus_handle(cx)
140 }
141}
142
143impl ModalView for TasksModal {}
144
145impl PickerDelegate for TasksModalDelegate {
146 type ListItem = ListItem;
147
148 fn match_count(&self) -> usize {
149 self.matches.len()
150 }
151
152 fn selected_index(&self) -> usize {
153 self.selected_index
154 }
155
156 fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<picker::Picker<Self>>) {
157 self.selected_index = ix;
158 }
159
160 fn placeholder_text(&self, cx: &mut WindowContext) -> Arc<str> {
161 Arc::from(format!(
162 "{} use task name as prompt, {} spawns a bash-like task from the prompt, {} runs the selected task",
163 cx.keystroke_text_for(&menu::UseSelectedQuery),
164 cx.keystroke_text_for(&menu::SecondaryConfirm),
165 cx.keystroke_text_for(&menu::Confirm),
166 ))
167 }
168
169 fn update_matches(
170 &mut self,
171 query: String,
172 cx: &mut ViewContext<picker::Picker<Self>>,
173 ) -> gpui::Task<()> {
174 cx.spawn(move |picker, mut cx| async move {
175 let Some(candidates) = picker
176 .update(&mut cx, |picker, cx| {
177 let (path, worktree) = match picker.delegate.active_item_path(cx) {
178 Some((abs_path, project_path)) => {
179 (Some(abs_path), Some(project_path.worktree_id))
180 }
181 None => (None, None),
182 };
183 picker.delegate.candidates =
184 picker.delegate.inventory.update(cx, |inventory, cx| {
185 inventory.list_tasks(path.as_deref(), worktree, true, cx)
186 });
187 picker
188 .delegate
189 .candidates
190 .iter()
191 .enumerate()
192 .map(|(index, (_, candidate))| StringMatchCandidate {
193 id: index,
194 char_bag: candidate.name().chars().collect(),
195 string: candidate.name().into(),
196 })
197 .collect::<Vec<_>>()
198 })
199 .ok()
200 else {
201 return;
202 };
203 let matches = fuzzy::match_strings(
204 &candidates,
205 &query,
206 true,
207 1000,
208 &Default::default(),
209 cx.background_executor().clone(),
210 )
211 .await;
212 picker
213 .update(&mut cx, |picker, _| {
214 let delegate = &mut picker.delegate;
215 delegate.matches = matches;
216 delegate.prompt = query;
217
218 if delegate.matches.is_empty() {
219 delegate.selected_index = 0;
220 } else {
221 delegate.selected_index =
222 delegate.selected_index.min(delegate.matches.len() - 1);
223 }
224 })
225 .log_err();
226 })
227 }
228
229 fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
230 let current_match_index = self.selected_index();
231 let task = if secondary {
232 if !self.prompt.trim().is_empty() {
233 self.spawn_oneshot(cx)
234 } else {
235 None
236 }
237 } else {
238 self.matches.get(current_match_index).map(|current_match| {
239 let ix = current_match.candidate_id;
240 self.candidates[ix].1.clone()
241 })
242 };
243
244 let Some(task) = task else {
245 return;
246 };
247
248 self.workspace
249 .update(cx, |workspace, cx| {
250 schedule_task(workspace, task.as_ref(), self.task_context.clone(), cx);
251 })
252 .ok();
253 cx.emit(DismissEvent);
254 }
255
256 fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
257 cx.emit(DismissEvent);
258 }
259
260 fn render_match(
261 &self,
262 ix: usize,
263 selected: bool,
264 cx: &mut ViewContext<picker::Picker<Self>>,
265 ) -> Option<Self::ListItem> {
266 let hit = &self.matches[ix];
267 let (source_kind, _) = &self.candidates[hit.candidate_id];
268 let details = match source_kind {
269 TaskSourceKind::UserInput => "user input".to_string(),
270 TaskSourceKind::Worktree { abs_path, .. } | TaskSourceKind::AbsPath(abs_path) => {
271 abs_path.compact().to_string_lossy().to_string()
272 }
273 };
274
275 let highlighted_location = HighlightedMatchWithPaths {
276 match_label: HighlightedText {
277 text: hit.string.clone(),
278 highlight_positions: hit.positions.clone(),
279 char_count: hit.string.chars().count(),
280 },
281 paths: vec![HighlightedText {
282 char_count: details.chars().count(),
283 highlight_positions: Vec::new(),
284 text: details,
285 }],
286 };
287 Some(
288 ListItem::new(SharedString::from(format!("tasks-modal-{ix}")))
289 .inset(true)
290 .spacing(ListItemSpacing::Sparse)
291 .selected(selected)
292 .child(highlighted_location.render(cx)),
293 )
294 }
295
296 fn selected_as_query(&self) -> Option<String> {
297 Some(self.matches.get(self.selected_index())?.string.clone())
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use gpui::{TestAppContext, VisualTestContext};
304 use project::{FakeFs, Project};
305 use serde_json::json;
306
307 use super::*;
308
309 #[gpui::test]
310 async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
311 crate::tests::init_test(cx);
312 let fs = FakeFs::new(cx.executor());
313 fs.insert_tree(
314 "/dir",
315 json!({
316 ".zed": {
317 "tasks.json": r#"[
318 {
319 "label": "example task",
320 "command": "echo",
321 "args": ["4"]
322 },
323 {
324 "label": "another one",
325 "command": "echo",
326 "args": ["55"]
327 },
328 ]"#,
329 },
330 "a.ts": "a"
331 }),
332 )
333 .await;
334
335 let project = Project::test(fs, ["/dir".as_ref()], cx).await;
336 project.update(cx, |project, cx| {
337 project.task_inventory().update(cx, |inventory, cx| {
338 inventory.add_source(TaskSourceKind::UserInput, |cx| OneshotSource::new(cx), cx)
339 })
340 });
341
342 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
343
344 let tasks_picker = open_spawn_tasks(&workspace, cx);
345 assert_eq!(
346 query(&tasks_picker, cx),
347 "",
348 "Initial query should be empty"
349 );
350 assert_eq!(
351 task_names(&tasks_picker, cx),
352 vec!["another one", "example task"],
353 "Initial tasks should be listed in alphabetical order"
354 );
355
356 let query_str = "tas";
357 cx.simulate_input(query_str);
358 assert_eq!(query(&tasks_picker, cx), query_str);
359 assert_eq!(
360 task_names(&tasks_picker, cx),
361 vec!["example task"],
362 "Only one task should match the query {query_str}"
363 );
364
365 cx.dispatch_action(menu::UseSelectedQuery);
366 assert_eq!(
367 query(&tasks_picker, cx),
368 "example task",
369 "Query should be set to the selected task's name"
370 );
371 assert_eq!(
372 task_names(&tasks_picker, cx),
373 vec!["example task"],
374 "No other tasks should be listed"
375 );
376 cx.dispatch_action(menu::Confirm);
377
378 let tasks_picker = open_spawn_tasks(&workspace, cx);
379 assert_eq!(
380 query(&tasks_picker, cx),
381 "",
382 "Query should be reset after confirming"
383 );
384 assert_eq!(
385 task_names(&tasks_picker, cx),
386 vec!["example task", "another one"],
387 "Last recently used task should be listed first"
388 );
389
390 let query_str = "echo 4";
391 cx.simulate_input(query_str);
392 assert_eq!(query(&tasks_picker, cx), query_str);
393 assert_eq!(
394 task_names(&tasks_picker, cx),
395 Vec::<String>::new(),
396 "No tasks should match custom command query"
397 );
398
399 cx.dispatch_action(menu::SecondaryConfirm);
400 let tasks_picker = open_spawn_tasks(&workspace, cx);
401 assert_eq!(
402 query(&tasks_picker, cx),
403 "",
404 "Query should be reset after confirming"
405 );
406 assert_eq!(
407 task_names(&tasks_picker, cx),
408 vec![query_str, "example task", "another one"],
409 "Last recently used one show task should be listed first"
410 );
411
412 cx.dispatch_action(menu::UseSelectedQuery);
413 assert_eq!(
414 query(&tasks_picker, cx),
415 query_str,
416 "Query should be set to the custom task's name"
417 );
418 assert_eq!(
419 task_names(&tasks_picker, cx),
420 vec![query_str],
421 "Only custom task should be listed"
422 );
423 }
424
425 fn open_spawn_tasks(
426 workspace: &View<Workspace>,
427 cx: &mut VisualTestContext,
428 ) -> View<Picker<TasksModalDelegate>> {
429 cx.dispatch_action(crate::modal::Spawn);
430 workspace.update(cx, |workspace, cx| {
431 workspace
432 .active_modal::<TasksModal>(cx)
433 .unwrap()
434 .read(cx)
435 .picker
436 .clone()
437 })
438 }
439
440 fn query(spawn_tasks: &View<Picker<TasksModalDelegate>>, cx: &mut VisualTestContext) -> String {
441 spawn_tasks.update(cx, |spawn_tasks, cx| spawn_tasks.query(cx))
442 }
443
444 fn task_names(
445 spawn_tasks: &View<Picker<TasksModalDelegate>>,
446 cx: &mut VisualTestContext,
447 ) -> Vec<String> {
448 spawn_tasks.update(cx, |spawn_tasks, _| {
449 spawn_tasks
450 .delegate
451 .matches
452 .iter()
453 .map(|hit| hit.string.clone())
454 .collect::<Vec<_>>()
455 })
456 }
457}