421 lines · 14.7 KB · Rust
RawDownload
use anyhow::{Context as _, bail};use futures::{FutureExt, StreamExt as _, channel::mpsc, future::Shared};use language::Buffer;use remote::RemoteClient;use rpc::proto::{self, REMOTE_SERVER_PROJECT_ID};use std::{collections::VecDeque, path::Path, sync::Arc};use task::{Shell, shell_to_proto};use terminal::terminal_settings::TerminalSettings;use util::{ResultExt, command::new_smol_command, rel_path::RelPath};use worktree::Worktree; use collections::HashMap;use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task, WeakEntity};use settings::Settings as _; use crate::{    project_settings::{DirenvSettings, ProjectSettings},    worktree_store::WorktreeStore,}; pub struct ProjectEnvironment {    cli_environment: Option<HashMap<String, String>>,    local_environments: HashMap<(Shell, Arc<Path>), Shared<Task<Option<HashMap<String, String>>>>>,    remote_environments: HashMap<(Shell, Arc<Path>), Shared<Task<Option<HashMap<String, String>>>>>,    environment_error_messages: VecDeque<String>,    environment_error_messages_tx: mpsc::UnboundedSender<String>,    worktree_store: WeakEntity<WorktreeStore>,    remote_client: Option<WeakEntity<RemoteClient>>,    is_remote_project: bool,    _tasks: Vec<Task<()>>,} pub enum ProjectEnvironmentEvent {    ErrorsUpdated,} impl EventEmitter<ProjectEnvironmentEvent> for ProjectEnvironment {} impl ProjectEnvironment {    pub fn new(        cli_environment: Option<HashMap<String, String>>,        worktree_store: WeakEntity<WorktreeStore>,        remote_client: Option<WeakEntity<RemoteClient>>,        is_remote_project: bool,        cx: &mut Context<Self>,    ) -> Self {        let (tx, mut rx) = mpsc::unbounded();        let task = cx.spawn(async move |this, cx| {            while let Some(message) = rx.next().await {                this.update(cx, |this, cx| {                    this.environment_error_messages.push_back(message);                    cx.emit(ProjectEnvironmentEvent::ErrorsUpdated);                })                .ok();            }        });        Self {            cli_environment,            local_environments: Default::default(),            remote_environments: Default::default(),            environment_error_messages: Default::default(),            environment_error_messages_tx: tx,            worktree_store,            remote_client,            is_remote_project,            _tasks: vec![task],        }    }     /// 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 cfg!(any(test, feature = "test-support")) {            return Some(HashMap::default());        }        if let Some(mut env) = self.cli_environment.clone() {            set_origin_marker(&mut env, EnvironmentOrigin::Cli);            Some(env)        } else {            None        }    }     pub fn buffer_environment(        &mut self,        buffer: &Entity<Buffer>,        worktree_store: &Entity<WorktreeStore>,        cx: &mut Context<Self>,    ) -> Shared<Task<Option<HashMap<String, String>>>> {        if let Some(cli_environment) = self.get_cli_environment() {            log::debug!("using project environment variables from CLI");            return Task::ready(Some(cli_environment)).shared();        }         let Some(worktree) = buffer            .read(cx)            .file()            .map(|f| f.worktree_id(cx))            .and_then(|worktree_id| worktree_store.read(cx).worktree_for_id(worktree_id, cx))        else {            return Task::ready(None).shared();        };        self.worktree_environment(worktree, cx)    }     pub fn worktree_environment(        &mut self,        worktree: Entity<Worktree>,        cx: &mut App,    ) -> Shared<Task<Option<HashMap<String, String>>>> {        if let Some(cli_environment) = self.get_cli_environment() {            log::debug!("using project environment variables from CLI");            return Task::ready(Some(cli_environment)).shared();        }         let worktree = worktree.read(cx);        let mut abs_path = worktree.abs_path();        if worktree.is_single_file() {            let Some(parent) = abs_path.parent() else {                return Task::ready(None).shared();            };            abs_path = parent.into();        }         let remote_client = self.remote_client.as_ref().and_then(|it| it.upgrade());        match remote_client {            Some(remote_client) => remote_client.clone().read(cx).shell().map(|shell| {                self.remote_directory_environment(                    &Shell::Program(shell),                    abs_path,                    remote_client,                    cx,                )            }),            None if self.is_remote_project => {                Some(self.local_directory_environment(&Shell::System, abs_path, cx))            }            None => Some({                let shell = TerminalSettings::get(                    Some(settings::SettingsLocation {                        worktree_id: worktree.id(),                        path: RelPath::empty(),                    }),                    cx,                )                .shell                .clone();                 self.local_directory_environment(&shell, abs_path, cx)            }),        }        .unwrap_or_else(|| Task::ready(None).shared())    }     pub fn directory_environment(        &mut self,        abs_path: Arc<Path>,        cx: &mut App,    ) -> Shared<Task<Option<HashMap<String, String>>>> {        let remote_client = self.remote_client.as_ref().and_then(|it| it.upgrade());        match remote_client {            Some(remote_client) => remote_client.clone().read(cx).shell().map(|shell| {                self.remote_directory_environment(                    &Shell::Program(shell),                    abs_path,                    remote_client,                    cx,                )            }),            None if self.is_remote_project => {                Some(self.local_directory_environment(&Shell::System, abs_path, cx))            }            None => self                .worktree_store                .read_with(cx, |worktree_store, cx| {                    worktree_store.find_worktree(&abs_path, cx)                })                .ok()                .map(|worktree| {                    let shell = terminal::terminal_settings::TerminalSettings::get(                        worktree                            .as_ref()                            .map(|(worktree, path)| settings::SettingsLocation {                                worktree_id: worktree.read(cx).id(),                                path: &path,                            }),                        cx,                    )                    .shell                    .clone();                     self.local_directory_environment(&shell, abs_path, cx)                }),        }        .unwrap_or_else(|| Task::ready(None).shared())    }     /// 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 an absolute path is given, then a shell is spawned in    /// that directory, to get environment variables as if the user has `cd`'d there.    pub fn local_directory_environment(        &mut self,        shell: &Shell,        abs_path: Arc<Path>,        cx: &mut App,    ) -> Shared<Task<Option<HashMap<String, String>>>> {        if let Some(cli_environment) = self.get_cli_environment() {            log::debug!("using project environment variables from CLI");            return Task::ready(Some(cli_environment)).shared();        }         self.local_environments            .entry((shell.clone(), abs_path.clone()))            .or_insert_with(|| {                let load_direnv = ProjectSettings::get_global(cx).load_direnv.clone();                let shell = shell.clone();                let tx = self.environment_error_messages_tx.clone();                cx.spawn(async move |cx| {                    let mut shell_env = cx                        .background_spawn(load_directory_shell_environment(                            shell,                            abs_path.clone(),                            load_direnv,                            tx,                        ))                        .await                        .log_err();                     if let Some(shell_env) = shell_env.as_mut() {                        let path = shell_env                            .get("PATH")                            .map(|path| path.as_str())                            .unwrap_or_default();                        log::debug!(                            "using project environment variables shell launched in {:?}. PATH={:?}",                            abs_path,                            path                        );                         set_origin_marker(shell_env, EnvironmentOrigin::WorktreeShell);                    }                     shell_env                })                .shared()            })            .clone()    }     pub fn remote_directory_environment(        &mut self,        shell: &Shell,        abs_path: Arc<Path>,        remote_client: Entity<RemoteClient>,        cx: &mut App,    ) -> Shared<Task<Option<HashMap<String, String>>>> {        if cfg!(any(test, feature = "test-support")) {            return Task::ready(Some(HashMap::default())).shared();        }         self.remote_environments            .entry((shell.clone(), abs_path.clone()))            .or_insert_with(|| {                let response =                    remote_client                        .read(cx)                        .proto_client()                        .request(proto::GetDirectoryEnvironment {                            project_id: REMOTE_SERVER_PROJECT_ID,                            shell: Some(shell_to_proto(shell.clone())),                            directory: abs_path.to_string_lossy().to_string(),                        });                cx.background_spawn(async move {                    let environment = response.await.log_err()?;                    Some(environment.environment.into_iter().collect())                })                .shared()            })            .clone()    }     pub fn peek_environment_error(&self) -> Option<&String> {        self.environment_error_messages.front()    }     pub fn pop_environment_error(&mut self) -> Option<String> {        self.environment_error_messages.pop_front()    }} 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 From<EnvironmentOrigin> for String {    fn from(val: EnvironmentOrigin) -> Self {        match val {            EnvironmentOrigin::Cli => "cli".into(),            EnvironmentOrigin::WorktreeShell => "worktree-shell".into(),        }    }} async fn load_directory_shell_environment(    shell: Shell,    abs_path: Arc<Path>,    load_direnv: DirenvSettings,    tx: mpsc::UnboundedSender<String>,) -> anyhow::Result<HashMap<String, String>> {    if let DirenvSettings::Disabled = load_direnv {        return Ok(HashMap::default());    }     let meta = smol::fs::metadata(&abs_path).await.with_context(|| {        tx.unbounded_send(format!("Failed to open {}", abs_path.display()))            .ok();        format!("stat {abs_path:?}")    })?;     let dir = if meta.is_dir() {        abs_path.clone()    } else {        abs_path            .parent()            .with_context(|| {                tx.unbounded_send(format!("Failed to open {}", abs_path.display()))                    .ok();                format!("getting parent of {abs_path:?}")            })?            .into()    };     let (shell, args) = shell.program_and_args();    let mut envs = util::shell_env::capture(shell.clone(), args, abs_path)        .await        .with_context(|| {            tx.unbounded_send("Failed to load environment variables".into())                .ok();            format!("capturing shell environment with {shell:?}")        })?;     if cfg!(target_os = "windows")        && let Some(path) = envs.remove("Path")    {        // windows env vars are case-insensitive, so normalize the path var        // so we can just assume `PATH` in other places        envs.insert("PATH".into(), path);    }    // 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.    let direnv_environment = match load_direnv {        DirenvSettings::ShellHook => None,        DirenvSettings::Disabled => bail!("direnv integration is disabled"),        // Note: direnv is not available on Windows, so we skip direnv processing        // and just return the shell environment        DirenvSettings::Direct if cfg!(target_os = "windows") => None,        DirenvSettings::Direct => load_direnv_environment(&envs, &dir)            .await            .with_context(|| {                tx.unbounded_send("Failed to load direnv environment".into())                    .ok();                "load direnv environment"            })            .log_err(),    };    if let Some(direnv_environment) = direnv_environment {        for (key, value) in direnv_environment {            if let Some(value) = value {                envs.insert(key, value);            } else {                envs.remove(&key);            }        }    }     Ok(envs)} async fn load_direnv_environment(    env: &HashMap<String, String>,    dir: &Path,) -> anyhow::Result<HashMap<String, Option<String>>> {    let Some(direnv_path) = which::which("direnv").ok() else {        return Ok(HashMap::default());    };     let args = &["export", "json"];    let direnv_output = new_smol_command(&direnv_path)        .args(args)        .envs(env)        .env("TERM", "dumb")        .current_dir(dir)        .output()        .await        .context("running direnv")?;     if !direnv_output.status.success() {        bail!(            "Loading direnv environment failed ({}), stderr: {}",            direnv_output.status,            String::from_utf8_lossy(&direnv_output.stderr)        );    }     let output = String::from_utf8_lossy(&direnv_output.stdout);    if output.is_empty() {        // direnv outputs nothing when it has no changes to apply to environment variables        return Ok(HashMap::default());    }     serde_json::from_str(&output).context("parsing direnv json")}