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