diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3e8455..cdce564 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ jobs: # The Linux job container runs as root, which ignores the file permissions the tests check. - if: runner.os == 'Linux' run: | + apt-get update && apt-get install -y zsh fish useradd --create-home ci chmod o+x "$HOME" chown -R ci "$GITHUB_WORKSPACE" "$HOME/.cargo" "$HOME/.rustup" diff --git a/AGENTS.md b/AGENTS.md index 53a5d66..55ca8cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,8 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - `src/main.rs`: start, window. `src/lib.rs`: the modules, public so tests reach them. - `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`. - `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance. -- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. +- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. +- `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title. - `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them). ## Rules @@ -38,4 +39,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-14: Git through the `git` command line, not libgit2. - 2026-10-14: license MIT, as Ely. - 2026-10-14: on Unix the pty is ELYTerminal's own: alacritty 0.26 ignores `chdir`'s result, so a session could start in the wrong folder. Its traits feed alacritty's event loop, keyed 0 and 1 as alacritty's private constants are; alacritty is pinned to =0.26.0 for that. +- 2026-10-14: bash integration loads through `--rcfile`, not Ghostty's `--posix` and `ENV`: macOS's bash 3.2 ignores `ENV` there. bash then runs as a non-login shell; on macOS the script reads the login files itself, so `logout` and `~/.bash_logout` do not apply. - 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten. diff --git a/assets/shell-integration/bash/elyterminal.bash b/assets/shell-integration/bash/elyterminal.bash new file mode 100644 index 0000000..7b1134f --- /dev/null +++ b/assets/shell-integration/bash/elyterminal.bash @@ -0,0 +1,18 @@ +# ELYTerminal: load the usual startup files, report folder and title. +if [ -n "${ELY_BASH_LOGIN-}" ]; then + builtin unset ELY_BASH_LOGIN + [ -r /etc/profile ] && builtin source /etc/profile + for _ely_file in ~/.bash_profile ~/.bash_login ~/.profile; do + if [ -r "$_ely_file" ]; then + builtin source "$_ely_file" + break + fi + done + builtin unset _ely_file +else + [ -r ~/.bashrc ] && builtin source ~/.bashrc +fi +_ely_prompt() { + builtin printf '\e]7;file://%s\a\e]2;%s\a' "${PWD//\%/%25}" "${PWD##*/}" +} +PROMPT_COMMAND="_ely_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND}" diff --git a/assets/shell-integration/fish/vendor_conf.d/elyterminal.fish b/assets/shell-integration/fish/vendor_conf.d/elyterminal.fish new file mode 100644 index 0000000..30bf31d --- /dev/null +++ b/assets/shell-integration/fish/vendor_conf.d/elyterminal.fish @@ -0,0 +1,12 @@ +# ELYTerminal: restore XDG_DATA_DIRS, report the folder; fish_title keeps the title. +if test -n "$ELY_FISH_XDG_DATA_DIRS" + set -gx XDG_DATA_DIRS $ELY_FISH_XDG_DATA_DIRS +else + set -e XDG_DATA_DIRS +end +set -e ELY_FISH_XDG_DATA_DIRS +if status is-interactive + function _ely_prompt --on-event fish_prompt + printf '\e]7;file://%s\a' (string replace -a % %25 -- $PWD) + end +end diff --git a/assets/shell-integration/pwsh/elyterminal.ps1 b/assets/shell-integration/pwsh/elyterminal.ps1 new file mode 100644 index 0000000..87b2dda --- /dev/null +++ b/assets/shell-integration/pwsh/elyterminal.ps1 @@ -0,0 +1,13 @@ +# ELYTerminal: wrap the prompt to report folder and title. +$global:ElyPrompt = $function:prompt +function global:prompt { + $folder = $executionContext.SessionState.Path.CurrentLocation + if ($folder.Provider.Name -eq 'FileSystem') { + $path = $folder.ProviderPath.Replace('\', '/').Replace('%', '%25') + if (-not $path.StartsWith('/')) { $path = "/$path" } + $leaf = Split-Path -Leaf $folder.ProviderPath + $esc, $bel = [char]27, [char]7 + [Console]::Write("$esc]7;file://$path$bel$esc]2;$leaf$bel") + } + & $global:ElyPrompt +} diff --git a/assets/shell-integration/zsh/.zshenv b/assets/shell-integration/zsh/.zshenv new file mode 100644 index 0000000..8ed927f --- /dev/null +++ b/assets/shell-integration/zsh/.zshenv @@ -0,0 +1,19 @@ +# ELYTerminal: restore ZDOTDIR, load the user's .zshenv, report folder and title. +if [[ -n "${ELY_ZSH_ZDOTDIR+set}" ]]; then + ZDOTDIR=$ELY_ZSH_ZDOTDIR + builtin unset ELY_ZSH_ZDOTDIR +else + builtin unset ZDOTDIR +fi +[[ -r "${ZDOTDIR:-$HOME}/.zshenv" ]] && builtin source "${ZDOTDIR:-$HOME}/.zshenv" +if [[ -o interactive ]]; then + _ely_precmd() { + builtin printf '\e]7;file://%s\a\e]2;%s\a' "${PWD//\%/%25}" "${${PWD:t}:-/}" + } + _ely_preexec() { + builtin printf '\e]2;%s\a' "${1//[[:cntrl:]]/}" + } + builtin autoload -Uz add-zsh-hook + add-zsh-hook precmd _ely_precmd + add-zsh-hook preexec _ely_preexec +fi diff --git a/src/main.rs b/src/main.rs index 969c9e3..f30335c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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); } diff --git a/src/term/mod.rs b/src/term/mod.rs index 79e6604..83babaa 100644 --- a/src/term/mod.rs +++ b/src/term/mod.rs @@ -1,4 +1,5 @@ mod pty; +pub mod shell; mod tap; #[cfg(unix)] mod unix; diff --git a/src/term/shell.rs b/src/term/shell.rs new file mode 100644 index 0000000..60f2705 --- /dev/null +++ b/src/term/shell.rs @@ -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 { + 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 { + Ok(super::unix::login_shell_path()?) +} + +#[cfg(windows)] +fn default_shell() -> anyhow::Result { + Ok("powershell.exe".into()) +} + +#[cfg(all(test, unix))] +mod tests; diff --git a/src/term/shell/tests.rs b/src/term/shell/tests.rs new file mode 100644 index 0000000..1099223 --- /dev/null +++ b/src/term/shell/tests.rs @@ -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, Vec) { + 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::>() + .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()); +} diff --git a/src/term/unix.rs b/src/term/unix.rs index 96ccea8..fa0bf03 100644 --- a/src/term/unix.rs +++ b/src/term/unix.rs @@ -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 { + 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 { diff --git a/src/testlog.rs b/src/testlog.rs index 2f8a726..fdcdd56 100644 --- a/src/testlog.rs +++ b/src/testlog.rs @@ -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) {