tasks.rs

  1use std::process::ExitStatus;
  2
  3use anyhow::Result;
  4use collections::HashSet;
  5use gpui::{AppContext, AsyncWindowContext, Context, Entity, Task, WeakEntity};
  6use language::Buffer;
  7use project::{TaskSourceKind, WorktreeId};
  8use remote::ConnectionState;
  9use task::{
 10    DebugScenario, ResolvedTask, SaveStrategy, SharedTaskContext, SpawnInTerminal, TaskContext,
 11    TaskHook, TaskTemplate, TaskVariables, VariableName,
 12};
 13use ui::Window;
 14use util::TryFutureExt;
 15
 16use crate::{SaveIntent, Toast, Workspace, notifications::NotificationId};
 17
 18impl Workspace {
 19    pub fn schedule_task(
 20        self: &mut Workspace,
 21        task_source_kind: TaskSourceKind,
 22        task_to_resolve: &TaskTemplate,
 23        task_cx: &TaskContext,
 24        omit_history: bool,
 25        window: &mut Window,
 26        cx: &mut Context<Self>,
 27    ) {
 28        match self.project.read(cx).remote_connection_state(cx) {
 29            None | Some(ConnectionState::Connected) => {}
 30            Some(
 31                ConnectionState::Connecting
 32                | ConnectionState::Disconnected
 33                | ConnectionState::HeartbeatMissed
 34                | ConnectionState::Reconnecting,
 35            ) => {
 36                log::warn!("Cannot schedule tasks when disconnected from a remote host");
 37                return;
 38            }
 39        }
 40
 41        if let Some(spawn_in_terminal) =
 42            task_to_resolve.resolve_task(&task_source_kind.to_id_base(), task_cx)
 43        {
 44            self.schedule_resolved_task(
 45                task_source_kind,
 46                spawn_in_terminal,
 47                omit_history,
 48                window,
 49                cx,
 50            );
 51        }
 52    }
 53
 54    pub fn schedule_resolved_task(
 55        self: &mut Workspace,
 56        task_source_kind: TaskSourceKind,
 57        resolved_task: ResolvedTask,
 58        omit_history: bool,
 59        window: &mut Window,
 60        cx: &mut Context<Workspace>,
 61    ) {
 62        let spawn_in_terminal = resolved_task.resolved.clone();
 63        if !omit_history {
 64            if let Some(debugger_provider) = self.debugger_provider.as_ref() {
 65                debugger_provider.task_scheduled(cx);
 66            }
 67
 68            self.project().update(cx, |project, cx| {
 69                if let Some(task_inventory) =
 70                    project.task_store().read(cx).task_inventory().cloned()
 71                {
 72                    task_inventory.update(cx, |inventory, _| {
 73                        inventory.task_scheduled(task_source_kind, resolved_task);
 74                    })
 75                }
 76            });
 77        }
 78
 79        if self.terminal_provider.is_some() {
 80            let task = cx.spawn_in(window, async move |workspace, cx| {
 81                Self::save_for_task(&workspace, spawn_in_terminal.save, cx).await;
 82
 83                let spawn_task = workspace.update_in(cx, |workspace, window, cx| {
 84                    workspace
 85                        .terminal_provider
 86                        .as_ref()
 87                        .map(|terminal_provider| {
 88                            terminal_provider.spawn(spawn_in_terminal, window, cx)
 89                        })
 90                });
 91                if let Some(spawn_task) = spawn_task.ok().flatten() {
 92                    let res = cx.background_spawn(spawn_task).await;
 93                    match res {
 94                        Some(Ok(status)) => {
 95                            if status.success() {
 96                                log::debug!("Task spawn succeeded");
 97                            } else {
 98                                log::debug!("Task spawn failed, code: {:?}", status.code());
 99                            }
100                        }
101                        Some(Err(e)) => {
102                            log::error!("Task spawn failed: {e:#}");
103                            _ = workspace.update(cx, |w, cx| {
104                                let id = NotificationId::unique::<ResolvedTask>();
105                                w.show_toast(Toast::new(id, format!("Task spawn failed: {e}")), cx);
106                            })
107                        }
108                        None => log::debug!("Task spawn got cancelled"),
109                    };
110                }
111            });
112            self.scheduled_tasks.push(task);
113        }
114    }
115
116    pub async fn save_for_task(
117        workspace: &WeakEntity<Self>,
118        save_strategy: SaveStrategy,
119        cx: &mut AsyncWindowContext,
120    ) {
121        let save_action = match save_strategy {
122            SaveStrategy::All => {
123                let save_all = workspace.update_in(cx, |workspace, window, cx| {
124                    let task = workspace.save_all_internal(SaveIntent::SaveAll, window, cx);
125                    cx.background_spawn(async { task.await.map(|_| ()) })
126                });
127                save_all.ok()
128            }
129            SaveStrategy::Current => {
130                let save_current = workspace.update_in(cx, |workspace, window, cx| {
131                    workspace.save_active_item(SaveIntent::SaveAll, window, cx)
132                });
133                save_current.ok()
134            }
135            SaveStrategy::None => None,
136        };
137        if let Some(save_action) = save_action {
138            save_action.log_err().await;
139        }
140    }
141
142    pub fn start_debug_session(
143        &mut self,
144        scenario: DebugScenario,
145        task_context: SharedTaskContext,
146        active_buffer: Option<Entity<Buffer>>,
147        worktree_id: Option<WorktreeId>,
148        window: &mut Window,
149        cx: &mut Context<Self>,
150    ) {
151        if let Some(provider) = self.debugger_provider.as_mut() {
152            provider.start_session(
153                scenario,
154                task_context,
155                active_buffer,
156                worktree_id,
157                window,
158                cx,
159            )
160        }
161    }
162
163    pub fn spawn_in_terminal(
164        self: &mut Workspace,
165        spawn_in_terminal: SpawnInTerminal,
166        window: &mut Window,
167        cx: &mut Context<Workspace>,
168    ) -> Task<Option<Result<ExitStatus>>> {
169        if let Some(terminal_provider) = self.terminal_provider.as_ref() {
170            terminal_provider.spawn(spawn_in_terminal, window, cx)
171        } else {
172            Task::ready(None)
173        }
174    }
175
176    pub fn run_create_worktree_tasks(&mut self, window: &mut Window, cx: &mut Context<Self>) {
177        let project = self.project().clone();
178        let hooks = HashSet::from_iter([TaskHook::CreateWorktree]);
179
180        let worktree_tasks: Vec<(WorktreeId, TaskContext, Vec<TaskTemplate>)> = {
181            let project = project.read(cx);
182            let task_store = project.task_store();
183            let Some(inventory) = task_store.read(cx).task_inventory().cloned() else {
184                return;
185            };
186
187            let git_store = project.git_store().read(cx);
188
189            let mut worktree_tasks = Vec::new();
190            for worktree in project.worktrees(cx) {
191                let worktree = worktree.read(cx);
192                let worktree_id = worktree.id();
193                let worktree_abs_path = worktree.abs_path();
194
195                let templates: Vec<TaskTemplate> = inventory
196                    .read(cx)
197                    .templates_with_hooks(&hooks, worktree_id)
198                    .into_iter()
199                    .map(|(_, template)| template)
200                    .collect();
201
202                if templates.is_empty() {
203                    continue;
204                }
205
206                let mut task_variables = TaskVariables::default();
207                task_variables.insert(
208                    VariableName::WorktreeRoot,
209                    worktree_abs_path.to_string_lossy().into_owned(),
210                );
211
212                if let Some(path) = git_store.original_repo_path_for_worktree(worktree_id, cx) {
213                    task_variables.insert(
214                        VariableName::MainGitWorktree,
215                        path.to_string_lossy().into_owned(),
216                    );
217                }
218
219                let task_context = TaskContext {
220                    cwd: Some(worktree_abs_path.to_path_buf()),
221                    task_variables,
222                    project_env: Default::default(),
223                };
224
225                worktree_tasks.push((worktree_id, task_context, templates));
226            }
227            worktree_tasks
228        };
229
230        if worktree_tasks.is_empty() {
231            return;
232        }
233
234        let task = cx.spawn_in(window, async move |workspace, cx| {
235            let mut tasks = Vec::new();
236            for (worktree_id, task_context, templates) in worktree_tasks {
237                let id_base = format!("worktree_setup_{worktree_id}");
238
239                tasks.push(cx.spawn({
240                    let workspace = workspace.clone();
241                    async move |cx| {
242                        for task_template in templates {
243                            let Some(resolved) =
244                                task_template.resolve_task(&id_base, &task_context)
245                            else {
246                                continue;
247                            };
248
249                            let status = workspace.update_in(cx, |workspace, window, cx| {
250                                workspace.spawn_in_terminal(resolved.resolved, window, cx)
251                            })?;
252
253                            if let Some(result) = status.await {
254                                match result {
255                                    Ok(exit_status) if !exit_status.success() => {
256                                        log::error!(
257                                            "Git worktree setup task failed with status: {:?}",
258                                            exit_status.code()
259                                        );
260                                        break;
261                                    }
262                                    Err(error) => {
263                                        log::error!("Git worktree setup task error: {error:#}");
264                                        break;
265                                    }
266                                    _ => {}
267                                }
268                            }
269                        }
270                        anyhow::Ok(())
271                    }
272                }));
273            }
274
275            futures::future::join_all(tasks).await;
276            anyhow::Ok(())
277        });
278        task.detach_and_log_err(cx);
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::{
286        TerminalProvider,
287        item::test::{TestItem, TestProjectItem},
288        register_serializable_item,
289    };
290    use gpui::{App, TestAppContext};
291    use parking_lot::Mutex;
292    use project::{FakeFs, Project, TaskSourceKind};
293    use serde_json::json;
294    use std::sync::Arc;
295    use task::TaskTemplate;
296
297    struct Fixture {
298        workspace: Entity<Workspace>,
299        item: Entity<TestItem>,
300        task: ResolvedTask,
301        dirty_before_spawn: Arc<Mutex<Option<bool>>>,
302    }
303
304    #[gpui::test]
305    async fn test_schedule_resolved_task_save_all(cx: &mut TestAppContext) {
306        let (fixture, cx) = create_fixture(cx, SaveStrategy::All).await;
307        fixture.workspace.update_in(cx, |workspace, window, cx| {
308            workspace.schedule_resolved_task(
309                TaskSourceKind::UserInput,
310                fixture.task,
311                false,
312                window,
313                cx,
314            );
315        });
316        cx.executor().run_until_parked();
317
318        assert_eq!(*fixture.dirty_before_spawn.lock(), Some(false));
319        assert!(cx.read(|cx| !fixture.item.read(cx).is_dirty));
320    }
321
322    #[gpui::test]
323    async fn test_schedule_resolved_task_save_current(cx: &mut TestAppContext) {
324        let (fixture, cx) = create_fixture(cx, SaveStrategy::Current).await;
325        // Add a second inactive dirty item
326        let inactive = add_test_item(&fixture.workspace, "file2.txt", false, cx);
327        fixture.workspace.update_in(cx, |workspace, window, cx| {
328            workspace.schedule_resolved_task(
329                TaskSourceKind::UserInput,
330                fixture.task,
331                false,
332                window,
333                cx,
334            );
335        });
336        cx.executor().run_until_parked();
337
338        // The active item (fixture.item) should be saved
339        assert_eq!(*fixture.dirty_before_spawn.lock(), Some(false));
340        assert!(cx.read(|cx| !fixture.item.read(cx).is_dirty));
341        // The inactive item should not be saved
342        assert!(cx.read(|cx| inactive.read(cx).is_dirty));
343    }
344
345    #[gpui::test]
346    async fn test_schedule_resolved_task_save_none(cx: &mut TestAppContext) {
347        let (fixture, cx) = create_fixture(cx, SaveStrategy::None).await;
348        fixture.workspace.update_in(cx, |workspace, window, cx| {
349            workspace.schedule_resolved_task(
350                TaskSourceKind::UserInput,
351                fixture.task,
352                false,
353                window,
354                cx,
355            );
356        });
357        cx.executor().run_until_parked();
358
359        assert_eq!(*fixture.dirty_before_spawn.lock(), Some(true));
360        assert!(cx.read(|cx| fixture.item.read(cx).is_dirty));
361    }
362
363    async fn create_fixture(
364        cx: &mut TestAppContext,
365        save_strategy: SaveStrategy,
366    ) -> (Fixture, &mut gpui::VisualTestContext) {
367        cx.update(|cx| {
368            let settings_store = settings::SettingsStore::test(cx);
369            cx.set_global(settings_store);
370            theme_settings::init(theme::LoadThemes::JustBase, cx);
371            register_serializable_item::<TestItem>(cx);
372        });
373        let fs = FakeFs::new(cx.executor());
374        fs.insert_tree("/root", json!({ "file.txt": "dirty" }))
375            .await;
376        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
377        let (workspace, cx) =
378            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
379
380        // Add a dirty item to the workspace
381        let item = add_test_item(&workspace, "file.txt", true, cx);
382
383        let template = TaskTemplate {
384            label: "test".to_string(),
385            command: "echo".to_string(),
386            save: save_strategy,
387            ..Default::default()
388        };
389        let task = template
390            .resolve_task("test", &task::TaskContext::default())
391            .unwrap();
392        let dirty_before_spawn: Arc<Mutex<Option<bool>>> = Arc::default();
393        let terminal_provider = Box::new(TestTerminalProvider {
394            item: item.clone(),
395            dirty_before_spawn: dirty_before_spawn.clone(),
396        });
397        workspace.update(cx, |workspace, _| {
398            workspace.terminal_provider = Some(terminal_provider);
399        });
400        let fixture = Fixture {
401            workspace,
402            item,
403            task,
404            dirty_before_spawn,
405        };
406        (fixture, cx)
407    }
408
409    fn add_test_item(
410        workspace: &Entity<Workspace>,
411        name: &str,
412        active: bool,
413        cx: &mut gpui::VisualTestContext,
414    ) -> Entity<TestItem> {
415        let item = cx.new(|cx| {
416            TestItem::new(cx)
417                .with_dirty(true)
418                .with_project_items(&[TestProjectItem::new(1, name, cx)])
419        });
420        workspace.update_in(cx, |workspace, window, cx| {
421            let pane = workspace.active_pane().clone();
422            workspace.add_item(pane, Box::new(item.clone()), None, true, active, window, cx);
423        });
424        item
425    }
426
427    #[gpui::test]
428    async fn test_save_for_task_all(cx: &mut TestAppContext) {
429        let (fixture, cx) = create_fixture(cx, SaveStrategy::All).await;
430        let workspace = fixture.workspace.downgrade();
431        cx.run_until_parked();
432
433        assert!(cx.read(|cx| fixture.item.read(cx).is_dirty));
434        fixture.workspace.update_in(cx, |_workspace, window, cx| {
435            cx.spawn_in(window, {
436                let workspace = workspace.clone();
437                async move |_this, cx| {
438                    Workspace::save_for_task(&workspace, SaveStrategy::All, cx).await;
439                }
440            })
441            .detach();
442        });
443        cx.run_until_parked();
444        assert!(cx.read(|cx| !fixture.item.read(cx).is_dirty));
445    }
446
447    #[gpui::test]
448    async fn test_save_for_task_none(cx: &mut TestAppContext) {
449        let (fixture, cx) = create_fixture(cx, SaveStrategy::None).await;
450        let workspace = fixture.workspace.downgrade();
451        cx.run_until_parked();
452
453        assert!(cx.read(|cx| fixture.item.read(cx).is_dirty));
454        fixture.workspace.update_in(cx, |_workspace, window, cx| {
455            cx.spawn_in(window, {
456                let workspace = workspace.clone();
457                async move |_this, cx| {
458                    Workspace::save_for_task(&workspace, SaveStrategy::None, cx).await;
459                }
460            })
461            .detach();
462        });
463        cx.run_until_parked();
464        assert!(cx.read(|cx| fixture.item.read(cx).is_dirty));
465    }
466
467    #[gpui::test]
468    async fn test_save_for_task_current(cx: &mut TestAppContext) {
469        let (fixture, cx) = create_fixture(cx, SaveStrategy::Current).await;
470        let inactive = add_test_item(&fixture.workspace, "file2.txt", false, cx);
471        let workspace = fixture.workspace.downgrade();
472        cx.run_until_parked();
473
474        assert!(cx.read(|cx| fixture.item.read(cx).is_dirty));
475        assert!(cx.read(|cx| inactive.read(cx).is_dirty));
476        fixture.workspace.update_in(cx, |_workspace, window, cx| {
477            cx.spawn_in(window, {
478                let workspace = workspace.clone();
479                async move |_this, cx| {
480                    Workspace::save_for_task(&workspace, SaveStrategy::Current, cx).await;
481                }
482            })
483            .detach();
484        });
485        cx.run_until_parked();
486        assert!(cx.read(|cx| !fixture.item.read(cx).is_dirty));
487        assert!(cx.read(|cx| inactive.read(cx).is_dirty));
488    }
489
490    struct TestTerminalProvider {
491        item: Entity<TestItem>,
492        dirty_before_spawn: Arc<Mutex<Option<bool>>>,
493    }
494
495    impl TerminalProvider for TestTerminalProvider {
496        fn spawn(
497            &self,
498            _task: task::SpawnInTerminal,
499            _window: &mut ui::Window,
500            cx: &mut App,
501        ) -> Task<Option<Result<ExitStatus>>> {
502            *self.dirty_before_spawn.lock() = Some(cx.read_entity(&self.item, |e, _| e.is_dirty));
503            Task::ready(Some(Ok(ExitStatus::default())))
504        }
505    }
506}