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