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