static_source.rs

  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, 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_to_schedule(
272        &mut self,
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}