environment.rs

  1use anyhow::{Context as _, bail};
  2use futures::{FutureExt, StreamExt as _, channel::mpsc, future::Shared};
  3use language::Buffer;
  4use remote::RemoteClient;
  5use rpc::proto::{self, REMOTE_SERVER_PROJECT_ID};
  6use std::{collections::VecDeque, path::Path, sync::Arc};
  7use task::{Shell, shell_to_proto};
  8use terminal::terminal_settings::TerminalSettings;
  9use util::{ResultExt, command::new_command, rel_path::RelPath};
 10use worktree::Worktree;
 11
 12use collections::HashMap;
 13use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task, WeakEntity};
 14use settings::Settings as _;
 15
 16use crate::{
 17    project_settings::{DirenvSettings, ProjectSettings},
 18    worktree_store::WorktreeStore,
 19};
 20
 21pub struct ProjectEnvironment {
 22    cli_environment: Option<HashMap<String, String>>,
 23    local_environments: HashMap<(Shell, Arc<Path>), Shared<Task<Option<HashMap<String, String>>>>>,
 24    remote_environments: HashMap<(Shell, Arc<Path>), Shared<Task<Option<HashMap<String, String>>>>>,
 25    environment_error_messages: VecDeque<String>,
 26    environment_error_messages_tx: mpsc::UnboundedSender<String>,
 27    worktree_store: WeakEntity<WorktreeStore>,
 28    remote_client: Option<WeakEntity<RemoteClient>>,
 29    is_remote_project: bool,
 30    _tasks: Vec<Task<()>>,
 31}
 32
 33pub enum ProjectEnvironmentEvent {
 34    ErrorsUpdated,
 35}
 36
 37impl EventEmitter<ProjectEnvironmentEvent> for ProjectEnvironment {}
 38
 39impl ProjectEnvironment {
 40    pub fn new(
 41        cli_environment: Option<HashMap<String, String>>,
 42        worktree_store: WeakEntity<WorktreeStore>,
 43        remote_client: Option<WeakEntity<RemoteClient>>,
 44        is_remote_project: bool,
 45        cx: &mut Context<Self>,
 46    ) -> Self {
 47        let (tx, mut rx) = mpsc::unbounded();
 48        let task = cx.spawn(async move |this, cx| {
 49            while let Some(message) = rx.next().await {
 50                this.update(cx, |this, cx| {
 51                    this.environment_error_messages.push_back(message);
 52                    cx.emit(ProjectEnvironmentEvent::ErrorsUpdated);
 53                })
 54                .ok();
 55            }
 56        });
 57        Self {
 58            cli_environment,
 59            local_environments: Default::default(),
 60            remote_environments: Default::default(),
 61            environment_error_messages: Default::default(),
 62            environment_error_messages_tx: tx,
 63            worktree_store,
 64            remote_client,
 65            is_remote_project,
 66            _tasks: vec![task],
 67        }
 68    }
 69
 70    /// Returns the inherited CLI environment, if this project was opened from the Zed CLI.
 71    pub(crate) fn get_cli_environment(&self) -> Option<HashMap<String, String>> {
 72        if cfg!(any(test, feature = "test-support")) {
 73            return Some(HashMap::default());
 74        }
 75        if let Some(mut env) = self.cli_environment.clone() {
 76            set_origin_marker(&mut env, EnvironmentOrigin::Cli);
 77            Some(env)
 78        } else {
 79            None
 80        }
 81    }
 82
 83    pub fn buffer_environment(
 84        &mut self,
 85        buffer: &Entity<Buffer>,
 86        worktree_store: &Entity<WorktreeStore>,
 87        cx: &mut Context<Self>,
 88    ) -> Shared<Task<Option<HashMap<String, String>>>> {
 89        if let Some(cli_environment) = self.get_cli_environment() {
 90            log::debug!("using project environment variables from CLI");
 91            return Task::ready(Some(cli_environment)).shared();
 92        }
 93
 94        let Some(worktree) = buffer
 95            .read(cx)
 96            .file()
 97            .map(|f| f.worktree_id(cx))
 98            .and_then(|worktree_id| worktree_store.read(cx).worktree_for_id(worktree_id, cx))
 99        else {
100            return Task::ready(None).shared();
101        };
102        self.worktree_environment(worktree, cx)
103    }
104
105    pub fn worktree_environment(
106        &mut self,
107        worktree: Entity<Worktree>,
108        cx: &mut App,
109    ) -> Shared<Task<Option<HashMap<String, String>>>> {
110        if let Some(cli_environment) = self.get_cli_environment() {
111            log::debug!("using project environment variables from CLI");
112            return Task::ready(Some(cli_environment)).shared();
113        }
114
115        let worktree = worktree.read(cx);
116        let mut abs_path = worktree.abs_path();
117        if worktree.is_single_file() {
118            let Some(parent) = abs_path.parent() else {
119                return Task::ready(None).shared();
120            };
121            abs_path = parent.into();
122        }
123
124        let remote_client = self.remote_client.as_ref().and_then(|it| it.upgrade());
125        match remote_client {
126            Some(remote_client) => remote_client.clone().read(cx).shell().map(|shell| {
127                self.remote_directory_environment(
128                    &Shell::Program(shell),
129                    abs_path,
130                    remote_client,
131                    cx,
132                )
133            }),
134            None if self.is_remote_project => {
135                Some(self.local_directory_environment(&Shell::System, abs_path, cx))
136            }
137            None => Some({
138                let shell = TerminalSettings::get(
139                    Some(settings::SettingsLocation {
140                        worktree_id: worktree.id(),
141                        path: RelPath::empty(),
142                    }),
143                    cx,
144                )
145                .shell
146                .clone();
147
148                self.local_directory_environment(&shell, abs_path, cx)
149            }),
150        }
151        .unwrap_or_else(|| Task::ready(None).shared())
152    }
153
154    pub fn directory_environment(
155        &mut self,
156        abs_path: Arc<Path>,
157        cx: &mut App,
158    ) -> Shared<Task<Option<HashMap<String, String>>>> {
159        let remote_client = self.remote_client.as_ref().and_then(|it| it.upgrade());
160        match remote_client {
161            Some(remote_client) => remote_client.clone().read(cx).shell().map(|shell| {
162                self.remote_directory_environment(
163                    &Shell::Program(shell),
164                    abs_path,
165                    remote_client,
166                    cx,
167                )
168            }),
169            None if self.is_remote_project => {
170                Some(self.local_directory_environment(&Shell::System, abs_path, cx))
171            }
172            None => self
173                .worktree_store
174                .read_with(cx, |worktree_store, cx| {
175                    worktree_store.find_worktree(&abs_path, cx)
176                })
177                .ok()
178                .map(|worktree| {
179                    let shell = terminal::terminal_settings::TerminalSettings::get(
180                        worktree
181                            .as_ref()
182                            .map(|(worktree, path)| settings::SettingsLocation {
183                                worktree_id: worktree.read(cx).id(),
184                                path: &path,
185                            }),
186                        cx,
187                    )
188                    .shell
189                    .clone();
190
191                    self.local_directory_environment(&shell, abs_path, cx)
192                }),
193        }
194        .unwrap_or_else(|| Task::ready(None).shared())
195    }
196
197    /// Returns the project environment using the default worktree path.
198    /// This ensures that project-specific environment variables (e.g. from `.envrc`)
199    /// are loaded from the project directory rather than the home directory.
200    pub fn default_environment(
201        &mut self,
202        cx: &mut App,
203    ) -> Shared<Task<Option<HashMap<String, String>>>> {
204        let abs_path = self
205            .worktree_store
206            .read_with(cx, |worktree_store, cx| {
207                crate::Project::default_visible_worktree_paths(worktree_store, cx)
208                    .into_iter()
209                    .next()
210            })
211            .ok()
212            .flatten()
213            .map(|path| Arc::<Path>::from(path))
214            .unwrap_or_else(|| paths::home_dir().as_path().into());
215        self.local_directory_environment(&Shell::System, abs_path, cx)
216    }
217
218    /// Returns the project environment, if possible.
219    /// If the project was opened from the CLI, then the inherited CLI environment is returned.
220    /// If it wasn't opened from the CLI, and an absolute path is given, then a shell is spawned in
221    /// that directory, to get environment variables as if the user has `cd`'d there.
222    pub fn local_directory_environment(
223        &mut self,
224        shell: &Shell,
225        abs_path: Arc<Path>,
226        cx: &mut App,
227    ) -> Shared<Task<Option<HashMap<String, String>>>> {
228        if let Some(cli_environment) = self.get_cli_environment() {
229            log::debug!("using project environment variables from CLI");
230            return Task::ready(Some(cli_environment)).shared();
231        }
232
233        self.local_environments
234            .entry((shell.clone(), abs_path.clone()))
235            .or_insert_with(|| {
236                let load_direnv = ProjectSettings::get_global(cx).load_direnv.clone();
237                let shell = shell.clone();
238                let tx = self.environment_error_messages_tx.clone();
239                cx.spawn(async move |cx| {
240                    let mut shell_env = match cx
241                        .background_spawn(load_directory_shell_environment(
242                            shell,
243                            abs_path.clone(),
244                            load_direnv,
245                            tx,
246                        ))
247                        .await
248                    {
249                        Ok(shell_env) => Some(shell_env),
250                        Err(e) => {
251                            log::error!(
252                                "Failed to load shell environment for directory {abs_path:?}: {e:#}"
253                            );
254                            None
255                        }
256                    };
257
258                    if let Some(shell_env) = shell_env.as_mut() {
259                        let path = shell_env
260                            .get("PATH")
261                            .map(|path| path.as_str())
262                            .unwrap_or_default();
263                        log::debug!(
264                            "using project environment variables shell launched in {:?}. PATH={:?}",
265                            abs_path,
266                            path
267                        );
268
269                        set_origin_marker(shell_env, EnvironmentOrigin::WorktreeShell);
270                    }
271
272                    shell_env
273                })
274                .shared()
275            })
276            .clone()
277    }
278
279    pub fn remote_directory_environment(
280        &mut self,
281        shell: &Shell,
282        abs_path: Arc<Path>,
283        remote_client: Entity<RemoteClient>,
284        cx: &mut App,
285    ) -> Shared<Task<Option<HashMap<String, String>>>> {
286        if cfg!(any(test, feature = "test-support")) {
287            return Task::ready(Some(HashMap::default())).shared();
288        }
289
290        self.remote_environments
291            .entry((shell.clone(), abs_path.clone()))
292            .or_insert_with(|| {
293                let response =
294                    remote_client
295                        .read(cx)
296                        .proto_client()
297                        .request(proto::GetDirectoryEnvironment {
298                            project_id: REMOTE_SERVER_PROJECT_ID,
299                            shell: Some(shell_to_proto(shell.clone())),
300                            directory: abs_path.to_string_lossy().to_string(),
301                        });
302                cx.background_spawn(async move {
303                    let environment = response.await.log_err()?;
304                    Some(environment.environment.into_iter().collect())
305                })
306                .shared()
307            })
308            .clone()
309    }
310
311    pub fn peek_environment_error(&self) -> Option<&String> {
312        self.environment_error_messages.front()
313    }
314
315    pub fn pop_environment_error(&mut self) -> Option<String> {
316        self.environment_error_messages.pop_front()
317    }
318}
319
320fn set_origin_marker(env: &mut HashMap<String, String>, origin: EnvironmentOrigin) {
321    env.insert(ZED_ENVIRONMENT_ORIGIN_MARKER.to_string(), origin.into());
322}
323
324const ZED_ENVIRONMENT_ORIGIN_MARKER: &str = "ZED_ENVIRONMENT";
325
326enum EnvironmentOrigin {
327    Cli,
328    WorktreeShell,
329}
330
331impl From<EnvironmentOrigin> for String {
332    fn from(val: EnvironmentOrigin) -> Self {
333        match val {
334            EnvironmentOrigin::Cli => "cli".into(),
335            EnvironmentOrigin::WorktreeShell => "worktree-shell".into(),
336        }
337    }
338}
339
340async fn load_directory_shell_environment(
341    shell: Shell,
342    abs_path: Arc<Path>,
343    load_direnv: DirenvSettings,
344    tx: mpsc::UnboundedSender<String>,
345) -> anyhow::Result<HashMap<String, String>> {
346    if let DirenvSettings::Disabled = load_direnv {
347        return Ok(HashMap::default());
348    }
349
350    let meta = smol::fs::metadata(&abs_path).await.with_context(|| {
351        tx.unbounded_send(format!("Failed to open {}", abs_path.display()))
352            .ok();
353        format!("stat {abs_path:?}")
354    })?;
355
356    let dir = if meta.is_dir() {
357        abs_path.clone()
358    } else {
359        abs_path
360            .parent()
361            .with_context(|| {
362                tx.unbounded_send(format!("Failed to open {}", abs_path.display()))
363                    .ok();
364                format!("getting parent of {abs_path:?}")
365            })?
366            .into()
367    };
368
369    let (shell, args) = shell.program_and_args();
370    let mut envs = util::shell_env::capture(shell.clone(), args, abs_path)
371        .await
372        .with_context(|| {
373            tx.unbounded_send("Failed to load environment variables".into())
374                .ok();
375            format!("capturing shell environment with {shell:?}")
376        })?;
377
378    if cfg!(target_os = "windows")
379        && let Some(path) = envs.remove("Path")
380    {
381        // windows env vars are case-insensitive, so normalize the path var
382        // so we can just assume `PATH` in other places
383        envs.insert("PATH".into(), path);
384    }
385    // If the user selects `Direct` for direnv, it would set an environment
386    // variable that later uses to know that it should not run the hook.
387    // We would include in `.envs` call so it is okay to run the hook
388    // even if direnv direct mode is enabled.
389    let direnv_environment = match load_direnv {
390        DirenvSettings::ShellHook => None,
391        DirenvSettings::Disabled => bail!("direnv integration is disabled"),
392        // Note: direnv is not available on Windows, so we skip direnv processing
393        // and just return the shell environment
394        DirenvSettings::Direct if cfg!(target_os = "windows") => None,
395        DirenvSettings::Direct => load_direnv_environment(&envs, &dir)
396            .await
397            .with_context(|| {
398                tx.unbounded_send("Failed to load direnv environment".into())
399                    .ok();
400                "load direnv environment"
401            })
402            .log_err(),
403    };
404    if let Some(direnv_environment) = direnv_environment {
405        for (key, value) in direnv_environment {
406            if let Some(value) = value {
407                envs.insert(key, value);
408            } else {
409                envs.remove(&key);
410            }
411        }
412    }
413
414    Ok(envs)
415}
416
417async fn load_direnv_environment(
418    env: &HashMap<String, String>,
419    dir: &Path,
420) -> anyhow::Result<HashMap<String, Option<String>>> {
421    let Some(direnv_path) = which::which("direnv").ok() else {
422        return Ok(HashMap::default());
423    };
424
425    let args = &["export", "json"];
426    let direnv_output = new_command(&direnv_path)
427        .args(args)
428        .envs(env)
429        .env("TERM", "dumb")
430        .current_dir(dir)
431        .output()
432        .await
433        .context("running direnv")?;
434
435    if !direnv_output.status.success() {
436        bail!(
437            "Loading direnv environment failed ({}), stderr: {}",
438            direnv_output.status,
439            String::from_utf8_lossy(&direnv_output.stderr)
440        );
441    }
442
443    let output = String::from_utf8_lossy(&direnv_output.stdout);
444    if output.is_empty() {
445        // direnv outputs nothing when it has no changes to apply to environment variables
446        return Ok(HashMap::default());
447    }
448
449    serde_json::from_str(&output).context("parsing direnv json")
450}