Add activation script support for terminals/toolchains

This commit is contained in:
Lukas Wirth 2025-08-19 11:39:08 +02:00
parent 7b3d73d6fd
commit 8e11e6a03e
11 changed files with 98 additions and 32 deletions

View file

@ -10,14 +10,14 @@ use std::{
};
use async_trait::async_trait;
use collections::HashMap;
use collections::{FxHashMap, HashMap};
use gpui::{AsyncApp, SharedString};
use settings::WorktreeId;
use crate::{LanguageName, ManifestName};
/// Represents a single toolchain.
#[derive(Clone, Debug, Eq)]
#[derive(Clone, Eq, Debug)]
pub struct Toolchain {
/// User-facing label
pub name: SharedString,
@ -25,24 +25,41 @@ pub struct Toolchain {
pub language_name: LanguageName,
/// Full toolchain data (including language-specific details)
pub as_json: serde_json::Value,
/// shell -> script
pub startup_script: FxHashMap<String, String>,
}
impl std::hash::Hash for Toolchain {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.name.hash(state);
self.path.hash(state);
self.language_name.hash(state);
let Self {
name,
path,
language_name,
as_json: _,
startup_script: _,
} = self;
name.hash(state);
path.hash(state);
language_name.hash(state);
}
}
impl PartialEq for Toolchain {
fn eq(&self, other: &Self) -> bool {
let Self {
name,
path,
language_name,
as_json: _,
startup_script,
} = self;
// Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced.
// Thus, there could be multiple entries that look the same in the UI.
(&self.name, &self.path, &self.language_name).eq(&(
(name, path, language_name, startup_script).eq(&(
&other.name,
&other.path,
&other.language_name,
&other.startup_script,
))
}
}
@ -82,7 +99,7 @@ pub trait LocalLanguageToolchainStore: Send + Sync + 'static {
) -> Option<Toolchain>;
}
#[async_trait(?Send )]
#[async_trait(?Send)]
impl<T: LocalLanguageToolchainStore> LanguageToolchainStore for T {
async fn active_toolchain(
self: Arc<Self>,

View file

@ -878,6 +878,8 @@ impl ToolchainLister for PythonToolchainProvider {
path: toolchain.executable.as_ref()?.to_str()?.to_owned().into(),
language_name: LanguageName::new("Python"),
as_json: serde_json::to_value(toolchain).ok()?,
startup_script: std::iter::once(("fish".to_owned(), "test".to_owned()))
.collect(),
})
})
.collect();

View file

@ -293,6 +293,7 @@ impl DapStore {
binary.cwd.as_deref(),
binary.envs,
path_style,
None,
);
Ok(DebugAdapterBinary {

View file

@ -9204,6 +9204,7 @@ fn python_lang(fs: Arc<FakeFs>) -> Arc<Language> {
path: venv_path.to_string_lossy().into_owned().into(),
language_name: LanguageName(SharedString::new_static("Python")),
as_json: serde_json::Value::Null,
startup_script: Default::default(),
})
}
}

View file

@ -11,7 +11,7 @@ use std::{
path::{Path, PathBuf},
sync::Arc,
};
use task::{Shell, ShellBuilder, SpawnInTerminal};
use task::{Shell, ShellBuilder, SpawnInTerminal, system_shell};
use terminal::{
TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::TerminalSettings,
};
@ -173,6 +173,7 @@ impl Project {
path.as_deref(),
env,
path_style,
None,
);
env = HashMap::default();
if let Some(envs) = envs {
@ -213,6 +214,7 @@ impl Project {
cx.entity_id().as_u64(),
Some(completion_tx),
cx,
None,
)
.map(|builder| {
let terminal_handle = cx.new(|cx| builder.subscribe(cx));
@ -276,21 +278,33 @@ impl Project {
let toolchain =
project_path_context.map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx));
cx.spawn(async move |project, cx| {
let toolchain = maybe!(async {
let toolchain = toolchain?.await?;
let shell = match &ssh_details {
Some(ssh) => ssh.shell.clone(),
None => match &settings.shell {
Shell::Program(program) => program.clone(),
Shell::WithArguments {
program,
args: _,
title_override: _,
} => program.clone(),
Shell::System => system_shell(),
},
};
Some(())
let scripts = maybe!(async {
let toolchain = toolchain?.await?;
Some(toolchain.startup_script)
})
.await;
let (spawn_task, shell) = {
let activation_script = scripts.as_ref().and_then(|it| it.get(&shell));
let shell = {
match ssh_details {
Some(SshDetails {
host,
ssh_command,
envs,
path_style,
shell,
shell: _,
}) => {
log::debug!("Connecting to a remote server: {ssh_command:?}");
@ -308,28 +322,37 @@ impl Project {
path.as_deref(),
env,
path_style,
activation_script.map(String::as_str),
);
env = HashMap::default();
if let Some(envs) = envs {
env.extend(envs);
}
(
Option::<TaskState>::None,
Shell::WithArguments {
program,
args,
title_override: Some(format!("{} — Terminal", host).into()),
},
)
}
None => (None, settings.shell),
}
None if activation_script.is_some() => Shell::WithArguments {
program: shell.clone(),
args: vec![
"-c".to_owned(),
format!(
"{}; exec {} -l",
activation_script.unwrap().to_string(),
shell
),
],
title_override: None,
},
None => settings.shell,
}
};
project.update(cx, move |this, cx| {
TerminalBuilder::new(
local_path.map(|path| path.to_path_buf()),
spawn_task,
None,
shell,
env,
settings.cursor_shape.unwrap_or_default(),
@ -339,6 +362,7 @@ impl Project {
cx.entity_id().as_u64(),
None,
cx,
None,
)
.map(|builder| {
let terminal_handle = cx.new(|cx| builder.subscribe(cx));
@ -447,6 +471,7 @@ impl Project {
path.as_deref(),
env,
path_style,
None,
);
let mut command = std::process::Command::new(command);
command.args(args);
@ -479,6 +504,7 @@ pub fn wrap_for_ssh(
path: Option<&Path>,
env: HashMap<String, String>,
path_style: PathStyle,
activation_script: Option<&str>,
) -> (String, Vec<String>) {
let to_run = if let Some((command, args)) = command {
let command: Option<Cow<str>> = shlex::try_quote(command).ok();
@ -495,6 +521,9 @@ pub fn wrap_for_ssh(
}
}
let activation_script = activation_script
.map(|s| format!(" {s};"))
.unwrap_or_default();
let commands = if let Some(path) = path {
let path = RemotePathBuf::new(path.to_path_buf(), path_style).to_string();
// shlex will wrap the command in single quotes (''), disabling ~ expansion,
@ -506,12 +535,12 @@ pub fn wrap_for_ssh(
.trim_start_matches("~")
.trim_start_matches("/");
format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}")
format!("cd \"$HOME/{trimmed_path}\";{activation_script} {env_changes} {to_run}")
} else {
format!("cd \"{path}\"; {env_changes} {to_run}")
format!("cd \"{path}\";{activation_script} {env_changes} {to_run}")
}
} else {
format!("cd; {env_changes} {to_run}")
format!("cd;{activation_script} {env_changes} {to_run}")
};
let shell_invocation = format!("{shell} -c {}", shlex::try_quote(&commands).unwrap());

View file

@ -138,6 +138,7 @@ impl ToolchainStore {
// Do we need to convert path to native string?
path: PathBuf::from(toolchain.path).to_proto().into(),
as_json: serde_json::Value::from_str(&toolchain.raw_json)?,
startup_script: toolchain.activation_script.into_iter().collect(),
language_name,
};
let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
@ -178,6 +179,7 @@ impl ToolchainStore {
name: toolchain.name.into(),
path: path.to_proto(),
raw_json: toolchain.as_json.to_string(),
activation_script: toolchain.startup_script.into_iter().collect(),
}
}),
})
@ -221,6 +223,7 @@ impl ToolchainStore {
name: toolchain.name.to_string(),
path: path.to_proto(),
raw_json: toolchain.as_json.to_string(),
activation_script: toolchain.startup_script.into_iter().collect(),
}
})
.collect::<Vec<_>>();
@ -449,6 +452,7 @@ impl RemoteToolchainStore {
name: toolchain.name.into(),
path: path.to_proto(),
raw_json: toolchain.as_json.to_string(),
activation_script: toolchain.startup_script.into_iter().collect(),
}),
path: Some(project_path.path.to_string_lossy().into_owned()),
})
@ -501,6 +505,7 @@ impl RemoteToolchainStore {
.to_string()
.into(),
as_json: serde_json::Value::from_str(&toolchain.raw_json).ok()?,
startup_script: toolchain.activation_script.into_iter().collect(),
})
})
.collect();
@ -557,6 +562,7 @@ impl RemoteToolchainStore {
.to_string()
.into(),
as_json: serde_json::Value::from_str(&toolchain.raw_json).ok()?,
startup_script: toolchain.activation_script.into_iter().collect(),
})
})
})

View file

@ -12,6 +12,7 @@ message Toolchain {
string name = 1;
string path = 2;
string raw_json = 3;
map<string, string> activation_script = 4;
}
message ToolchainGroup {

View file

@ -178,7 +178,7 @@ impl ShellKind {
}
}
fn system_shell() -> String {
pub fn system_shell() -> String {
if cfg!(target_os = "windows") {
// `alacritty_terminal` uses this as default on Windows. See:
// https://github.com/alacritty/alacritty/blob/0d4ab7bca43213d96ddfe40048fc0f922543c6f8/alacritty_terminal/src/tty/windows/mod.rs#L130

View file

@ -22,7 +22,7 @@ pub use debug_format::{
AttachRequest, BuildTaskDefinition, DebugRequest, DebugScenario, DebugTaskFile, LaunchRequest,
Request, TcpArgumentsTemplate, ZedDebugConfig,
};
pub use shell_builder::{ShellBuilder, ShellKind};
pub use shell_builder::{ShellBuilder, ShellKind, system_shell};
pub use task_template::{
DebugArgsRequest, HideStrategy, RevealStrategy, TaskTemplate, TaskTemplates,
substitute_variables_in_map, substitute_variables_in_str,

View file

@ -354,6 +354,7 @@ impl TerminalBuilder {
window_id: u64,
completion_tx: Option<Sender<Option<ExitStatus>>>,
cx: &App,
startup_script: Option<String>,
) -> Result<TerminalBuilder> {
// If the parent environment doesn't have a locale set
// (As is the case when launched from a .app on MacOS),
@ -517,6 +518,7 @@ impl TerminalBuilder {
last_hyperlink_search_position: None,
#[cfg(windows)]
shell_program,
startup_script,
template: CopyTemplate {
shell,
env,
@ -710,6 +712,7 @@ pub struct Terminal {
#[cfg(windows)]
shell_program: Option<String>,
template: CopyTemplate,
startup_script: Option<String>,
}
struct CopyTemplate {
@ -1983,6 +1986,7 @@ impl Terminal {
self.template.window_id,
None,
cx,
self.startup_script.clone(),
)
}
}
@ -2214,6 +2218,7 @@ mod tests {
0,
Some(completion_tx),
cx,
None,
)
.unwrap()
.subscribe(cx)

View file

@ -1403,7 +1403,9 @@ impl WorkspaceDb {
name: name.into(),
path: path.into(),
language_name,
as_json: serde_json::Value::from_str(&raw_json).ok()?
as_json: serde_json::Value::from_str(&raw_json).ok()?,
// todo refresh?
startup_script: Default::default(),
})))
})
.await
@ -1427,7 +1429,9 @@ impl WorkspaceDb {
name: name.into(),
path: path.into(),
language_name: LanguageName::new(&language_name),
as_json: serde_json::Value::from_str(&raw_json).ok()?
as_json: serde_json::Value::from_str(&raw_json).ok()?,
// todo refresh?
startup_script: Default::default(),
}, WorktreeId::from_proto(worktree_id), Arc::from(relative_worktree_path.as_ref())))).collect())
})
.await