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> { + let project = workspace.project().read(cx); + let available_worktrees = project + .worktrees() + .filter(|worktree| { + let worktree = worktree.read(cx); + worktree.is_visible() + && worktree.is_local() + && worktree.root_entry().map_or(false, |e| e.is_dir()) + }) + .collect::>(); + let cwd = match available_worktrees.len() { + 0 => None, + 1 => Some(available_worktrees[0].read(cx).abs_path()), + _ => { + let cwd_for_active_entry = project.active_entry().and_then(|entry_id| { + available_worktrees.into_iter().find_map(|worktree| { + let worktree = worktree.read(cx); + if worktree.contains_entry(entry_id) { + Some(worktree.abs_path()) + } else { + None + } + }) + }); + anyhow::ensure!( + cwd_for_active_entry.is_some(), + "Cannot determine runnable cwd for multiple worktrees" + ); + cwd_for_active_entry + } + }; + Ok(cwd.map(|path| path.to_path_buf())) +} diff --git a/crates/runnables_ui/src/modal.rs b/crates/runnables_ui/src/modal.rs new file mode 100644 index 0000000000..f15bf86a2f --- /dev/null +++ b/crates/runnables_ui/src/modal.rs @@ -0,0 +1,201 @@ +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, + VisualContext, WeakView, +}; +use picker::{Picker, PickerDelegate}; +use project::Inventory; +use runnable::Runnable; +use ui::{v_flex, HighlightedLabel, ListItem, ListItemSpacing, Selectable}; +use util::ResultExt; +use workspace::{ModalView, Workspace}; + +use crate::schedule_runnable; + +actions!(runnables, [Spawn, Rerun]); + +/// A modal used to spawn new runnables. +pub(crate) struct RunnablesModalDelegate { + inventory: Model, + candidates: Vec>, + matches: Vec, + selected_index: usize, + placeholder_text: Arc, + workspace: WeakView, +} + +impl RunnablesModalDelegate { + fn new(inventory: Model, workspace: WeakView) -> Self { + Self { + inventory, + workspace, + candidates: Vec::new(), + matches: Vec::new(), + selected_index: 0, + placeholder_text: Arc::from("Select runnable..."), + } + } +} + +pub(crate) struct RunnablesModal { + picker: View>, + _subscription: Subscription, +} + +impl RunnablesModal { + pub(crate) fn new( + inventory: Model, + workspace: WeakView, + cx: &mut ViewContext, + ) -> Self { + let picker = cx.new_view(|cx| { + Picker::uniform_list(RunnablesModalDelegate::new(inventory, workspace), cx) + }); + let _subscription = cx.subscribe(&picker, |_, _, _, cx| { + cx.emit(DismissEvent); + }); + Self { + picker, + _subscription, + } + } +} +impl Render for RunnablesModal { + fn render(&mut self, cx: &mut ViewContext) -> impl gpui::prelude::IntoElement { + v_flex() + .w(rems(34.)) + .child(self.picker.clone()) + .on_mouse_down_out(cx.listener(|modal, _, cx| { + modal.picker.update(cx, |picker, cx| { + picker.cancel(&Default::default(), cx); + }) + })) + } +} + +impl EventEmitter for RunnablesModal {} +impl FocusableView for RunnablesModal { + fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle { + self.picker.read(cx).focus_handle(cx) + } +} +impl ModalView for RunnablesModal {} + +impl PickerDelegate for RunnablesModalDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext>) { + self.selected_index = ix; + } + + fn placeholder_text(&self) -> Arc { + self.placeholder_text.clone() + } + + fn update_matches( + &mut self, + query: String, + cx: &mut ViewContext>, + ) -> Task<()> { + cx.spawn(move |picker, mut cx| async move { + let Some(candidates) = picker + .update(&mut cx, |picker, cx| { + picker.delegate.candidates = picker + .delegate + .inventory + .update(cx, |inventory, cx| inventory.list_runnables(None, cx)); + picker + .delegate + .candidates + .sort_by(|a, b| a.name().cmp(&b.name())); + + picker + .delegate + .candidates + .iter() + .enumerate() + .map(|(index, candidate)| StringMatchCandidate { + id: index, + char_bag: candidate.name().chars().collect(), + string: candidate.name().into(), + }) + .collect::>() + }) + .ok() + else { + return; + }; + let matches = fuzzy::match_strings( + &candidates, + &query, + true, + 1000, + &Default::default(), + cx.background_executor().clone(), + ) + .await; + picker + .update(&mut cx, |picker, _| { + let delegate = &mut picker.delegate; + delegate.matches = matches; + + if delegate.matches.is_empty() { + delegate.selected_index = 0; + } else { + delegate.selected_index = + delegate.selected_index.min(delegate.matches.len() - 1); + } + }) + .log_err(); + }) + } + + fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { + let current_match_index = self.selected_index(); + let Some(current_match) = self.matches.get(current_match_index) 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); + }) + .ok(); + cx.emit(DismissEvent); + } + + fn dismissed(&mut self, cx: &mut ViewContext>) { + cx.emit(DismissEvent); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _cx: &mut ViewContext>, + ) -> Option { + let hit = &self.matches[ix]; + //let runnable = self.candidates[target_index].metadata(); + let highlights: Vec<_> = hit.positions.iter().copied().collect(); + Some( + ListItem::new(SharedString::from(format!("runnables-modal-{ix}"))) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .selected(selected) + .start_slot(HighlightedLabel::new(hit.string.clone(), highlights)), + ) + } +} diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index a8fa37c77e..b7145aac5e 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -36,3 +36,7 @@ pub fn initial_user_settings_content() -> Cow<'static, str> { pub fn initial_local_settings_content() -> Cow<'static, str> { asset_str::("settings/initial_local_settings.json") } + +pub fn initial_runnables_content() -> Cow<'static, str> { + asset_str::("settings/initial_runnables.json") +} diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index a52a7c6f93..4dc535d846 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -24,6 +24,7 @@ libc = "0.2" mio-extras = "2.0.6" ordered-float.workspace = true procinfo = { git = "https://github.com/zed-industries/wezterm", rev = "5cd757e5f2eb039ed0c6bb6512223e69d5efc64d", default-features = false } +runnable.workspace = true schemars.workspace = true serde.workspace = true serde_derive.workspace = true diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 46c7cb0c19..0ac714dd7b 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -31,9 +31,12 @@ use mappings::mouse::{ }; use collections::{HashMap, VecDeque}; +use futures::StreamExt; use procinfo::LocalProcessInfo; +use runnable::RunnableId; use serde::{Deserialize, Serialize}; use settings::Settings; +use smol::channel::{Receiver, Sender}; use terminal_settings::{AlternateScroll, Shell, TerminalBlink, TerminalSettings}; use theme::{ActiveTheme, Theme}; use util::truncate_and_trailoff; @@ -277,6 +280,14 @@ impl Display for TerminalError { } } +pub struct SpawnRunnable { + pub id: RunnableId, + pub label: String, + pub command: String, + pub args: Vec, + pub env: HashMap, +} + pub struct TerminalBuilder { terminal: Terminal, events_rx: UnboundedReceiver, @@ -285,11 +296,13 @@ pub struct TerminalBuilder { impl TerminalBuilder { pub fn new( working_directory: Option, + runnable: Option, shell: Shell, env: HashMap, blink_settings: Option, alternate_scroll: AlternateScroll, window: AnyWindowHandle, + completion_tx: Sender<()>, ) -> Result { let pty_options = { let alac_shell = match shell.clone() { @@ -322,7 +335,7 @@ impl TerminalBuilder { let config = Config { scrolling_history: 10000, - ..Default::default() + ..Config::default() }; //Spawn a task so the Alacritty EventLoop can communicate with us in a view context @@ -383,7 +396,9 @@ impl TerminalBuilder { let word_regex = RegexSearch::new(r#"[\w.\[\]:/@\-~]+"#).unwrap(); let terminal = Terminal { + runnable, pty_tx: Notifier(pty_tx), + completion_tx, term, events: VecDeque::with_capacity(10), //Should never get this high. last_content: Default::default(), @@ -412,17 +427,15 @@ impl TerminalBuilder { pub fn subscribe(mut self, cx: &mut ModelContext) -> Terminal { //Event loop - cx.spawn(|this, mut cx| async move { - use futures::StreamExt; - + cx.spawn(|terminal, mut cx| async move { while let Some(event) = self.events_rx.next().await { - this.update(&mut cx, |this, cx| { + terminal.update(&mut cx, |terminal, cx| { //Process the first event immediately for lowered latency - this.process_event(&event, cx); + terminal.process_event(&event, cx); })?; 'outer: loop { - let mut events = vec![]; + let mut events = Vec::new(); let mut timer = cx .background_executor() .timer(Duration::from_millis(4)) @@ -453,7 +466,7 @@ impl TerminalBuilder { smol::future::yield_now().await; break 'outer; } else { - this.update(&mut cx, |this, cx| { + terminal.update(&mut cx, |this, cx| { if wakeup { this.process_event(&AlacTermEvent::Wakeup, cx); } @@ -538,6 +551,7 @@ pub enum SelectionPhase { pub struct Terminal { pty_tx: Notifier, + completion_tx: Sender<()>, term: Arc>>, events: VecDeque, /// This is only used for mouse mode cell change detection @@ -558,6 +572,14 @@ pub struct Terminal { hovered_word: bool, url_regex: RegexSearch, word_regex: RegexSearch, + runnable: Option, +} + +pub struct RunableState { + pub id: RunnableId, + pub label: String, + pub completed: bool, + pub completion_rx: Receiver<()>, } impl Terminal { @@ -589,7 +611,13 @@ impl Terminal { AlacTermEvent::Bell => { cx.emit(Event::Bell); } - AlacTermEvent::Exit => cx.emit(Event::CloseTerminal), + AlacTermEvent::Exit => match &mut self.runnable { + Some(runnable) => { + runnable.completed = true; + self.completion_tx.try_send(()).ok(); + } + None => cx.emit(Event::CloseTerminal), + }, AlacTermEvent::MouseCursorDirty => { //NOOP, Handled in render } @@ -1307,39 +1335,64 @@ impl Terminal { } pub fn title(&self, truncate: bool) -> String { - self.foreground_process_info - .as_ref() - .map(|fpi| { - let process_file = fpi - .cwd - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_default(); - let process_name = format!( - "{}{}", - fpi.name, - if fpi.argv.len() >= 1 { - format!(" {}", (fpi.argv[1..]).join(" ")) + const MAX_CHARS: usize = 25; + match &self.runnable { + Some(runnable_state) => truncate_and_trailoff(&runnable_state.label, MAX_CHARS), + None => self + .foreground_process_info + .as_ref() + .map(|fpi| { + let process_file = fpi + .cwd + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + let process_name = format!( + "{}{}", + fpi.name, + if fpi.argv.len() >= 1 { + format!(" {}", (fpi.argv[1..]).join(" ")) + } else { + "".to_string() + } + ); + let (process_file, process_name) = if truncate { + ( + truncate_and_trailoff(&process_file, MAX_CHARS), + truncate_and_trailoff(&process_name, MAX_CHARS), + ) } else { - "".to_string() - } - ); - let (process_file, process_name) = if truncate { - ( - truncate_and_trailoff(&process_file, 25), - truncate_and_trailoff(&process_name, 25), - ) - } else { - (process_file, process_name) - }; - format!("{process_file} — {process_name}") - }) - .unwrap_or_else(|| "Terminal".to_string()) + (process_file, process_name) + }; + format!("{process_file} — {process_name}") + }) + .unwrap_or_else(|| "Terminal".to_string()), + } } pub fn can_navigate_to_selected_word(&self) -> bool { self.cmd_pressed && self.hovered_word } + + pub fn runnable(&self) -> Option<&RunableState> { + self.runnable.as_ref() + } + + pub fn wait_for_completed_runnable(&self, cx: &mut AppContext) -> Task<()> { + match self.runnable() { + Some(runnable) => { + if runnable.completed { + Task::ready(()) + } else { + let mut completion_receiver = runnable.completion_rx.clone(); + cx.spawn(|_| async move { + completion_receiver.next().await; + }) + } + } + None => Task::ready(()), + } + } } impl Drop for Terminal { diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index 55eaa70589..64972a78e8 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -25,6 +25,7 @@ mio-extras = "2.0.6" ordered-float.workspace = true procinfo = { git = "https://github.com/zed-industries/wezterm", rev = "5cd757e5f2eb039ed0c6bb6512223e69d5efc64d", default-features = false } project.workspace = true +runnable.workspace = true search.workspace = true serde.workspace = true serde_derive.workspace = true diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 7a988851d8..aee6e907b6 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1,7 +1,9 @@ use std::{ops::ControlFlow, path::PathBuf, sync::Arc}; use crate::TerminalView; +use collections::{HashMap, HashSet}; use db::kvp::KEY_VALUE_STORE; +use futures::future::join_all; use gpui::{ actions, AppContext, AsyncWindowContext, Entity, EventEmitter, ExternalPaths, FocusHandle, FocusableView, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, Task, View, @@ -9,10 +11,14 @@ use gpui::{ }; use itertools::Itertools; use project::{Fs, ProjectEntryId}; +use runnable::RunnableId; use search::{buffer_search::DivRegistrar, BufferSearchBar}; use serde::{Deserialize, Serialize}; use settings::Settings; -use terminal::terminal_settings::{TerminalDockPosition, TerminalSettings}; +use terminal::{ + terminal_settings::{TerminalDockPosition, TerminalSettings}, + SpawnRunnable, +}; use ui::{h_flex, ButtonCommon, Clickable, IconButton, IconSize, Selectable, Tooltip}; use util::{ResultExt, TryFutureExt}; use workspace::{ @@ -49,7 +55,9 @@ pub struct TerminalPanel { width: Option, height: Option, pending_serialization: Task>, + pending_terminals_to_add: usize, _subscriptions: Vec, + deferred_runnables: HashMap>, } impl TerminalPanel { @@ -75,7 +83,7 @@ impl TerminalPanel { .icon_size(IconSize::Small) .on_click(move |_, cx| { terminal_panel - .update(cx, |panel, cx| panel.add_terminal(None, cx)) + .update(cx, |panel, cx| panel.add_terminal(None, None, cx)) .log_err(); }) .tooltip(|cx| Tooltip::text("New Terminal", cx)), @@ -157,6 +165,8 @@ impl TerminalPanel { pending_serialization: Task::ready(None), width: None, height: None, + pending_terminals_to_add: 0, + deferred_runnables: HashMap::default(), _subscriptions: subscriptions, }; this @@ -201,12 +211,27 @@ impl TerminalPanel { }) }) } else { - Default::default() + Vec::new() }; let pane = panel.read(cx).pane.clone(); (panel, pane, items) })?; + if let Some(workspace) = workspace.upgrade() { + panel + .update(&mut cx, |panel, cx| { + panel._subscriptions.push(cx.subscribe( + &workspace, + |terminal_panel, _, e, cx| { + if let workspace::Event::SpawnRunnable(spawn_in_terminal) = e { + terminal_panel.spawn_runnable(spawn_in_terminal, cx); + }; + }, + )) + }) + .ok(); + } + let pane = pane.downgrade(); let items = futures::future::join_all(items).await; pane.update(&mut cx, |pane, cx| { @@ -266,10 +291,97 @@ impl TerminalPanel { }; this.update(cx, |this, cx| { - this.add_terminal(Some(action.working_directory.clone()), cx) + this.add_terminal(Some(action.working_directory.clone()), None, cx) }) } + pub fn spawn_runnable( + &mut self, + spawn_in_terminal: &runnable::SpawnInTerminal, + cx: &mut ViewContext, + ) { + let 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(), + }; + 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; + + if allow_concurrent_runs && use_new_terminal { + self.spawn_in_new_terminal(spawn_runnable, working_directory, cx); + return; + } + + let terminals_for_runnable = self.terminals_for_runnable(&spawn_in_terminal.id, cx); + if terminals_for_runnable.is_empty() { + self.spawn_in_new_terminal(spawn_runnable, working_directory, cx); + return; + } + let (existing_item_index, existing_terminal) = terminals_for_runnable + .last() + .expect("covered no terminals case above") + .clone(); + if allow_concurrent_runs { + debug_assert!( + !use_new_terminal, + "Should have handled 'allow_concurrent_runs && use_new_terminal' case above" + ); + self.replace_terminal( + working_directory, + spawn_runnable, + existing_item_index, + existing_terminal, + cx, + ); + } else { + self.deferred_runnables.insert( + spawn_in_terminal.id.clone(), + cx.spawn(|terminal_panel, mut cx| async move { + wait_for_terminals_tasks(terminals_for_runnable, &mut cx).await; + terminal_panel + .update(&mut cx, |terminal_panel, cx| { + if use_new_terminal { + terminal_panel.spawn_in_new_terminal( + spawn_runnable, + working_directory, + cx, + ); + } else { + terminal_panel.replace_terminal( + working_directory, + spawn_runnable, + existing_item_index, + existing_terminal, + cx, + ); + } + }) + .ok(); + }), + ); + } + } + + fn spawn_in_new_terminal( + &mut self, + spawn_runnable: SpawnRunnable, + working_directory: Option, + cx: &mut ViewContext, + ) { + self.add_terminal(working_directory, Some(spawn_runnable), cx); + let task_workspace = self.workspace.clone(); + cx.spawn(|_, mut cx| async move { + task_workspace + .update(&mut cx, |workspace, cx| workspace.focus_panel::(cx)) + .ok() + }) + .detach(); + } + ///Create a new Terminal in the current working directory or the user's home directory fn new_terminal( workspace: &mut Workspace, @@ -280,13 +392,46 @@ impl TerminalPanel { return; }; - this.update(cx, |this, cx| this.add_terminal(None, cx)) + this.update(cx, |this, cx| this.add_terminal(None, None, cx)) } - fn add_terminal(&mut self, working_directory: Option, cx: &mut ViewContext) { + fn terminals_for_runnable( + &self, + id: &RunnableId, + cx: &mut AppContext, + ) -> Vec<(usize, View)> { + self.pane + .read(cx) + .items() + .enumerate() + .filter_map(|(index, item)| Some((index, item.act_as::(cx)?))) + .filter_map(|(index, terminal_view)| { + let runnable_state = terminal_view.read(cx).terminal().read(cx).runnable()?; + if &runnable_state.id == id { + Some((index, terminal_view)) + } else { + None + } + }) + .collect() + } + + fn activate_terminal_view(&self, item_index: usize, cx: &mut WindowContext) { + self.pane.update(cx, |pane, cx| { + pane.activate_item(item_index, true, true, cx) + }) + } + + fn add_terminal( + &mut self, + working_directory: Option, + spawn_runnable: Option, + cx: &mut ViewContext, + ) { let workspace = self.workspace.clone(); - cx.spawn(|this, mut cx| async move { - let pane = this.update(&mut cx, |this, _| this.pane.clone())?; + self.pending_terminals_to_add += 1; + cx.spawn(|terminal_panel, mut cx| async move { + let pane = terminal_panel.update(&mut cx, |this, _| this.pane.clone())?; workspace.update(&mut cx, |workspace, cx| { let working_directory = if let Some(working_directory) = working_directory { Some(working_directory) @@ -299,7 +444,7 @@ impl TerminalPanel { let window = cx.window_handle(); if let Some(terminal) = workspace.project().update(cx, |project, cx| { project - .create_terminal(working_directory, window, cx) + .create_terminal(working_directory, spawn_runnable, window, cx) .log_err() }) { let terminal = Box::new(cx.new_view(|cx| { @@ -316,24 +461,44 @@ impl TerminalPanel { }); } })?; - this.update(&mut cx, |this, cx| this.serialize(cx))?; + terminal_panel.update(&mut cx, |this, cx| { + this.pending_terminals_to_add = this.pending_terminals_to_add.saturating_sub(1); + this.serialize(cx) + })?; anyhow::Ok(()) }) .detach_and_log_err(cx); } fn serialize(&mut self, cx: &mut ViewContext) { + let mut items_to_serialize = HashSet::default(); let items = self .pane .read(cx) .items() - .map(|item| item.item_id().as_u64()) + .filter_map(|item| { + let terminal_view = item.act_as::(cx)?; + if terminal_view + .read(cx) + .terminal() + .read(cx) + .runnable() + .is_some() + { + None + } else { + let id = item.item_id().as_u64(); + items_to_serialize.insert(id); + Some(id) + } + }) .collect::>(); let active_item_id = self .pane .read(cx) .active_item() - .map(|item| item.item_id().as_u64()); + .map(|item| item.item_id().as_u64()) + .filter(|active_id| items_to_serialize.contains(active_id)); let height = self.height; let width = self.width; self.pending_serialization = cx.background_executor().spawn( @@ -354,6 +519,47 @@ impl TerminalPanel { .log_err(), ); } + + fn replace_terminal( + &self, + working_directory: Option, + spawn_runnable: SpawnRunnable, + terminal_item_index: usize, + terminal_to_replace: View, + cx: &mut ViewContext<'_, Self>, + ) -> Option<()> { + let project = self + .workspace + .update(cx, |workspace, _| workspace.project().clone()) + .ok()?; + let window = cx.window_handle(); + let new_terminal = project.update(cx, |project, cx| { + project + .create_terminal(working_directory, Some(spawn_runnable), window, cx) + .log_err() + })?; + terminal_to_replace.update(cx, |terminal_to_replace, cx| { + terminal_to_replace.set_terminal(new_terminal, cx); + }); + self.activate_terminal_view(terminal_item_index, cx); + Some(()) + } +} + +async fn wait_for_terminals_tasks( + terminals_for_runnable: Vec<(usize, View)>, + cx: &mut AsyncWindowContext, +) { + let pending_tasks = terminals_for_runnable.iter().filter_map(|(_, terminal)| { + terminal + .update(cx, |terminal_view, cx| { + terminal_view + .terminal() + .update(cx, |terminal, cx| terminal.wait_for_completed_runnable(cx)) + }) + .ok() + }); + let _: Vec<()> = join_all(pending_tasks).await; } fn add_paths_to_terminal(pane: &mut Pane, paths: &[PathBuf], cx: &mut ViewContext<'_, Pane>) { @@ -450,8 +656,8 @@ impl Panel for TerminalPanel { } fn set_active(&mut self, active: bool, cx: &mut ViewContext) { - if active && self.pane.read(cx).items_len() == 0 { - self.add_terminal(None, cx) + if active && self.pane.read(cx).items_len() == 0 && self.pending_terminals_to_add == 0 { + self.add_terminal(None, None, cx) } } diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 7ce5f1e514..fbf2448702 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -91,6 +91,7 @@ pub struct TerminalView { can_navigate_to_selected_word: bool, workspace_id: WorkspaceId, _subscriptions: Vec, + _terminal_subscriptions: Vec, } impl EventEmitter for TerminalView {} @@ -118,7 +119,7 @@ impl TerminalView { let terminal = workspace .project() .update(cx, |project, cx| { - project.create_terminal(working_directory, window, cx) + project.create_terminal(working_directory, None, window, cx) }) .notify_err(workspace, cx); @@ -142,161 +143,7 @@ impl TerminalView { cx: &mut ViewContext, ) -> Self { let workspace_handle = workspace.clone(); - cx.observe(&terminal, |_, _, cx| cx.notify()).detach(); - cx.subscribe(&terminal, move |this, _, event, cx| match event { - Event::Wakeup => { - cx.notify(); - cx.emit(Event::Wakeup); - cx.emit(ItemEvent::UpdateTab); - cx.emit(SearchEvent::MatchesInvalidated); - } - - Event::Bell => { - this.has_bell = true; - cx.emit(Event::Wakeup); - } - - Event::BlinkChanged => this.blinking_on = !this.blinking_on, - - Event::TitleChanged => { - cx.emit(ItemEvent::UpdateTab); - if let Some(foreground_info) = &this.terminal().read(cx).foreground_process_info { - let cwd = foreground_info.cwd.clone(); - - let item_id = cx.entity_id(); - let workspace_id = this.workspace_id; - cx.background_executor() - .spawn(async move { - TERMINAL_DB - .save_working_directory(item_id.as_u64(), workspace_id, cwd) - .await - .log_err(); - }) - .detach(); - } - } - - Event::NewNavigationTarget(maybe_navigation_target) => { - this.can_navigate_to_selected_word = match maybe_navigation_target { - Some(MaybeNavigationTarget::Url(_)) => true, - Some(MaybeNavigationTarget::PathLike(path_like_target)) => { - if let Ok(fs) = workspace.update(cx, |workspace, cx| { - workspace.project().read(cx).fs().clone() - }) { - let valid_files_to_open_task = possible_open_targets( - fs, - &workspace, - &path_like_target.terminal_dir, - &path_like_target.maybe_path, - cx, - ); - smol::block_on(valid_files_to_open_task).len() > 0 - } else { - false - } - } - None => false, - } - } - - Event::Open(maybe_navigation_target) => match maybe_navigation_target { - MaybeNavigationTarget::Url(url) => cx.open_url(url), - - MaybeNavigationTarget::PathLike(path_like_target) => { - if !this.can_navigate_to_selected_word { - return; - } - let task_workspace = workspace.clone(); - let Some(fs) = workspace - .update(cx, |workspace, cx| { - workspace.project().read(cx).fs().clone() - }) - .ok() - else { - return; - }; - - let path_like_target = path_like_target.clone(); - cx.spawn(|terminal_view, mut cx| async move { - let valid_files_to_open = terminal_view - .update(&mut cx, |_, cx| { - possible_open_targets( - fs, - &task_workspace, - &path_like_target.terminal_dir, - &path_like_target.maybe_path, - cx, - ) - })? - .await; - let paths_to_open = valid_files_to_open - .iter() - .map(|(p, _)| p.path_like.clone()) - .collect(); - let opened_items = task_workspace - .update(&mut cx, |workspace, cx| { - workspace.open_paths( - paths_to_open, - OpenVisible::OnlyDirectories, - None, - cx, - ) - }) - .context("workspace update")? - .await; - - let mut has_dirs = false; - for ((path, metadata), opened_item) in valid_files_to_open - .into_iter() - .zip(opened_items.into_iter()) - { - if metadata.is_dir { - has_dirs = true; - } else if let Some(Ok(opened_item)) = opened_item { - if let Some(row) = path.row { - let col = path.column.unwrap_or(0); - if let Some(active_editor) = opened_item.downcast::() { - active_editor - .downgrade() - .update(&mut cx, |editor, cx| { - let snapshot = editor.snapshot(cx).display_snapshot; - let point = snapshot.buffer_snapshot.clip_point( - language::Point::new( - row.saturating_sub(1), - col.saturating_sub(1), - ), - Bias::Left, - ); - editor.change_selections( - Some(Autoscroll::center()), - cx, - |s| s.select_ranges([point..point]), - ); - }) - .log_err(); - } - } - } - } - - if has_dirs { - task_workspace.update(&mut cx, |workspace, cx| { - workspace.project().update(cx, |_, cx| { - cx.emit(project::Event::ActivateProjectPanel); - }) - })?; - } - - anyhow::Ok(()) - }) - .detach_and_log_err(cx) - } - }, - Event::BreadcrumbsChanged => cx.emit(ItemEvent::UpdateBreadcrumbs), - Event::CloseTerminal => cx.emit(ItemEvent::CloseItem), - Event::SelectionsChanged => cx.emit(SearchEvent::ActiveMatchChanged), - }) - .detach(); + let terminal_subscriptions = subscribe_for_terminal_events(&terminal, workspace, cx); let focus_handle = cx.focus_handle(); let focus_in = cx.on_focus_in(&focus_handle, |terminal_view, cx| { @@ -319,6 +166,7 @@ impl TerminalView { can_navigate_to_selected_word: false, workspace_id, _subscriptions: vec![focus_in, focus_out], + _terminal_subscriptions: terminal_subscriptions, } } @@ -560,6 +408,178 @@ impl TerminalView { }; dispatch_context } + + fn set_terminal(&mut self, terminal: Model, cx: &mut ViewContext<'_, TerminalView>) { + self._terminal_subscriptions = + subscribe_for_terminal_events(&terminal, self.workspace.clone(), cx); + self.terminal = terminal; + } +} + +fn subscribe_for_terminal_events( + terminal: &Model, + workspace: WeakView, + cx: &mut ViewContext<'_, TerminalView>, +) -> Vec { + let terminal_subscription = cx.observe(terminal, |_, _, cx| cx.notify()); + let terminal_events_subscription = + cx.subscribe(terminal, move |this, _, event, cx| match event { + Event::Wakeup => { + cx.notify(); + cx.emit(Event::Wakeup); + cx.emit(ItemEvent::UpdateTab); + cx.emit(SearchEvent::MatchesInvalidated); + } + + Event::Bell => { + this.has_bell = true; + cx.emit(Event::Wakeup); + } + + Event::BlinkChanged => this.blinking_on = !this.blinking_on, + + Event::TitleChanged => { + cx.emit(ItemEvent::UpdateTab); + let terminal = this.terminal().read(cx); + if !terminal.runnable().is_some() { + if let Some(foreground_info) = &terminal.foreground_process_info { + let cwd = foreground_info.cwd.clone(); + + let item_id = cx.entity_id(); + let workspace_id = this.workspace_id; + cx.background_executor() + .spawn(async move { + TERMINAL_DB + .save_working_directory(item_id.as_u64(), workspace_id, cwd) + .await + .log_err(); + }) + .detach(); + } + } + } + + Event::NewNavigationTarget(maybe_navigation_target) => { + this.can_navigate_to_selected_word = match maybe_navigation_target { + Some(MaybeNavigationTarget::Url(_)) => true, + Some(MaybeNavigationTarget::PathLike(path_like_target)) => { + if let Ok(fs) = workspace.update(cx, |workspace, cx| { + workspace.project().read(cx).fs().clone() + }) { + let valid_files_to_open_task = possible_open_targets( + fs, + &workspace, + &path_like_target.terminal_dir, + &path_like_target.maybe_path, + cx, + ); + smol::block_on(valid_files_to_open_task).len() > 0 + } else { + false + } + } + None => false, + } + } + + Event::Open(maybe_navigation_target) => match maybe_navigation_target { + MaybeNavigationTarget::Url(url) => cx.open_url(url), + + MaybeNavigationTarget::PathLike(path_like_target) => { + if !this.can_navigate_to_selected_word { + return; + } + let task_workspace = workspace.clone(); + let Some(fs) = workspace + .update(cx, |workspace, cx| { + workspace.project().read(cx).fs().clone() + }) + .ok() + else { + return; + }; + + let path_like_target = path_like_target.clone(); + cx.spawn(|terminal_view, mut cx| async move { + let valid_files_to_open = terminal_view + .update(&mut cx, |_, cx| { + possible_open_targets( + fs, + &task_workspace, + &path_like_target.terminal_dir, + &path_like_target.maybe_path, + cx, + ) + })? + .await; + let paths_to_open = valid_files_to_open + .iter() + .map(|(p, _)| p.path_like.clone()) + .collect(); + let opened_items = task_workspace + .update(&mut cx, |workspace, cx| { + workspace.open_paths( + paths_to_open, + OpenVisible::OnlyDirectories, + None, + cx, + ) + }) + .context("workspace update")? + .await; + + let mut has_dirs = false; + for ((path, metadata), opened_item) in valid_files_to_open + .into_iter() + .zip(opened_items.into_iter()) + { + if metadata.is_dir { + has_dirs = true; + } else if let Some(Ok(opened_item)) = opened_item { + if let Some(row) = path.row { + let col = path.column.unwrap_or(0); + if let Some(active_editor) = opened_item.downcast::() { + active_editor + .downgrade() + .update(&mut cx, |editor, cx| { + let snapshot = editor.snapshot(cx).display_snapshot; + let point = snapshot.buffer_snapshot.clip_point( + language::Point::new( + row.saturating_sub(1), + col.saturating_sub(1), + ), + Bias::Left, + ); + editor.change_selections( + Some(Autoscroll::center()), + cx, + |s| s.select_ranges([point..point]), + ); + }) + .log_err(); + } + } + } + } + + if has_dirs { + task_workspace.update(&mut cx, |workspace, cx| { + workspace.project().update(cx, |_, cx| { + cx.emit(project::Event::ActivateProjectPanel); + }) + })?; + } + + anyhow::Ok(()) + }) + .detach_and_log_err(cx) + } + }, + Event::BreadcrumbsChanged => cx.emit(ItemEvent::UpdateBreadcrumbs), + Event::CloseTerminal => cx.emit(ItemEvent::CloseItem), + Event::SelectionsChanged => cx.emit(SearchEvent::ActiveMatchChanged), + }); + vec![terminal_subscription, terminal_events_subscription] } fn possible_open_paths_metadata( @@ -755,10 +775,16 @@ impl Item for TerminalView { selected: bool, cx: &WindowContext, ) -> AnyElement { - let title = self.terminal().read(cx).title(true); + let terminal = self.terminal().read(cx); + let title = terminal.title(true); + let icon = if terminal.runnable().is_some() { + IconName::Play + } else { + IconName::Terminal + }; h_flex() .gap_2() - .child(Icon::new(IconName::Terminal)) + .child(Icon::new(icon)) .child(Label::new(title).color(if selected { Color::Default } else { @@ -790,8 +816,11 @@ impl Item for TerminalView { None } - fn is_dirty(&self, _cx: &gpui::AppContext) -> bool { - self.has_bell() + fn is_dirty(&self, cx: &gpui::AppContext) -> bool { + match self.terminal.read(cx).runnable() { + Some(runnable) => !runnable.completed, + None => self.has_bell(), + } } fn has_conflict(&self, _cx: &AppContext) -> bool { @@ -845,7 +874,7 @@ impl Item for TerminalView { }); let terminal = project.update(&mut cx, |project, cx| { - project.create_terminal(cwd, window, cx) + project.create_terminal(cwd, None, window, cx) })??; pane.update(&mut cx, |_, cx| { cx.new_view(|cx| TerminalView::new(terminal, workspace, workspace_id, cx)) @@ -854,14 +883,16 @@ impl Item for TerminalView { } fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { - cx.background_executor() - .spawn(TERMINAL_DB.update_workspace_id( - workspace.database_id(), - self.workspace_id, - cx.entity_id().as_u64(), - )) - .detach(); - self.workspace_id = workspace.database_id(); + if !self.terminal().read(cx).runnable().is_some() { + cx.background_executor() + .spawn(TERMINAL_DB.update_workspace_id( + workspace.database_id(), + self.workspace_id, + cx.entity_id().as_u64(), + )) + .detach(); + self.workspace_id = workspace.database_id(); + } } fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index e02785ec37..c05544533e 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -93,6 +93,7 @@ pub enum IconName { Option, PageDown, PageUp, + Play, Plus, Public, Quote, @@ -185,6 +186,7 @@ impl IconName { IconName::Option => "icons/option.svg", IconName::PageDown => "icons/page_down.svg", IconName::PageUp => "icons/page_up.svg", + IconName::Play => "icons/play.svg", IconName::Plus => "icons/plus.svg", IconName::Public => "icons/public.svg", IconName::Quote => "icons/quote.svg", diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index d21bdb94b0..81be37237e 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -39,6 +39,7 @@ lazy_static::lazy_static! { }; pub static ref SETTINGS: PathBuf = CONFIG_DIR.join("settings.json"); pub static ref KEYMAP: PathBuf = CONFIG_DIR.join("keymap.json"); + pub static ref RUNNABLES: PathBuf = CONFIG_DIR.join("runnables.json"); pub static ref LAST_USERNAME: PathBuf = CONFIG_DIR.join("last-username.txt"); pub static ref LOG: PathBuf = LOGS_DIR.join("Zed.log"); pub static ref OLD_LOG: PathBuf = LOGS_DIR.join("Zed.log.old"); diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 6e0d4d4c53..4e70d93e2a 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -16,7 +16,7 @@ test-support = [ "project/test-support", "settings/test-support", "gpui/test-support", - "fs/test-support" + "fs/test-support", ] [dependencies] @@ -40,6 +40,7 @@ node_runtime.workspace = true parking_lot.workspace = true postage.workspace = true project.workspace = true +runnable.workspace = true schemars.workspace = true serde.workspace = true serde_derive.workspace = true diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index ffab5249e2..afedb7645b 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -661,7 +661,6 @@ impl Render for PanelButtons { let panel = entry.panel.clone(); let is_active_button = i == active_index && is_open; - let (action, tooltip) = if is_active_button { let action = dock.toggle_action(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 858dbb3ab7..ffaf37e6f9 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -50,6 +50,7 @@ pub use persistence::{ }; use postage::stream::Stream; use project::{Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId}; +use runnable::SpawnInTerminal; use serde::Deserialize; use settings::Settings; use shared_screen::SharedScreen; @@ -108,7 +109,6 @@ actions!( FollowNextCollaborator, NewTerminal, NewCenterTerminal, - ToggleTerminalFocus, NewSearch, Feedback, Restart, @@ -464,6 +464,7 @@ pub enum Event { PaneAdded(View), ContactRequestedJoin(u64), WorkspaceCreated(WeakView), + SpawnRunnable(SpawnInTerminal), } pub enum OpenVisible { diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 24b63900af..a0bbf53fde 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -88,6 +88,8 @@ release_channel.workspace = true rope.workspace = true rpc.workspace = true rsa = "0.4" +runnable.workspace = true +runnables_ui.workspace = true rust-embed.workspace = true schemars.workspace = true search.workspace = true diff --git a/crates/zed/src/languages/json.rs b/crates/zed/src/languages/json.rs index 6245d15046..eeb4d7b619 100644 --- a/crates/zed/src/languages/json.rs +++ b/crates/zed/src/languages/json.rs @@ -53,7 +53,7 @@ impl JsonLspAdapter { }, cx, ); - + let runnables_schema = runnable::static_source::DefinitionProvider::generate_json_schema(); serde_json::json!({ "json": { "format": { @@ -70,6 +70,10 @@ impl JsonLspAdapter { { "fileMatch": [schema_file_match(&paths::KEYMAP)], "schema": KeymapFile::generate_json_schema(&action_names), + }, + { + "fileMatch": [schema_file_match(&paths::RUNNABLES)], + "schema": runnables_schema, } ] } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 44c78cc226..aeca532c79 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -244,6 +244,7 @@ fn main() { outline::init(cx); project_symbols::init(cx); project_panel::init(Assets, cx); + runnables_ui::init(cx); channel::init(&client, user_store.clone(), cx); search::init(cx); semantic_index::init(fs.clone(), http.clone(), languages.clone(), cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index b30d2148f4..c9205dc8f3 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -22,8 +22,11 @@ use project_panel::ProjectPanel; use quick_action_bar::QuickActionBar; use release_channel::{AppCommitSha, ReleaseChannel}; use rope::Rope; +use runnable::static_source::StaticSource; use search::project_search::ProjectSearchBar; -use settings::{initial_local_settings_content, KeymapFile, Settings, SettingsStore}; +use settings::{ + initial_local_settings_content, watch_config_file, KeymapFile, Settings, SettingsStore, +}; use std::{borrow::Cow, ops::Deref, path::Path, sync::Arc}; use terminal_view::terminal_panel::{self, TerminalPanel}; use util::{ @@ -54,6 +57,7 @@ actions!( OpenDefaultKeymap, OpenDefaultSettings, OpenKeymap, + OpenRunnables, OpenLicenses, OpenLocalSettings, OpenLog, @@ -151,6 +155,20 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { .unwrap_or(true) }); + let project = workspace.project().clone(); + if project.read(cx).is_local() { + let runnables_file_rx = watch_config_file( + &cx.background_executor(), + app_state.fs.clone(), + paths::RUNNABLES.clone(), + ); + let source = StaticSource::new(runnables_file_rx, cx); + project.update(cx, |project, cx| { + project + .runnable_inventory() + .update(cx, |inventory, cx| inventory.add_source(source, cx)) + }); + } cx.spawn(|workspace_handle, mut cx| async move { let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone()); @@ -254,6 +272,15 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { ); }, ) + .register_action( + move |_: &mut Workspace, _: &OpenRunnables, cx: &mut ViewContext| { + open_settings_file( + &paths::RUNNABLES, + || settings::initial_runnables_content().as_ref().into(), + cx, + ); + }, + ) .register_action(open_local_settings_file) .register_action( move |workspace: &mut Workspace,