1//! Project-wide storage of the tasks available, capable of updating itself from the sources set.
2
3use std::{
4 borrow::Cow,
5 cmp::{self, Reverse},
6 collections::hash_map,
7 path::{Path, PathBuf},
8 sync::Arc,
9};
10
11use anyhow::Result;
12use collections::{HashMap, HashSet, VecDeque};
13use gpui::{App, AppContext as _, Entity, SharedString, Task};
14use itertools::Itertools;
15use language::{ContextProvider, File, Language, LanguageToolchainStore, Location};
16use settings::{InvalidSettingsError, TaskKind, parse_json_with_comments};
17use task::{
18 DebugTaskDefinition, ResolvedTask, TaskContext, TaskId, TaskTemplate, TaskTemplates,
19 TaskVariables, VariableName,
20};
21use text::{Point, ToPoint};
22use util::{NumericPrefixWithSuffix, ResultExt as _, paths::PathExt as _, post_inc};
23use worktree::WorktreeId;
24
25use crate::{task_store::TaskSettingsLocation, worktree_store::WorktreeStore};
26
27/// Inventory tracks available tasks for a given project.
28#[derive(Debug, Default)]
29pub struct Inventory {
30 last_scheduled_tasks: VecDeque<(TaskSourceKind, ResolvedTask)>,
31 templates_from_settings: ParsedTemplates,
32}
33
34#[derive(Debug, Default)]
35struct ParsedTemplates {
36 global: HashMap<PathBuf, Vec<TaskTemplate>>,
37 worktree: HashMap<WorktreeId, HashMap<(Arc<Path>, TaskKind), Vec<TaskTemplate>>>,
38}
39
40/// Kind of a source the tasks are fetched from, used to display more source information in the UI.
41#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
42pub enum TaskSourceKind {
43 /// bash-like commands spawned by users, not associated with any path
44 UserInput,
45 /// Tasks from the worktree's .zed/task.json
46 Worktree {
47 id: WorktreeId,
48 directory_in_worktree: PathBuf,
49 id_base: Cow<'static, str>,
50 },
51 /// ~/.config/zed/task.json - like global files with task definitions, applicable to any path
52 AbsPath {
53 id_base: Cow<'static, str>,
54 abs_path: PathBuf,
55 },
56 /// Languages-specific tasks coming from extensions.
57 Language { name: SharedString },
58}
59
60/// A collection of task contexts, derived from the current state of the workspace.
61/// Only contains worktrees that are visible and with their root being a directory.
62#[derive(Debug, Default)]
63pub struct TaskContexts {
64 /// A context, related to the currently opened item.
65 /// Item can be opened from an invisible worktree, or any other, not necessarily active worktree.
66 pub active_item_context: Option<(Option<WorktreeId>, Option<Location>, TaskContext)>,
67 /// A worktree that corresponds to the active item, or the only worktree in the workspace.
68 pub active_worktree_context: Option<(WorktreeId, TaskContext)>,
69 /// If there are multiple worktrees in the workspace, all non-active ones are included here.
70 pub other_worktree_contexts: Vec<(WorktreeId, TaskContext)>,
71}
72
73impl TaskContexts {
74 pub fn active_context(&self) -> Option<&TaskContext> {
75 self.active_item_context
76 .as_ref()
77 .map(|(_, _, context)| context)
78 .or_else(|| {
79 self.active_worktree_context
80 .as_ref()
81 .map(|(_, context)| context)
82 })
83 }
84
85 pub fn location(&self) -> Option<&Location> {
86 self.active_item_context
87 .as_ref()
88 .and_then(|(_, location, _)| location.as_ref())
89 }
90
91 pub fn worktree(&self) -> Option<WorktreeId> {
92 self.active_item_context
93 .as_ref()
94 .and_then(|(worktree_id, _, _)| worktree_id.as_ref())
95 .or_else(|| {
96 self.active_worktree_context
97 .as_ref()
98 .map(|(worktree_id, _)| worktree_id)
99 })
100 .copied()
101 }
102}
103
104impl TaskSourceKind {
105 pub fn to_id_base(&self) -> String {
106 match self {
107 TaskSourceKind::UserInput => "oneshot".to_string(),
108 TaskSourceKind::AbsPath { id_base, abs_path } => {
109 format!("{id_base}_{}", abs_path.display())
110 }
111 TaskSourceKind::Worktree {
112 id,
113 id_base,
114 directory_in_worktree,
115 } => {
116 format!("{id_base}_{id}_{}", directory_in_worktree.display())
117 }
118 TaskSourceKind::Language { name } => format!("language_{name}"),
119 }
120 }
121}
122
123impl Inventory {
124 pub fn new(cx: &mut App) -> Entity<Self> {
125 cx.new(|_| Self::default())
126 }
127
128 /// Pulls its task sources relevant to the worktree and the language given,
129 /// returns all task templates with their source kinds, worktree tasks first, language tasks second
130 /// and global tasks last. No specific order inside source kinds groups.
131 pub fn list_tasks(
132 &self,
133 file: Option<Arc<dyn File>>,
134 language: Option<Arc<Language>>,
135 worktree: Option<WorktreeId>,
136 cx: &App,
137 ) -> Vec<(TaskSourceKind, TaskTemplate)> {
138 let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language {
139 name: language.name().into(),
140 });
141 let global_tasks = self.global_templates_from_settings();
142 let language_tasks = language
143 .and_then(|language| language.context_provider()?.associated_tasks(file, cx))
144 .into_iter()
145 .flat_map(|tasks| tasks.0.into_iter())
146 .flat_map(|task| Some((task_source_kind.clone()?, task)))
147 .chain(global_tasks);
148
149 self.worktree_templates_from_settings(worktree)
150 .chain(language_tasks)
151 .collect()
152 }
153
154 /// Pulls its task sources relevant to the worktree and the language given and resolves them with the [`TaskContexts`] given.
155 /// Joins the new resolutions with the resolved tasks that were used (spawned) before,
156 /// orders them so that the most recently used come first, all equally used ones are ordered so that the most specific tasks come first.
157 /// Deduplicates the tasks by their labels and context and splits the ordered list into two: used tasks and the rest, newly resolved tasks.
158 pub fn used_and_current_resolved_tasks(
159 &self,
160 task_contexts: &TaskContexts,
161 cx: &App,
162 ) -> (
163 Vec<(TaskSourceKind, ResolvedTask)>,
164 Vec<(TaskSourceKind, ResolvedTask)>,
165 ) {
166 let worktree = task_contexts.worktree();
167 let location = task_contexts.location();
168 let language = location
169 .and_then(|location| location.buffer.read(cx).language_at(location.range.start));
170 let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language {
171 name: language.name().into(),
172 });
173 let file = location.and_then(|location| location.buffer.read(cx).file().cloned());
174
175 let mut task_labels_to_ids = HashMap::<String, HashSet<TaskId>>::default();
176 let mut lru_score = 0_u32;
177 let previously_spawned_tasks = self
178 .last_scheduled_tasks
179 .iter()
180 .rev()
181 .filter(|(task_kind, _)| {
182 if matches!(task_kind, TaskSourceKind::Language { .. }) {
183 Some(task_kind) == task_source_kind.as_ref()
184 } else {
185 true
186 }
187 })
188 .filter(|(_, resolved_task)| {
189 match task_labels_to_ids.entry(resolved_task.resolved_label.clone()) {
190 hash_map::Entry::Occupied(mut o) => {
191 o.get_mut().insert(resolved_task.id.clone());
192 // Neber allow duplicate reused tasks with the same labels
193 false
194 }
195 hash_map::Entry::Vacant(v) => {
196 v.insert(HashSet::from_iter(Some(resolved_task.id.clone())));
197 true
198 }
199 }
200 })
201 .map(|(task_source_kind, resolved_task)| {
202 (
203 task_source_kind.clone(),
204 resolved_task.clone(),
205 post_inc(&mut lru_score),
206 )
207 })
208 .sorted_unstable_by(task_lru_comparator)
209 .map(|(kind, task, _)| (kind, task))
210 .collect::<Vec<_>>();
211
212 let not_used_score = post_inc(&mut lru_score);
213 let global_tasks = self.global_templates_from_settings();
214 let language_tasks = language
215 .and_then(|language| language.context_provider()?.associated_tasks(file, cx))
216 .into_iter()
217 .flat_map(|tasks| tasks.0.into_iter())
218 .flat_map(|task| Some((task_source_kind.clone()?, task)));
219 let worktree_tasks = self
220 .worktree_templates_from_settings(worktree)
221 .chain(language_tasks)
222 .chain(global_tasks);
223
224 let new_resolved_tasks = worktree_tasks
225 .flat_map(|(kind, task)| {
226 let id_base = kind.to_id_base();
227 if let TaskSourceKind::Worktree { id, .. } = &kind {
228 None.or_else(|| {
229 let (_, _, item_context) = task_contexts
230 .active_item_context
231 .as_ref()
232 .filter(|(worktree_id, _, _)| Some(id) == worktree_id.as_ref())?;
233 task.resolve_task(&id_base, item_context)
234 })
235 .or_else(|| {
236 let (_, worktree_context) = task_contexts
237 .active_worktree_context
238 .as_ref()
239 .filter(|(worktree_id, _)| id == worktree_id)?;
240 task.resolve_task(&id_base, worktree_context)
241 })
242 .or_else(|| {
243 if let TaskSourceKind::Worktree { id, .. } = &kind {
244 let worktree_context = task_contexts
245 .other_worktree_contexts
246 .iter()
247 .find(|(worktree_id, _)| worktree_id == id)
248 .map(|(_, context)| context)?;
249 task.resolve_task(&id_base, worktree_context)
250 } else {
251 None
252 }
253 })
254 } else {
255 None.or_else(|| {
256 let (_, _, item_context) = task_contexts.active_item_context.as_ref()?;
257 task.resolve_task(&id_base, item_context)
258 })
259 .or_else(|| {
260 let (_, worktree_context) =
261 task_contexts.active_worktree_context.as_ref()?;
262 task.resolve_task(&id_base, worktree_context)
263 })
264 }
265 .or_else(|| task.resolve_task(&id_base, &TaskContext::default()))
266 .map(move |resolved_task| (kind.clone(), resolved_task, not_used_score))
267 })
268 .filter(|(_, resolved_task, _)| {
269 match task_labels_to_ids.entry(resolved_task.resolved_label.clone()) {
270 hash_map::Entry::Occupied(mut o) => {
271 // Allow new tasks with the same label, if their context is different
272 o.get_mut().insert(resolved_task.id.clone())
273 }
274 hash_map::Entry::Vacant(v) => {
275 v.insert(HashSet::from_iter(Some(resolved_task.id.clone())));
276 true
277 }
278 }
279 })
280 .sorted_unstable_by(task_lru_comparator)
281 .map(|(kind, task, _)| (kind, task))
282 .collect::<Vec<_>>();
283
284 (previously_spawned_tasks, new_resolved_tasks)
285 }
286
287 /// Returns the last scheduled task by task_id if provided.
288 /// Otherwise, returns the last scheduled task.
289 pub fn last_scheduled_task(
290 &self,
291 task_id: Option<&TaskId>,
292 ) -> Option<(TaskSourceKind, ResolvedTask)> {
293 if let Some(task_id) = task_id {
294 self.last_scheduled_tasks
295 .iter()
296 .find(|(_, task)| &task.id == task_id)
297 .cloned()
298 } else {
299 self.last_scheduled_tasks.back().cloned()
300 }
301 }
302
303 /// Registers task "usage" as being scheduled – to be used for LRU sorting when listing all tasks.
304 pub fn task_scheduled(
305 &mut self,
306 task_source_kind: TaskSourceKind,
307 resolved_task: ResolvedTask,
308 ) {
309 self.last_scheduled_tasks
310 .push_back((task_source_kind, resolved_task));
311 if self.last_scheduled_tasks.len() > 5_000 {
312 self.last_scheduled_tasks.pop_front();
313 }
314 }
315
316 /// Deletes a resolved task from history, using its id.
317 /// A similar may still resurface in `used_and_current_resolved_tasks` when its [`TaskTemplate`] is resolved again.
318 pub fn delete_previously_used(&mut self, id: &TaskId) {
319 self.last_scheduled_tasks.retain(|(_, task)| &task.id != id);
320 }
321
322 fn global_templates_from_settings(
323 &self,
324 ) -> impl '_ + Iterator<Item = (TaskSourceKind, TaskTemplate)> {
325 self.templates_from_settings
326 .global
327 .iter()
328 .flat_map(|(file_path, templates)| {
329 templates.into_iter().map(|template| {
330 (
331 TaskSourceKind::AbsPath {
332 id_base: match template.task_type {
333 task::TaskType::Script => Cow::Borrowed("global tasks.json"),
334 task::TaskType::Debug(_) => Cow::Borrowed("global debug.json"),
335 },
336 abs_path: file_path.clone(),
337 },
338 template.clone(),
339 )
340 })
341 })
342 }
343
344 fn worktree_templates_from_settings(
345 &self,
346 worktree: Option<WorktreeId>,
347 ) -> impl '_ + Iterator<Item = (TaskSourceKind, TaskTemplate)> {
348 worktree.into_iter().flat_map(|worktree| {
349 self.templates_from_settings
350 .worktree
351 .get(&worktree)
352 .into_iter()
353 .flatten()
354 .flat_map(|(directory, templates)| {
355 templates.iter().map(move |template| (directory, template))
356 })
357 .map(move |((directory, _task_kind), template)| {
358 (
359 TaskSourceKind::Worktree {
360 id: worktree,
361 directory_in_worktree: directory.to_path_buf(),
362 id_base: Cow::Owned(format!(
363 "local worktree tasks from directory {directory:?}"
364 )),
365 },
366 template.clone(),
367 )
368 })
369 })
370 }
371
372 /// Updates in-memory task metadata from the JSON string given.
373 /// Will fail if the JSON is not a valid array of objects, but will continue if any object will not parse into a [`TaskTemplate`].
374 ///
375 /// Global tasks are updated for no worktree provided, otherwise the worktree metadata for a given path will be updated.
376 pub(crate) fn update_file_based_tasks(
377 &mut self,
378 location: TaskSettingsLocation<'_>,
379 raw_tasks_json: Option<&str>,
380 task_kind: TaskKind,
381 ) -> Result<(), InvalidSettingsError> {
382 let raw_tasks = match parse_json_with_comments::<Vec<serde_json::Value>>(
383 raw_tasks_json.unwrap_or("[]"),
384 ) {
385 Ok(tasks) => tasks,
386 Err(e) => {
387 return Err(InvalidSettingsError::Tasks {
388 path: match location {
389 TaskSettingsLocation::Global(path) => path.to_owned(),
390 TaskSettingsLocation::Worktree(settings_location) => {
391 task_kind.config_in_dir(settings_location.path)
392 }
393 },
394 message: format!("Failed to parse tasks file content as a JSON array: {e}"),
395 });
396 }
397 };
398 let new_templates = raw_tasks
399 .into_iter()
400 .filter_map(|raw_template| match &task_kind {
401 TaskKind::Script => serde_json::from_value::<TaskTemplate>(raw_template).log_err(),
402 TaskKind::Debug => serde_json::from_value::<DebugTaskDefinition>(raw_template)
403 .log_err()
404 .and_then(|content| content.to_zed_format().log_err()),
405 });
406
407 let parsed_templates = &mut self.templates_from_settings;
408 match location {
409 TaskSettingsLocation::Global(path) => {
410 parsed_templates
411 .global
412 .entry(path.to_owned())
413 .insert_entry(new_templates.collect());
414 }
415 TaskSettingsLocation::Worktree(location) => {
416 let new_templates = new_templates.collect::<Vec<_>>();
417 if new_templates.is_empty() {
418 if let Some(worktree_tasks) =
419 parsed_templates.worktree.get_mut(&location.worktree_id)
420 {
421 worktree_tasks.remove(&(Arc::from(location.path), task_kind));
422 }
423 } else {
424 parsed_templates
425 .worktree
426 .entry(location.worktree_id)
427 .or_default()
428 .insert((Arc::from(location.path), task_kind), new_templates);
429 }
430 }
431 }
432
433 Ok(())
434 }
435}
436
437fn task_lru_comparator(
438 (kind_a, task_a, lru_score_a): &(TaskSourceKind, ResolvedTask, u32),
439 (kind_b, task_b, lru_score_b): &(TaskSourceKind, ResolvedTask, u32),
440) -> cmp::Ordering {
441 lru_score_a
442 // First, display recently used templates above all.
443 .cmp(lru_score_b)
444 // Then, ensure more specific sources are displayed first.
445 .then(task_source_kind_preference(kind_a).cmp(&task_source_kind_preference(kind_b)))
446 // After that, display first more specific tasks, using more template variables.
447 // Bonus points for tasks with symbol variables.
448 .then(task_variables_preference(task_a).cmp(&task_variables_preference(task_b)))
449 // Finally, sort by the resolved label, but a bit more specifically, to avoid mixing letters and digits.
450 .then({
451 NumericPrefixWithSuffix::from_numeric_prefixed_str(&task_a.resolved_label)
452 .cmp(&NumericPrefixWithSuffix::from_numeric_prefixed_str(
453 &task_b.resolved_label,
454 ))
455 .then(task_a.resolved_label.cmp(&task_b.resolved_label))
456 .then(kind_a.cmp(kind_b))
457 })
458}
459
460fn task_source_kind_preference(kind: &TaskSourceKind) -> u32 {
461 match kind {
462 TaskSourceKind::Language { .. } => 1,
463 TaskSourceKind::UserInput => 2,
464 TaskSourceKind::Worktree { .. } => 3,
465 TaskSourceKind::AbsPath { .. } => 4,
466 }
467}
468
469fn task_variables_preference(task: &ResolvedTask) -> Reverse<usize> {
470 let task_variables = task.substituted_variables();
471 Reverse(if task_variables.contains(&VariableName::Symbol) {
472 task_variables.len() + 1
473 } else {
474 task_variables.len()
475 })
476}
477
478#[cfg(test)]
479mod test_inventory {
480 use gpui::{Entity, TestAppContext};
481 use itertools::Itertools;
482 use task::TaskContext;
483 use worktree::WorktreeId;
484
485 use crate::Inventory;
486
487 use super::TaskSourceKind;
488
489 pub(super) fn task_template_names(
490 inventory: &Entity<Inventory>,
491 worktree: Option<WorktreeId>,
492 cx: &mut TestAppContext,
493 ) -> Vec<String> {
494 inventory.update(cx, |inventory, cx| {
495 inventory
496 .list_tasks(None, None, worktree, cx)
497 .into_iter()
498 .map(|(_, task)| task.label)
499 .sorted()
500 .collect()
501 })
502 }
503
504 pub(super) fn register_task_used(
505 inventory: &Entity<Inventory>,
506 task_name: &str,
507 cx: &mut TestAppContext,
508 ) {
509 inventory.update(cx, |inventory, cx| {
510 let (task_source_kind, task) = inventory
511 .list_tasks(None, None, None, cx)
512 .into_iter()
513 .find(|(_, task)| task.label == task_name)
514 .unwrap_or_else(|| panic!("Failed to find task with name {task_name}"));
515 let id_base = task_source_kind.to_id_base();
516 inventory.task_scheduled(
517 task_source_kind.clone(),
518 task.resolve_task(&id_base, &TaskContext::default())
519 .unwrap_or_else(|| panic!("Failed to resolve task with name {task_name}")),
520 );
521 });
522 }
523
524 pub(super) async fn list_tasks(
525 inventory: &Entity<Inventory>,
526 worktree: Option<WorktreeId>,
527 cx: &mut TestAppContext,
528 ) -> Vec<(TaskSourceKind, String)> {
529 inventory.update(cx, |inventory, cx| {
530 let task_context = &TaskContext::default();
531 inventory
532 .list_tasks(None, None, worktree, cx)
533 .into_iter()
534 .filter_map(|(source_kind, task)| {
535 let id_base = source_kind.to_id_base();
536 Some((source_kind, task.resolve_task(&id_base, task_context)?))
537 })
538 .map(|(source_kind, resolved_task)| (source_kind, resolved_task.resolved_label))
539 .collect()
540 })
541 }
542}
543
544/// A context provided that tries to provide values for all non-custom [`VariableName`] variants for a currently opened file.
545/// Applied as a base for every custom [`ContextProvider`] unless explicitly oped out.
546pub struct BasicContextProvider {
547 worktree_store: Entity<WorktreeStore>,
548}
549
550impl BasicContextProvider {
551 pub fn new(worktree_store: Entity<WorktreeStore>) -> Self {
552 Self { worktree_store }
553 }
554}
555impl ContextProvider for BasicContextProvider {
556 fn build_context(
557 &self,
558 _: &TaskVariables,
559 location: &Location,
560 _: Option<HashMap<String, String>>,
561 _: Arc<dyn LanguageToolchainStore>,
562 cx: &mut App,
563 ) -> Task<Result<TaskVariables>> {
564 let buffer = location.buffer.read(cx);
565 let buffer_snapshot = buffer.snapshot();
566 let symbols = buffer_snapshot.symbols_containing(location.range.start, None);
567 let symbol = symbols.unwrap_or_default().last().map(|symbol| {
568 let range = symbol
569 .name_ranges
570 .last()
571 .cloned()
572 .unwrap_or(0..symbol.text.len());
573 symbol.text[range].to_string()
574 });
575
576 let current_file = buffer
577 .file()
578 .and_then(|file| file.as_local())
579 .map(|file| file.abs_path(cx).to_sanitized_string());
580 let Point { row, column } = location.range.start.to_point(&buffer_snapshot);
581 let row = row + 1;
582 let column = column + 1;
583 let selected_text = buffer
584 .chars_for_range(location.range.clone())
585 .collect::<String>();
586
587 let mut task_variables = TaskVariables::from_iter([
588 (VariableName::Row, row.to_string()),
589 (VariableName::Column, column.to_string()),
590 ]);
591
592 if let Some(symbol) = symbol {
593 task_variables.insert(VariableName::Symbol, symbol);
594 }
595 if !selected_text.trim().is_empty() {
596 task_variables.insert(VariableName::SelectedText, selected_text);
597 }
598 let worktree_root_dir =
599 buffer
600 .file()
601 .map(|file| file.worktree_id(cx))
602 .and_then(|worktree_id| {
603 self.worktree_store
604 .read(cx)
605 .worktree_for_id(worktree_id, cx)
606 .and_then(|worktree| worktree.read(cx).root_dir())
607 });
608 if let Some(worktree_path) = worktree_root_dir {
609 task_variables.insert(
610 VariableName::WorktreeRoot,
611 worktree_path.to_sanitized_string(),
612 );
613 if let Some(full_path) = current_file.as_ref() {
614 let relative_path = pathdiff::diff_paths(full_path, worktree_path);
615 if let Some(relative_path) = relative_path {
616 task_variables.insert(
617 VariableName::RelativeFile,
618 relative_path.to_sanitized_string(),
619 );
620 }
621 }
622 }
623
624 if let Some(path_as_string) = current_file {
625 let path = Path::new(&path_as_string);
626 if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
627 task_variables.insert(VariableName::Filename, String::from(filename));
628 }
629
630 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
631 task_variables.insert(VariableName::Stem, stem.into());
632 }
633
634 if let Some(dirname) = path.parent().and_then(|s| s.to_str()) {
635 task_variables.insert(VariableName::Dirname, dirname.into());
636 }
637
638 task_variables.insert(VariableName::File, path_as_string);
639 }
640
641 Task::ready(Ok(task_variables))
642 }
643}
644
645/// A ContextProvider that doesn't provide any task variables on it's own, though it has some associated tasks.
646pub struct ContextProviderWithTasks {
647 templates: TaskTemplates,
648}
649
650impl ContextProviderWithTasks {
651 pub fn new(definitions: TaskTemplates) -> Self {
652 Self {
653 templates: definitions,
654 }
655 }
656}
657
658impl ContextProvider for ContextProviderWithTasks {
659 fn associated_tasks(
660 &self,
661 _: Option<Arc<dyn language::File>>,
662 _: &App,
663 ) -> Option<TaskTemplates> {
664 Some(self.templates.clone())
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use gpui::TestAppContext;
671 use paths::tasks_file;
672 use pretty_assertions::assert_eq;
673 use serde_json::json;
674 use settings::SettingsLocation;
675
676 use crate::task_store::TaskStore;
677
678 use super::test_inventory::*;
679 use super::*;
680
681 #[gpui::test]
682 async fn test_task_list_sorting(cx: &mut TestAppContext) {
683 init_test(cx);
684 let inventory = cx.update(Inventory::new);
685 let initial_tasks = resolved_task_names(&inventory, None, cx).await;
686 assert!(
687 initial_tasks.is_empty(),
688 "No tasks expected for empty inventory, but got {initial_tasks:?}"
689 );
690 let initial_tasks = task_template_names(&inventory, None, cx);
691 assert!(
692 initial_tasks.is_empty(),
693 "No tasks expected for empty inventory, but got {initial_tasks:?}"
694 );
695 cx.run_until_parked();
696 let expected_initial_state = [
697 "1_a_task".to_string(),
698 "1_task".to_string(),
699 "2_task".to_string(),
700 "3_task".to_string(),
701 ];
702
703 inventory.update(cx, |inventory, _| {
704 inventory
705 .update_file_based_tasks(
706 TaskSettingsLocation::Global(tasks_file()),
707 Some(&mock_tasks_from_names(
708 expected_initial_state.iter().map(|name| name.as_str()),
709 )),
710 settings::TaskKind::Script,
711 )
712 .unwrap();
713 });
714 assert_eq!(
715 task_template_names(&inventory, None, cx),
716 &expected_initial_state,
717 );
718 assert_eq!(
719 resolved_task_names(&inventory, None, cx).await,
720 &expected_initial_state,
721 "Tasks with equal amount of usages should be sorted alphanumerically"
722 );
723
724 register_task_used(&inventory, "2_task", cx);
725 assert_eq!(
726 task_template_names(&inventory, None, cx),
727 &expected_initial_state,
728 );
729 assert_eq!(
730 resolved_task_names(&inventory, None, cx).await,
731 vec![
732 "2_task".to_string(),
733 "1_a_task".to_string(),
734 "1_task".to_string(),
735 "3_task".to_string()
736 ],
737 );
738
739 register_task_used(&inventory, "1_task", cx);
740 register_task_used(&inventory, "1_task", cx);
741 register_task_used(&inventory, "1_task", cx);
742 register_task_used(&inventory, "3_task", cx);
743 assert_eq!(
744 task_template_names(&inventory, None, cx),
745 &expected_initial_state,
746 );
747 assert_eq!(
748 resolved_task_names(&inventory, None, cx).await,
749 vec![
750 "3_task".to_string(),
751 "1_task".to_string(),
752 "2_task".to_string(),
753 "1_a_task".to_string(),
754 ],
755 );
756
757 inventory.update(cx, |inventory, _| {
758 inventory
759 .update_file_based_tasks(
760 TaskSettingsLocation::Global(tasks_file()),
761 Some(&mock_tasks_from_names(
762 ["10_hello", "11_hello"]
763 .into_iter()
764 .chain(expected_initial_state.iter().map(|name| name.as_str())),
765 )),
766 settings::TaskKind::Script,
767 )
768 .unwrap();
769 });
770 cx.run_until_parked();
771 let expected_updated_state = [
772 "10_hello".to_string(),
773 "11_hello".to_string(),
774 "1_a_task".to_string(),
775 "1_task".to_string(),
776 "2_task".to_string(),
777 "3_task".to_string(),
778 ];
779 assert_eq!(
780 task_template_names(&inventory, None, cx),
781 &expected_updated_state,
782 );
783 assert_eq!(
784 resolved_task_names(&inventory, None, cx).await,
785 vec![
786 "3_task".to_string(),
787 "1_task".to_string(),
788 "2_task".to_string(),
789 "1_a_task".to_string(),
790 "10_hello".to_string(),
791 "11_hello".to_string(),
792 ],
793 );
794
795 register_task_used(&inventory, "11_hello", cx);
796 assert_eq!(
797 task_template_names(&inventory, None, cx),
798 &expected_updated_state,
799 );
800 assert_eq!(
801 resolved_task_names(&inventory, None, cx).await,
802 vec![
803 "11_hello".to_string(),
804 "3_task".to_string(),
805 "1_task".to_string(),
806 "2_task".to_string(),
807 "1_a_task".to_string(),
808 "10_hello".to_string(),
809 ],
810 );
811 }
812
813 #[gpui::test]
814 async fn test_inventory_static_task_filters(cx: &mut TestAppContext) {
815 init_test(cx);
816 let inventory = cx.update(Inventory::new);
817 let common_name = "common_task_name";
818 let worktree_1 = WorktreeId::from_usize(1);
819 let worktree_2 = WorktreeId::from_usize(2);
820
821 cx.run_until_parked();
822 let worktree_independent_tasks = vec![
823 (
824 TaskSourceKind::AbsPath {
825 id_base: "global tasks.json".into(),
826 abs_path: paths::tasks_file().clone(),
827 },
828 common_name.to_string(),
829 ),
830 (
831 TaskSourceKind::AbsPath {
832 id_base: "global tasks.json".into(),
833 abs_path: paths::tasks_file().clone(),
834 },
835 "static_source_1".to_string(),
836 ),
837 (
838 TaskSourceKind::AbsPath {
839 id_base: "global tasks.json".into(),
840 abs_path: paths::tasks_file().clone(),
841 },
842 "static_source_2".to_string(),
843 ),
844 ];
845 let worktree_1_tasks = [
846 (
847 TaskSourceKind::Worktree {
848 id: worktree_1,
849 directory_in_worktree: PathBuf::from(".zed"),
850 id_base: "local worktree tasks from directory \".zed\"".into(),
851 },
852 common_name.to_string(),
853 ),
854 (
855 TaskSourceKind::Worktree {
856 id: worktree_1,
857 directory_in_worktree: PathBuf::from(".zed"),
858 id_base: "local worktree tasks from directory \".zed\"".into(),
859 },
860 "worktree_1".to_string(),
861 ),
862 ];
863 let worktree_2_tasks = [
864 (
865 TaskSourceKind::Worktree {
866 id: worktree_2,
867 directory_in_worktree: PathBuf::from(".zed"),
868 id_base: "local worktree tasks from directory \".zed\"".into(),
869 },
870 common_name.to_string(),
871 ),
872 (
873 TaskSourceKind::Worktree {
874 id: worktree_2,
875 directory_in_worktree: PathBuf::from(".zed"),
876 id_base: "local worktree tasks from directory \".zed\"".into(),
877 },
878 "worktree_2".to_string(),
879 ),
880 ];
881
882 inventory.update(cx, |inventory, _| {
883 inventory
884 .update_file_based_tasks(
885 TaskSettingsLocation::Global(tasks_file()),
886 Some(&mock_tasks_from_names(
887 worktree_independent_tasks
888 .iter()
889 .map(|(_, name)| name.as_str()),
890 )),
891 settings::TaskKind::Script,
892 )
893 .unwrap();
894 inventory
895 .update_file_based_tasks(
896 TaskSettingsLocation::Worktree(SettingsLocation {
897 worktree_id: worktree_1,
898 path: Path::new(".zed"),
899 }),
900 Some(&mock_tasks_from_names(
901 worktree_1_tasks.iter().map(|(_, name)| name.as_str()),
902 )),
903 settings::TaskKind::Script,
904 )
905 .unwrap();
906 inventory
907 .update_file_based_tasks(
908 TaskSettingsLocation::Worktree(SettingsLocation {
909 worktree_id: worktree_2,
910 path: Path::new(".zed"),
911 }),
912 Some(&mock_tasks_from_names(
913 worktree_2_tasks.iter().map(|(_, name)| name.as_str()),
914 )),
915 settings::TaskKind::Script,
916 )
917 .unwrap();
918 });
919
920 assert_eq!(
921 list_tasks_sorted_by_last_used(&inventory, None, cx).await,
922 worktree_independent_tasks,
923 "Without a worktree, only worktree-independent tasks should be listed"
924 );
925 assert_eq!(
926 list_tasks_sorted_by_last_used(&inventory, Some(worktree_1), cx).await,
927 worktree_1_tasks
928 .iter()
929 .chain(worktree_independent_tasks.iter())
930 .cloned()
931 .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
932 .collect::<Vec<_>>(),
933 );
934 assert_eq!(
935 list_tasks_sorted_by_last_used(&inventory, Some(worktree_2), cx).await,
936 worktree_2_tasks
937 .iter()
938 .chain(worktree_independent_tasks.iter())
939 .cloned()
940 .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
941 .collect::<Vec<_>>(),
942 );
943
944 assert_eq!(
945 list_tasks(&inventory, None, cx).await,
946 worktree_independent_tasks,
947 "Without a worktree, only worktree-independent tasks should be listed"
948 );
949 assert_eq!(
950 list_tasks(&inventory, Some(worktree_1), cx).await,
951 worktree_1_tasks
952 .iter()
953 .chain(worktree_independent_tasks.iter())
954 .cloned()
955 .collect::<Vec<_>>(),
956 );
957 assert_eq!(
958 list_tasks(&inventory, Some(worktree_2), cx).await,
959 worktree_2_tasks
960 .iter()
961 .chain(worktree_independent_tasks.iter())
962 .cloned()
963 .collect::<Vec<_>>(),
964 );
965 }
966
967 fn init_test(_cx: &mut TestAppContext) {
968 if std::env::var("RUST_LOG").is_ok() {
969 env_logger::try_init().ok();
970 }
971 TaskStore::init(None);
972 }
973
974 async fn resolved_task_names(
975 inventory: &Entity<Inventory>,
976 worktree: Option<WorktreeId>,
977 cx: &mut TestAppContext,
978 ) -> Vec<String> {
979 let (used, current) = inventory.update(cx, |inventory, cx| {
980 let mut task_contexts = TaskContexts::default();
981 task_contexts.active_worktree_context =
982 worktree.map(|worktree| (worktree, TaskContext::default()));
983 inventory.used_and_current_resolved_tasks(&task_contexts, cx)
984 });
985 used.into_iter()
986 .chain(current)
987 .map(|(_, task)| task.original_task().label.clone())
988 .collect()
989 }
990
991 fn mock_tasks_from_names<'a>(task_names: impl Iterator<Item = &'a str> + 'a) -> String {
992 serde_json::to_string(&serde_json::Value::Array(
993 task_names
994 .map(|task_name| {
995 json!({
996 "label": task_name,
997 "command": "echo",
998 "args": vec![task_name],
999 })
1000 })
1001 .collect::<Vec<_>>(),
1002 ))
1003 .unwrap()
1004 }
1005
1006 async fn list_tasks_sorted_by_last_used(
1007 inventory: &Entity<Inventory>,
1008 worktree: Option<WorktreeId>,
1009 cx: &mut TestAppContext,
1010 ) -> Vec<(TaskSourceKind, String)> {
1011 let (used, current) = inventory.update(cx, |inventory, cx| {
1012 let mut task_contexts = TaskContexts::default();
1013 task_contexts.active_worktree_context =
1014 worktree.map(|worktree| (worktree, TaskContext::default()));
1015 inventory.used_and_current_resolved_tasks(&task_contexts, cx)
1016 });
1017 let mut all = used;
1018 all.extend(current);
1019 all.into_iter()
1020 .map(|(source_kind, task)| (source_kind, task.resolved_label))
1021 .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
1022 .collect()
1023 }
1024}