task_inventory.rs

  1//! Project-wide storage of the tasks available, capable of updating itself from the sources set.
  2
  3use std::{
  4    cmp::{self, Reverse},
  5    path::{Path, PathBuf},
  6    sync::Arc,
  7};
  8
  9use collections::{btree_map, BTreeMap, VecDeque};
 10use futures::{
 11    channel::mpsc::{unbounded, UnboundedSender},
 12    StreamExt,
 13};
 14use gpui::{AppContext, Context, Model, ModelContext, Task};
 15use itertools::Itertools;
 16use language::Language;
 17use task::{
 18    static_source::StaticSource, ResolvedTask, TaskContext, TaskId, TaskTemplate, VariableName,
 19};
 20use util::{post_inc, NumericPrefixWithSuffix};
 21use worktree::WorktreeId;
 22
 23/// Inventory tracks available tasks for a given project.
 24pub struct Inventory {
 25    sources: Vec<SourceInInventory>,
 26    last_scheduled_tasks: VecDeque<(TaskSourceKind, ResolvedTask)>,
 27    update_sender: UnboundedSender<()>,
 28    _update_pooler: Task<anyhow::Result<()>>,
 29}
 30
 31struct SourceInInventory {
 32    source: StaticSource,
 33    kind: TaskSourceKind,
 34}
 35
 36/// Kind of a source the tasks are fetched from, used to display more source information in the UI.
 37#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
 38pub enum TaskSourceKind {
 39    /// bash-like commands spawned by users, not associated with any path
 40    UserInput,
 41    /// Tasks from the worktree's .zed/task.json
 42    Worktree {
 43        id: WorktreeId,
 44        abs_path: PathBuf,
 45        id_base: &'static str,
 46    },
 47    /// ~/.config/zed/task.json - like global files with task definitions, applicable to any path
 48    AbsPath {
 49        id_base: &'static str,
 50        abs_path: PathBuf,
 51    },
 52    /// Languages-specific tasks coming from extensions.
 53    Language { name: Arc<str> },
 54}
 55
 56impl TaskSourceKind {
 57    pub fn abs_path(&self) -> Option<&Path> {
 58        match self {
 59            Self::AbsPath { abs_path, .. } | Self::Worktree { abs_path, .. } => Some(abs_path),
 60            Self::UserInput | Self::Language { .. } => None,
 61        }
 62    }
 63
 64    pub fn worktree(&self) -> Option<WorktreeId> {
 65        match self {
 66            Self::Worktree { id, .. } => Some(*id),
 67            _ => None,
 68        }
 69    }
 70
 71    pub fn to_id_base(&self) -> String {
 72        match self {
 73            TaskSourceKind::UserInput => "oneshot".to_string(),
 74            TaskSourceKind::AbsPath { id_base, abs_path } => {
 75                format!("{id_base}_{}", abs_path.display())
 76            }
 77            TaskSourceKind::Worktree {
 78                id,
 79                id_base,
 80                abs_path,
 81            } => {
 82                format!("{id_base}_{id}_{}", abs_path.display())
 83            }
 84            TaskSourceKind::Language { name } => format!("language_{name}"),
 85        }
 86    }
 87}
 88
 89impl Inventory {
 90    pub fn new(cx: &mut AppContext) -> Model<Self> {
 91        cx.new_model(|cx| {
 92            let (update_sender, mut rx) = unbounded();
 93            let _update_pooler = cx.spawn(|this, mut cx| async move {
 94                while let Some(()) = rx.next().await {
 95                    this.update(&mut cx, |_, cx| {
 96                        cx.notify();
 97                    })?;
 98                }
 99                Ok(())
100            });
101            Self {
102                sources: Vec::new(),
103                last_scheduled_tasks: VecDeque::new(),
104                update_sender,
105                _update_pooler,
106            }
107        })
108    }
109
110    /// If the task with the same path was not added yet,
111    /// registers a new tasks source to fetch for available tasks later.
112    /// Unless a source is removed, ignores future additions for the same path.
113    pub fn add_source(
114        &mut self,
115        kind: TaskSourceKind,
116        create_source: impl FnOnce(UnboundedSender<()>, &mut AppContext) -> StaticSource,
117        cx: &mut ModelContext<Self>,
118    ) {
119        let abs_path = kind.abs_path();
120        if abs_path.is_some() {
121            if let Some(a) = self.sources.iter().find(|s| s.kind.abs_path() == abs_path) {
122                log::debug!("Source for path {abs_path:?} already exists, not adding. Old kind: {OLD_KIND:?}, new kind: {kind:?}", OLD_KIND = a.kind);
123                return;
124            }
125        }
126        let source = create_source(self.update_sender.clone(), cx);
127        let source = SourceInInventory { source, kind };
128        self.sources.push(source);
129        cx.notify();
130    }
131
132    /// If present, removes the local static source entry that has the given path,
133    /// making corresponding task definitions unavailable in the fetch results.
134    ///
135    /// Now, entry for this path can be re-added again.
136    pub fn remove_local_static_source(&mut self, abs_path: &Path) {
137        self.sources.retain(|s| s.kind.abs_path() != Some(abs_path));
138    }
139
140    /// If present, removes the worktree source entry that has the given worktree id,
141    /// making corresponding task definitions unavailable in the fetch results.
142    ///
143    /// Now, entry for this path can be re-added again.
144    pub fn remove_worktree_sources(&mut self, worktree: WorktreeId) {
145        self.sources.retain(|s| s.kind.worktree() != Some(worktree));
146    }
147
148    /// Pulls its task sources relevant to the worktree and the language given,
149    /// returns all task templates with their source kinds, in no specific order.
150    pub fn list_tasks(
151        &self,
152        language: Option<Arc<Language>>,
153        worktree: Option<WorktreeId>,
154    ) -> Vec<(TaskSourceKind, TaskTemplate)> {
155        let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language {
156            name: language.name(),
157        });
158        let language_tasks = language
159            .and_then(|language| language.context_provider()?.associated_tasks())
160            .into_iter()
161            .flat_map(|tasks| tasks.0.into_iter())
162            .flat_map(|task| Some((task_source_kind.as_ref()?, task)));
163
164        self.sources
165            .iter()
166            .filter(|source| {
167                let source_worktree = source.kind.worktree();
168                worktree.is_none() || source_worktree.is_none() || source_worktree == worktree
169            })
170            .flat_map(|source| {
171                source
172                    .source
173                    .tasks_to_schedule()
174                    .0
175                    .into_iter()
176                    .map(|task| (&source.kind, task))
177            })
178            .chain(language_tasks)
179            .map(|(task_source_kind, task)| (task_source_kind.clone(), task))
180            .collect()
181    }
182
183    /// Pulls its task sources relevant to the worktree and the language given and resolves them with the [`TaskContext`] given.
184    /// Joins the new resolutions with the resolved tasks that were used (spawned) before,
185    /// orders them so that the most recently used come first, all equally used ones are ordered so that the most specific tasks come first.
186    /// Deduplicates the tasks by their labels and splits the ordered list into two: used tasks and the rest, newly resolved tasks.
187    pub fn used_and_current_resolved_tasks(
188        &self,
189        language: Option<Arc<Language>>,
190        worktree: Option<WorktreeId>,
191        task_context: &TaskContext,
192    ) -> (
193        Vec<(TaskSourceKind, ResolvedTask)>,
194        Vec<(TaskSourceKind, ResolvedTask)>,
195    ) {
196        let task_source_kind = language.as_ref().map(|language| TaskSourceKind::Language {
197            name: language.name(),
198        });
199        let language_tasks = language
200            .and_then(|language| language.context_provider()?.associated_tasks())
201            .into_iter()
202            .flat_map(|tasks| tasks.0.into_iter())
203            .flat_map(|task| Some((task_source_kind.as_ref()?, task)));
204
205        let mut lru_score = 0_u32;
206        let mut task_usage = self
207            .last_scheduled_tasks
208            .iter()
209            .rev()
210            .filter(|(task_kind, _)| {
211                if matches!(task_kind, TaskSourceKind::Language { .. }) {
212                    Some(task_kind) == task_source_kind.as_ref()
213                } else {
214                    true
215                }
216            })
217            .fold(
218                BTreeMap::default(),
219                |mut tasks, (task_source_kind, resolved_task)| {
220                    tasks.entry(&resolved_task.id).or_insert_with(|| {
221                        (task_source_kind, resolved_task, post_inc(&mut lru_score))
222                    });
223                    tasks
224                },
225            );
226        let not_used_score = post_inc(&mut lru_score);
227        let currently_resolved_tasks = self
228            .sources
229            .iter()
230            .filter(|source| {
231                let source_worktree = source.kind.worktree();
232                worktree.is_none() || source_worktree.is_none() || source_worktree == worktree
233            })
234            .flat_map(|source| {
235                source
236                    .source
237                    .tasks_to_schedule()
238                    .0
239                    .into_iter()
240                    .map(|task| (&source.kind, task))
241            })
242            .chain(language_tasks)
243            .filter_map(|(kind, task)| {
244                let id_base = kind.to_id_base();
245                Some((kind, task.resolve_task(&id_base, task_context)?))
246            })
247            .map(|(kind, task)| {
248                let lru_score = task_usage
249                    .remove(&task.id)
250                    .map(|(_, _, lru_score)| lru_score)
251                    .unwrap_or(not_used_score);
252                (kind.clone(), task, lru_score)
253            })
254            .collect::<Vec<_>>();
255        let previously_spawned_tasks = task_usage
256            .into_iter()
257            .map(|(_, (kind, task, lru_score))| (kind.clone(), task.clone(), lru_score));
258
259        let mut tasks_by_label = BTreeMap::default();
260        tasks_by_label = previously_spawned_tasks.into_iter().fold(
261            tasks_by_label,
262            |mut tasks_by_label, (source, task, lru_score)| {
263                match tasks_by_label.entry((source, task.resolved_label.clone())) {
264                    btree_map::Entry::Occupied(mut o) => {
265                        let (_, previous_lru_score) = o.get();
266                        if previous_lru_score >= &lru_score {
267                            o.insert((task, lru_score));
268                        }
269                    }
270                    btree_map::Entry::Vacant(v) => {
271                        v.insert((task, lru_score));
272                    }
273                }
274                tasks_by_label
275            },
276        );
277        tasks_by_label = currently_resolved_tasks.iter().fold(
278            tasks_by_label,
279            |mut tasks_by_label, (source, task, lru_score)| {
280                match tasks_by_label.entry((source.clone(), task.resolved_label.clone())) {
281                    btree_map::Entry::Occupied(mut o) => {
282                        let (previous_task, _) = o.get();
283                        let new_template = task.original_task();
284                        if new_template != previous_task.original_task() {
285                            o.insert((task.clone(), *lru_score));
286                        }
287                    }
288                    btree_map::Entry::Vacant(v) => {
289                        v.insert((task.clone(), *lru_score));
290                    }
291                }
292                tasks_by_label
293            },
294        );
295
296        let resolved = tasks_by_label
297            .into_iter()
298            .map(|((kind, _), (task, lru_score))| (kind, task, lru_score))
299            .sorted_by(task_lru_comparator)
300            .filter_map(|(kind, task, lru_score)| {
301                if lru_score < not_used_score {
302                    Some((kind, task))
303                } else {
304                    None
305                }
306            })
307            .collect();
308
309        (
310            resolved,
311            currently_resolved_tasks
312                .into_iter()
313                .sorted_unstable_by(task_lru_comparator)
314                .map(|(kind, task, _)| (kind, task))
315                .collect(),
316        )
317    }
318
319    /// Returns the last scheduled task, if any of the sources contains one with the matching id.
320    pub fn last_scheduled_task(&self) -> Option<(TaskSourceKind, ResolvedTask)> {
321        self.last_scheduled_tasks.back().cloned()
322    }
323
324    /// Registers task "usage" as being scheduled – to be used for LRU sorting when listing all tasks.
325    pub fn task_scheduled(
326        &mut self,
327        task_source_kind: TaskSourceKind,
328        resolved_task: ResolvedTask,
329    ) {
330        self.last_scheduled_tasks
331            .push_back((task_source_kind, resolved_task));
332        if self.last_scheduled_tasks.len() > 5_000 {
333            self.last_scheduled_tasks.pop_front();
334        }
335    }
336
337    /// Deletes a resolved task from history, using its id.
338    /// A similar may still resurface in `used_and_current_resolved_tasks` when its [`TaskTemplate`] is resolved again.
339    pub fn delete_previously_used(&mut self, id: &TaskId) {
340        self.last_scheduled_tasks.retain(|(_, task)| &task.id != id);
341    }
342}
343
344fn task_lru_comparator(
345    (kind_a, task_a, lru_score_a): &(TaskSourceKind, ResolvedTask, u32),
346    (kind_b, task_b, lru_score_b): &(TaskSourceKind, ResolvedTask, u32),
347) -> cmp::Ordering {
348    lru_score_a
349        // First, display recently used templates above all.
350        .cmp(&lru_score_b)
351        // Then, ensure more specific sources are displayed first.
352        .then(task_source_kind_preference(kind_a).cmp(&task_source_kind_preference(kind_b)))
353        // After that, display first more specific tasks, using more template variables.
354        // Bonus points for tasks with symbol variables.
355        .then(task_variables_preference(task_a).cmp(&task_variables_preference(task_b)))
356        // Finally, sort by the resolved label, but a bit more specifically, to avoid mixing letters and digits.
357        .then({
358            NumericPrefixWithSuffix::from_numeric_prefixed_str(&task_a.resolved_label)
359                .cmp(&NumericPrefixWithSuffix::from_numeric_prefixed_str(
360                    &task_b.resolved_label,
361                ))
362                .then(task_a.resolved_label.cmp(&task_b.resolved_label))
363                .then(kind_a.cmp(kind_b))
364        })
365}
366
367fn task_source_kind_preference(kind: &TaskSourceKind) -> u32 {
368    match kind {
369        TaskSourceKind::Language { .. } => 1,
370        TaskSourceKind::UserInput => 2,
371        TaskSourceKind::Worktree { .. } => 3,
372        TaskSourceKind::AbsPath { .. } => 4,
373    }
374}
375
376fn task_variables_preference(task: &ResolvedTask) -> Reverse<usize> {
377    let task_variables = task.substituted_variables();
378    Reverse(if task_variables.contains(&VariableName::Symbol) {
379        task_variables.len() + 1
380    } else {
381        task_variables.len()
382    })
383}
384
385#[cfg(test)]
386mod test_inventory {
387    use gpui::{AppContext, Model, TestAppContext};
388    use itertools::Itertools;
389    use task::{
390        static_source::{StaticSource, TrackedFile},
391        TaskContext, TaskTemplate, TaskTemplates,
392    };
393    use worktree::WorktreeId;
394
395    use crate::Inventory;
396
397    use super::{task_source_kind_preference, TaskSourceKind, UnboundedSender};
398
399    #[derive(Debug, Clone, PartialEq, Eq)]
400    pub struct TestTask {
401        name: String,
402    }
403
404    pub(super) fn static_test_source(
405        task_names: impl IntoIterator<Item = String>,
406        updates: UnboundedSender<()>,
407        cx: &mut AppContext,
408    ) -> StaticSource {
409        let tasks = TaskTemplates(
410            task_names
411                .into_iter()
412                .map(|name| TaskTemplate {
413                    label: name,
414                    command: "test command".to_owned(),
415                    ..TaskTemplate::default()
416                })
417                .collect(),
418        );
419        let (tx, rx) = futures::channel::mpsc::unbounded();
420        let file = TrackedFile::new(rx, updates, cx);
421        tx.unbounded_send(serde_json::to_string(&tasks).unwrap())
422            .unwrap();
423        StaticSource::new(file)
424    }
425
426    pub(super) fn task_template_names(
427        inventory: &Model<Inventory>,
428        worktree: Option<WorktreeId>,
429        cx: &mut TestAppContext,
430    ) -> Vec<String> {
431        inventory.update(cx, |inventory, _| {
432            inventory
433                .list_tasks(None, worktree)
434                .into_iter()
435                .map(|(_, task)| task.label)
436                .sorted()
437                .collect()
438        })
439    }
440
441    pub(super) fn resolved_task_names(
442        inventory: &Model<Inventory>,
443        worktree: Option<WorktreeId>,
444        cx: &mut TestAppContext,
445    ) -> Vec<String> {
446        inventory.update(cx, |inventory, _| {
447            let (used, current) =
448                inventory.used_and_current_resolved_tasks(None, worktree, &TaskContext::default());
449            used.into_iter()
450                .chain(current)
451                .map(|(_, task)| task.original_task().label.clone())
452                .collect()
453        })
454    }
455
456    pub(super) fn register_task_used(
457        inventory: &Model<Inventory>,
458        task_name: &str,
459        cx: &mut TestAppContext,
460    ) {
461        inventory.update(cx, |inventory, _| {
462            let (task_source_kind, task) = inventory
463                .list_tasks(None, None)
464                .into_iter()
465                .find(|(_, task)| task.label == task_name)
466                .unwrap_or_else(|| panic!("Failed to find task with name {task_name}"));
467            let id_base = task_source_kind.to_id_base();
468            inventory.task_scheduled(
469                task_source_kind.clone(),
470                task.resolve_task(&id_base, &TaskContext::default())
471                    .unwrap_or_else(|| panic!("Failed to resolve task with name {task_name}")),
472            );
473        });
474    }
475
476    pub(super) fn list_tasks(
477        inventory: &Model<Inventory>,
478        worktree: Option<WorktreeId>,
479        cx: &mut TestAppContext,
480    ) -> Vec<(TaskSourceKind, String)> {
481        inventory.update(cx, |inventory, _| {
482            let (used, current) =
483                inventory.used_and_current_resolved_tasks(None, worktree, &TaskContext::default());
484            let mut all = used;
485            all.extend(current);
486            all.into_iter()
487                .map(|(source_kind, task)| (source_kind, task.resolved_label))
488                .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
489                .collect()
490        })
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use gpui::TestAppContext;
497
498    use super::test_inventory::*;
499    use super::*;
500
501    #[gpui::test]
502    fn test_task_list_sorting(cx: &mut TestAppContext) {
503        let inventory = cx.update(Inventory::new);
504        let initial_tasks = resolved_task_names(&inventory, None, cx);
505        assert!(
506            initial_tasks.is_empty(),
507            "No tasks expected for empty inventory, but got {initial_tasks:?}"
508        );
509        let initial_tasks = task_template_names(&inventory, None, cx);
510        assert!(
511            initial_tasks.is_empty(),
512            "No tasks expected for empty inventory, but got {initial_tasks:?}"
513        );
514
515        inventory.update(cx, |inventory, cx| {
516            inventory.add_source(
517                TaskSourceKind::UserInput,
518                |tx, cx| static_test_source(vec!["3_task".to_string()], tx, cx),
519                cx,
520            );
521        });
522        inventory.update(cx, |inventory, cx| {
523            inventory.add_source(
524                TaskSourceKind::UserInput,
525                |tx, cx| {
526                    static_test_source(
527                        vec![
528                            "1_task".to_string(),
529                            "2_task".to_string(),
530                            "1_a_task".to_string(),
531                        ],
532                        tx,
533                        cx,
534                    )
535                },
536                cx,
537            );
538        });
539        cx.run_until_parked();
540        let expected_initial_state = [
541            "1_a_task".to_string(),
542            "1_task".to_string(),
543            "2_task".to_string(),
544            "3_task".to_string(),
545        ];
546        assert_eq!(
547            task_template_names(&inventory, None, cx),
548            &expected_initial_state,
549        );
550        assert_eq!(
551            resolved_task_names(&inventory, None, cx),
552            &expected_initial_state,
553            "Tasks with equal amount of usages should be sorted alphanumerically"
554        );
555
556        register_task_used(&inventory, "2_task", cx);
557        assert_eq!(
558            task_template_names(&inventory, None, cx),
559            &expected_initial_state,
560        );
561        assert_eq!(
562            resolved_task_names(&inventory, None, cx),
563            vec![
564                "2_task".to_string(),
565                "2_task".to_string(),
566                "1_a_task".to_string(),
567                "1_task".to_string(),
568                "3_task".to_string()
569            ],
570        );
571
572        register_task_used(&inventory, "1_task", cx);
573        register_task_used(&inventory, "1_task", cx);
574        register_task_used(&inventory, "1_task", cx);
575        register_task_used(&inventory, "3_task", cx);
576        assert_eq!(
577            task_template_names(&inventory, None, cx),
578            &expected_initial_state,
579        );
580        assert_eq!(
581            resolved_task_names(&inventory, None, cx),
582            vec![
583                "3_task".to_string(),
584                "1_task".to_string(),
585                "2_task".to_string(),
586                "3_task".to_string(),
587                "1_task".to_string(),
588                "2_task".to_string(),
589                "1_a_task".to_string(),
590            ],
591        );
592
593        inventory.update(cx, |inventory, cx| {
594            inventory.add_source(
595                TaskSourceKind::UserInput,
596                |tx, cx| {
597                    static_test_source(vec!["10_hello".to_string(), "11_hello".to_string()], tx, cx)
598                },
599                cx,
600            );
601        });
602        cx.run_until_parked();
603        let expected_updated_state = [
604            "10_hello".to_string(),
605            "11_hello".to_string(),
606            "1_a_task".to_string(),
607            "1_task".to_string(),
608            "2_task".to_string(),
609            "3_task".to_string(),
610        ];
611        assert_eq!(
612            task_template_names(&inventory, None, cx),
613            &expected_updated_state,
614        );
615        assert_eq!(
616            resolved_task_names(&inventory, None, cx),
617            vec![
618                "3_task".to_string(),
619                "1_task".to_string(),
620                "2_task".to_string(),
621                "3_task".to_string(),
622                "1_task".to_string(),
623                "2_task".to_string(),
624                "1_a_task".to_string(),
625                "10_hello".to_string(),
626                "11_hello".to_string(),
627            ],
628        );
629
630        register_task_used(&inventory, "11_hello", cx);
631        assert_eq!(
632            task_template_names(&inventory, None, cx),
633            &expected_updated_state,
634        );
635        assert_eq!(
636            resolved_task_names(&inventory, None, cx),
637            vec![
638                "11_hello".to_string(),
639                "3_task".to_string(),
640                "1_task".to_string(),
641                "2_task".to_string(),
642                "11_hello".to_string(),
643                "3_task".to_string(),
644                "1_task".to_string(),
645                "2_task".to_string(),
646                "1_a_task".to_string(),
647                "10_hello".to_string(),
648            ],
649        );
650    }
651
652    #[gpui::test]
653    fn test_inventory_static_task_filters(cx: &mut TestAppContext) {
654        let inventory_with_statics = cx.update(Inventory::new);
655        let common_name = "common_task_name";
656        let path_1 = Path::new("path_1");
657        let path_2 = Path::new("path_2");
658        let worktree_1 = WorktreeId::from_usize(1);
659        let worktree_path_1 = Path::new("worktree_path_1");
660        let worktree_2 = WorktreeId::from_usize(2);
661        let worktree_path_2 = Path::new("worktree_path_2");
662
663        inventory_with_statics.update(cx, |inventory, cx| {
664            inventory.add_source(
665                TaskSourceKind::UserInput,
666                |tx, cx| {
667                    static_test_source(
668                        vec!["user_input".to_string(), common_name.to_string()],
669                        tx,
670                        cx,
671                    )
672                },
673                cx,
674            );
675            inventory.add_source(
676                TaskSourceKind::AbsPath {
677                    id_base: "test source",
678                    abs_path: path_1.to_path_buf(),
679                },
680                |tx, cx| {
681                    static_test_source(
682                        vec!["static_source_1".to_string(), common_name.to_string()],
683                        tx,
684                        cx,
685                    )
686                },
687                cx,
688            );
689            inventory.add_source(
690                TaskSourceKind::AbsPath {
691                    id_base: "test source",
692                    abs_path: path_2.to_path_buf(),
693                },
694                |tx, cx| {
695                    static_test_source(
696                        vec!["static_source_2".to_string(), common_name.to_string()],
697                        tx,
698                        cx,
699                    )
700                },
701                cx,
702            );
703            inventory.add_source(
704                TaskSourceKind::Worktree {
705                    id: worktree_1,
706                    abs_path: worktree_path_1.to_path_buf(),
707                    id_base: "test_source",
708                },
709                |tx, cx| {
710                    static_test_source(
711                        vec!["worktree_1".to_string(), common_name.to_string()],
712                        tx,
713                        cx,
714                    )
715                },
716                cx,
717            );
718            inventory.add_source(
719                TaskSourceKind::Worktree {
720                    id: worktree_2,
721                    abs_path: worktree_path_2.to_path_buf(),
722                    id_base: "test_source",
723                },
724                |tx, cx| {
725                    static_test_source(
726                        vec!["worktree_2".to_string(), common_name.to_string()],
727                        tx,
728                        cx,
729                    )
730                },
731                cx,
732            );
733        });
734        cx.run_until_parked();
735        let worktree_independent_tasks = vec![
736            (
737                TaskSourceKind::AbsPath {
738                    id_base: "test source",
739                    abs_path: path_1.to_path_buf(),
740                },
741                "static_source_1".to_string(),
742            ),
743            (
744                TaskSourceKind::AbsPath {
745                    id_base: "test source",
746                    abs_path: path_1.to_path_buf(),
747                },
748                common_name.to_string(),
749            ),
750            (
751                TaskSourceKind::AbsPath {
752                    id_base: "test source",
753                    abs_path: path_2.to_path_buf(),
754                },
755                common_name.to_string(),
756            ),
757            (
758                TaskSourceKind::AbsPath {
759                    id_base: "test source",
760                    abs_path: path_2.to_path_buf(),
761                },
762                "static_source_2".to_string(),
763            ),
764            (TaskSourceKind::UserInput, common_name.to_string()),
765            (TaskSourceKind::UserInput, "user_input".to_string()),
766        ];
767        let worktree_1_tasks = [
768            (
769                TaskSourceKind::Worktree {
770                    id: worktree_1,
771                    abs_path: worktree_path_1.to_path_buf(),
772                    id_base: "test_source",
773                },
774                common_name.to_string(),
775            ),
776            (
777                TaskSourceKind::Worktree {
778                    id: worktree_1,
779                    abs_path: worktree_path_1.to_path_buf(),
780                    id_base: "test_source",
781                },
782                "worktree_1".to_string(),
783            ),
784        ];
785        let worktree_2_tasks = [
786            (
787                TaskSourceKind::Worktree {
788                    id: worktree_2,
789                    abs_path: worktree_path_2.to_path_buf(),
790                    id_base: "test_source",
791                },
792                common_name.to_string(),
793            ),
794            (
795                TaskSourceKind::Worktree {
796                    id: worktree_2,
797                    abs_path: worktree_path_2.to_path_buf(),
798                    id_base: "test_source",
799                },
800                "worktree_2".to_string(),
801            ),
802        ];
803
804        let all_tasks = worktree_1_tasks
805            .iter()
806            .chain(worktree_2_tasks.iter())
807            // worktree-less tasks come later in the list
808            .chain(worktree_independent_tasks.iter())
809            .cloned()
810            .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
811            .collect::<Vec<_>>();
812
813        assert_eq!(list_tasks(&inventory_with_statics, None, cx), all_tasks);
814        assert_eq!(
815            list_tasks(&inventory_with_statics, Some(worktree_1), cx),
816            worktree_1_tasks
817                .iter()
818                .chain(worktree_independent_tasks.iter())
819                .cloned()
820                .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
821                .collect::<Vec<_>>(),
822        );
823        assert_eq!(
824            list_tasks(&inventory_with_statics, Some(worktree_2), cx),
825            worktree_2_tasks
826                .iter()
827                .chain(worktree_independent_tasks.iter())
828                .cloned()
829                .sorted_by_key(|(kind, label)| (task_source_kind_preference(kind), label.clone()))
830                .collect::<Vec<_>>(),
831        );
832    }
833}