Runnables: Add oneshot runnables (#8061)

Piotr Osiewicz created

/cc @SomeoneToIgnore 
Fixes #7460 and partially addresses #7108 
Release Notes:

- N/A

Change summary

Cargo.lock                                 |  5 
crates/project/src/runnable_inventory.rs   | 19 +++++
crates/runnable/src/lib.rs                 |  4 
crates/runnable/src/static_runnable.rs     |  1 
crates/runnables_ui/Cargo.toml             |  1 
crates/runnables_ui/src/lib.rs             |  2 
crates/runnables_ui/src/modal.rs           | 36 ++++++++--
crates/runnables_ui/src/oneshot_source.rs  | 79 ++++++++++++++++++++++++
crates/terminal_view/src/terminal_panel.rs | 21 +++++
crates/zed/src/zed.rs                      | 12 ++-
10 files changed, 163 insertions(+), 17 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7786,6 +7786,7 @@ dependencies = [
  "fuzzy",
  "gpui",
  "log",
+ "menu",
  "picker",
  "project",
  "runnable",
@@ -8561,9 +8562,9 @@ dependencies = [
 
 [[package]]
 name = "shlex"
-version = "1.2.0"
+version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
 
 [[package]]
 name = "signal-hook"

crates/project/src/runnable_inventory.rs 🔗

@@ -1,6 +1,6 @@
 //! Project-wide storage of the runnables available, capable of updating itself from the sources set.
 
-use std::{path::Path, sync::Arc};
+use std::{any::TypeId, path::Path, sync::Arc};
 
 use gpui::{AppContext, Context, Model, ModelContext, Subscription};
 use runnable::{Runnable, RunnableId, Source};
@@ -14,6 +14,7 @@ pub struct Inventory {
 struct SourceInInventory {
     source: Model<Box<dyn Source>>,
     _subscription: Subscription,
+    type_id: TypeId,
 }
 
 impl Inventory {
@@ -29,13 +30,29 @@ impl Inventory {
         let _subscription = cx.observe(&source, |_, _, cx| {
             cx.notify();
         });
+        let type_id = source.read(cx).type_id();
         let source = SourceInInventory {
             source,
             _subscription,
+            type_id,
         };
         self.sources.push(source);
         cx.notify();
     }
+    pub fn source<T: Source>(&self) -> Option<Model<Box<dyn Source>>> {
+        let target_type_id = std::any::TypeId::of::<T>();
+        self.sources.iter().find_map(
+            |SourceInInventory {
+                 type_id, source, ..
+             }| {
+                if &target_type_id == type_id {
+                    Some(source.clone())
+                } else {
+                    None
+                }
+            },
+        )
+    }
 
     /// Pulls its sources to list runanbles for the path given (up to the source to decide what to return for no path).
     pub fn list_runnables(

crates/runnable/src/lib.rs 🔗

@@ -15,7 +15,7 @@ use std::sync::Arc;
 /// Runnable identifier, unique within the application.
 /// Based on it, runnable reruns and terminal tabs are managed.
 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
-pub struct RunnableId(String);
+pub struct RunnableId(pub String);
 
 /// Contains all information needed by Zed to spawn a new terminal tab for the given runnable.
 #[derive(Debug, Clone)]
@@ -36,6 +36,8 @@ pub struct SpawnInTerminal {
     pub use_new_terminal: bool,
     /// Whether to allow multiple instances of the same runnable to be run, or rather wait for the existing ones to finish.
     pub allow_concurrent_runs: bool,
+    /// Whether the command should be spawned in a separate shell instance.
+    pub separate_shell: bool,
 }
 
 /// Represents a short lived recipe of a runnable, whose main purpose

crates/runnable/src/static_runnable.rs 🔗

@@ -31,6 +31,7 @@ impl Runnable for StaticRunnable {
             command: self.definition.command.clone(),
             args: self.definition.args.clone(),
             env: self.definition.env.clone(),
+            separate_shell: false,
         })
     }
 

crates/runnables_ui/Cargo.toml 🔗

@@ -14,6 +14,7 @@ futures.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
 log.workspace = true
+menu.workspace = true
 picker.workspace = true
 project.workspace = true
 runnable.workspace = true

crates/runnables_ui/src/lib.rs 🔗

@@ -2,11 +2,13 @@ use std::path::PathBuf;
 
 use gpui::{AppContext, ViewContext, WindowContext};
 use modal::RunnablesModal;
+pub use oneshot_source::OneshotSource;
 use runnable::Runnable;
 use util::ResultExt;
 use workspace::Workspace;
 
 mod modal;
+mod oneshot_source;
 
 pub fn init(cx: &mut AppContext) {
     cx.observe_new_views(

crates/runnables_ui/src/modal.rs 🔗

@@ -2,8 +2,8 @@ use std::sync::Arc;
 
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
-    actions, rems, DismissEvent, EventEmitter, FocusableView, InteractiveElement, Model,
-    ParentElement, Render, SharedString, Styled, Subscription, Task, View, ViewContext,
+    actions, rems, AppContext, DismissEvent, EventEmitter, FocusableView, InteractiveElement,
+    Model, ParentElement, Render, SharedString, Styled, Subscription, Task, View, ViewContext,
     VisualContext, WeakView,
 };
 use picker::{Picker, PickerDelegate};
@@ -13,7 +13,7 @@ use ui::{v_flex, HighlightedLabel, ListItem, ListItemSpacing, Selectable};
 use util::ResultExt;
 use workspace::{ModalView, Workspace};
 
-use crate::schedule_runnable;
+use crate::{schedule_runnable, OneshotSource};
 
 actions!(runnables, [Spawn, Rerun]);
 
@@ -25,6 +25,7 @@ pub(crate) struct RunnablesModalDelegate {
     selected_index: usize,
     placeholder_text: Arc<str>,
     workspace: WeakView<Workspace>,
+    last_prompt: String,
 }
 
 impl RunnablesModalDelegate {
@@ -36,8 +37,21 @@ impl RunnablesModalDelegate {
             matches: Vec::new(),
             selected_index: 0,
             placeholder_text: Arc::from("Select runnable..."),
+            last_prompt: String::default(),
         }
     }
+
+    fn spawn_oneshot(&mut self, cx: &mut AppContext) -> Option<Arc<dyn Runnable>> {
+        let oneshot_source = self
+            .inventory
+            .update(cx, |this, _| this.source::<OneshotSource>())?;
+        oneshot_source.update(cx, |this, _| {
+            let Some(this) = this.as_any().downcast_mut::<OneshotSource>() else {
+                return None;
+            };
+            Some(this.spawn(self.last_prompt.clone()))
+        })
+    }
 }
 
 pub(crate) struct RunnablesModal {
@@ -149,6 +163,7 @@ impl PickerDelegate for RunnablesModalDelegate {
                 .update(&mut cx, |picker, _| {
                     let delegate = &mut picker.delegate;
                     delegate.matches = matches;
+                    delegate.last_prompt = query;
 
                     if delegate.matches.is_empty() {
                         delegate.selected_index = 0;
@@ -161,14 +176,21 @@ impl PickerDelegate for RunnablesModalDelegate {
         })
     }
 
-    fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
+    fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
         let current_match_index = self.selected_index();
-        let Some(current_match) = self.matches.get(current_match_index) else {
+        let Some(runnable) = secondary
+            .then(|| self.spawn_oneshot(cx))
+            .flatten()
+            .or_else(|| {
+                self.matches.get(current_match_index).map(|current_match| {
+                    let ix = current_match.candidate_id;
+                    self.candidates[ix].clone()
+                })
+            })
+        else {
             return;
         };
 
-        let ix = current_match.candidate_id;
-        let runnable = &self.candidates[ix];
         self.workspace
             .update(cx, |workspace, cx| {
                 schedule_runnable(workspace, runnable.as_ref(), cx);

crates/runnables_ui/src/oneshot_source.rs 🔗

@@ -0,0 +1,79 @@
+use std::sync::Arc;
+
+use gpui::{AppContext, Model};
+use runnable::{Runnable, RunnableId, Source};
+use ui::Context;
+
+pub struct OneshotSource {
+    runnables: Vec<Arc<dyn runnable::Runnable>>,
+}
+
+#[derive(Clone)]
+struct OneshotRunnable {
+    id: RunnableId,
+}
+
+impl OneshotRunnable {
+    fn new(prompt: String) -> Self {
+        Self {
+            id: RunnableId(prompt),
+        }
+    }
+}
+
+impl Runnable for OneshotRunnable {
+    fn id(&self) -> &runnable::RunnableId {
+        &self.id
+    }
+
+    fn name(&self) -> &str {
+        &self.id.0
+    }
+
+    fn cwd(&self) -> Option<&std::path::Path> {
+        None
+    }
+
+    fn exec(&self, cwd: Option<std::path::PathBuf>) -> Option<runnable::SpawnInTerminal> {
+        if self.id().0.is_empty() {
+            return None;
+        }
+        Some(runnable::SpawnInTerminal {
+            id: self.id().clone(),
+            label: self.name().to_owned(),
+            command: self.id().0.clone(),
+            args: vec![],
+            cwd,
+            env: Default::default(),
+            use_new_terminal: Default::default(),
+            allow_concurrent_runs: Default::default(),
+            separate_shell: true,
+        })
+    }
+}
+
+impl OneshotSource {
+    pub fn new(cx: &mut AppContext) -> Model<Box<dyn Source>> {
+        cx.new_model(|_| Box::new(Self { runnables: vec![] }) as Box<dyn Source>)
+    }
+
+    pub fn spawn(&mut self, prompt: String) -> Arc<dyn runnable::Runnable> {
+        let ret = Arc::new(OneshotRunnable::new(prompt));
+        self.runnables.push(ret.clone());
+        ret
+    }
+}
+
+impl Source for OneshotSource {
+    fn as_any(&mut self) -> &mut dyn std::any::Any {
+        self
+    }
+
+    fn runnables_for_path(
+        &mut self,
+        _path: Option<&std::path::Path>,
+        _cx: &mut gpui::ModelContext<Box<dyn Source>>,
+    ) -> Vec<Arc<dyn runnable::Runnable>> {
+        self.runnables.clone()
+    }
+}

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -16,7 +16,7 @@ use search::{buffer_search::DivRegistrar, BufferSearchBar};
 use serde::{Deserialize, Serialize};
 use settings::Settings;
 use terminal::{
-    terminal_settings::{TerminalDockPosition, TerminalSettings},
+    terminal_settings::{Shell, TerminalDockPosition, TerminalSettings},
     SpawnRunnable,
 };
 use ui::{h_flex, ButtonCommon, Clickable, IconButton, IconSize, Selectable, Tooltip};
@@ -300,13 +300,30 @@ impl TerminalPanel {
         spawn_in_terminal: &runnable::SpawnInTerminal,
         cx: &mut ViewContext<Self>,
     ) {
-        let spawn_runnable = SpawnRunnable {
+        let mut spawn_runnable = SpawnRunnable {
             id: spawn_in_terminal.id.clone(),
             label: spawn_in_terminal.label.clone(),
             command: spawn_in_terminal.command.clone(),
             args: spawn_in_terminal.args.clone(),
             env: spawn_in_terminal.env.clone(),
         };
+        if spawn_in_terminal.separate_shell {
+            let Some((shell, mut user_args)) = (match TerminalSettings::get_global(cx).shell.clone()
+            {
+                Shell::System => std::env::var("SHELL").ok().map(|shell| (shell, vec![])),
+                Shell::Program(shell) => Some((shell, vec![])),
+                Shell::WithArguments { program, args } => Some((program, args)),
+            }) else {
+                return;
+            };
+
+            let command = std::mem::take(&mut spawn_runnable.command);
+            let args = std::mem::take(&mut spawn_runnable.args);
+            spawn_runnable.command = shell;
+            user_args.extend(["-c".to_owned(), command]);
+            user_args.extend(args);
+            spawn_runnable.args = user_args;
+        }
         let working_directory = spawn_in_terminal.cwd.clone();
         let allow_concurrent_runs = spawn_in_terminal.allow_concurrent_runs;
         let use_new_terminal = spawn_in_terminal.use_new_terminal;

crates/zed/src/zed.rs 🔗

@@ -23,6 +23,7 @@ use quick_action_bar::QuickActionBar;
 use release_channel::{AppCommitSha, ReleaseChannel};
 use rope::Rope;
 use runnable::static_source::StaticSource;
+use runnables_ui::OneshotSource;
 use search::project_search::ProjectSearchBar;
 use settings::{
     initial_local_settings_content, watch_config_file, KeymapFile, Settings, SettingsStore,
@@ -163,11 +164,14 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
                 app_state.fs.clone(),
                 paths::RUNNABLES.clone(),
             );
-            let source = StaticSource::new(runnables_file_rx, cx);
+            let static_source = StaticSource::new(runnables_file_rx, cx);
+            let oneshot_source = OneshotSource::new(cx);
+
             project.update(cx, |project, cx| {
-                project
-                    .runnable_inventory()
-                    .update(cx, |inventory, cx| inventory.add_source(source, cx))
+                project.runnable_inventory().update(cx, |inventory, cx| {
+                    inventory.add_source(oneshot_source, cx);
+                    inventory.add_source(static_source, cx);
+                })
             });
         }
         cx.spawn(|workspace_handle, mut cx| async move {