zed: Use CLI env for lang servers, tasks, terminal (#17075)

This changes the Zed CLI `zed` to pass along the environment to the Zed
project that it opens (if it opens a new one).

In projects, this CLI environment will now take precedence over any
environment that's acquired by running a login shell in a projects
folder.

The result is that `zed my/folder` now always behaves as if one would
run `zed --foreground` without any previous Zed version running.


Closes #7894
Closes #16293 

Related issues:
- It fixes the issue described in here:
https://github.com/zed-industries/zed/issues/4977#issuecomment-2305272027


Release Notes:

- Improved the Zed CLI `zed` to pass along the environment as it was on
the CLI to the opened Zed project. That environment is then used when
opening new terminals, spawning tasks, or language servers.
Specifically:
- If Zed was started via `zed my-folder`, a terminal spawned with
`workspace: new terminal` will inherit these environment variables that
existed on the CLI
- Specific language servers that allow looking up the language server
binary in the environments `$PATH` (such as `gopls`, `zls`,
`rust-analyzer` if configured, ...) will look up the language server
binary in the CLI environment too and use that environment when starting
the process.
- Language servers that are _not_ found in the CLI environment (or
configured to not be found in there), will be spawned with the CLI
environment in case that's set. That means users can do something like
`RA_LOG=info zed .` and it will be picked up the rust-analyzer that was
spawned.

Demo/explanation:



https://github.com/user-attachments/assets/455905cc-8b7c-4fc4-b98a-7e027d97cdfa
This commit is contained in:
Thorsten Ball 2024-08-29 18:09:06 +02:00 committed by GitHub
parent 4f408ec65a
commit fc4c533d0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 484 additions and 227 deletions

1
Cargo.lock generated
View File

@ -2278,6 +2278,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"collections",
"core-foundation 0.9.4",
"core-services",
"exec",

View File

@ -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

View File

@ -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<bool>,
dev_server_token: Option<String>,
env: Option<HashMap<String, String>>,
},
}

View File

@ -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::<HashMap<_, _>>());
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() {

View File

@ -916,6 +916,7 @@ impl TestClient {
self.app_state.user_store.clone(),
self.app_state.languages.clone(),
self.app_state.fs.clone(),
None,
cx,
)
})

View File

@ -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();

View File

@ -244,6 +244,7 @@ impl DevServer {
this.app_state.user_store.clone(),
this.app_state.languages.clone(),
this.app_state.fs.clone(),
None,
cx,
);

View File

@ -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<Self>,
stderr_capture: Arc<Mutex<Option<String>>>,
@ -726,6 +727,7 @@ impl LanguageRegistry {
adapter: Arc<CachedLspAdapter>,
root_path: Arc<Path>,
delegate: Arc<dyn LspAdapterDelegate>,
cli_environment: Option<HashMap<String, String>>,
cx: &mut AppContext,
) -> Option<PendingLanguageServer> {
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()

View File

@ -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<HashMap<String, String>>,
get_environment_task: Option<Shared<Task<Option<HashMap<String, String>>>>>,
cached_shell_environments: HashMap<WorktreeId, HashMap<String, String>>,
}
impl ProjectEnvironment {
pub(crate) fn new(
cli_environment: Option<HashMap<String, String>>,
cx: &mut AppContext,
) -> Model<Self> {
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<String, String>)],
cx: &mut AppContext,
) -> Model<Self> {
cx.new_model(|_| Self {
cli_environment: None,
get_environment_task: None,
cached_shell_environments: shell_environments
.iter()
.cloned()
.collect::<HashMap<_, _>>(),
})
}
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<HashMap<String, String>> {
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<WorktreeId>,
worktree_abs_path: Option<Arc<Path>>,
cx: &ModelContext<Self>,
) -> Shared<Task<Option<HashMap<String, String>>>> {
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<WorktreeId>,
worktree_abs_path: Option<Arc<Path>>,
cx: &ModelContext<Self>,
) -> Task<Option<HashMap<String, String>>> {
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<Path>,
cx: &ModelContext<Self>,
) -> Task<Option<HashMap<String, String>>> {
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<String, String>, 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<String> 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<HashMap<String, String>> {
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<Option<HashMap<String, String>>> {
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")?,
))
}

View File

@ -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<SnippetProvider>,
yarn: Model<YarnPathStore>,
cached_shell_environments: HashMap<WorktreeId, HashMap<String, String>>,
environment: Model<ProjectEnvironment>,
}
#[derive(Default)]
@ -787,6 +788,7 @@ impl Project {
user_store: Model<UserStore>,
languages: Arc<LanguageRegistry>,
fs: Arc<dyn Fs>,
env: Option<HashMap<String, String>>,
cx: &mut AppContext,
) -> Model<Self> {
cx.new_model(|cx: &mut ModelContext<Self>| {
@ -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<dyn Fs>,
cx: &mut AppContext,
) -> Model<Self> {
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<HashMap<String, String>> {
self.environment.read(cx).get_cli_environment()
}
#[cfg(any(test, feature = "test-support"))]
pub fn has_open_buffer(&self, path: impl Into<ProjectPath>, 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<Option<TaskContext>> {
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<Self>,
worktree_id: WorktreeId,
cwd: &PathBuf,
mut cx: AsyncAppContext,
) -> Option<HashMap<String, String>> {
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<WorktreeId>,
@ -10549,10 +10519,6 @@ impl Project {
}),
}
}
fn task_cwd(&self, worktree: Model<Worktree>, cx: &AppContext) -> PathBuf {
worktree.read(cx).abs_path().to_path_buf()
}
}
fn combine_task_variables(
@ -10850,39 +10816,30 @@ pub struct ProjectLspAdapterDelegate {
fs: Arc<dyn Fs>,
http_client: Arc<dyn HttpClient>,
language_registry: Arc<LanguageRegistry>,
shell_env: Mutex<Option<HashMap<String, String>>>,
load_direnv: DirenvSettings,
load_shell_env_task: Shared<Task<Option<HashMap<String, String>>>>,
}
impl ProjectLspAdapterDelegate {
pub fn new(
project: &Project,
worktree: &Model<Worktree>,
cx: &ModelContext<Project>,
cx: &mut ModelContext<Project>,
) -> Arc<Self> {
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<String, String> {
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<PathBuf> {
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<bool> {
}
}
async fn load_direnv_environment(dir: &Path) -> Result<Option<HashMap<String, String>>> {
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<HashMap<String, String>> {
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> {
hover
.contents

View File

@ -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()

View File

@ -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))

View File

@ -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.

View File

@ -46,7 +46,7 @@ pub fn show_welcome_view(
app_state: Arc<AppState>,
cx: &mut AppContext,
) -> Task<anyhow::Result<()>> {
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);

View File

@ -1061,6 +1061,7 @@ impl Workspace {
abs_paths: Vec<PathBuf>,
app_state: Arc<AppState>,
requesting_window: Option<WindowHandle<Workspace>>,
env: Option<HashMap<String, String>>,
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<WindowHandle<Workspace>>
pub struct OpenOptions {
pub open_new_workspace: Option<bool>,
pub replace_window: Option<WindowHandle<Workspace>>,
pub env: Option<HashMap<String, String>>,
}
#[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<AppState>,
cx: &mut AppContext,
init: impl FnOnce(&mut Workspace, &mut ViewContext<Workspace>) + 'static + Send,
) -> Task<anyhow::Result<()>> {
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| {

View File

@ -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)
})
})?

View File

@ -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();

View File

@ -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<CliResponse>,
wait: bool,
app_state: Arc<AppState>,
env: Option<collections::HashMap<String, String>>,
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<bool>,
wait: bool,
responses: &IpcSender<CliResponse>,
env: Option<&HashMap<String, String>>,
app_state: &Arc<AppState>,
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,
)

92
docs/src/environment.md Normal file
View File

@ -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.