diff --git a/assets/settings/initial_tasks.json b/assets/settings/initial_tasks.json index 5bedafbd3a1e75a755598e37cd673742e146fdcc..0d6f4471320e443f3c4a483f53f6901c76e7dc72 100644 --- a/assets/settings/initial_tasks.json +++ b/assets/settings/initial_tasks.json @@ -48,6 +48,11 @@ "show_summary": true, // Whether to show the command line in the output of the spawned task, defaults to `true`. "show_command": true, + // Which edited buffers to save before running the task: + // * `all` — save all edited buffers + // * `current` — save current buffer only + // * `none` — don't save any buffers + "save": "all", // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. // "tags": [] }, diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index ef92135c879ea22a09569df0f1b2fc9c5ae12473..836f76a73fe69aa5dfdacf3359be34946d8c3740 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -1313,6 +1313,7 @@ impl RunningState { show_summary: false, show_command: false, show_rerun: false, + save: task::SaveStrategy::default(), }; let workspace = self.workspace.clone(); diff --git a/crates/project/tests/integration/debugger.rs b/crates/project/tests/integration/debugger.rs index 2a15f8bc55b611b3b2fbd23fb9ccb052cadac387..6cdc126d9750ae38f36e27879e5e9b635295015c 100644 --- a/crates/project/tests/integration/debugger.rs +++ b/crates/project/tests/integration/debugger.rs @@ -3,7 +3,7 @@ mod go_locator { use dap::{DapLocator, adapters::DebugAdapterName}; use gpui::TestAppContext; use project::debugger::locators::go::{DelveLaunchRequest, GoLocator}; - use task::{HideStrategy, RevealStrategy, RevealTarget, Shell, TaskTemplate}; + use task::{HideStrategy, RevealStrategy, RevealTarget, SaveStrategy, Shell, TaskTemplate}; #[gpui::test] async fn test_create_scenario_for_go_build(_: &mut TestAppContext) { let locator = GoLocator; @@ -22,6 +22,7 @@ mod go_locator { tags: vec![], show_summary: true, show_command: true, + save: SaveStrategy::default(), }; let scenario = locator @@ -49,6 +50,7 @@ mod go_locator { tags: vec![], show_summary: true, show_command: true, + save: SaveStrategy::default(), }; let scenario = locator @@ -187,6 +189,7 @@ mod go_locator { tags: vec![], show_summary: true, show_command: true, + save: SaveStrategy::default(), }; let scenario = locator @@ -221,6 +224,7 @@ mod python_locator { shell: task::Shell::System, show_summary: false, show_command: false, + save: task::SaveStrategy::default(), }; let expected_scenario = DebugScenario { diff --git a/crates/task/src/task.rs b/crates/task/src/task.rs index e91a0bfb3b54b8780f139a63b342cd58755e6355..ba5f4ae4fed9e676add2eafc8dc14f47cb2200ed 100644 --- a/crates/task/src/task.rs +++ b/crates/task/src/task.rs @@ -23,7 +23,7 @@ pub use debug_format::{ Request, TcpArgumentsTemplate, ZedDebugConfig, }; pub use task_template::{ - DebugArgsRequest, HideStrategy, RevealStrategy, TaskTemplate, TaskTemplates, + DebugArgsRequest, HideStrategy, RevealStrategy, SaveStrategy, TaskTemplate, TaskTemplates, substitute_variables_in_map, substitute_variables_in_str, }; pub use util::shell::{Shell, ShellKind}; @@ -75,6 +75,8 @@ pub struct SpawnInTerminal { pub show_command: bool, /// Whether to show the rerun button in the terminal tab. pub show_rerun: bool, + /// Which edited buffers to save before running the task. + pub save: SaveStrategy, } impl SpawnInTerminal { diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs index a85c3565e2869e10f093a47f71024384e496fbd2..cee6024ca62fb1ed74489f55ae99f6334db3d0f0 100644 --- a/crates/task/src/task_template.rs +++ b/crates/task/src/task_template.rs @@ -72,6 +72,9 @@ pub struct TaskTemplate { /// Whether to show the command line in the task output. #[serde(default = "default_true")] pub show_command: bool, + /// Which edited buffers to save before running the task. + #[serde(default)] + pub save: SaveStrategy, } #[derive(Deserialize, Eq, PartialEq, Clone, Debug)] @@ -109,6 +112,19 @@ pub enum HideStrategy { OnSuccess, } +/// Which edited buffers to save before running a task. +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SaveStrategy { + #[default] + /// Save all edited buffers. + All, + /// Save the current buffer. + Current, + /// Don't save any buffers. + None, +} + /// A group of Tasks defined in a JSON file. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct TaskTemplates(pub Vec); @@ -271,6 +287,7 @@ impl TaskTemplate { show_summary: self.show_summary, show_command: self.show_command, show_rerun: true, + save: self.save, }, }) } @@ -1072,7 +1089,6 @@ mod tests { command, ..TaskTemplate::default() }; - assert!(task.unknown_variables().is_empty()); } } diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 0eebcd9532a82fd999519c6c33a1c8df3bb16667..17a63cd398dff0e8b28f30907f1c9074fffd4b16 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -28,7 +28,7 @@ use std::{ sync::OnceLock, time::Instant, }; -use task::{HideStrategy, RevealStrategy, SpawnInTerminal, TaskId}; +use task::{HideStrategy, RevealStrategy, SaveStrategy, SpawnInTerminal, TaskId}; use ui::ActiveTheme; use util::{ ResultExt, @@ -2479,6 +2479,7 @@ impl ShellExec { show_summary: false, show_command: false, show_rerun: false, + save: SaveStrategy::default(), }; let task_status = workspace.spawn_in_terminal(spawn_in_terminal, window, cx); diff --git a/crates/workspace/src/tasks.rs b/crates/workspace/src/tasks.rs index f85e1488f97491a73314297d91c597bd7d3bb841..8a2ae6a40ab6328c2a2328fbdbe0e5be5972cf22 100644 --- a/crates/workspace/src/tasks.rs +++ b/crates/workspace/src/tasks.rs @@ -6,11 +6,13 @@ use language::Buffer; use project::{TaskSourceKind, WorktreeId}; use remote::ConnectionState; use task::{ - DebugScenario, ResolvedTask, SharedTaskContext, SpawnInTerminal, TaskContext, TaskTemplate, + DebugScenario, ResolvedTask, SaveStrategy, SharedTaskContext, SpawnInTerminal, TaskContext, + TaskTemplate, }; use ui::Window; +use util::TryFutureExt; -use crate::{Toast, Workspace, notifications::NotificationId}; +use crate::{SaveIntent, Toast, Workspace, notifications::NotificationId}; impl Workspace { pub fn schedule_task( @@ -73,28 +75,57 @@ impl Workspace { }); } - if let Some(terminal_provider) = self.terminal_provider.as_ref() { - let task_status = terminal_provider.spawn(spawn_in_terminal, window, cx); - - let task = cx.spawn(async |w, cx| { - let res = cx.background_spawn(task_status).await; - match res { - Some(Ok(status)) => { - if status.success() { - log::debug!("Task spawn succeeded"); - } else { - log::debug!("Task spawn failed, code: {:?}", status.code()); - } + if self.terminal_provider.is_some() { + let task = cx.spawn_in(window, async move |workspace, cx| { + let save_action = match spawn_in_terminal.save { + SaveStrategy::All => { + let save_all = workspace.update_in(cx, |workspace, window, cx| { + let task = workspace.save_all_internal(SaveIntent::SaveAll, window, cx); + // Match the type of the other arm by ignoring the bool value returned + cx.background_spawn(async { task.await.map(|_| ()) }) + }); + save_all.ok() } - Some(Err(e)) => { - log::error!("Task spawn failed: {e:#}"); - _ = w.update(cx, |w, cx| { - let id = NotificationId::unique::(); - w.show_toast(Toast::new(id, format!("Task spawn failed: {e}")), cx); - }) + SaveStrategy::Current => { + let save_current = workspace.update_in(cx, |workspace, window, cx| { + workspace.save_active_item(SaveIntent::SaveAll, window, cx) + }); + save_current.ok() } - None => log::debug!("Task spawn got cancelled"), + SaveStrategy::None => None, }; + if let Some(save_action) = save_action { + save_action.log_err().await; + } + + let spawn_task = workspace.update_in(cx, |workspace, window, cx| { + workspace + .terminal_provider + .as_ref() + .map(|terminal_provider| { + terminal_provider.spawn(spawn_in_terminal, window, cx) + }) + }); + if let Some(spawn_task) = spawn_task.ok().flatten() { + let res = cx.background_spawn(spawn_task).await; + match res { + Some(Ok(status)) => { + if status.success() { + log::debug!("Task spawn succeeded"); + } else { + log::debug!("Task spawn failed, code: {:?}", status.code()); + } + } + Some(Err(e)) => { + log::error!("Task spawn failed: {e:#}"); + _ = workspace.update(cx, |w, cx| { + let id = NotificationId::unique::(); + w.show_toast(Toast::new(id, format!("Task spawn failed: {e}")), cx); + }) + } + None => log::debug!("Task spawn got cancelled"), + }; + } }); self.scheduled_tasks.push(task); } @@ -134,3 +165,166 @@ impl Workspace { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + TerminalProvider, + item::test::{TestItem, TestProjectItem}, + register_serializable_item, + }; + use gpui::{App, TestAppContext}; + use parking_lot::Mutex; + use project::{FakeFs, Project, TaskSourceKind}; + use serde_json::json; + use std::sync::Arc; + use task::TaskTemplate; + + struct Fixture { + workspace: Entity, + item: Entity, + task: ResolvedTask, + dirty_before_spawn: Arc>>, + } + + #[gpui::test] + async fn test_schedule_resolved_task_save_all(cx: &mut TestAppContext) { + let (fixture, cx) = create_fixture(cx, SaveStrategy::All).await; + fixture.workspace.update_in(cx, |workspace, window, cx| { + workspace.schedule_resolved_task( + TaskSourceKind::UserInput, + fixture.task, + false, + window, + cx, + ); + }); + cx.executor().run_until_parked(); + + assert_eq!(*fixture.dirty_before_spawn.lock(), Some(false)); + assert!(cx.read(|cx| !fixture.item.read(cx).is_dirty)); + } + + #[gpui::test] + async fn test_schedule_resolved_task_save_current(cx: &mut TestAppContext) { + let (fixture, cx) = create_fixture(cx, SaveStrategy::Current).await; + // Add a second inactive dirty item + let inactive = add_test_item(&fixture.workspace, "file2.txt", false, cx); + fixture.workspace.update_in(cx, |workspace, window, cx| { + workspace.schedule_resolved_task( + TaskSourceKind::UserInput, + fixture.task, + false, + window, + cx, + ); + }); + cx.executor().run_until_parked(); + + // The active item (fixture.item) should be saved + assert_eq!(*fixture.dirty_before_spawn.lock(), Some(false)); + assert!(cx.read(|cx| !fixture.item.read(cx).is_dirty)); + // The inactive item should not be saved + assert!(cx.read(|cx| inactive.read(cx).is_dirty)); + } + + #[gpui::test] + async fn test_schedule_resolved_task_save_none(cx: &mut TestAppContext) { + let (fixture, cx) = create_fixture(cx, SaveStrategy::None).await; + fixture.workspace.update_in(cx, |workspace, window, cx| { + workspace.schedule_resolved_task( + TaskSourceKind::UserInput, + fixture.task, + false, + window, + cx, + ); + }); + cx.executor().run_until_parked(); + + assert_eq!(*fixture.dirty_before_spawn.lock(), Some(true)); + assert!(cx.read(|cx| fixture.item.read(cx).is_dirty)); + } + + async fn create_fixture( + cx: &mut TestAppContext, + save_strategy: SaveStrategy, + ) -> (Fixture, &mut gpui::VisualTestContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + register_serializable_item::(cx); + }); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({ "file.txt": "dirty" })) + .await; + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + // Add a dirty item to the workspace + let item = add_test_item(&workspace, "file.txt", true, cx); + + let template = TaskTemplate { + label: "test".to_string(), + command: "echo".to_string(), + save: save_strategy, + ..Default::default() + }; + let task = template + .resolve_task("test", &task::TaskContext::default()) + .unwrap(); + let dirty_before_spawn: Arc>> = Arc::default(); + let terminal_provider = Box::new(TestTerminalProvider { + item: item.clone(), + dirty_before_spawn: dirty_before_spawn.clone(), + }); + workspace.update(cx, |workspace, _| { + workspace.terminal_provider = Some(terminal_provider); + }); + let fixture = Fixture { + workspace, + item, + task, + dirty_before_spawn, + }; + (fixture, cx) + } + + fn add_test_item( + workspace: &Entity, + name: &str, + active: bool, + cx: &mut gpui::VisualTestContext, + ) -> Entity { + let item = cx.new(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_project_items(&[TestProjectItem::new(1, name, cx)]) + }); + workspace.update_in(cx, |workspace, window, cx| { + let pane = workspace.active_pane().clone(); + workspace.add_item(pane, Box::new(item.clone()), None, true, active, window, cx); + }); + item + } + + struct TestTerminalProvider { + item: Entity, + dirty_before_spawn: Arc>>, + } + + impl TerminalProvider for TestTerminalProvider { + fn spawn( + &self, + _task: task::SpawnInTerminal, + _window: &mut ui::Window, + cx: &mut App, + ) -> Task>> { + *self.dirty_before_spawn.lock() = Some(cx.read_entity(&self.item, |e, _| e.is_dirty)); + Task::ready(Some(Ok(ExitStatus::default()))) + } + } +} diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 52598e11ad40ecfc125ba6d03860809452ae8e43..b4c9ba8a2abf5ce03e4a9a43fe7fc7e55f9240a4 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -50,7 +50,12 @@ Zed supports ways to spawn (and rerun) commands using its integrated [terminal]( // Whether to show the task line in the output of the spawned task, defaults to `true`. "show_summary": true, // Whether to show the command line in the output of the spawned task, defaults to `true`. - "show_command": true + "show_command": true, + // Which edited buffers to save before running the task: + // * `all` — save all edited buffers + // * `current` — save current buffer only + // * `none` — don't save any buffers + "save": "all" // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. // "tags": [] }