diff --git a/Cargo.lock b/Cargo.lock index 1c72e4aca40ed46fb544858408a3317c0037707b..c7d88a44a40b7463ef4fd5d26d5edfbe2c4907c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2278,6 +2278,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "collections", "core-foundation 0.9.4", "core-services", "exec", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index f0d4bb77a56303961fe2c25bc817c81c8867b51c..426fa25f3e975261027d82d3e65868568b406c28 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -19,6 +19,7 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true clap.workspace = true +collections.workspace = true ipc-channel = "0.18" once_cell.workspace = true parking_lot.workspace = true diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index d6ea61d4d0961537abfc9f913fed0af2e423a349..8e76ae759c66bf51a9529fcf14a5c44fba570b9a 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -1,3 +1,4 @@ +use collections::HashMap; pub use ipc_channel::ipc; use serde::{Deserialize, Serialize}; @@ -15,6 +16,7 @@ pub enum CliRequest { wait: bool, open_new_workspace: Option, dev_server_token: Option, + env: Option>, }, } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index b0c8fdcbf702564adc7d1ea9cd19c7ba5c56a68e..a09deaaf9438234657f97a2acda94bf92f0c9caf 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -3,6 +3,7 @@ use anyhow::{Context, Result}; use clap::Parser; use cli::{ipc::IpcOneShotServer, CliRequest, CliResponse, IpcHandshake}; +use collections::HashMap; use parking_lot::Mutex; use std::{ env, fs, io, @@ -122,6 +123,7 @@ fn main() -> Result<()> { None }; + let env = Some(std::env::vars().collect::>()); let exit_status = Arc::new(Mutex::new(None)); let mut paths = vec![]; let mut urls = vec![]; @@ -149,12 +151,14 @@ fn main() -> Result<()> { move || { let (_, handshake) = server.accept().context("Handshake after Zed spawn")?; let (tx, rx) = (handshake.requests, handshake.responses); + tx.send(CliRequest::Open { paths, urls, wait: args.wait, open_new_workspace, dev_server_token: args.dev_server_token, + env, })?; while let Ok(response) = rx.recv() { diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 6522eed3037dd6fb963f5eea2efc8f57ddb3e28c..12c4f3bfcb88053f7e84c9a6e821ce7b9e83b9d4 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -916,6 +916,7 @@ impl TestClient { self.app_state.user_store.clone(), self.app_state.languages.clone(), self.app_state.fs.clone(), + None, cx, ) }) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ff866bf11747fd20cc20a7d1c449da35a575c63f..71fe31474bd365e53eea0d470f7dc2106a783338 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -307,7 +307,7 @@ pub fn init(cx: &mut AppContext) { cx.on_action(move |_: &workspace::NewFile, cx| { let app_state = workspace::AppState::global(cx); if let Some(app_state) = app_state.upgrade() { - workspace::open_new(app_state, cx, |workspace, cx| { + workspace::open_new(Default::default(), app_state, cx, |workspace, cx| { Editor::new_file(workspace, &Default::default(), cx) }) .detach(); @@ -316,7 +316,7 @@ pub fn init(cx: &mut AppContext) { cx.on_action(move |_: &workspace::NewWindow, cx| { let app_state = workspace::AppState::global(cx); if let Some(app_state) = app_state.upgrade() { - workspace::open_new(app_state, cx, |workspace, cx| { + workspace::open_new(Default::default(), app_state, cx, |workspace, cx| { Editor::new_file(workspace, &Default::default(), cx) }) .detach(); diff --git a/crates/headless/src/headless.rs b/crates/headless/src/headless.rs index aede91a0961cf3af0bece6f84ff5671b701cbdb0..ea29f42192bfbb22c377b99aea5e0dcd20454c42 100644 --- a/crates/headless/src/headless.rs +++ b/crates/headless/src/headless.rs @@ -244,6 +244,7 @@ impl DevServer { this.app_state.user_store.clone(), this.app_state.languages.clone(), this.app_state.fs.clone(), + None, cx, ); diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index c90b8c772b832a3fb313054613e36697055eca06..33338fc4cdcba0661f282b2ccfc45a311df23723 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -719,6 +719,7 @@ impl LanguageRegistry { self.lsp_binary_status_tx.send(server_name, status); } + #[allow(clippy::too_many_arguments)] pub fn create_pending_language_server( self: &Arc, stderr_capture: Arc>>, @@ -726,6 +727,7 @@ impl LanguageRegistry { adapter: Arc, root_path: Arc, delegate: Arc, + cli_environment: Option>, cx: &mut AppContext, ) -> Option { let server_id = self.state.write().next_language_server_id(); @@ -764,7 +766,19 @@ impl LanguageRegistry { delegate.update_status(adapter.name.clone(), LanguageServerBinaryStatus::None); - let binary = binary_result?; + let mut binary = binary_result?; + + // If this Zed project was opened from the CLI and the language server command itself + // doesn't have an environment (which it would have, if it was found in $PATH), then + // we pass along the CLI environment that we inherited. + if binary.env.is_none() && cli_environment.is_some() { + log::info!( + "using CLI environment for language server {:?}, id: {server_id}", + adapter.name.0 + ); + binary.env = cli_environment.clone(); + } + let options = adapter .adapter .clone() diff --git a/crates/project/src/environment.rs b/crates/project/src/environment.rs new file mode 100644 index 0000000000000000000000000000000000000000..3882491cd623d37bceb64492ed69ff1c723703da --- /dev/null +++ b/crates/project/src/environment.rs @@ -0,0 +1,269 @@ +use anyhow::{anyhow, Context as _, Result}; +use futures::{future::Shared, FutureExt}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use util::{parse_env_output, ResultExt}; + +use collections::HashMap; +use gpui::{AppContext, Context, Model, ModelContext, Task}; +use settings::Settings as _; +use worktree::WorktreeId; + +use crate::project_settings::{DirenvSettings, ProjectSettings}; + +pub(crate) struct ProjectEnvironment { + cli_environment: Option>, + get_environment_task: Option>>>>, + cached_shell_environments: HashMap>, +} + +impl ProjectEnvironment { + pub(crate) fn new( + cli_environment: Option>, + cx: &mut AppContext, + ) -> Model { + cx.new_model(|_| Self { + cli_environment, + get_environment_task: None, + cached_shell_environments: Default::default(), + }) + } + + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn test( + shell_environments: &[(WorktreeId, HashMap)], + cx: &mut AppContext, + ) -> Model { + cx.new_model(|_| Self { + cli_environment: None, + get_environment_task: None, + cached_shell_environments: shell_environments + .iter() + .cloned() + .collect::>(), + }) + } + + pub(crate) fn remove_worktree_environment(&mut self, worktree_id: WorktreeId) { + self.cached_shell_environments.remove(&worktree_id); + } + + /// Returns the inherited CLI environment, if this project was opened from the Zed CLI. + pub(crate) fn get_cli_environment(&self) -> Option> { + if let Some(mut env) = self.cli_environment.clone() { + set_origin_marker(&mut env, EnvironmentOrigin::Cli); + Some(env) + } else { + None + } + } + + /// Returns the project environment, if possible. + /// If the project was opened from the CLI, then the inherited CLI environment is returned. + /// If it wasn't opened from the CLI, and a worktree is given, then a shell is spawned in + /// the worktree's path, to get environment variables as if the user has `cd`'d into + /// the worktrees path. + pub(crate) fn get_environment( + &mut self, + worktree_id: Option, + worktree_abs_path: Option>, + cx: &ModelContext, + ) -> Shared>>> { + if let Some(task) = self.get_environment_task.as_ref() { + task.clone() + } else { + let task = self + .build_environment_task(worktree_id, worktree_abs_path, cx) + .shared(); + + self.get_environment_task = Some(task.clone()); + task + } + } + + fn build_environment_task( + &mut self, + worktree_id: Option, + worktree_abs_path: Option>, + cx: &ModelContext, + ) -> Task>> { + let worktree = worktree_id.zip(worktree_abs_path); + + let cli_environment = self.get_cli_environment(); + if cli_environment.is_some() { + Task::ready(cli_environment) + } else if let Some((worktree_id, worktree_abs_path)) = worktree { + self.get_worktree_env(worktree_id, worktree_abs_path, cx) + } else { + Task::ready(None) + } + } + + fn get_worktree_env( + &mut self, + worktree_id: WorktreeId, + worktree_abs_path: Arc, + cx: &ModelContext, + ) -> Task>> { + let cached_env = self.cached_shell_environments.get(&worktree_id).cloned(); + if let Some(env) = cached_env { + Task::ready(Some(env)) + } else { + let load_direnv = ProjectSettings::get_global(cx).load_direnv.clone(); + + cx.spawn(|this, mut cx| async move { + let mut shell_env = cx + .background_executor() + .spawn({ + let cwd = worktree_abs_path.clone(); + async move { load_shell_environment(&cwd, &load_direnv).await } + }) + .await + .ok(); + + if let Some(shell_env) = shell_env.as_mut() { + this.update(&mut cx, |this, _| { + this.cached_shell_environments + .insert(worktree_id, shell_env.clone()) + }) + .log_err(); + + set_origin_marker(shell_env, EnvironmentOrigin::WorktreeShell); + } + + shell_env + }) + } + } +} + +fn set_origin_marker(env: &mut HashMap, origin: EnvironmentOrigin) { + env.insert(ZED_ENVIRONMENT_ORIGIN_MARKER.to_string(), origin.into()); +} + +const ZED_ENVIRONMENT_ORIGIN_MARKER: &str = "ZED_ENVIRONMENT"; + +enum EnvironmentOrigin { + Cli, + WorktreeShell, +} + +impl Into for EnvironmentOrigin { + fn into(self) -> String { + match self { + EnvironmentOrigin::Cli => "cli".into(), + EnvironmentOrigin::WorktreeShell => "worktree-shell".into(), + } + } +} + +async fn load_shell_environment( + dir: &Path, + load_direnv: &DirenvSettings, +) -> Result> { + let direnv_environment = match load_direnv { + DirenvSettings::ShellHook => None, + DirenvSettings::Direct => load_direnv_environment(dir).await?, + } + .unwrap_or(HashMap::default()); + + let marker = "ZED_SHELL_START"; + let shell = std::env::var("SHELL").context( + "SHELL environment variable is not assigned so we can't source login environment variables", + )?; + + // What we're doing here is to spawn a shell and then `cd` into + // the project directory to get the env in there as if the user + // `cd`'d into it. We do that because tools like direnv, asdf, ... + // hook into `cd` and only set up the env after that. + // + // If the user selects `Direct` for direnv, it would set an environment + // variable that later uses to know that it should not run the hook. + // We would include in `.envs` call so it is okay to run the hook + // even if direnv direct mode is enabled. + // + // In certain shells we need to execute additional_command in order to + // trigger the behavior of direnv, etc. + // + // + // The `exit 0` is the result of hours of debugging, trying to find out + // why running this command here, without `exit 0`, would mess + // up signal process for our process so that `ctrl-c` doesn't work + // anymore. + // + // We still don't know why `$SHELL -l -i -c '/usr/bin/env -0'` would + // do that, but it does, and `exit 0` helps. + let additional_command = PathBuf::from(&shell) + .file_name() + .and_then(|f| f.to_str()) + .and_then(|shell| match shell { + "fish" => Some("emit fish_prompt;"), + _ => None, + }); + + let command = format!( + "cd '{}';{} printf '%s' {marker}; /usr/bin/env; exit 0;", + dir.display(), + additional_command.unwrap_or("") + ); + + let output = smol::process::Command::new(&shell) + .args(["-i", "-c", &command]) + .envs(direnv_environment) + .output() + .await + .context("failed to spawn login shell to source login environment variables")?; + + anyhow::ensure!( + output.status.success(), + "login shell exited with error {:?}", + output.status + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let env_output_start = stdout.find(marker).ok_or_else(|| { + anyhow!( + "failed to parse output of `env` command in login shell: {}", + stdout + ) + })?; + + let mut parsed_env = HashMap::default(); + let env_output = &stdout[env_output_start + marker.len()..]; + + parse_env_output(env_output, |key, value| { + parsed_env.insert(key, value); + }); + + Ok(parsed_env) +} + +async fn load_direnv_environment(dir: &Path) -> Result>> { + let Ok(direnv_path) = which::which("direnv") else { + return Ok(None); + }; + + let direnv_output = smol::process::Command::new(direnv_path) + .args(["export", "json"]) + .current_dir(dir) + .output() + .await + .context("failed to spawn direnv to get local environment variables")?; + + anyhow::ensure!( + direnv_output.status.success(), + "direnv exited with error {:?}", + direnv_output.status + ); + + let output = String::from_utf8_lossy(&direnv_output.stdout); + if output.is_empty() { + return Ok(None); + } + + Ok(Some( + serde_json::from_str(&output).context("failed to parse direnv output")?, + )) +} diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index a446c03bdd6a35cf7a410644b225e9ec3cb3b2bf..08ed57134a4731e0a5ffd859a08fecd03bc19caf 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -13,6 +13,7 @@ pub mod worktree_store; #[cfg(test)] mod project_tests; +mod environment; pub mod search_history; mod yarn; @@ -26,6 +27,7 @@ use client::{ use clock::ReplicaId; use collections::{btree_map, BTreeMap, BTreeSet, HashMap, HashSet}; use debounced_delay::DebouncedDelay; +use environment::ProjectEnvironment; use futures::{ channel::mpsc::{self, UnboundedReceiver}, future::{join_all, try_join_all, Shared}, @@ -74,7 +76,7 @@ use paths::{ }; use postage::watch; use prettier_support::{DefaultPrettier, PrettierInstance}; -use project_settings::{DirenvSettings, LspSettings, ProjectSettings}; +use project_settings::{LspSettings, ProjectSettings}; use rand::prelude::*; use remote::SshSession; use rpc::{ @@ -95,7 +97,6 @@ use std::{ cell::RefCell, cmp::Ordering, convert::TryInto, - env, ffi::OsStr, hash::Hash, iter, mem, @@ -116,8 +117,8 @@ use task::{ use terminals::Terminals; use text::{Anchor, BufferId, LineEnding}; use util::{ - debug_panic, defer, maybe, merge_json_value_into, parse_env_output, paths::compare_paths, - post_inc, ResultExt, TryFutureExt as _, + debug_panic, defer, maybe, merge_json_value_into, paths::compare_paths, post_inc, ResultExt, + TryFutureExt as _, }; use worktree::{CreatedEntry, Snapshot, Traversal}; use worktree_store::{WorktreeStore, WorktreeStoreEvent}; @@ -231,7 +232,7 @@ pub struct Project { search_history: SearchHistory, snippets: Model, yarn: Model, - cached_shell_environments: HashMap>, + environment: Model, } #[derive(Default)] @@ -787,6 +788,7 @@ impl Project { user_store: Model, languages: Arc, fs: Arc, + env: Option>, cx: &mut AppContext, ) -> Model { cx.new_model(|cx: &mut ModelContext| { @@ -808,6 +810,7 @@ impl Project { .detach(); let yarn = YarnPathStore::new(fs.clone(), cx); + let environment = ProjectEnvironment::new(env, cx); Self { buffer_ordered_messages_tx: tx, @@ -862,7 +865,7 @@ impl Project { hosted_project_id: None, dev_server_project_id: None, search_history: Self::new_search_history(), - cached_shell_environments: HashMap::default(), + environment, remotely_created_buffers: Default::default(), } }) @@ -877,7 +880,7 @@ impl Project { fs: Arc, cx: &mut AppContext, ) -> Model { - let this = Self::local(client, node, user_store, languages, fs, cx); + let this = Self::local(client, node, user_store, languages, fs, None, cx); this.update(cx, |this, cx| { let buffer_store = this.buffer_store.downgrade(); @@ -1057,7 +1060,7 @@ impl Project { .dev_server_project_id .map(|dev_server_project_id| DevServerProjectId(dev_server_project_id)), search_history: Self::new_search_history(), - cached_shell_environments: HashMap::default(), + environment: ProjectEnvironment::new(None, cx), remotely_created_buffers: Arc::new(Mutex::new(RemotelyCreatedBuffers::default())), }; this.set_role(role, cx); @@ -1190,6 +1193,7 @@ impl Project { user_store, Arc::new(languages), fs, + None, cx, ) }) @@ -1229,6 +1233,7 @@ impl Project { user_store, Arc::new(languages), fs, + None, cx, ) }); @@ -1241,11 +1246,10 @@ impl Project { .unwrap(); project.update(cx, |project, cx| { - let tree_id = tree.read(cx).id(); // In tests we always populate the environment to be empty so we don't run the shell - project - .cached_shell_environments - .insert(tree_id, HashMap::default()); + let tree_id = tree.read(cx).id(); + project.environment = + ProjectEnvironment::test(&[(tree_id, HashMap::default())], cx); }); tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete()) @@ -1380,6 +1384,10 @@ impl Project { self.buffer_store.read(cx).buffers().collect() } + pub fn cli_environment(&self, cx: &AppContext) -> Option> { + self.environment.read(cx).get_cli_environment() + } + #[cfg(any(test, feature = "test-support"))] pub fn has_open_buffer(&self, path: impl Into, cx: &AppContext) -> bool { self.buffer_store @@ -3044,12 +3052,14 @@ impl Project { let stderr_capture = Arc::new(Mutex::new(Some(String::new()))); let lsp_adapter_delegate = ProjectLspAdapterDelegate::new(self, worktree_handle, cx); + let cli_environment = self.environment.read(cx).get_cli_environment(); let pending_server = match self.languages.create_pending_language_server( stderr_capture.clone(), language.clone(), adapter.clone(), Arc::clone(&worktree_path), lsp_adapter_delegate.clone(), + cli_environment, cx, ) { Some(pending_server) => pending_server, @@ -7918,7 +7928,9 @@ impl Project { } self.diagnostics.remove(&id_to_remove); self.diagnostic_summaries.remove(&id_to_remove); - self.cached_shell_environments.remove(&id_to_remove); + self.environment.update(cx, |environment, _| { + environment.remove_worktree_environment(id_to_remove); + }); let mut servers_to_remove = HashMap::default(); let mut servers_to_preserve = HashSet::default(); @@ -10281,16 +10293,16 @@ impl Project { cx: &mut ModelContext<'_, Project>, ) -> Task> { if self.is_local_or_ssh() { - let (worktree_id, cwd) = if let Some(worktree) = self.task_worktree(cx) { + let (worktree_id, worktree_abs_path) = if let Some(worktree) = self.task_worktree(cx) { ( Some(worktree.read(cx).id()), - Some(self.task_cwd(worktree, cx)), + Some(worktree.read(cx).abs_path()), ) } else { (None, None) }; - cx.spawn(|project, cx| async move { + cx.spawn(|project, mut cx| async move { let mut task_variables = cx .update(|cx| { combine_task_variables( @@ -10306,17 +10318,19 @@ impl Project { // Remove all custom entries starting with _, as they're not intended for use by the end user. task_variables.sweep(); - let mut project_env = None; - if let Some((worktree_id, cwd)) = worktree_id.zip(cwd.as_ref()) { - let env = Self::get_worktree_shell_env(project, worktree_id, cwd, cx).await; - if let Some(env) = env { - project_env.replace(env); - } - }; + let project_env = project + .update(&mut cx, |project, cx| { + let worktree_abs_path = worktree_abs_path.clone(); + project.environment.update(cx, |environment, cx| { + environment.get_environment(worktree_id, worktree_abs_path, cx) + }) + }) + .ok()? + .await; Some(TaskContext { project_env: project_env.unwrap_or_default(), - cwd, + cwd: worktree_abs_path.map(|p| p.to_path_buf()), task_variables, }) }) @@ -10357,50 +10371,6 @@ impl Project { } } - async fn get_worktree_shell_env( - this: WeakModel, - worktree_id: WorktreeId, - cwd: &PathBuf, - mut cx: AsyncAppContext, - ) -> Option> { - let cached_env = this - .update(&mut cx, |project, _| { - project.cached_shell_environments.get(&worktree_id).cloned() - }) - .ok()?; - - if let Some(env) = cached_env { - Some(env) - } else { - let load_direnv = this - .update(&mut cx, |_, cx| { - ProjectSettings::get_global(cx).load_direnv.clone() - }) - .ok()?; - - let shell_env = cx - .background_executor() - .spawn({ - let cwd = cwd.clone(); - async move { - load_shell_environment(&cwd, &load_direnv) - .await - .unwrap_or_default() - } - }) - .await; - - this.update(&mut cx, |project, _| { - project - .cached_shell_environments - .insert(worktree_id, shell_env.clone()); - }) - .ok()?; - - Some(shell_env) - } - } - pub fn task_templates( &self, worktree: Option, @@ -10549,10 +10519,6 @@ impl Project { }), } } - - fn task_cwd(&self, worktree: Model, cx: &AppContext) -> PathBuf { - worktree.read(cx).abs_path().to_path_buf() - } } fn combine_task_variables( @@ -10850,39 +10816,30 @@ pub struct ProjectLspAdapterDelegate { fs: Arc, http_client: Arc, language_registry: Arc, - shell_env: Mutex>>, - load_direnv: DirenvSettings, + load_shell_env_task: Shared>>>, } impl ProjectLspAdapterDelegate { pub fn new( project: &Project, worktree: &Model, - cx: &ModelContext, + cx: &mut ModelContext, ) -> Arc { - let load_direnv = ProjectSettings::get_global(cx).load_direnv.clone(); + let worktree_id = worktree.read(cx).id(); + let worktree_abs_path = worktree.read(cx).abs_path(); + let load_shell_env_task = project.environment.update(cx, |env, cx| { + env.get_environment(Some(worktree_id), Some(worktree_abs_path), cx) + }); + Arc::new(Self { project: cx.weak_model(), worktree: worktree.read(cx).snapshot(), fs: project.fs.clone(), http_client: project.client.http_client(), language_registry: project.languages.clone(), - shell_env: Default::default(), - load_direnv, + load_shell_env_task, }) } - - async fn load_shell_env(&self) { - let worktree_abs_path = self.worktree.abs_path(); - let shell_env = load_shell_environment(&worktree_abs_path, &self.load_direnv) - .await - .with_context(|| { - format!("failed to determine load login shell environment in {worktree_abs_path:?}") - }) - .log_err() - .unwrap_or_default(); - *self.shell_env.lock() = Some(shell_env); - } } #[async_trait] @@ -10906,19 +10863,14 @@ impl LspAdapterDelegate for ProjectLspAdapterDelegate { } async fn shell_env(&self) -> HashMap { - self.load_shell_env().await; - self.shell_env.lock().as_ref().cloned().unwrap_or_default() + let task = self.load_shell_env_task.clone(); + task.await.unwrap_or_default() } #[cfg(not(target_os = "windows"))] async fn which(&self, command: &OsStr) -> Option { let worktree_abs_path = self.worktree.abs_path(); - self.load_shell_env().await; - let shell_path = self - .shell_env - .lock() - .as_ref() - .and_then(|shell_env| shell_env.get("PATH").cloned()); + let shell_path = self.shell_env().await.get("PATH").cloned(); which::which_in(command, shell_path.as_ref(), &worktree_abs_path).ok() } @@ -11082,115 +11034,6 @@ fn include_text(server: &lsp::LanguageServer) -> Option { } } -async fn load_direnv_environment(dir: &Path) -> Result>> { - let Ok(direnv_path) = which::which("direnv") else { - return Ok(None); - }; - - let direnv_output = smol::process::Command::new(direnv_path) - .args(["export", "json"]) - .current_dir(dir) - .output() - .await - .context("failed to spawn direnv to get local environment variables")?; - - anyhow::ensure!( - direnv_output.status.success(), - "direnv exited with error {:?}", - direnv_output.status - ); - - let output = String::from_utf8_lossy(&direnv_output.stdout); - if output.is_empty() { - return Ok(None); - } - - Ok(Some( - serde_json::from_str(&output).context("failed to parse direnv output")?, - )) -} - -async fn load_shell_environment( - dir: &Path, - load_direnv: &DirenvSettings, -) -> Result> { - let direnv_environment = match load_direnv { - DirenvSettings::ShellHook => None, - DirenvSettings::Direct => load_direnv_environment(dir).await?, - } - .unwrap_or(HashMap::default()); - - let marker = "ZED_SHELL_START"; - let shell = env::var("SHELL").context( - "SHELL environment variable is not assigned so we can't source login environment variables", - )?; - - // What we're doing here is to spawn a shell and then `cd` into - // the project directory to get the env in there as if the user - // `cd`'d into it. We do that because tools like direnv, asdf, ... - // hook into `cd` and only set up the env after that. - // - // If the user selects `Direct` for direnv, it would set an environment - // variable that later uses to know that it should not run the hook. - // We would include in `.envs` call so it is okay to run the hook - // even if direnv direct mode is enabled. - // - // In certain shells we need to execute additional_command in order to - // trigger the behavior of direnv, etc. - // - // - // The `exit 0` is the result of hours of debugging, trying to find out - // why running this command here, without `exit 0`, would mess - // up signal process for our process so that `ctrl-c` doesn't work - // anymore. - // - // We still don't know why `$SHELL -l -i -c '/usr/bin/env -0'` would - // do that, but it does, and `exit 0` helps. - let additional_command = PathBuf::from(&shell) - .file_name() - .and_then(|f| f.to_str()) - .and_then(|shell| match shell { - "fish" => Some("emit fish_prompt;"), - _ => None, - }); - - let command = format!( - "cd '{}';{} printf '%s' {marker}; /usr/bin/env; exit 0;", - dir.display(), - additional_command.unwrap_or("") - ); - - let output = smol::process::Command::new(&shell) - .args(["-i", "-c", &command]) - .envs(direnv_environment) - .output() - .await - .context("failed to spawn login shell to source login environment variables")?; - - anyhow::ensure!( - output.status.success(), - "login shell exited with error {:?}", - output.status - ); - - let stdout = String::from_utf8_lossy(&output.stdout); - let env_output_start = stdout.find(marker).ok_or_else(|| { - anyhow!( - "failed to parse output of `env` command in login shell: {}", - stdout - ) - })?; - - let mut parsed_env = HashMap::default(); - let env_output = &stdout[env_output_start + marker.len()..]; - - parse_env_output(env_output, |key, value| { - parsed_env.insert(key, value); - }); - - Ok(parsed_env) -} - fn remove_empty_hover_blocks(mut hover: Hover) -> Option { hover .contents diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index bdf32b3221149ced417082dabe1880e4c3d09825..125c33095d4031383323f070e840b5ca6e3a2340 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -112,7 +112,15 @@ impl Project { let (completion_tx, completion_rx) = bounded(1); - let mut env = settings.env.clone(); + // Start with the environment that we might have inherited from the Zed CLI. + let mut env = self + .environment + .read(cx) + .get_cli_environment() + .unwrap_or_default(); + // Then extend it with the explicit env variables from the settings, so they take + // precedence. + env.extend(settings.env.clone()); let local_path = if ssh_command.is_none() { path.clone() diff --git a/crates/recent_projects/src/ssh_connections.rs b/crates/recent_projects/src/ssh_connections.rs index 3057096738ddb574ba1f6c2a4e46dd963f04a17e..7e573c4c555159125ceadf593f8d74633c0005c6 100644 --- a/crates/recent_projects/src/ssh_connections.rs +++ b/crates/recent_projects/src/ssh_connections.rs @@ -358,6 +358,7 @@ pub async fn open_ssh_project( app_state.user_store.clone(), app_state.languages.clone(), app_state.fs.clone(), + None, cx, ); cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx)) diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 7ef481d79c66507e0da869cbff264ab8a4b17025..0ace5bb7dbaaf49ceb1497e98191da404b9c06b1 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -17,7 +17,7 @@ use alacritty_terminal::{ search::{Match, RegexIter, RegexSearch}, Config, RenderableCursor, TermMode, }, - tty::{self, setup_env}, + tty::{self}, vte::ansi::{ClearMode, Handler, NamedPrivateMode, PrivateMode}, Term, }; @@ -350,8 +350,8 @@ impl TerminalBuilder { } }; - // Setup Alacritty's env - setup_env(); + // Setup Alacritty's env, which modifies the current process's environment + alacritty_terminal::tty::setup_env(); let scrolling_history = if task.is_some() { // Tasks like `cargo build --all` may produce a lot of output, ergo allow maximum scrolling. diff --git a/crates/welcome/src/welcome.rs b/crates/welcome/src/welcome.rs index cba91add011488bae917d858648ed7704edbdcc4..fc837c68671a31d918a0318dd98136e3b61c0395 100644 --- a/crates/welcome/src/welcome.rs +++ b/crates/welcome/src/welcome.rs @@ -46,7 +46,7 @@ pub fn show_welcome_view( app_state: Arc, cx: &mut AppContext, ) -> Task> { - open_new(app_state, cx, |workspace, cx| { + open_new(Default::default(), app_state, cx, |workspace, cx| { workspace.toggle_dock(DockPosition::Left, cx); let welcome_page = WelcomePage::new(workspace, cx); workspace.add_item_to_center(Box::new(welcome_page.clone()), cx); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 26d5207c7e5eeced97dc0640627ec7ba4f43393d..92014d624227f9e5e9cfe40fb3c28d56210ccf2c 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1061,6 +1061,7 @@ impl Workspace { abs_paths: Vec, app_state: Arc, requesting_window: Option>, + env: Option>, cx: &mut AppContext, ) -> Task< anyhow::Result<( @@ -1074,6 +1075,7 @@ impl Workspace { app_state.user_store.clone(), app_state.languages.clone(), app_state.fs.clone(), + env, cx, ); @@ -1579,7 +1581,8 @@ impl Workspace { if self.project.read(cx).is_local_or_ssh() { Task::Ready(Some(Ok(callback(self, cx)))) } else { - let task = Self::new_local(Vec::new(), self.app_state.clone(), None, cx); + let env = self.project.read(cx).cli_environment(cx); + let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, cx); cx.spawn(|_vh, mut cx| async move { let (workspace, _) = task.await?; workspace.update(&mut cx, callback) @@ -5205,7 +5208,7 @@ pub fn join_channel( // no open workspaces, make one to show the error in (blergh) let (window_handle, _) = cx .update(|cx| { - Workspace::new_local(vec![], app_state.clone(), requesting_window, cx) + Workspace::new_local(vec![], app_state.clone(), requesting_window, None, cx) })? .await?; @@ -5263,7 +5266,7 @@ pub async fn get_any_active_workspace( // find an existing workspace to focus and show call controls let active_window = activate_any_workspace_window(&mut cx); if active_window.is_none() { - cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, cx))? + cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, None, cx))? .await?; } activate_any_workspace_window(&mut cx).context("could not open zed") @@ -5308,6 +5311,7 @@ pub fn local_workspace_windows(cx: &AppContext) -> Vec> pub struct OpenOptions { pub open_new_workspace: Option, pub replace_window: Option>, + pub env: Option>, } #[allow(clippy::type_complexity)] @@ -5385,6 +5389,7 @@ pub fn open_paths( abs_paths, app_state.clone(), open_options.replace_window, + open_options.env, cx, ) })? @@ -5394,11 +5399,12 @@ pub fn open_paths( } pub fn open_new( + open_options: OpenOptions, app_state: Arc, cx: &mut AppContext, init: impl FnOnce(&mut Workspace, &mut ViewContext) + 'static + Send, ) -> Task> { - let task = Workspace::new_local(Vec::new(), app_state, None, cx); + let task = Workspace::new_local(Vec::new(), app_state, None, open_options.env, cx); cx.spawn(|mut cx| async move { let (workspace, opened_paths) = task.await?; workspace.update(&mut cx, |workspace, cx| { diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 826e154ef9179ffd5736777af9df862befe25215..0081f23329a3535caef4fb5e7919c4fc6a503d81 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -784,7 +784,7 @@ async fn restore_or_create_workspace( cx.update(|cx| show_welcome_view(app_state, cx))?.await?; } else { cx.update(|cx| { - workspace::open_new(app_state, cx, |workspace, cx| { + workspace::open_new(Default::default(), app_state, cx, |workspace, cx| { Editor::new_file(workspace, &Default::default(), cx) }) })? diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index ff50a0766fde56d34df1f0db02c8b814da29c1d0..ce56c5e752f498016f1de419a07324b8ef6aeb63 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -525,7 +525,7 @@ pub fn initialize_workspace( let app_state = Arc::downgrade(&app_state); move |_, _: &NewWindow, cx| { if let Some(app_state) = app_state.upgrade() { - open_new(app_state, cx, |workspace, cx| { + open_new(Default::default(), app_state, cx, |workspace, cx| { Editor::new_file(workspace, &Default::default(), cx) }) .detach(); @@ -536,7 +536,7 @@ pub fn initialize_workspace( let app_state = Arc::downgrade(&app_state); move |_, _: &NewFile, cx| { if let Some(app_state) = app_state.upgrade() { - open_new(app_state, cx, |workspace, cx| { + open_new(Default::default(), app_state, cx, |workspace, cx| { Editor::new_file(workspace, &Default::default(), cx) }) .detach(); @@ -1592,9 +1592,12 @@ mod tests { async fn test_new_empty_workspace(cx: &mut TestAppContext) { let app_state = init_test(cx); cx.update(|cx| { - open_new(app_state.clone(), cx, |workspace, cx| { - Editor::new_file(workspace, &Default::default(), cx) - }) + open_new( + Default::default(), + app_state.clone(), + cx, + |workspace, cx| Editor::new_file(workspace, &Default::default(), cx), + ) }) .await .unwrap(); diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index a9c741f7a0476431a1dccfbb552252d14a6b2528..cbc322c4a25fee33149a009715fdec4f68eda20c 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -22,7 +22,7 @@ use util::paths::PathWithPosition; use util::ResultExt; use welcome::{show_welcome_view, FIRST_OPEN}; use workspace::item::ItemHandle; -use workspace::{AppState, Workspace}; +use workspace::{AppState, OpenOptions, Workspace}; #[derive(Default, Debug)] pub struct OpenRequest { @@ -257,6 +257,7 @@ pub async fn handle_cli_connection( wait, open_new_workspace, dev_server_token, + env, } => { if let Some(dev_server_token) = dev_server_token { match cx @@ -332,6 +333,7 @@ pub async fn handle_cli_connection( &responses, wait, app_state.clone(), + env, &mut cx, ) .await; @@ -349,6 +351,7 @@ async fn open_workspaces( responses: &IpcSender, wait: bool, app_state: Arc, + env: Option>, mut cx: &mut AsyncAppContext, ) -> Result<()> { let grouped_paths = if paths.is_empty() { @@ -397,7 +400,11 @@ async fn open_workspaces( // If not the first launch, show an empty window with empty editor else { cx.update(|cx| { - workspace::open_new(app_state, cx, |workspace, cx| { + let open_options = OpenOptions { + env, + ..Default::default() + }; + workspace::open_new(open_options, app_state, cx, |workspace, cx| { Editor::new_file(workspace, &Default::default(), cx) }) .detach(); @@ -414,6 +421,7 @@ async fn open_workspaces( open_new_workspace, wait, responses, + env.as_ref(), &app_state, &mut cx, ) @@ -437,6 +445,7 @@ async fn open_workspace( open_new_workspace: Option, wait: bool, responses: &IpcSender, + env: Option<&HashMap>, app_state: &Arc, cx: &mut AsyncAppContext, ) -> bool { @@ -447,6 +456,7 @@ async fn open_workspace( app_state.clone(), workspace::OpenOptions { open_new_workspace, + env: env.cloned(), ..Default::default() }, cx, @@ -669,6 +679,7 @@ mod tests { open_new_workspace, false, &response_tx, + None, &app_state, &mut cx, ) diff --git a/docs/src/environment.md b/docs/src/environment.md new file mode 100644 index 0000000000000000000000000000000000000000..783311cace7e8a8cfcd76d41b247127d1e77319f --- /dev/null +++ b/docs/src/environment.md @@ -0,0 +1,92 @@ +# Environment Variables + +_**Note**: The following only applies to Zed 0.152.0 and later._ + +Multiple features in Zed are affected by environment variables: + +- Tasks +- Built-in terminal +- Look-up of language servers +- Language servers + +In order to make the best use of these features, it's helpful to understand where Zed gets its environment variables from and how they're used. + +## Where does Zed get its environment variables from? + +How Zed was started — whether it's icon was clicked in the macOS Dock or in a Linux window manager, or whether it was started via the CLI `zed` that comes with Zed — influences which environment variables Zed can use. + +### Launched from the CLI + +If Zed is opened via the CLI (`zed`), it will inherit the environment variables from the surrounding shell session. + +That means if you do + +``` +$ export MY_ENV_VAR=hello +$ zed . +``` + +the environment variable `MY_ENV_VAR` is now available inside Zed. For example, in the built-in terminal. + +Starting with Zed 0.152.0, the CLI `zed` will _always_ pass along its environment to Zed, regardless of whether a Zed instance was previously running or not. Prior to Zed 0.152.0 this was not the case and only the first Zed instance would inherit the environment variables. + +### Launched via window manager, Dock, or launcher + +When Zed has been launched via the macOS Dock, or a GNOME or KDE icon on Linux, or an application launcher like Alfred or Raycats, it has no surrounding shell environment from which to inherit its environment variables. + +In order to still have a useful environment, Zed spawns a login shell in the user's home directory and gets its environment. This environment is then set on the Zed _process_. That means all Zed windows and projects will inherit that home directory environment. + +Since that can lead to problems for users that require different environment variables for a project (because they use `direnv`, or `asdf`, or `mise`, ... in that project), when opening project, Zed spawns another login shell. This time in the project's directory. The environment from that login shell is _not_ set on the process (because that would mean opening a new project changes the environment for all Zed windows). Instead, the environment is stored and passed along when running tasks, opening terminals, or spawning language servers. + +## Where and how are environment variables used? + +There are two sets of environment variables: + +1. Environment variables of the Zed process +2. Environment variables stored per project + +The variables from (1) are always used, since they are stored on the process itself and every spawned process (tasks, terminals, language servers, ...) will inherit them by default. + +The variables from (2) are used explicitly, depending on the feature. + +### Tasks + +Tasks are spawned with an combined environment. In order of precedence (low to high, with the last overwriting the first): + +- the Zed process environment +- if the project was opened from the CLI: the CLI environment +- if the project was not opened from the CLI: the project environment variables obtained by running a login shell in the project's root folder +- optional, explicitly configured environment in settings + +### Built-in terminal + +Built-in terminals, like tasks, are spawned with an combined environment. In order of precedence (low to high): + +- the Zed process environment +- if the project was opened from the CLI: the CLI environment +- if the project was not opened from the CLI: the project environment variables obtained by running a login shell in the project's root folder +- optional, explicitly configured environment in settings + +### Look-up of language servers + +For some languages the language server adapters lookup the binary in the user's `$PATH`. Examples: + +- Go +- Zig +- Rust (if [configured to do so](./languages/rust.md#binary)) +- C +- TypeScript + +For this look-up, Zed uses the following the environment: + +- if the project was opened from the CLI: the CLI environment +- if the project was not opened from the CLI: the project environment variables obtained by running a login shell in the project's root folder + +### Language servers + +After looking up a language server, Zed starts them. + +These language server processes always inherit Zed's process environment. But, depending on the language server look-up, additional environment variables might be set or overwrite the process environment. + +- If the language server was found in the project environment's `$PATH`, then the project environment's is passed along to the language server process. Where the project environment comes from depends on how the project was opened, via CLI or not. See previous point on look-up of language servers. +- If the language servers was not found in the project environment, Zed tries to install it globally and start it globally. In that case, the process will inherit Zed's process environment, and — if the project was opened via ClI — from the CLI.