diff --git a/Cargo.lock b/Cargo.lock
index 2a88f523d4..943f033079 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6904,6 +6904,7 @@ dependencies = [
"regex",
"release_channel",
"rpc",
+ "runnable",
"schemars",
"serde",
"serde_derive",
@@ -7760,6 +7761,48 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "runnable"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "collections",
+ "futures 0.3.28",
+ "gpui",
+ "parking_lot 0.11.2",
+ "schemars",
+ "serde",
+ "serde_json",
+ "serde_json_lenient",
+ "settings",
+ "smol",
+ "util",
+]
+
+[[package]]
+name = "runnables_ui"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "db",
+ "editor",
+ "fs",
+ "futures 0.3.28",
+ "fuzzy",
+ "gpui",
+ "log",
+ "picker",
+ "project",
+ "runnable",
+ "schemars",
+ "serde",
+ "serde_json",
+ "theme",
+ "ui",
+ "util",
+ "workspace",
+]
+
[[package]]
name = "rusqlite"
version = "0.29.0"
@@ -9383,6 +9426,7 @@ dependencies = [
"ordered-float 2.10.0",
"procinfo",
"rand 0.8.5",
+ "runnable",
"schemars",
"serde",
"serde_derive",
@@ -9417,6 +9461,7 @@ dependencies = [
"procinfo",
"project",
"rand 0.8.5",
+ "runnable",
"search",
"serde",
"serde_derive",
@@ -11674,6 +11719,7 @@ dependencies = [
"parking_lot 0.11.2",
"postage",
"project",
+ "runnable",
"schemars",
"serde",
"serde_derive",
@@ -11934,6 +11980,8 @@ dependencies = [
"rope",
"rpc",
"rsa 0.4.0",
+ "runnable",
+ "runnables_ui",
"rust-embed",
"schemars",
"search",
diff --git a/Cargo.toml b/Cargo.toml
index 6a63ded8f1..bfb2df80e3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -63,6 +63,8 @@ members = [
"crates/rich_text",
"crates/rope",
"crates/rpc",
+ "crates/runnable",
+ "crates/runnables_ui",
"crates/search",
"crates/semantic_index",
"crates/settings",
@@ -153,6 +155,8 @@ release_channel = { path = "crates/release_channel" }
rich_text = { path = "crates/rich_text" }
rope = { path = "crates/rope" }
rpc = { path = "crates/rpc" }
+runnable = { path = "crates/runnable" }
+runnables_ui = { path = "crates/runnables_ui" }
search = { path = "crates/search" }
semantic_index = { path = "crates/semantic_index" }
settings = { path = "crates/settings" }
diff --git a/assets/icons/play.svg b/assets/icons/play.svg
new file mode 100644
index 0000000000..2fc2a23aa9
--- /dev/null
+++ b/assets/icons/play.svg
@@ -0,0 +1 @@
+
diff --git a/assets/settings/initial_runnables.json b/assets/settings/initial_runnables.json
new file mode 100644
index 0000000000..93bfd66b73
--- /dev/null
+++ b/assets/settings/initial_runnables.json
@@ -0,0 +1,19 @@
+// Static runnables configuration.
+//
+// Example:
+// {
+// "label": "human-readable label for UI",
+// "command": "bash",
+// // rest of the parameters are optional
+// "args": ["-c", "for i in {1..10}; do echo \"Second $i\"; sleep 1; done"],
+// // Env overrides for the command, will be appended to the terminal's environment from the settings.
+// "env": {"foo": "bar"},
+// // Current working directory to spawn the command into, defaults to current project root.
+// "cwd": "/path/to/working/directory",
+// // Whether to use a new terminal tab or reuse the existing one to spawn the process, defaults to `false`.
+// "use_new_terminal": false,
+// // Whether to allow multiple instances of the same runnable to be run, or rather wait for the existing ones to finish, defaults to `false`.
+// "allow_concurrent_runs": false,
+// },
+//
+{}
diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml
index 8f36511c64..c11cdb6874 100644
--- a/crates/project/Cargo.toml
+++ b/crates/project/Cargo.toml
@@ -50,6 +50,7 @@ prettier.workspace = true
rand.workspace = true
regex.workspace = true
rpc.workspace = true
+runnable.workspace = true
schemars.workspace = true
serde.workspace = true
serde_derive.workspace = true
@@ -71,7 +72,7 @@ collections = { workspace = true, features = ["test-support"] }
ctor.workspace = true
db = { workspace = true, features = ["test-support"] }
env_logger.workspace = true
-fs = { workspace = true, features = ["test-support"] }
+fs = { workspace = true, features = ["test-support"] }
git2.workspace = true
gpui = { workspace = true, features = ["test-support"] }
language = { workspace = true, features = ["test-support"] }
diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs
index 20843a8e35..b4253ec059 100644
--- a/crates/project/src/project.rs
+++ b/crates/project/src/project.rs
@@ -4,6 +4,7 @@ pub mod lsp_command;
pub mod lsp_ext_command;
mod prettier_support;
pub mod project_settings;
+mod runnable_inventory;
pub mod search;
pub mod terminals;
pub mod worktree;
@@ -57,7 +58,8 @@ use postage::watch;
use prettier_support::{DefaultPrettier, PrettierInstance};
use project_settings::{LspSettings, ProjectSettings};
use rand::prelude::*;
-use rpc::{ErrorCode, ErrorExt};
+
+use rpc::{ErrorCode, ErrorExt as _};
use search::SearchQuery;
use serde::Serialize;
use settings::{Settings, SettingsStore};
@@ -91,6 +93,7 @@ use util::{
pub use fs::*;
#[cfg(any(test, feature = "test-support"))]
pub use prettier::FORMAT_SUFFIX as TEST_PRETTIER_FORMAT_SUFFIX;
+pub use runnable_inventory::Inventory;
pub use worktree::*;
const MAX_SERVER_REINSTALL_ATTEMPT_COUNT: u64 = 4;
@@ -153,6 +156,7 @@ pub struct Project {
default_prettier: DefaultPrettier,
prettiers_per_worktree: HashMap>>,
prettier_instances: HashMap,
+ runnables: Model,
}
pub enum LanguageServerToQuery {
@@ -615,6 +619,8 @@ impl Project {
.detach();
let copilot_lsp_subscription =
Copilot::global(cx).map(|copilot| subscribe_for_copilot_events(&copilot, cx));
+ let runnables = Inventory::new(cx);
+
Self {
worktrees: Vec::new(),
buffer_ordered_messages_tx: tx,
@@ -665,6 +671,7 @@ impl Project {
default_prettier: DefaultPrettier::default(),
prettiers_per_worktree: HashMap::default(),
prettier_instances: HashMap::default(),
+ runnables,
}
})
}
@@ -688,7 +695,10 @@ impl Project {
.await?;
let this = cx.new_model(|cx| {
let replica_id = response.payload.replica_id as ReplicaId;
-
+ let runnables = Inventory::new(cx);
+ // BIG CAUTION NOTE: The order in which we initialize fields here matters and it should match what's done in Self::local.
+ // Otherwise, you might run into issues where worktree id on remote is different than what's on local host.
+ // That's because Worktree's identifier is entity id, which should probably be changed.
let mut worktrees = Vec::new();
for worktree in response.payload.worktrees {
let worktree =
@@ -770,6 +780,7 @@ impl Project {
default_prettier: DefaultPrettier::default(),
prettiers_per_worktree: HashMap::default(),
prettier_instances: HashMap::default(),
+ runnables,
};
this.set_role(role, cx);
for worktree in worktrees {
@@ -1052,6 +1063,10 @@ impl Project {
cx.notify();
}
+ pub fn runnable_inventory(&self) -> &Model {
+ &self.runnables
+ }
+
pub fn collaborators(&self) -> &HashMap {
&self.collaborators
}
diff --git a/crates/project/src/runnable_inventory.rs b/crates/project/src/runnable_inventory.rs
new file mode 100644
index 0000000000..52867acc49
--- /dev/null
+++ b/crates/project/src/runnable_inventory.rs
@@ -0,0 +1,66 @@
+//! Project-wide storage of the runnables available, capable of updating itself from the sources set.
+
+use std::{path::Path, sync::Arc};
+
+use gpui::{AppContext, Context, Model, ModelContext, Subscription};
+use runnable::{Runnable, RunnableId, Source};
+
+/// Inventory tracks available runnables for a given project.
+pub struct Inventory {
+ sources: Vec,
+ pub last_scheduled_runnable: Option,
+}
+
+struct SourceInInventory {
+ source: Model>,
+ _subscription: Subscription,
+}
+
+impl Inventory {
+ pub(crate) fn new(cx: &mut AppContext) -> Model {
+ cx.new_model(|_| Self {
+ sources: Vec::new(),
+ last_scheduled_runnable: None,
+ })
+ }
+
+ /// Registers a new runnables source, that would be fetched for available runnables.
+ pub fn add_source(&mut self, source: Model>, cx: &mut ModelContext) {
+ let _subscription = cx.observe(&source, |_, _, cx| {
+ cx.notify();
+ });
+ let source = SourceInInventory {
+ source,
+ _subscription,
+ };
+ self.sources.push(source);
+ cx.notify();
+ }
+
+ /// 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(
+ &self,
+ path: Option<&Path>,
+ cx: &mut AppContext,
+ ) -> Vec> {
+ let mut runnables = Vec::new();
+ for source in &self.sources {
+ runnables.extend(
+ source
+ .source
+ .update(cx, |source, cx| source.runnables_for_path(path, cx)),
+ );
+ }
+ runnables
+ }
+
+ /// Returns the last scheduled runnable, if any of the sources contains one with the matching id.
+ pub fn last_scheduled_runnable(&self, cx: &mut AppContext) -> Option> {
+ self.last_scheduled_runnable.as_ref().and_then(|id| {
+ // TODO straighten the `Path` story to understand what has to be passed here: or it will break in the future.
+ self.list_runnables(None, cx)
+ .into_iter()
+ .find(|runnable| runnable.id() == id)
+ })
+ }
+}
diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs
index 5109f9e126..b4c8bb6bee 100644
--- a/crates/project/src/terminals.rs
+++ b/crates/project/src/terminals.rs
@@ -1,10 +1,11 @@
use crate::Project;
use gpui::{AnyWindowHandle, Context, Entity, Model, ModelContext, WeakModel};
use settings::Settings;
+use smol::channel::bounded;
use std::path::{Path, PathBuf};
use terminal::{
- terminal_settings::{self, TerminalSettings, VenvSettingsContent},
- Terminal, TerminalBuilder,
+ terminal_settings::{self, Shell, TerminalSettings, VenvSettingsContent},
+ RunableState, SpawnRunnable, Terminal, TerminalBuilder,
};
// #[cfg(target_os = "macos")]
@@ -18,63 +19,83 @@ impl Project {
pub fn create_terminal(
&mut self,
working_directory: Option,
+ spawn_runnable: Option,
window: AnyWindowHandle,
cx: &mut ModelContext,
) -> anyhow::Result> {
- if self.is_remote() {
- return Err(anyhow::anyhow!(
- "creating terminals as a guest is not supported yet"
- ));
- } else {
- let settings = TerminalSettings::get_global(cx);
- let python_settings = settings.detect_venv.clone();
- let shell = settings.shell.clone();
+ anyhow::ensure!(
+ !self.is_remote(),
+ "creating terminals as a guest is not supported yet"
+ );
- let terminal = TerminalBuilder::new(
- working_directory.clone(),
- shell.clone(),
- settings.env.clone(),
- Some(settings.blinking.clone()),
- settings.alternate_scroll,
- window,
+ let settings = TerminalSettings::get_global(cx);
+ let python_settings = settings.detect_venv.clone();
+ let (completion_tx, completion_rx) = bounded(1);
+ let mut env = settings.env.clone();
+ let (spawn_runnable, shell) = if let Some(spawn_runnable) = spawn_runnable {
+ env.extend(spawn_runnable.env);
+ (
+ Some(RunableState {
+ id: spawn_runnable.id,
+ label: spawn_runnable.label,
+ completed: false,
+ completion_rx,
+ }),
+ Shell::WithArguments {
+ program: spawn_runnable.command,
+ args: spawn_runnable.args,
+ },
)
- .map(|builder| {
- let terminal_handle = cx.new_model(|cx| builder.subscribe(cx));
+ } else {
+ (None, settings.shell.clone())
+ };
- self.terminals
- .local_handles
- .push(terminal_handle.downgrade());
+ let terminal = TerminalBuilder::new(
+ working_directory.clone(),
+ spawn_runnable,
+ shell,
+ env,
+ Some(settings.blinking.clone()),
+ settings.alternate_scroll,
+ window,
+ completion_tx,
+ )
+ .map(|builder| {
+ let terminal_handle = cx.new_model(|cx| builder.subscribe(cx));
- let id = terminal_handle.entity_id();
- cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
- let handles = &mut project.terminals.local_handles;
+ self.terminals
+ .local_handles
+ .push(terminal_handle.downgrade());
- if let Some(index) = handles
- .iter()
- .position(|terminal| terminal.entity_id() == id)
- {
- handles.remove(index);
- cx.notify();
- }
- })
- .detach();
+ let id = terminal_handle.entity_id();
+ cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
+ let handles = &mut project.terminals.local_handles;
- if let Some(python_settings) = &python_settings.as_option() {
- let activate_command = Project::get_activate_command(python_settings);
- let activate_script_path =
- self.find_activate_script_path(python_settings, working_directory);
- self.activate_python_virtual_environment(
- activate_command,
- activate_script_path,
- &terminal_handle,
- cx,
- );
+ if let Some(index) = handles
+ .iter()
+ .position(|terminal| terminal.entity_id() == id)
+ {
+ handles.remove(index);
+ cx.notify();
}
- terminal_handle
- });
+ })
+ .detach();
- terminal
- }
+ if let Some(python_settings) = &python_settings.as_option() {
+ let activate_command = Project::get_activate_command(python_settings);
+ let activate_script_path =
+ self.find_activate_script_path(python_settings, working_directory);
+ self.activate_python_virtual_environment(
+ activate_command,
+ activate_script_path,
+ &terminal_handle,
+ cx,
+ );
+ }
+ terminal_handle
+ });
+
+ terminal
}
pub fn find_activate_script_path(
diff --git a/crates/runnable/Cargo.toml b/crates/runnable/Cargo.toml
new file mode 100644
index 0000000000..3906d0448a
--- /dev/null
+++ b/crates/runnable/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "runnable"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "GPL-3.0-or-later"
+
+[dependencies]
+anyhow.workspace = true
+collections.workspace = true
+futures.workspace = true
+gpui.workspace = true
+parking_lot.workspace = true
+schemars.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+serde_json_lenient.workspace = true
+settings.workspace = true
+smol.workspace = true
+util.workspace = true
+
+[dev-dependencies]
+gpui = { workspace = true, features = ["test-support"] }
diff --git a/crates/runnable/src/lib.rs b/crates/runnable/src/lib.rs
new file mode 100644
index 0000000000..99c485c1a4
--- /dev/null
+++ b/crates/runnable/src/lib.rs
@@ -0,0 +1,68 @@
+//! Baseline interface of Runnables in Zed: all runnables in Zed are intended to use those for implementing their own logic.
+#![deny(missing_docs)]
+
+mod static_runnable;
+pub mod static_source;
+
+pub use static_runnable::StaticRunnable;
+
+use collections::HashMap;
+use gpui::ModelContext;
+use std::any::Any;
+use std::path::{Path, PathBuf};
+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);
+
+/// Contains all information needed by Zed to spawn a new terminal tab for the given runnable.
+#[derive(Debug, Clone)]
+pub struct SpawnInTerminal {
+ /// Id of the runnable to use when determining task tab affinity.
+ pub id: RunnableId,
+ /// Human readable name of the terminal tab.
+ pub label: String,
+ /// Executable command to spawn.
+ pub command: String,
+ /// Arguments to the command.
+ pub args: Vec,
+ /// Current working directory to spawn the command into.
+ pub cwd: Option,
+ /// Env overrides for the command, will be appended to the terminal's environment from the settings.
+ pub env: HashMap,
+ /// Whether to use a new terminal tab or reuse the existing one to spawn the process.
+ 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,
+}
+
+/// Represents a short lived recipe of a runnable, whose main purpose
+/// is to get spawned.
+pub trait Runnable {
+ /// Unique identifier of the runnable to spawn.
+ fn id(&self) -> &RunnableId;
+ /// Human readable name of the runnable to display in the UI.
+ fn name(&self) -> &str;
+ /// Task's current working directory. If `None`, current project's root will be used.
+ fn cwd(&self) -> Option<&Path>;
+ /// Sets up everything needed to spawn the runnable in the given directory (`cwd`).
+ /// If a runnable is intended to be spawned in the terminal, it should return the corresponding struct filled with the data necessary.
+ fn exec(&self, cwd: Option) -> Option;
+}
+
+/// [`Source`] produces runnables that can be scheduled.
+///
+/// Implementations of this trait could be e.g. [`StaticSource`] that parses runnables from a .json files and provides process templates to be spawned;
+/// another one could be a language server providing lenses with tests or build server listing all targets for a given project.
+pub trait Source: Any {
+ /// A way to erase the type of the source, processing and storing them generically.
+ fn as_any(&mut self) -> &mut dyn Any;
+ /// Collects all runnables available for scheduling, for the path given.
+ fn runnables_for_path(
+ &mut self,
+ path: Option<&Path>,
+ cx: &mut ModelContext>,
+ ) -> Vec>;
+}
diff --git a/crates/runnable/src/static_runnable.rs b/crates/runnable/src/static_runnable.rs
new file mode 100644
index 0000000000..7138dc91c2
--- /dev/null
+++ b/crates/runnable/src/static_runnable.rs
@@ -0,0 +1,48 @@
+//! Definitions of runnables with a static file config definition, not dependent on the application state.
+
+use std::path::{Path, PathBuf};
+
+use crate::{static_source::Definition, Runnable, RunnableId, SpawnInTerminal};
+
+/// A single config file entry with the deserialized runnable definition.
+#[derive(Clone, Debug, PartialEq)]
+pub struct StaticRunnable {
+ id: RunnableId,
+ definition: Definition,
+}
+
+impl StaticRunnable {
+ pub(super) fn new(id: usize, runnable: Definition) -> Self {
+ Self {
+ id: RunnableId(format!("static_{}_{}", runnable.label, id)),
+ definition: runnable,
+ }
+ }
+}
+
+impl Runnable for StaticRunnable {
+ fn exec(&self, cwd: Option) -> Option {
+ Some(SpawnInTerminal {
+ id: self.id.clone(),
+ cwd,
+ use_new_terminal: self.definition.use_new_terminal,
+ allow_concurrent_runs: self.definition.allow_concurrent_runs,
+ label: self.definition.label.clone(),
+ command: self.definition.command.clone(),
+ args: self.definition.args.clone(),
+ env: self.definition.env.clone(),
+ })
+ }
+
+ fn name(&self) -> &str {
+ &self.definition.label
+ }
+
+ fn id(&self) -> &RunnableId {
+ &self.id
+ }
+
+ fn cwd(&self) -> Option<&Path> {
+ self.definition.cwd.as_deref()
+ }
+}
diff --git a/crates/runnable/src/static_source.rs b/crates/runnable/src/static_source.rs
new file mode 100644
index 0000000000..3a274b0a12
--- /dev/null
+++ b/crates/runnable/src/static_source.rs
@@ -0,0 +1,157 @@
+//! A source of runnables, based on a static configuration, deserialized from the runnables config file, and related infrastructure for tracking changes to the file.
+
+use std::{
+ path::{Path, PathBuf},
+ sync::Arc,
+};
+
+use collections::HashMap;
+use futures::StreamExt;
+use gpui::{AppContext, Context, Model, ModelContext, Subscription};
+use schemars::{gen::SchemaSettings, JsonSchema};
+use serde::{Deserialize, Serialize};
+use util::ResultExt;
+
+use crate::{Runnable, Source, StaticRunnable};
+use futures::channel::mpsc::UnboundedReceiver;
+
+/// The source of runnables defined in a runnables config file.
+pub struct StaticSource {
+ runnables: Vec,
+ _definitions: Model>,
+ _subscription: Subscription,
+}
+
+/// Static runnable definition from the runnables config file.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+pub(crate) struct Definition {
+ /// Human readable name of the runnable to display in the UI.
+ pub label: String,
+ /// Executable command to spawn.
+ pub command: String,
+ /// Arguments to the command.
+ #[serde(default)]
+ pub args: Vec,
+ /// Env overrides for the command, will be appended to the terminal's environment from the settings.
+ #[serde(default)]
+ pub env: HashMap,
+ /// Current working directory to spawn the command into, defaults to current project root.
+ #[serde(default)]
+ pub cwd: Option,
+ /// Whether to use a new terminal tab or reuse the existing one to spawn the process.
+ #[serde(default)]
+ 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.
+ #[serde(default)]
+ pub allow_concurrent_runs: bool,
+}
+
+/// A group of Runnables defined in a JSON file.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+pub struct DefinitionProvider {
+ version: String,
+ runnables: Vec,
+}
+
+impl DefinitionProvider {
+ /// Generates JSON schema of Runnables JSON definition format.
+ pub fn generate_json_schema() -> serde_json_lenient::Value {
+ let schema = SchemaSettings::draft07()
+ .with(|settings| settings.option_add_null_type = false)
+ .into_generator()
+ .into_root_schema_for::();
+
+ serde_json_lenient::to_value(schema).unwrap()
+ }
+}
+/// A Wrapper around deserializable T that keeps track of it's contents
+/// via a provided channel. Once T value changes, the observers of [`TrackedFile`] are
+/// notified.
+struct TrackedFile {
+ parsed_contents: T,
+}
+
+impl Deserialize<'a> + PartialEq + 'static> TrackedFile {
+ fn new(
+ parsed_contents: T,
+ mut tracker: UnboundedReceiver,
+ cx: &mut AppContext,
+ ) -> Model {
+ cx.new_model(move |cx| {
+ cx.spawn(|tracked_file, mut cx| async move {
+ while let Some(new_contents) = tracker.next().await {
+ let Some(new_contents) = serde_json_lenient::from_str(&new_contents).log_err()
+ else {
+ continue;
+ };
+ tracked_file.update(&mut cx, |tracked_file: &mut TrackedFile, cx| {
+ if tracked_file.parsed_contents != new_contents {
+ tracked_file.parsed_contents = new_contents;
+ cx.notify();
+ };
+ })?;
+ }
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ Self { parsed_contents }
+ })
+ }
+
+ fn get(&self) -> &T {
+ &self.parsed_contents
+ }
+}
+
+impl StaticSource {
+ /// Initializes the static source, reacting on runnables config changes.
+ pub fn new(
+ runnables_file_tracker: UnboundedReceiver,
+ cx: &mut AppContext,
+ ) -> Model> {
+ let definitions =
+ TrackedFile::new(DefinitionProvider::default(), runnables_file_tracker, cx);
+ cx.new_model(|cx| {
+ let _subscription = cx.observe(
+ &definitions,
+ |source: &mut Box<(dyn Source + 'static)>, new_definitions, cx| {
+ if let Some(static_source) = source.as_any().downcast_mut::() {
+ static_source.runnables = new_definitions
+ .read(cx)
+ .get()
+ .runnables
+ .clone()
+ .into_iter()
+ .enumerate()
+ .map(|(id, definition)| StaticRunnable::new(id, definition))
+ .collect();
+ cx.notify();
+ }
+ },
+ );
+ Box::new(Self {
+ runnables: Vec::new(),
+ _definitions: definitions,
+ _subscription,
+ })
+ })
+ }
+}
+
+impl Source for StaticSource {
+ fn runnables_for_path(
+ &mut self,
+ _: Option<&Path>,
+ _: &mut ModelContext>,
+ ) -> Vec> {
+ self.runnables
+ .clone()
+ .into_iter()
+ .map(|runnable| Arc::new(runnable) as Arc)
+ .collect()
+ }
+
+ fn as_any(&mut self) -> &mut dyn std::any::Any {
+ self
+ }
+}
diff --git a/crates/runnables_ui/Cargo.toml b/crates/runnables_ui/Cargo.toml
new file mode 100644
index 0000000000..b6bd181b3d
--- /dev/null
+++ b/crates/runnables_ui/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "runnables_ui"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+anyhow.workspace = true
+db.workspace = true
+editor.workspace = true
+fs.workspace = true
+futures.workspace = true
+fuzzy.workspace = true
+gpui.workspace = true
+log.workspace = true
+picker.workspace = true
+project.workspace = true
+runnable.workspace = true
+schemars.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+theme.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace.workspace = true
diff --git a/crates/runnables_ui/src/lib.rs b/crates/runnables_ui/src/lib.rs
new file mode 100644
index 0000000000..90a642bb58
--- /dev/null
+++ b/crates/runnables_ui/src/lib.rs
@@ -0,0 +1,89 @@
+use std::path::PathBuf;
+
+use gpui::{AppContext, ViewContext, WindowContext};
+use modal::RunnablesModal;
+use runnable::Runnable;
+use util::ResultExt;
+use workspace::Workspace;
+
+mod modal;
+
+pub fn init(cx: &mut AppContext) {
+ cx.observe_new_views(
+ |workspace: &mut Workspace, _: &mut ViewContext| {
+ workspace
+ .register_action(|workspace, _: &modal::Spawn, cx| {
+ let inventory = workspace.project().read(cx).runnable_inventory().clone();
+ let workspace_handle = workspace.weak_handle();
+ workspace.toggle_modal(cx, |cx| {
+ RunnablesModal::new(inventory, workspace_handle, cx)
+ })
+ })
+ .register_action(move |workspace, _: &modal::Rerun, cx| {
+ if let Some(runnable) = workspace.project().update(cx, |project, cx| {
+ project
+ .runnable_inventory()
+ .update(cx, |inventory, cx| inventory.last_scheduled_runnable(cx))
+ }) {
+ schedule_runnable(workspace, runnable.as_ref(), cx)
+ };
+ });
+ },
+ )
+ .detach();
+}
+
+fn schedule_runnable(
+ workspace: &Workspace,
+ runnable: &dyn Runnable,
+ cx: &mut ViewContext<'_, Workspace>,
+) {
+ let cwd = match runnable.cwd() {
+ Some(cwd) => Some(cwd.to_path_buf()),
+ None => runnable_cwd(workspace, cx).log_err().flatten(),
+ };
+ let spawn_in_terminal = runnable.exec(cwd);
+ if let Some(spawn_in_terminal) = spawn_in_terminal {
+ workspace.project().update(cx, |project, cx| {
+ project.runnable_inventory().update(cx, |inventory, _| {
+ inventory.last_scheduled_runnable = Some(runnable.id().clone());
+ })
+ });
+ cx.emit(workspace::Event::SpawnRunnable(spawn_in_terminal));
+ }
+}
+
+fn runnable_cwd(workspace: &Workspace, cx: &mut WindowContext) -> anyhow::Result