1//! A source of tasks, based on a static configuration, deserialized from the tasks config file, and related infrastructure for tracking changes to the file.
2
3use std::{borrow::Cow, path::Path, sync::Arc};
4
5use collections::HashMap;
6use futures::StreamExt;
7use gpui::{AppContext, Context, Model, ModelContext, Subscription};
8use schemars::{gen::SchemaSettings, JsonSchema};
9use serde::{Deserialize, Serialize};
10use util::ResultExt;
11
12use crate::{SpawnInTerminal, Task, TaskContext, TaskId, TaskSource};
13use futures::channel::mpsc::UnboundedReceiver;
14
15/// A single config file entry with the deserialized task definition.
16#[derive(Clone, Debug, PartialEq)]
17struct StaticTask {
18 id: TaskId,
19 definition: Definition,
20}
21
22impl Task for StaticTask {
23 fn exec(&self, cx: TaskContext) -> Option<SpawnInTerminal> {
24 let TaskContext { cwd, env } = cx;
25 let cwd = self
26 .definition
27 .cwd
28 .clone()
29 .and_then(|path| subst::substitute(&path, &env).map(Into::into).ok())
30 .or(cwd);
31 let mut definition_env = self.definition.env.clone();
32 definition_env.extend(env);
33 Some(SpawnInTerminal {
34 id: self.id.clone(),
35 cwd,
36 use_new_terminal: self.definition.use_new_terminal,
37 allow_concurrent_runs: self.definition.allow_concurrent_runs,
38 label: self.definition.label.clone(),
39 command: self.definition.command.clone(),
40 args: self.definition.args.clone(),
41 env: definition_env,
42 })
43 }
44
45 fn name(&self) -> &str {
46 &self.definition.label
47 }
48
49 fn id(&self) -> &TaskId {
50 &self.id
51 }
52
53 fn cwd(&self) -> Option<&str> {
54 self.definition.cwd.as_deref()
55 }
56}
57
58/// The source of tasks defined in a tasks config file.
59pub struct StaticSource {
60 tasks: Vec<StaticTask>,
61 _definitions: Model<TrackedFile<DefinitionProvider>>,
62 _subscription: Subscription,
63}
64
65/// Static task definition from the tasks config file.
66#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67pub(crate) struct Definition {
68 /// Human readable name of the task to display in the UI.
69 pub label: String,
70 /// Executable command to spawn.
71 pub command: String,
72 /// Arguments to the command.
73 #[serde(default)]
74 pub args: Vec<String>,
75 /// Env overrides for the command, will be appended to the terminal's environment from the settings.
76 #[serde(default)]
77 pub env: HashMap<String, String>,
78 /// Current working directory to spawn the command into, defaults to current project root.
79 #[serde(default)]
80 pub cwd: Option<String>,
81 /// Whether to use a new terminal tab or reuse the existing one to spawn the process.
82 #[serde(default)]
83 pub use_new_terminal: bool,
84 /// Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish.
85 #[serde(default)]
86 pub allow_concurrent_runs: bool,
87}
88
89/// A group of Tasks defined in a JSON file.
90#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
91pub struct DefinitionProvider(Vec<Definition>);
92
93impl DefinitionProvider {
94 /// Generates JSON schema of Tasks JSON definition format.
95 pub fn generate_json_schema() -> serde_json_lenient::Value {
96 let schema = SchemaSettings::draft07()
97 .with(|settings| settings.option_add_null_type = false)
98 .into_generator()
99 .into_root_schema_for::<Self>();
100
101 serde_json_lenient::to_value(schema).unwrap()
102 }
103}
104/// A Wrapper around deserializable T that keeps track of its contents
105/// via a provided channel. Once T value changes, the observers of [`TrackedFile`] are
106/// notified.
107struct TrackedFile<T> {
108 parsed_contents: T,
109}
110
111impl<T: for<'a> Deserialize<'a> + PartialEq + 'static> TrackedFile<T> {
112 fn new(
113 parsed_contents: T,
114 mut tracker: UnboundedReceiver<String>,
115 cx: &mut AppContext,
116 ) -> Model<Self> {
117 cx.new_model(move |cx| {
118 cx.spawn(|tracked_file, mut cx| async move {
119 while let Some(new_contents) = tracker.next().await {
120 if !new_contents.trim().is_empty() {
121 let Some(new_contents) =
122 serde_json_lenient::from_str(&new_contents).log_err()
123 else {
124 continue;
125 };
126 tracked_file.update(&mut cx, |tracked_file: &mut TrackedFile<T>, cx| {
127 if tracked_file.parsed_contents != new_contents {
128 tracked_file.parsed_contents = new_contents;
129 cx.notify();
130 };
131 })?;
132 }
133 }
134 anyhow::Ok(())
135 })
136 .detach_and_log_err(cx);
137 Self { parsed_contents }
138 })
139 }
140
141 fn get(&self) -> &T {
142 &self.parsed_contents
143 }
144}
145
146impl StaticSource {
147 /// Initializes the static source, reacting on tasks config changes.
148 pub fn new(
149 id_base: impl Into<Cow<'static, str>>,
150 tasks_file_tracker: UnboundedReceiver<String>,
151 cx: &mut AppContext,
152 ) -> Model<Box<dyn TaskSource>> {
153 let definitions = TrackedFile::new(DefinitionProvider::default(), tasks_file_tracker, cx);
154 cx.new_model(|cx| {
155 let id_base = id_base.into();
156 let _subscription = cx.observe(
157 &definitions,
158 move |source: &mut Box<(dyn TaskSource + 'static)>, new_definitions, cx| {
159 if let Some(static_source) = source.as_any().downcast_mut::<Self>() {
160 static_source.tasks = new_definitions
161 .read(cx)
162 .get()
163 .0
164 .clone()
165 .into_iter()
166 .enumerate()
167 .map(|(i, definition)| StaticTask {
168 id: TaskId(format!("static_{id_base}_{i}_{}", definition.label)),
169 definition,
170 })
171 .collect();
172 cx.notify();
173 }
174 },
175 );
176 Box::new(Self {
177 tasks: Vec::new(),
178 _definitions: definitions,
179 _subscription,
180 })
181 })
182 }
183}
184
185impl TaskSource for StaticSource {
186 fn tasks_for_path(
187 &mut self,
188 _: Option<&Path>,
189 _: &mut ModelContext<Box<dyn TaskSource>>,
190 ) -> Vec<Arc<dyn Task>> {
191 self.tasks
192 .clone()
193 .into_iter()
194 .map(|task| Arc::new(task) as Arc<dyn Task>)
195 .collect()
196 }
197
198 fn as_any(&mut self) -> &mut dyn std::any::Any {
199 self
200 }
201}