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::{
4 borrow::Cow,
5 path::{Path, PathBuf},
6 sync::Arc,
7};
8
9use collections::HashMap;
10use futures::StreamExt;
11use gpui::{AppContext, Context, Model, ModelContext, Subscription};
12use schemars::{gen::SchemaSettings, JsonSchema};
13use serde::{Deserialize, Serialize};
14use util::ResultExt;
15
16use crate::{SpawnInTerminal, Task, TaskId, TaskSource};
17use futures::channel::mpsc::UnboundedReceiver;
18
19/// A single config file entry with the deserialized task definition.
20#[derive(Clone, Debug, PartialEq)]
21struct StaticTask {
22 id: TaskId,
23 definition: Definition,
24}
25
26impl Task for StaticTask {
27 fn exec(&self, cwd: Option<PathBuf>) -> Option<SpawnInTerminal> {
28 Some(SpawnInTerminal {
29 id: self.id.clone(),
30 cwd,
31 use_new_terminal: self.definition.use_new_terminal,
32 allow_concurrent_runs: self.definition.allow_concurrent_runs,
33 label: self.definition.label.clone(),
34 command: self.definition.command.clone(),
35 args: self.definition.args.clone(),
36 env: self.definition.env.clone(),
37 separate_shell: false,
38 })
39 }
40
41 fn name(&self) -> &str {
42 &self.definition.label
43 }
44
45 fn id(&self) -> &TaskId {
46 &self.id
47 }
48
49 fn cwd(&self) -> Option<&Path> {
50 self.definition.cwd.as_deref()
51 }
52}
53
54/// The source of tasks defined in a tasks config file.
55pub struct StaticSource {
56 tasks: Vec<StaticTask>,
57 _definitions: Model<TrackedFile<DefinitionProvider>>,
58 _subscription: Subscription,
59}
60
61/// Static task definition from the tasks config file.
62#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
63pub(crate) struct Definition {
64 /// Human readable name of the task to display in the UI.
65 pub label: String,
66 /// Executable command to spawn.
67 pub command: String,
68 /// Arguments to the command.
69 #[serde(default)]
70 pub args: Vec<String>,
71 /// Env overrides for the command, will be appended to the terminal's environment from the settings.
72 #[serde(default)]
73 pub env: HashMap<String, String>,
74 /// Current working directory to spawn the command into, defaults to current project root.
75 #[serde(default)]
76 pub cwd: Option<PathBuf>,
77 /// Whether to use a new terminal tab or reuse the existing one to spawn the process.
78 #[serde(default)]
79 pub use_new_terminal: bool,
80 /// Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish.
81 #[serde(default)]
82 pub allow_concurrent_runs: bool,
83}
84
85/// A group of Tasks defined in a JSON file.
86#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
87pub struct DefinitionProvider(Vec<Definition>);
88
89impl DefinitionProvider {
90 /// Generates JSON schema of Tasks JSON definition format.
91 pub fn generate_json_schema() -> serde_json_lenient::Value {
92 let schema = SchemaSettings::draft07()
93 .with(|settings| settings.option_add_null_type = false)
94 .into_generator()
95 .into_root_schema_for::<Self>();
96
97 serde_json_lenient::to_value(schema).unwrap()
98 }
99}
100/// A Wrapper around deserializable T that keeps track of its contents
101/// via a provided channel. Once T value changes, the observers of [`TrackedFile`] are
102/// notified.
103struct TrackedFile<T> {
104 parsed_contents: T,
105}
106
107impl<T: for<'a> Deserialize<'a> + PartialEq + 'static> TrackedFile<T> {
108 fn new(
109 parsed_contents: T,
110 mut tracker: UnboundedReceiver<String>,
111 cx: &mut AppContext,
112 ) -> Model<Self> {
113 cx.new_model(move |cx| {
114 cx.spawn(|tracked_file, mut cx| async move {
115 while let Some(new_contents) = tracker.next().await {
116 if !new_contents.trim().is_empty() {
117 let Some(new_contents) =
118 serde_json_lenient::from_str(&new_contents).log_err()
119 else {
120 continue;
121 };
122 tracked_file.update(&mut cx, |tracked_file: &mut TrackedFile<T>, cx| {
123 if tracked_file.parsed_contents != new_contents {
124 tracked_file.parsed_contents = new_contents;
125 cx.notify();
126 };
127 })?;
128 }
129 }
130 anyhow::Ok(())
131 })
132 .detach_and_log_err(cx);
133 Self { parsed_contents }
134 })
135 }
136
137 fn get(&self) -> &T {
138 &self.parsed_contents
139 }
140}
141
142impl StaticSource {
143 /// Initializes the static source, reacting on tasks config changes.
144 pub fn new(
145 id_base: impl Into<Cow<'static, str>>,
146 tasks_file_tracker: UnboundedReceiver<String>,
147 cx: &mut AppContext,
148 ) -> Model<Box<dyn TaskSource>> {
149 let definitions = TrackedFile::new(DefinitionProvider::default(), tasks_file_tracker, cx);
150 cx.new_model(|cx| {
151 let id_base = id_base.into();
152 let _subscription = cx.observe(
153 &definitions,
154 move |source: &mut Box<(dyn TaskSource + 'static)>, new_definitions, cx| {
155 if let Some(static_source) = source.as_any().downcast_mut::<Self>() {
156 static_source.tasks = new_definitions
157 .read(cx)
158 .get()
159 .0
160 .clone()
161 .into_iter()
162 .enumerate()
163 .map(|(i, definition)| StaticTask {
164 id: TaskId(format!("static_{id_base}_{i}_{}", definition.label)),
165 definition,
166 })
167 .collect();
168 cx.notify();
169 }
170 },
171 );
172 Box::new(Self {
173 tasks: Vec::new(),
174 _definitions: definitions,
175 _subscription,
176 })
177 })
178 }
179}
180
181impl TaskSource for StaticSource {
182 fn tasks_for_path(
183 &mut self,
184 _: Option<&Path>,
185 _: &mut ModelContext<Box<dyn TaskSource>>,
186 ) -> Vec<Arc<dyn Task>> {
187 self.tasks
188 .clone()
189 .into_iter()
190 .map(|task| Arc::new(task) as Arc<dyn Task>)
191 .collect()
192 }
193
194 fn as_any(&mut self) -> &mut dyn std::any::Any {
195 self
196 }
197}