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 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}