task_inventory.rs

  1//! Project-wide storage of the tasks available, capable of updating itself from the sources set.
  2
  3use std::{
  4    any::TypeId,
  5    path::{Path, PathBuf},
  6    sync::Arc,
  7};
  8
  9use collections::{HashMap, VecDeque};
 10use gpui::{AppContext, Context, Model, ModelContext, Subscription};
 11use itertools::Itertools;
 12use task::{Task, TaskContext, TaskId, TaskSource};
 13use util::{post_inc, NumericPrefixWithSuffix};
 14use worktree::WorktreeId;
 15
 16/// Inventory tracks available tasks for a given project.
 17pub struct Inventory {
 18    sources: Vec<SourceInInventory>,
 19    last_scheduled_tasks: VecDeque<(TaskId, TaskContext)>,
 20}
 21
 22struct SourceInInventory {
 23    source: Model<Box<dyn TaskSource>>,
 24    _subscription: Subscription,
 25    type_id: TypeId,
 26    kind: TaskSourceKind,
 27}
 28
 29/// Kind of a source the tasks are fetched from, used to display more source information in the UI.
 30#[derive(Debug, Clone, PartialEq, Eq)]
 31pub enum TaskSourceKind {
 32    /// bash-like commands spawned by users, not associated with any path
 33    UserInput,
 34    /// ~/.config/zed/task.json - like global files with task definitions, applicable to any path
 35    AbsPath(PathBuf),
 36    /// Worktree-specific task definitions, e.g. dynamic tasks from open worktree file, or tasks from the worktree's .zed/task.json
 37    Worktree { id: WorktreeId, abs_path: PathBuf },
 38}
 39
 40impl TaskSourceKind {
 41    fn abs_path(&self) -> Option<&Path> {
 42        match self {
 43            Self::AbsPath(abs_path) | Self::Worktree { abs_path, .. } => Some(abs_path),
 44            Self::UserInput => None,
 45        }
 46    }
 47
 48    fn worktree(&self) -> Option<WorktreeId> {
 49        match self {
 50            Self::Worktree { id, .. } => Some(*id),
 51            _ => None,
 52        }
 53    }
 54}
 55
 56impl Inventory {
 57    pub fn new(cx: &mut AppContext) -> Model<Self> {
 58        cx.new_model(|_| Self {
 59            sources: Vec::new(),
 60            last_scheduled_tasks: VecDeque::new(),
 61        })
 62    }
 63
 64    /// If the task with the same path was not added yet,
 65    /// registers a new tasks source to fetch for available tasks later.
 66    /// Unless a source is removed, ignores future additions for the same path.
 67    pub fn add_source(
 68        &mut self,
 69        kind: TaskSourceKind,
 70        create_source: impl FnOnce(&mut ModelContext<Self>) -> Model<Box<dyn TaskSource>>,
 71        cx: &mut ModelContext<Self>,
 72    ) {
 73        let abs_path = kind.abs_path();
 74        if abs_path.is_some() {
 75            if let Some(a) = self.sources.iter().find(|s| s.kind.abs_path() == abs_path) {
 76                log::debug!("Source for path {abs_path:?} already exists, not adding. Old kind: {OLD_KIND:?}, new kind: {kind:?}", OLD_KIND = a.kind);
 77                return;
 78            }
 79        }
 80
 81        let source = create_source(cx);
 82        let type_id = source.read(cx).type_id();
 83        let source = SourceInInventory {
 84            _subscription: cx.observe(&source, |_, _, cx| {
 85                cx.notify();
 86            }),
 87            source,
 88            type_id,
 89            kind,
 90        };
 91        self.sources.push(source);
 92        cx.notify();
 93    }
 94
 95    /// If present, removes the local static source entry that has the given path,
 96    /// making corresponding task definitions unavailable in the fetch results.
 97    ///
 98    /// Now, entry for this path can be re-added again.
 99    pub fn remove_local_static_source(&mut self, abs_path: &Path) {
100        self.sources.retain(|s| s.kind.abs_path() != Some(abs_path));
101    }
102
103    /// If present, removes the worktree source entry that has the given worktree id,
104    /// making corresponding task definitions unavailable in the fetch results.
105    ///
106    /// Now, entry for this path can be re-added again.
107    pub fn remove_worktree_sources(&mut self, worktree: WorktreeId) {
108        self.sources.retain(|s| s.kind.worktree() != Some(worktree));
109    }
110
111    pub fn source<T: TaskSource>(&self) -> Option<Model<Box<dyn TaskSource>>> {
112        let target_type_id = std::any::TypeId::of::<T>();
113        self.sources.iter().find_map(
114            |SourceInInventory {
115                 type_id, source, ..
116             }| {
117                if &target_type_id == type_id {
118                    Some(source.clone())
119                } else {
120                    None
121                }
122            },
123        )
124    }
125
126    /// Pulls its sources to list runanbles for the path given (up to the source to decide what to return for no path).
127    pub fn list_tasks(
128        &self,
129        path: Option<&Path>,
130        worktree: Option<WorktreeId>,
131        lru: bool,
132        cx: &mut AppContext,
133    ) -> Vec<(TaskSourceKind, Arc<dyn Task>)> {
134        let mut lru_score = 0_u32;
135        let tasks_by_usage = if lru {
136            self.last_scheduled_tasks.iter().rev().fold(
137                HashMap::default(),
138                |mut tasks, (id, context)| {
139                    tasks
140                        .entry(id)
141                        .or_insert_with(|| (post_inc(&mut lru_score), Some(context)));
142                    tasks
143                },
144            )
145        } else {
146            HashMap::default()
147        };
148        let not_used_task_context = None;
149        let not_used_score = (post_inc(&mut lru_score), not_used_task_context);
150        self.sources
151            .iter()
152            .filter(|source| {
153                let source_worktree = source.kind.worktree();
154                worktree.is_none() || source_worktree.is_none() || source_worktree == worktree
155            })
156            .flat_map(|source| {
157                source
158                    .source
159                    .update(cx, |source, cx| source.tasks_for_path(path, cx))
160                    .into_iter()
161                    .map(|task| (&source.kind, task))
162            })
163            .map(|task| {
164                let usages = if lru {
165                    tasks_by_usage
166                        .get(&task.1.id())
167                        .copied()
168                        .unwrap_or(not_used_score)
169                } else {
170                    not_used_score
171                };
172                (task, usages)
173            })
174            .sorted_unstable_by(
175                |((kind_a, task_a), usages_a), ((kind_b, task_b), usages_b)| {
176                    usages_a
177                        .0
178                        .cmp(&usages_b.0)
179                        .then(
180                            kind_a
181                                .worktree()
182                                .is_none()
183                                .cmp(&kind_b.worktree().is_none()),
184                        )
185                        .then(kind_a.worktree().cmp(&kind_b.worktree()))
186                        .then(
187                            kind_a
188                                .abs_path()
189                                .is_none()
190                                .cmp(&kind_b.abs_path().is_none()),
191                        )
192                        .then(kind_a.abs_path().cmp(&kind_b.abs_path()))
193                        .then({
194                            NumericPrefixWithSuffix::from_numeric_prefixed_str(task_a.name())
195                                .cmp(&NumericPrefixWithSuffix::from_numeric_prefixed_str(
196                                    task_b.name(),
197                                ))
198                                .then(task_a.name().cmp(task_b.name()))
199                        })
200                },
201            )
202            .map(|((kind, task), _)| (kind.clone(), task))
203            .collect()
204    }
205
206    /// Returns the last scheduled task, if any of the sources contains one with the matching id.
207    pub fn last_scheduled_task(&self, cx: &mut AppContext) -> Option<(Arc<dyn Task>, TaskContext)> {
208        self.last_scheduled_tasks
209            .back()
210            .and_then(|(id, task_context)| {
211                // TODO straighten the `Path` story to understand what has to be passed here: or it will break in the future.
212                self.list_tasks(None, None, false, cx)
213                    .into_iter()
214                    .find(|(_, task)| task.id() == id)
215                    .map(|(_, task)| (task, task_context.clone()))
216            })
217    }
218
219    /// Registers task "usage" as being scheduled – to be used for LRU sorting when listing all tasks.
220    pub fn task_scheduled(&mut self, id: TaskId, task_context: TaskContext) {
221        self.last_scheduled_tasks.push_back((id, task_context));
222        if self.last_scheduled_tasks.len() > 5_000 {
223            self.last_scheduled_tasks.pop_front();
224        }
225    }
226}
227
228#[cfg(any(test, feature = "test-support"))]
229pub mod test_inventory {
230    use std::{path::Path, sync::Arc};
231
232    use gpui::{AppContext, Context as _, Model, ModelContext, TestAppContext};
233    use task::{Task, TaskContext, TaskId, TaskSource};
234    use worktree::WorktreeId;
235
236    use crate::Inventory;
237
238    use super::TaskSourceKind;
239
240    #[derive(Debug, Clone, PartialEq, Eq)]
241    pub struct TestTask {
242        pub id: task::TaskId,
243        pub name: String,
244    }
245
246    impl Task for TestTask {
247        fn id(&self) -> &TaskId {
248            &self.id
249        }
250
251        fn name(&self) -> &str {
252            &self.name
253        }
254
255        fn cwd(&self) -> Option<&str> {
256            None
257        }
258
259        fn exec(&self, _cwd: TaskContext) -> Option<task::SpawnInTerminal> {
260            None
261        }
262    }
263
264    pub struct StaticTestSource {
265        pub tasks: Vec<TestTask>,
266    }
267
268    impl StaticTestSource {
269        pub fn new(
270            task_names: impl IntoIterator<Item = String>,
271            cx: &mut AppContext,
272        ) -> Model<Box<dyn TaskSource>> {
273            cx.new_model(|_| {
274                Box::new(Self {
275                    tasks: task_names
276                        .into_iter()
277                        .enumerate()
278                        .map(|(i, name)| TestTask {
279                            id: TaskId(format!("task_{i}_{name}")),
280                            name,
281                        })
282                        .collect(),
283                }) as Box<dyn TaskSource>
284            })
285        }
286    }
287
288    impl TaskSource for StaticTestSource {
289        fn tasks_for_path(
290            &mut self,
291            _path: Option<&Path>,
292            _cx: &mut ModelContext<Box<dyn TaskSource>>,
293        ) -> Vec<Arc<dyn Task>> {
294            self.tasks
295                .clone()
296                .into_iter()
297                .map(|task| Arc::new(task) as Arc<dyn Task>)
298                .collect()
299        }
300
301        fn as_any(&mut self) -> &mut dyn std::any::Any {
302            self
303        }
304    }
305
306    pub fn list_task_names(
307        inventory: &Model<Inventory>,
308        path: Option<&Path>,
309        worktree: Option<WorktreeId>,
310        lru: bool,
311        cx: &mut TestAppContext,
312    ) -> Vec<String> {
313        inventory.update(cx, |inventory, cx| {
314            inventory
315                .list_tasks(path, worktree, lru, cx)
316                .into_iter()
317                .map(|(_, task)| task.name().to_string())
318                .collect()
319        })
320    }
321
322    pub fn register_task_used(
323        inventory: &Model<Inventory>,
324        task_name: &str,
325        cx: &mut TestAppContext,
326    ) {
327        inventory.update(cx, |inventory, cx| {
328            let task = inventory
329                .list_tasks(None, None, false, cx)
330                .into_iter()
331                .find(|(_, task)| task.name() == task_name)
332                .unwrap_or_else(|| panic!("Failed to find task with name {task_name}"));
333            inventory.task_scheduled(task.1.id().clone(), TaskContext::default());
334        });
335    }
336
337    pub fn list_tasks(
338        inventory: &Model<Inventory>,
339        path: Option<&Path>,
340        worktree: Option<WorktreeId>,
341        lru: bool,
342        cx: &mut TestAppContext,
343    ) -> Vec<(TaskSourceKind, String)> {
344        inventory.update(cx, |inventory, cx| {
345            inventory
346                .list_tasks(path, worktree, lru, cx)
347                .into_iter()
348                .map(|(source_kind, task)| (source_kind, task.name().to_string()))
349                .collect()
350        })
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use gpui::TestAppContext;
357
358    use super::test_inventory::*;
359    use super::*;
360
361    #[gpui::test]
362    fn test_task_list_sorting(cx: &mut TestAppContext) {
363        let inventory = cx.update(Inventory::new);
364        let initial_tasks = list_task_names(&inventory, None, None, true, cx);
365        assert!(
366            initial_tasks.is_empty(),
367            "No tasks expected for empty inventory, but got {initial_tasks:?}"
368        );
369        let initial_tasks = list_task_names(&inventory, None, None, false, cx);
370        assert!(
371            initial_tasks.is_empty(),
372            "No tasks expected for empty inventory, but got {initial_tasks:?}"
373        );
374
375        inventory.update(cx, |inventory, cx| {
376            inventory.add_source(
377                TaskSourceKind::UserInput,
378                |cx| StaticTestSource::new(vec!["3_task".to_string()], cx),
379                cx,
380            );
381        });
382        inventory.update(cx, |inventory, cx| {
383            inventory.add_source(
384                TaskSourceKind::UserInput,
385                |cx| {
386                    StaticTestSource::new(
387                        vec![
388                            "1_task".to_string(),
389                            "2_task".to_string(),
390                            "1_a_task".to_string(),
391                        ],
392                        cx,
393                    )
394                },
395                cx,
396            );
397        });
398
399        let expected_initial_state = [
400            "1_a_task".to_string(),
401            "1_task".to_string(),
402            "2_task".to_string(),
403            "3_task".to_string(),
404        ];
405        assert_eq!(
406            list_task_names(&inventory, None, None, false, cx),
407            &expected_initial_state,
408            "Task list without lru sorting, should be sorted alphanumerically"
409        );
410        assert_eq!(
411            list_task_names(&inventory, None, None, true, cx),
412            &expected_initial_state,
413            "Tasks with equal amount of usages should be sorted alphanumerically"
414        );
415
416        register_task_used(&inventory, "2_task", cx);
417        assert_eq!(
418            list_task_names(&inventory, None, None, false, cx),
419            &expected_initial_state,
420            "Task list without lru sorting, should be sorted alphanumerically"
421        );
422        assert_eq!(
423            list_task_names(&inventory, None, None, true, cx),
424            vec![
425                "2_task".to_string(),
426                "1_a_task".to_string(),
427                "1_task".to_string(),
428                "3_task".to_string()
429            ],
430        );
431
432        register_task_used(&inventory, "1_task", cx);
433        register_task_used(&inventory, "1_task", cx);
434        register_task_used(&inventory, "1_task", cx);
435        register_task_used(&inventory, "3_task", cx);
436        assert_eq!(
437            list_task_names(&inventory, None, None, false, cx),
438            &expected_initial_state,
439            "Task list without lru sorting, should be sorted alphanumerically"
440        );
441        assert_eq!(
442            list_task_names(&inventory, None, None, true, cx),
443            vec![
444                "3_task".to_string(),
445                "1_task".to_string(),
446                "2_task".to_string(),
447                "1_a_task".to_string(),
448            ],
449        );
450
451        inventory.update(cx, |inventory, cx| {
452            inventory.add_source(
453                TaskSourceKind::UserInput,
454                |cx| {
455                    StaticTestSource::new(vec!["10_hello".to_string(), "11_hello".to_string()], cx)
456                },
457                cx,
458            );
459        });
460        let expected_updated_state = [
461            "1_a_task".to_string(),
462            "1_task".to_string(),
463            "2_task".to_string(),
464            "3_task".to_string(),
465            "10_hello".to_string(),
466            "11_hello".to_string(),
467        ];
468        assert_eq!(
469            list_task_names(&inventory, None, None, false, cx),
470            &expected_updated_state,
471            "Task list without lru sorting, should be sorted alphanumerically"
472        );
473        assert_eq!(
474            list_task_names(&inventory, None, None, true, cx),
475            vec![
476                "3_task".to_string(),
477                "1_task".to_string(),
478                "2_task".to_string(),
479                "1_a_task".to_string(),
480                "10_hello".to_string(),
481                "11_hello".to_string(),
482            ],
483        );
484
485        register_task_used(&inventory, "11_hello", cx);
486        assert_eq!(
487            list_task_names(&inventory, None, None, false, cx),
488            &expected_updated_state,
489            "Task list without lru sorting, should be sorted alphanumerically"
490        );
491        assert_eq!(
492            list_task_names(&inventory, None, None, true, cx),
493            vec![
494                "11_hello".to_string(),
495                "3_task".to_string(),
496                "1_task".to_string(),
497                "2_task".to_string(),
498                "1_a_task".to_string(),
499                "10_hello".to_string(),
500            ],
501        );
502    }
503
504    #[gpui::test]
505    fn test_inventory_static_task_filters(cx: &mut TestAppContext) {
506        let inventory_with_statics = cx.update(Inventory::new);
507        let common_name = "common_task_name";
508        let path_1 = Path::new("path_1");
509        let path_2 = Path::new("path_2");
510        let worktree_1 = WorktreeId::from_usize(1);
511        let worktree_path_1 = Path::new("worktree_path_1");
512        let worktree_2 = WorktreeId::from_usize(2);
513        let worktree_path_2 = Path::new("worktree_path_2");
514        inventory_with_statics.update(cx, |inventory, cx| {
515            inventory.add_source(
516                TaskSourceKind::UserInput,
517                |cx| {
518                    StaticTestSource::new(
519                        vec!["user_input".to_string(), common_name.to_string()],
520                        cx,
521                    )
522                },
523                cx,
524            );
525            inventory.add_source(
526                TaskSourceKind::AbsPath(path_1.to_path_buf()),
527                |cx| {
528                    StaticTestSource::new(
529                        vec!["static_source_1".to_string(), common_name.to_string()],
530                        cx,
531                    )
532                },
533                cx,
534            );
535            inventory.add_source(
536                TaskSourceKind::AbsPath(path_2.to_path_buf()),
537                |cx| {
538                    StaticTestSource::new(
539                        vec!["static_source_2".to_string(), common_name.to_string()],
540                        cx,
541                    )
542                },
543                cx,
544            );
545            inventory.add_source(
546                TaskSourceKind::Worktree {
547                    id: worktree_1,
548                    abs_path: worktree_path_1.to_path_buf(),
549                },
550                |cx| {
551                    StaticTestSource::new(
552                        vec!["worktree_1".to_string(), common_name.to_string()],
553                        cx,
554                    )
555                },
556                cx,
557            );
558            inventory.add_source(
559                TaskSourceKind::Worktree {
560                    id: worktree_2,
561                    abs_path: worktree_path_2.to_path_buf(),
562                },
563                |cx| {
564                    StaticTestSource::new(
565                        vec!["worktree_2".to_string(), common_name.to_string()],
566                        cx,
567                    )
568                },
569                cx,
570            );
571        });
572
573        let worktree_independent_tasks = vec![
574            (
575                TaskSourceKind::AbsPath(path_1.to_path_buf()),
576                common_name.to_string(),
577            ),
578            (
579                TaskSourceKind::AbsPath(path_1.to_path_buf()),
580                "static_source_1".to_string(),
581            ),
582            (
583                TaskSourceKind::AbsPath(path_2.to_path_buf()),
584                common_name.to_string(),
585            ),
586            (
587                TaskSourceKind::AbsPath(path_2.to_path_buf()),
588                "static_source_2".to_string(),
589            ),
590            (TaskSourceKind::UserInput, common_name.to_string()),
591            (TaskSourceKind::UserInput, "user_input".to_string()),
592        ];
593        let worktree_1_tasks = vec![
594            (
595                TaskSourceKind::Worktree {
596                    id: worktree_1,
597                    abs_path: worktree_path_1.to_path_buf(),
598                },
599                common_name.to_string(),
600            ),
601            (
602                TaskSourceKind::Worktree {
603                    id: worktree_1,
604                    abs_path: worktree_path_1.to_path_buf(),
605                },
606                "worktree_1".to_string(),
607            ),
608        ];
609        let worktree_2_tasks = vec![
610            (
611                TaskSourceKind::Worktree {
612                    id: worktree_2,
613                    abs_path: worktree_path_2.to_path_buf(),
614                },
615                common_name.to_string(),
616            ),
617            (
618                TaskSourceKind::Worktree {
619                    id: worktree_2,
620                    abs_path: worktree_path_2.to_path_buf(),
621                },
622                "worktree_2".to_string(),
623            ),
624        ];
625
626        let all_tasks = worktree_1_tasks
627            .iter()
628            .chain(worktree_2_tasks.iter())
629            // worktree-less tasks come later in the list
630            .chain(worktree_independent_tasks.iter())
631            .cloned()
632            .collect::<Vec<_>>();
633
634        for path in [
635            None,
636            Some(path_1),
637            Some(path_2),
638            Some(worktree_path_1),
639            Some(worktree_path_2),
640        ] {
641            assert_eq!(
642                list_tasks(&inventory_with_statics, path, None, false, cx),
643                all_tasks,
644                "Path {path:?} choice should not adjust static runnables"
645            );
646            assert_eq!(
647                list_tasks(&inventory_with_statics, path, Some(worktree_1), false, cx),
648                worktree_1_tasks
649                    .iter()
650                    .chain(worktree_independent_tasks.iter())
651                    .cloned()
652                    .collect::<Vec<_>>(),
653                "Path {path:?} choice should not adjust static runnables for worktree_1"
654            );
655            assert_eq!(
656                list_tasks(&inventory_with_statics, path, Some(worktree_2), false, cx),
657                worktree_2_tasks
658                    .iter()
659                    .chain(worktree_independent_tasks.iter())
660                    .cloned()
661                    .collect::<Vec<_>>(),
662                "Path {path:?} choice should not adjust static runnables for worktree_2"
663            );
664        }
665    }
666}