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 StaticTask {
23 fn new(definition: Definition, (id_base, index_in_file): (&str, usize)) -> Arc<Self> {
24 Arc::new(Self {
25 id: TaskId(format!(
26 "static_{id_base}_{index_in_file}_{}",
27 definition.label
28 )),
29 definition,
30 })
31 }
32}
33
34/// TODO: doc
35pub fn tasks_for(tasks: TaskDefinitions, id_base: &str) -> Vec<Arc<dyn Task>> {
36 tasks
37 .0
38 .into_iter()
39 .enumerate()
40 .map(|(index, task)| StaticTask::new(task, (id_base, index)) as Arc<_>)
41 .collect()
42}
43
44impl Task for StaticTask {
45 fn prepare_exec(&self, cx: TaskContext) -> Option<SpawnInTerminal> {
46 let TaskContext {
47 cwd,
48 task_variables,
49 } = cx;
50 let task_variables = task_variables.into_env_variables();
51 let cwd = self
52 .definition
53 .cwd
54 .clone()
55 .and_then(|path| {
56 subst::substitute(&path, &task_variables)
57 .map(Into::into)
58 .ok()
59 })
60 .or(cwd);
61 let mut definition_env = self.definition.env.clone();
62 definition_env.extend(task_variables);
63 Some(SpawnInTerminal {
64 id: self.id.clone(),
65 cwd,
66 use_new_terminal: self.definition.use_new_terminal,
67 allow_concurrent_runs: self.definition.allow_concurrent_runs,
68 label: self.definition.label.clone(),
69 command: self.definition.command.clone(),
70 args: self.definition.args.clone(),
71 reveal: self.definition.reveal,
72 env: definition_env,
73 })
74 }
75
76 fn name(&self) -> &str {
77 &self.definition.label
78 }
79
80 fn id(&self) -> &TaskId {
81 &self.id
82 }
83
84 fn cwd(&self) -> Option<&str> {
85 self.definition.cwd.as_deref()
86 }
87}
88
89/// The source of tasks defined in a tasks config file.
90pub struct StaticSource {
91 tasks: Vec<Arc<StaticTask>>,
92 _definitions: Model<TrackedFile<TaskDefinitions>>,
93 _subscription: Subscription,
94}
95
96/// Static task definition from the tasks config file.
97#[derive(Clone, Default, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
98#[serde(rename_all = "snake_case")]
99pub struct Definition {
100 /// Human readable name of the task to display in the UI.
101 pub label: String,
102 /// Executable command to spawn.
103 pub command: String,
104 /// Arguments to the command.
105 #[serde(default)]
106 pub args: Vec<String>,
107 /// Env overrides for the command, will be appended to the terminal's environment from the settings.
108 #[serde(default)]
109 pub env: HashMap<String, String>,
110 /// Current working directory to spawn the command into, defaults to current project root.
111 #[serde(default)]
112 pub cwd: Option<String>,
113 /// Whether to use a new terminal tab or reuse the existing one to spawn the process.
114 #[serde(default)]
115 pub use_new_terminal: bool,
116 /// Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish.
117 #[serde(default)]
118 pub allow_concurrent_runs: bool,
119 /// What to do with the terminal pane and tab, after the command was started:
120 /// * `always` — always show the terminal pane, add and focus the corresponding task's tab in it (default)
121 /// * `never` — avoid changing current terminal pane focus, but still add/reuse the task's tab there
122 #[serde(default)]
123 pub reveal: RevealStrategy,
124}
125
126/// What to do with the terminal pane and tab, after the command was started.
127#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
128#[serde(rename_all = "snake_case")]
129pub enum RevealStrategy {
130 /// Always show the terminal pane, add and focus the corresponding task's tab in it.
131 #[default]
132 Always,
133 /// Do not change terminal pane focus, but still add/reuse the task's tab there.
134 Never,
135}
136
137/// A group of Tasks defined in a JSON file.
138#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
139pub struct TaskDefinitions(pub Vec<Definition>);
140
141impl TaskDefinitions {
142 /// Generates JSON schema of Tasks JSON definition format.
143 pub fn generate_json_schema() -> serde_json_lenient::Value {
144 let schema = SchemaSettings::draft07()
145 .with(|settings| settings.option_add_null_type = false)
146 .into_generator()
147 .into_root_schema_for::<Self>();
148
149 serde_json_lenient::to_value(schema).unwrap()
150 }
151}
152/// A Wrapper around deserializable T that keeps track of its contents
153/// via a provided channel. Once T value changes, the observers of [`TrackedFile`] are
154/// notified.
155pub struct TrackedFile<T> {
156 parsed_contents: T,
157}
158
159impl<T: PartialEq + 'static> TrackedFile<T> {
160 /// Initializes new [`TrackedFile`] with a type that's deserializable.
161 pub fn new(mut tracker: UnboundedReceiver<String>, cx: &mut AppContext) -> Model<Self>
162 where
163 T: for<'a> Deserialize<'a> + Default,
164 {
165 cx.new_model(move |cx| {
166 cx.spawn(|tracked_file, mut cx| async move {
167 while let Some(new_contents) = tracker.next().await {
168 if !new_contents.trim().is_empty() {
169 // String -> T (ZedTaskFormat)
170 // String -> U (VsCodeFormat) -> Into::into T
171 let Some(new_contents) =
172 serde_json_lenient::from_str(&new_contents).log_err()
173 else {
174 continue;
175 };
176 tracked_file.update(&mut cx, |tracked_file: &mut TrackedFile<T>, cx| {
177 if tracked_file.parsed_contents != new_contents {
178 tracked_file.parsed_contents = new_contents;
179 cx.notify();
180 };
181 })?;
182 }
183 }
184 anyhow::Ok(())
185 })
186 .detach_and_log_err(cx);
187 Self {
188 parsed_contents: Default::default(),
189 }
190 })
191 }
192
193 /// Initializes new [`TrackedFile`] with a type that's convertible from another deserializable type.
194 pub fn new_convertible<U: for<'a> Deserialize<'a> + TryInto<T, Error = anyhow::Error>>(
195 mut tracker: UnboundedReceiver<String>,
196 cx: &mut AppContext,
197 ) -> Model<Self>
198 where
199 T: Default,
200 {
201 cx.new_model(move |cx| {
202 cx.spawn(|tracked_file, mut cx| async move {
203 while let Some(new_contents) = tracker.next().await {
204 if !new_contents.trim().is_empty() {
205 let Some(new_contents) =
206 serde_json_lenient::from_str::<U>(&new_contents).log_err()
207 else {
208 continue;
209 };
210 let Some(new_contents) = new_contents.try_into().log_err() else {
211 continue;
212 };
213 tracked_file.update(&mut cx, |tracked_file: &mut TrackedFile<T>, cx| {
214 if tracked_file.parsed_contents != new_contents {
215 tracked_file.parsed_contents = new_contents;
216 cx.notify();
217 };
218 })?;
219 }
220 }
221 anyhow::Ok(())
222 })
223 .detach_and_log_err(cx);
224 Self {
225 parsed_contents: Default::default(),
226 }
227 })
228 }
229
230 fn get(&self) -> &T {
231 &self.parsed_contents
232 }
233}
234
235impl StaticSource {
236 /// Initializes the static source, reacting on tasks config changes.
237 pub fn new(
238 id_base: impl Into<Cow<'static, str>>,
239 definitions: Model<TrackedFile<TaskDefinitions>>,
240 cx: &mut AppContext,
241 ) -> Model<Box<dyn TaskSource>> {
242 cx.new_model(|cx| {
243 let id_base = id_base.into();
244 let _subscription = cx.observe(
245 &definitions,
246 move |source: &mut Box<(dyn TaskSource + 'static)>, new_definitions, cx| {
247 if let Some(static_source) = source.as_any().downcast_mut::<Self>() {
248 static_source.tasks = new_definitions
249 .read(cx)
250 .get()
251 .0
252 .clone()
253 .into_iter()
254 .enumerate()
255 .map(|(i, definition)| StaticTask::new(definition, (&id_base, i)))
256 .collect();
257 cx.notify();
258 }
259 },
260 );
261 Box::new(Self {
262 tasks: Vec::new(),
263 _definitions: definitions,
264 _subscription,
265 })
266 })
267 }
268}
269
270impl TaskSource for StaticSource {
271 fn tasks_for_path(
272 &mut self,
273 _: Option<&Path>,
274 _: &mut ModelContext<Box<dyn TaskSource>>,
275 ) -> Vec<Arc<dyn Task>> {
276 self.tasks
277 .iter()
278 .map(|task| task.clone() as Arc<dyn Task>)
279 .collect()
280 }
281
282 fn as_any(&mut self) -> &mut dyn std::any::Any {
283 self
284 }
285}