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