T05: shell integration for zsh, bash, fish, PowerShell; installed at start
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Failing after 8m47s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 12:24:31 -04:00
1 parent 8c6f242bf1
commit dfaf378418
12 files changed
+375 -4

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
use ely_gpui_component::{Assets, primitives::FocusScope, theme::ActiveTheme};
use elyterminal::{settings::SettingsStore, themes};
use elyterminal::{settings::SettingsStore, term::shell::ShellIntegration, themes};
use gpui::{
App, AppContext, Bounds, Context, FocusHandle, IntoElement, Render, Styled, TitlebarOptions,
Window, WindowBounds, WindowOptions, px, size,
@@ -34,7 +34,7 @@ fn main() {
.init();
application().with_assets(Assets).run(|cx: &mut App| {
ely_gpui_component::init(cx).expect("Ely failed to start");
if let Err(error) = SettingsStore::init(cx) {
if let Err(error) = SettingsStore::init(cx).and_then(|()| ShellIntegration::init(cx)) {
log::error!("{error:#}");
std::process::exit(1);
}
+1
View File
@@ -1,4 +1,5 @@
mod pty;
pub mod shell;
mod tap;
#[cfg(unix)]
mod unix;
+138
View File
@@ -0,0 +1,138 @@
use std::{
env, fs,
path::{Path, PathBuf},
};
use anyhow::Context as _;
use gpui::{App, Global};
use super::pty::Launch;
/// Each script, by its path under the integration folder.
const SCRIPTS: [(&str, &str); 4] = [
(
"zsh/.zshenv",
include_str!("../../assets/shell-integration/zsh/.zshenv"),
),
(
"bash/elyterminal.bash",
include_str!("../../assets/shell-integration/bash/elyterminal.bash"),
),
(
"fish/vendor_conf.d/elyterminal.fish",
include_str!("../../assets/shell-integration/fish/vendor_conf.d/elyterminal.fish"),
),
(
"pwsh/elyterminal.ps1",
include_str!("../../assets/shell-integration/pwsh/elyterminal.ps1"),
),
];
/// The folder the scripts live in, written at start.
pub struct ShellIntegration(pub PathBuf);
impl Global for ShellIntegration {}
impl ShellIntegration {
/// Writes the scripts into the platform's data folder and keeps its path.
pub fn init(cx: &mut App) -> anyhow::Result<()> {
let dir = dirs::data_local_dir()
.context("shell integration: the platform names no data folder")?
.join("ELYTerminal")
.join("shell-integration");
install(&dir)?;
cx.set_global(Self(dir));
Ok(())
}
}
/// Writes the scripts into `dir`, replacing any that differ.
pub fn install(dir: &Path) -> anyhow::Result<()> {
for (name, text) in SCRIPTS {
let path = dir.join(name);
if fs::read_to_string(&path).is_ok_and(|kept| kept == text) {
continue;
}
let folder = path
.parent()
.context("shell integration: a script has no folder")?;
fs::create_dir_all(folder)
.with_context(|| format!("shell integration: cannot create {}", folder.display()))?;
fs::write(&path, text)
.with_context(|| format!("shell integration: cannot write {}", path.display()))?;
log::info!("shell integration: wrote {}", path.display());
}
Ok(())
}
/// How to start `shell`, or the login shell when empty, in `cwd` so it reports its folder.
pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result<Launch> {
let path = if shell.is_empty() {
default_shell()?
} else {
shell.to_string()
};
let name = Path::new(&path)
.file_stem()
.map(|stem| stem.to_string_lossy().to_lowercase())
.unwrap_or_default();
let mut launch = Launch {
program: (!shell.is_empty()).then(|| (path.clone(), Vec::new())),
cwd,
env: Vec::new(),
};
let script = |name: &str| dir.join(name).display().to_string();
match name.as_str() {
"zsh" => {
if let Ok(zdotdir) = env::var("ZDOTDIR") {
launch.env.push(("ELY_ZSH_ZDOTDIR".into(), zdotdir));
}
launch.env.push(("ZDOTDIR".into(), script("zsh")));
}
"bash" => {
let rcfile = script("bash/elyterminal.bash");
launch.program = Some((path.clone(), vec!["--rcfile".into(), rcfile]));
if cfg!(target_os = "macos") {
launch.env.push(("ELY_BASH_LOGIN".into(), "1".into()));
}
}
"fish" => {
let previous = env::var("XDG_DATA_DIRS").unwrap_or_default();
let data = if previous.is_empty() {
format!("{}:/usr/local/share:/usr/share", script("fish"))
} else {
format!("{}:{previous}", script("fish"))
};
launch.env.push(("XDG_DATA_DIRS".into(), data));
launch.env.push(("ELY_FISH_XDG_DATA_DIRS".into(), previous));
}
"pwsh" | "powershell" => {
let file = script("pwsh/elyterminal.ps1").replace('\'', "''");
launch.program = Some((
path.clone(),
vec![
"-NoLogo".into(),
"-NoExit".into(),
"-Command".into(),
format!(". '{file}'"),
],
));
}
_ => log::info!("shell integration: none for {path}; its folder goes unreported"),
}
log::info!("shell integration: {path} as {name}");
Ok(launch)
}
#[cfg(unix)]
fn default_shell() -> anyhow::Result<String> {
Ok(super::unix::login_shell_path()?)
}
#[cfg(windows)]
fn default_shell() -> anyhow::Result<String> {
Ok("powershell.exe".into())
}
#[cfg(all(test, unix))]
mod tests;
+162
View File
@@ -0,0 +1,162 @@
use std::time::{Duration, Instant};
use alacritty_terminal::event::Event;
use futures::channel::mpsc::TryRecvError;
use super::*;
use crate::term::{PtyEvent, spawn};
/// A fresh folder under the temp dir, by its real path.
fn folder(name: &str) -> PathBuf {
let folder = env::temp_dir().join(format!("elyterminal-{name}-{}", std::process::id()));
if folder.exists() {
fs::remove_dir_all(&folder).expect("a stale test folder goes");
}
fs::create_dir_all(&folder).expect("a test folder");
folder.canonicalize().expect("a real path")
}
fn installed(shell: &str) -> String {
let found = std::process::Command::new("/bin/sh")
.args(["-c", &format!("command -v {shell}")])
.output()
.expect("sh runs");
let path = String::from_utf8(found.stdout).expect("utf-8");
assert!(found.status.success(), "{shell} is not installed");
path.trim().to_string()
}
/// Starts `shell` with integration in a fresh folder, cds once, and returns folders and titles it reported.
fn reports(shell: &str) -> (PathBuf, Vec<PathBuf>, Vec<String>) {
crate::testlog::init();
let root = folder(&format!(
"shell-{}",
shell.rsplit('/').next().expect("a name")
));
let (dir, home, start) = (
root.join("integration"),
root.join("home"),
root.join("start"),
);
let inner = start.join("in ner%");
for made in [&home, &inner] {
fs::create_dir_all(made).expect("a folder");
}
install(&dir).expect("scripts install");
let mut launch = launch(shell, start.clone(), &dir).expect("a launch");
launch.env.push(("HOME".into(), home.display().to_string()));
let mut pty = spawn(&launch, (80, 24), (8, 16), 100).expect("the shell starts");
let (mut folders, mut titles) = (Vec::new(), Vec::new());
let deadline = Instant::now() + Duration::from_secs(20);
let mut asked = false;
while !folders.contains(&inner) {
assert!(
Instant::now() < deadline,
"{shell} reported {folders:?} and {titles:?} on\n{}",
screen(&pty)
);
match pty.events.try_recv() {
Ok(PtyEvent::Folder(folder)) => folders.push(folder),
Ok(PtyEvent::Term(Event::Title(title))) => titles.push(title),
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => pty.write(reply.into_bytes()),
Ok(PtyEvent::Term(Event::ChildExit(status))) => {
panic!("{shell} ended, {status}, after {folders:?}")
}
Ok(_) => {}
Err(TryRecvError::Closed) => panic!("{shell}'s events ended"),
Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)),
}
if !asked && folders.contains(&start) {
pty.write(format!("cd '{}'\r", inner.display()).into_bytes());
asked = true;
}
}
pty.write(&b"exit\r"[..]);
loop {
assert!(
Instant::now() < deadline,
"{shell} did not exit:\n{}",
screen(&pty)
);
match pty.events.try_recv() {
Ok(PtyEvent::Term(Event::ChildExit(_))) | Err(TryRecvError::Closed) => break,
Ok(PtyEvent::Term(Event::Title(title))) => titles.push(title),
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => pty.write(reply.into_bytes()),
Ok(_) => {}
Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)),
}
}
drop(pty);
fs::remove_dir_all(&root).expect("cleanup");
(start, folders, titles)
}
/// The screen's rows, for a failure's message.
fn screen(pty: &crate::term::Pty) -> String {
use alacritty_terminal::{grid::Dimensions, index::Line};
let term = pty.term.lock();
(0..term.screen_lines())
.map(|line| {
let row = &term.grid()[Line(line as i32)];
let text: String = row.into_iter().map(|cell| cell.c).collect();
text.trim_end().to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn the_scripts_install_once_and_mend_a_changed_one() {
let dir = folder("install");
install(&dir).expect("installs");
let zshenv = dir.join("zsh/.zshenv");
assert_eq!(fs::read_to_string(&zshenv).expect("written"), SCRIPTS[0].1);
fs::write(&zshenv, "changed").expect("write");
install(&dir).expect("installs again");
assert_eq!(fs::read_to_string(&zshenv).expect("mended"), SCRIPTS[0].1);
fs::remove_dir_all(dir).expect("cleanup");
}
#[test]
fn zsh_reports_its_folder_and_title() {
let (start, folders, titles) = reports(&installed("zsh"));
assert_eq!(folders[0], start);
assert!(titles.contains(&"start".to_string()), "{titles:?}");
assert!(
titles.iter().any(|title| title.starts_with("cd ")),
"{titles:?}"
);
}
#[test]
fn bash_reports_its_folder_and_title() {
let (start, folders, titles) = reports(&installed("bash"));
assert_eq!(folders[0], start);
assert!(titles.contains(&"in ner%".to_string()), "{titles:?}");
}
#[test]
fn fish_reports_its_folder_and_keeps_its_own_title() {
let (start, folders, titles) = reports(&installed("fish"));
assert_eq!(folders[0], start);
assert!(
titles.iter().any(|title| title.ends_with("start")),
"{titles:?}"
);
}
#[cfg(target_os = "macos")]
#[test]
fn powershell_reports_its_folder_and_title() {
let (start, folders, titles) = reports(&installed("pwsh"));
assert_eq!(folders[0], start);
assert!(titles.contains(&"in ner%".to_string()), "{titles:?}");
}
#[test]
fn an_unknown_shell_starts_without_integration() {
let dir = PathBuf::from("/nonexistent");
let launch = launch("/bin/sh", PathBuf::from("/"), &dir).expect("a launch");
assert_eq!(launch.program, Some(("/bin/sh".into(), Vec::new())));
assert!(launch.env.is_empty());
}
+5
View File
@@ -125,6 +125,11 @@ pub(crate) fn spawn(
})
}
/// The user's login shell: SHELL, else the password entry's.
pub(crate) fn login_shell_path() -> io::Result<String> {
Ok(User::find()?.shell)
}
/// macOS runs the shell through `login` so it is a login shell on a tty, as Terminal does.
#[cfg(target_os = "macos")]
fn login_shell(user: &User) -> Command {
+1 -1
View File
@@ -7,7 +7,7 @@ struct Capture;
impl log::Log for Capture {
fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= log::Level::Debug
metadata.level() <= log::Level::Debug && metadata.target().starts_with("elyterminal")
}
fn log(&self, record: &log::Record) {