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