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