//! Project-wide storage of the tasks available, capable of updating itself from the sources set. use std::{ any::TypeId, path::{Path, PathBuf}, sync::Arc, }; use collections::{HashMap, VecDeque}; use gpui::{AppContext, Context, Model, ModelContext, Subscription}; use itertools::Itertools; use task::{Task, TaskContext, TaskId, TaskSource}; use util::{post_inc, NumericPrefixWithSuffix}; use worktree::WorktreeId; /// Inventory tracks available tasks for a given project. pub struct Inventory { sources: Vec, last_scheduled_tasks: VecDeque<(TaskId, TaskContext)>, } struct SourceInInventory { source: Model>, _subscription: Subscription, type_id: TypeId, kind: TaskSourceKind, } /// Kind of a source the tasks are fetched from, used to display more source information in the UI. #[derive(Debug, Clone, PartialEq, Eq)] pub enum TaskSourceKind { /// bash-like commands spawned by users, not associated with any path UserInput, /// ~/.config/zed/task.json - like global files with task definitions, applicable to any path AbsPath(PathBuf), /// Worktree-specific task definitions, e.g. dynamic tasks from open worktree file, or tasks from the worktree's .zed/task.json Worktree { id: WorktreeId, abs_path: PathBuf }, /// Buffer-specific task definitions, originating in e.g. language extension. Buffer, } impl TaskSourceKind { fn abs_path(&self) -> Option<&Path> { match self { Self::AbsPath(abs_path) | Self::Worktree { abs_path, .. } => Some(abs_path), Self::UserInput | Self::Buffer => None, } } fn worktree(&self) -> Option { match self { Self::Worktree { id, .. } => Some(*id), _ => None, } } } impl Inventory { pub fn new(cx: &mut AppContext) -> Model { cx.new_model(|_| Self { sources: Vec::new(), last_scheduled_tasks: VecDeque::new(), }) } /// If the task with the same path was not added yet, /// registers a new tasks source to fetch for available tasks later. /// Unless a source is removed, ignores future additions for the same path. pub fn add_source( &mut self, kind: TaskSourceKind, create_source: impl FnOnce(&mut ModelContext) -> Model>, cx: &mut ModelContext, ) { let abs_path = kind.abs_path(); if abs_path.is_some() { if let Some(a) = self.sources.iter().find(|s| s.kind.abs_path() == abs_path) { log::debug!("Source for path {abs_path:?} already exists, not adding. Old kind: {OLD_KIND:?}, new kind: {kind:?}", OLD_KIND = a.kind); return; } } let source = create_source(cx); let type_id = source.read(cx).type_id(); let source = SourceInInventory { _subscription: cx.observe(&source, |_, _, cx| { cx.notify(); }), source, type_id, kind, }; self.sources.push(source); cx.notify(); } /// If present, removes the local static source entry that has the given path, /// making corresponding task definitions unavailable in the fetch results. /// /// Now, entry for this path can be re-added again. pub fn remove_local_static_source(&mut self, abs_path: &Path) { self.sources.retain(|s| s.kind.abs_path() != Some(abs_path)); } /// If present, removes the worktree source entry that has the given worktree id, /// making corresponding task definitions unavailable in the fetch results. /// /// Now, entry for this path can be re-added again. pub fn remove_worktree_sources(&mut self, worktree: WorktreeId) { self.sources.retain(|s| s.kind.worktree() != Some(worktree)); } pub fn source(&self) -> Option>> { let target_type_id = std::any::TypeId::of::(); self.sources.iter().find_map( |SourceInInventory { type_id, source, .. }| { if &target_type_id == type_id { Some(source.clone()) } else { None } }, ) } /// Pulls its sources to list runanbles for the path given (up to the source to decide what to return for no path). pub fn list_tasks( &self, path: Option<&Path>, worktree: Option, lru: bool, cx: &mut AppContext, ) -> Vec<(TaskSourceKind, Arc)> { let mut lru_score = 0_u32; let tasks_by_usage = if lru { self.last_scheduled_tasks.iter().rev().fold( HashMap::default(), |mut tasks, (id, context)| { tasks .entry(id) .or_insert_with(|| (post_inc(&mut lru_score), Some(context))); tasks }, ) } else { HashMap::default() }; let not_used_task_context = None; let not_used_score = (post_inc(&mut lru_score), not_used_task_context); self.sources .iter() .filter(|source| { let source_worktree = source.kind.worktree(); worktree.is_none() || source_worktree.is_none() || source_worktree == worktree }) .flat_map(|source| { source .source .update(cx, |source, cx| source.tasks_for_path(path, cx)) .into_iter() .map(|task| (&source.kind, task)) }) .map(|task| { let usages = if lru { tasks_by_usage .get(&task.1.id()) .copied() .unwrap_or(not_used_score) } else { not_used_score }; (task, usages) }) .sorted_unstable_by( |((kind_a, task_a), usages_a), ((kind_b, task_b), usages_b)| { usages_a .0 .cmp(&usages_b.0) .then( kind_a .worktree() .is_none() .cmp(&kind_b.worktree().is_none()), ) .then(kind_a.worktree().cmp(&kind_b.worktree())) .then( kind_a .abs_path() .is_none() .cmp(&kind_b.abs_path().is_none()), ) .then(kind_a.abs_path().cmp(&kind_b.abs_path())) .then({ NumericPrefixWithSuffix::from_numeric_prefixed_str(task_a.name()) .cmp(&NumericPrefixWithSuffix::from_numeric_prefixed_str( task_b.name(), )) .then(task_a.name().cmp(task_b.name())) }) }, ) .map(|((kind, task), _)| (kind.clone(), task)) .collect() } /// Returns the last scheduled task, if any of the sources contains one with the matching id. pub fn last_scheduled_task(&self, cx: &mut AppContext) -> Option<(Arc, TaskContext)> { self.last_scheduled_tasks .back() .and_then(|(id, task_context)| { // TODO straighten the `Path` story to understand what has to be passed here: or it will break in the future. self.list_tasks(None, None, false, cx) .into_iter() .find(|(_, task)| task.id() == id) .map(|(_, task)| (task, task_context.clone())) }) } /// Registers task "usage" as being scheduled – to be used for LRU sorting when listing all tasks. pub fn task_scheduled(&mut self, id: TaskId, task_context: TaskContext) { self.last_scheduled_tasks.push_back((id, task_context)); if self.last_scheduled_tasks.len() > 5_000 { self.last_scheduled_tasks.pop_front(); } } } #[cfg(any(test, feature = "test-support"))] pub mod test_inventory { use std::{path::Path, sync::Arc}; use gpui::{AppContext, Context as _, Model, ModelContext, TestAppContext}; use task::{Task, TaskContext, TaskId, TaskSource}; use worktree::WorktreeId; use crate::Inventory; use super::TaskSourceKind; #[derive(Debug, Clone, PartialEq, Eq)] pub struct TestTask { pub id: task::TaskId, pub name: String, } impl Task for TestTask { fn id(&self) -> &TaskId { &self.id } fn name(&self) -> &str { &self.name } fn cwd(&self) -> Option<&str> { None } fn exec(&self, _cwd: TaskContext) -> Option { None } } pub struct StaticTestSource { pub tasks: Vec, } impl StaticTestSource { pub fn new( task_names: impl IntoIterator, cx: &mut AppContext, ) -> Model> { cx.new_model(|_| { Box::new(Self { tasks: task_names .into_iter() .enumerate() .map(|(i, name)| TestTask { id: TaskId(format!("task_{i}_{name}")), name, }) .collect(), }) as Box }) } } impl TaskSource for StaticTestSource { fn tasks_for_path( &mut self, _path: Option<&Path>, _cx: &mut ModelContext>, ) -> Vec> { self.tasks .clone() .into_iter() .map(|task| Arc::new(task) as Arc) .collect() } fn as_any(&mut self) -> &mut dyn std::any::Any { self } } pub fn list_task_names( inventory: &Model, path: Option<&Path>, worktree: Option, lru: bool, cx: &mut TestAppContext, ) -> Vec { inventory.update(cx, |inventory, cx| { inventory .list_tasks(path, worktree, lru, cx) .into_iter() .map(|(_, task)| task.name().to_string()) .collect() }) } pub fn register_task_used( inventory: &Model, task_name: &str, cx: &mut TestAppContext, ) { inventory.update(cx, |inventory, cx| { let task = inventory .list_tasks(None, None, false, cx) .into_iter() .find(|(_, task)| task.name() == task_name) .unwrap_or_else(|| panic!("Failed to find task with name {task_name}")); inventory.task_scheduled(task.1.id().clone(), TaskContext::default()); }); } pub fn list_tasks( inventory: &Model, path: Option<&Path>, worktree: Option, lru: bool, cx: &mut TestAppContext, ) -> Vec<(TaskSourceKind, String)> { inventory.update(cx, |inventory, cx| { inventory .list_tasks(path, worktree, lru, cx) .into_iter() .map(|(source_kind, task)| (source_kind, task.name().to_string())) .collect() }) } } #[cfg(test)] mod tests { use gpui::TestAppContext; use super::test_inventory::*; use super::*; #[gpui::test] fn test_task_list_sorting(cx: &mut TestAppContext) { let inventory = cx.update(Inventory::new); let initial_tasks = list_task_names(&inventory, None, None, true, cx); assert!( initial_tasks.is_empty(), "No tasks expected for empty inventory, but got {initial_tasks:?}" ); let initial_tasks = list_task_names(&inventory, None, None, false, cx); assert!( initial_tasks.is_empty(), "No tasks expected for empty inventory, but got {initial_tasks:?}" ); inventory.update(cx, |inventory, cx| { inventory.add_source( TaskSourceKind::UserInput, |cx| StaticTestSource::new(vec!["3_task".to_string()], cx), cx, ); }); inventory.update(cx, |inventory, cx| { inventory.add_source( TaskSourceKind::UserInput, |cx| { StaticTestSource::new( vec![ "1_task".to_string(), "2_task".to_string(), "1_a_task".to_string(), ], cx, ) }, cx, ); }); let expected_initial_state = [ "1_a_task".to_string(), "1_task".to_string(), "2_task".to_string(), "3_task".to_string(), ]; assert_eq!( list_task_names(&inventory, None, None, false, cx), &expected_initial_state, "Task list without lru sorting, should be sorted alphanumerically" ); assert_eq!( list_task_names(&inventory, None, None, true, cx), &expected_initial_state, "Tasks with equal amount of usages should be sorted alphanumerically" ); register_task_used(&inventory, "2_task", cx); assert_eq!( list_task_names(&inventory, None, None, false, cx), &expected_initial_state, "Task list without lru sorting, should be sorted alphanumerically" ); assert_eq!( list_task_names(&inventory, None, None, true, cx), vec![ "2_task".to_string(), "1_a_task".to_string(), "1_task".to_string(), "3_task".to_string() ], ); register_task_used(&inventory, "1_task", cx); register_task_used(&inventory, "1_task", cx); register_task_used(&inventory, "1_task", cx); register_task_used(&inventory, "3_task", cx); assert_eq!( list_task_names(&inventory, None, None, false, cx), &expected_initial_state, "Task list without lru sorting, should be sorted alphanumerically" ); assert_eq!( list_task_names(&inventory, None, None, true, cx), vec![ "3_task".to_string(), "1_task".to_string(), "2_task".to_string(), "1_a_task".to_string(), ], ); inventory.update(cx, |inventory, cx| { inventory.add_source( TaskSourceKind::UserInput, |cx| { StaticTestSource::new(vec!["10_hello".to_string(), "11_hello".to_string()], cx) }, cx, ); }); let expected_updated_state = [ "1_a_task".to_string(), "1_task".to_string(), "2_task".to_string(), "3_task".to_string(), "10_hello".to_string(), "11_hello".to_string(), ]; assert_eq!( list_task_names(&inventory, None, None, false, cx), &expected_updated_state, "Task list without lru sorting, should be sorted alphanumerically" ); assert_eq!( list_task_names(&inventory, None, None, true, cx), vec![ "3_task".to_string(), "1_task".to_string(), "2_task".to_string(), "1_a_task".to_string(), "10_hello".to_string(), "11_hello".to_string(), ], ); register_task_used(&inventory, "11_hello", cx); assert_eq!( list_task_names(&inventory, None, None, false, cx), &expected_updated_state, "Task list without lru sorting, should be sorted alphanumerically" ); assert_eq!( list_task_names(&inventory, None, None, true, cx), vec![ "11_hello".to_string(), "3_task".to_string(), "1_task".to_string(), "2_task".to_string(), "1_a_task".to_string(), "10_hello".to_string(), ], ); } #[gpui::test] fn test_inventory_static_task_filters(cx: &mut TestAppContext) { let inventory_with_statics = cx.update(Inventory::new); let common_name = "common_task_name"; let path_1 = Path::new("path_1"); let path_2 = Path::new("path_2"); let worktree_1 = WorktreeId::from_usize(1); let worktree_path_1 = Path::new("worktree_path_1"); let worktree_2 = WorktreeId::from_usize(2); let worktree_path_2 = Path::new("worktree_path_2"); inventory_with_statics.update(cx, |inventory, cx| { inventory.add_source( TaskSourceKind::UserInput, |cx| { StaticTestSource::new( vec!["user_input".to_string(), common_name.to_string()], cx, ) }, cx, ); inventory.add_source( TaskSourceKind::AbsPath(path_1.to_path_buf()), |cx| { StaticTestSource::new( vec!["static_source_1".to_string(), common_name.to_string()], cx, ) }, cx, ); inventory.add_source( TaskSourceKind::AbsPath(path_2.to_path_buf()), |cx| { StaticTestSource::new( vec!["static_source_2".to_string(), common_name.to_string()], cx, ) }, cx, ); inventory.add_source( TaskSourceKind::Worktree { id: worktree_1, abs_path: worktree_path_1.to_path_buf(), }, |cx| { StaticTestSource::new( vec!["worktree_1".to_string(), common_name.to_string()], cx, ) }, cx, ); inventory.add_source( TaskSourceKind::Worktree { id: worktree_2, abs_path: worktree_path_2.to_path_buf(), }, |cx| { StaticTestSource::new( vec!["worktree_2".to_string(), common_name.to_string()], cx, ) }, cx, ); }); let worktree_independent_tasks = vec![ ( TaskSourceKind::AbsPath(path_1.to_path_buf()), common_name.to_string(), ), ( TaskSourceKind::AbsPath(path_1.to_path_buf()), "static_source_1".to_string(), ), ( TaskSourceKind::AbsPath(path_2.to_path_buf()), common_name.to_string(), ), ( TaskSourceKind::AbsPath(path_2.to_path_buf()), "static_source_2".to_string(), ), (TaskSourceKind::UserInput, common_name.to_string()), (TaskSourceKind::UserInput, "user_input".to_string()), ]; let worktree_1_tasks = [ ( TaskSourceKind::Worktree { id: worktree_1, abs_path: worktree_path_1.to_path_buf(), }, common_name.to_string(), ), ( TaskSourceKind::Worktree { id: worktree_1, abs_path: worktree_path_1.to_path_buf(), }, "worktree_1".to_string(), ), ]; let worktree_2_tasks = [ ( TaskSourceKind::Worktree { id: worktree_2, abs_path: worktree_path_2.to_path_buf(), }, common_name.to_string(), ), ( TaskSourceKind::Worktree { id: worktree_2, abs_path: worktree_path_2.to_path_buf(), }, "worktree_2".to_string(), ), ]; let all_tasks = worktree_1_tasks .iter() .chain(worktree_2_tasks.iter()) // worktree-less tasks come later in the list .chain(worktree_independent_tasks.iter()) .cloned() .collect::>(); for path in [ None, Some(path_1), Some(path_2), Some(worktree_path_1), Some(worktree_path_2), ] { assert_eq!( list_tasks(&inventory_with_statics, path, None, false, cx), all_tasks, "Path {path:?} choice should not adjust static runnables" ); assert_eq!( list_tasks(&inventory_with_statics, path, Some(worktree_1), false, cx), worktree_1_tasks .iter() .chain(worktree_independent_tasks.iter()) .cloned() .collect::>(), "Path {path:?} choice should not adjust static runnables for worktree_1" ); assert_eq!( list_tasks(&inventory_with_statics, path, Some(worktree_2), false, cx), worktree_2_tasks .iter() .chain(worktree_independent_tasks.iter()) .cloned() .collect::>(), "Path {path:?} choice should not adjust static runnables for worktree_2" ); } } }