diff --git a/CHANGELOG.md b/CHANGELOG.md index a35216a..b7b28be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- add `shell.env` to inject environment variables into spawned shells - add `--maximized` and `--fullscreen` flags to start the window in that mode - persist and restore window size, maximized, and fullscreen state per session - REP (`CSI Ps b`): repeat the last printed character `Ps` times diff --git a/README.md b/README.md index c55a473..2410e7a 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ cursor_blink_ms = 500 [shell] # program = "/bin/zsh" # defaults to $SHELL +# env = [["EDITOR", "nvim"]] # injected into every spawned shell; TOML-only, not editable in the in-app config panel [terminal] scrollback_lines = 10000 # minimum 100 diff --git a/assets/config.toml b/assets/config.toml index fbbd7ef..269d627 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -14,6 +14,7 @@ detect_urls = true [shell] # program = "/bin/zsh" +# env = [["EDITOR", "nvim"], ["LANG", "en_US.UTF-8"]] [terminal] scrollback_lines = 10000 diff --git a/doc/SPEC.md b/doc/SPEC.md index e3f10a6..cc2d495 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -210,6 +210,9 @@ Screenshot capture is a two-step flow: region selection followed by a name promp - Sections: `[font]`, `[window]`, `[shell]`, `[terminal]`, `[logging]`, `[status_bar]`, `[colors]`, `[theme]`. - In-process TUI config panel: `Ctrl+,` (editable fields, saved on Enter). +- `shell.env` injects environment variables into every spawned shell, in the + form `env = [["VAR", "value"], ["VAR2", "value2"]]`. It is TOML-only and is + not editable in the in-app config panel. | Section | Key | Type | Default | |---|---|---|---| @@ -223,6 +226,7 @@ Screenshot capture is a two-step flow: region selection followed by a name promp | window | detect_urls | bool | `true` | | terminal | scrollback_lines | uint | `10000` (min 100) | | shell | program | string? | `$SHELL` | +| shell | env | array | `[]` | | logging | auto_log | bool | `false` | | logging | log_dir | string | `""` (→ `~/.mmterm`) | | status_bar | right | string | `""` | diff --git a/src/app_state.rs b/src/app_state.rs index 84caecf..1b712dc 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1039,6 +1039,7 @@ impl AppState { pty_tx, "/bin/true", None, + &[], Box::new(|| {}), ) .expect("PTY spawn failed"); @@ -1102,6 +1103,7 @@ impl AppState { pty_tx, "/bin/true", None, + &[], Box::new(|| {}), ) .expect("PTY spawn failed"); diff --git a/src/config/config_test.rs b/src/config/config_test.rs index 4bd6877..757f765 100644 --- a/src/config/config_test.rs +++ b/src/config/config_test.rs @@ -283,3 +283,33 @@ fn general_update_defaults() { assert!(cfg.general.shell_integration); // OSC 133 prompt/exit badges on by default assert!(cfg.general.desktop_notifications); // OSC 777 desktop notifications on by default } + +#[test] +fn shell_env_defaults_empty() { + let cfg = ShellConfig::default(); + assert!(cfg.env.is_empty()); +} + +#[test] +fn shell_env_deserializes_pairs() { + let toml = r#"env = [["EDITOR", "nvim"], ["LANG", "en_US.UTF-8"]]"#; + let cfg: ShellConfig = toml::from_str(toml).unwrap(); + assert_eq!( + cfg.env, + vec![ + ("EDITOR".to_string(), "nvim".to_string()), + ("LANG".to_string(), "en_US.UTF-8".to_string()), + ] + ); +} + +#[test] +fn shell_env_round_trips() { + let cfg = ShellConfig { + program: Some("/bin/zsh".to_string()), + env: vec![("FOO".to_string(), "bar".to_string())], + }; + let serialized = toml::to_string(&cfg).unwrap(); + let back: ShellConfig = toml::from_str(&serialized).unwrap(); + assert_eq!(cfg, back); +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 571c12a..2df657f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -163,6 +163,10 @@ pub struct WindowConfig { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ShellConfig { pub program: Option, + /// Environment variables injected into every spawned shell. + /// TOML format: `env = [["VAR", "value"], ["VAR2", "value2"]]`. + #[serde(default)] + pub env: Vec<(String, String)>, } fn default_auto_log() -> bool { diff --git a/src/config/tui_config.rs b/src/config/tui_config.rs index 6060b52..4710fcf 100644 --- a/src/config/tui_config.rs +++ b/src/config/tui_config.rs @@ -90,6 +90,9 @@ pub struct ConfigPanel { /// Section names that are currently collapsed (body fields hidden). pub collapsed: HashSet<&'static str>, pub version: &'static str, + /// `shell.env` is TOML-only (not an editable field); carry it through the + /// panel round-trip so saving from the TUI does not wipe it. + pub shell_env: Vec<(String, String)>, } impl ConfigPanel { @@ -313,6 +316,7 @@ impl ConfigPanel { status: None, collapsed, version: env!("MMTERM_VERSION"), + shell_env: cfg.shell.env.clone(), } } @@ -688,7 +692,10 @@ impl ConfigPanel { inactive_dim, detect_urls, }, - shell: ShellConfig { program: shell }, + shell: ShellConfig { + program: shell, + env: self.shell_env.clone(), + }, terminal: TerminalConfig { scrollback_lines }, logging: LogConfig { auto_log, log_dir }, colors: ColorsConfig { diff --git a/src/config/tui_config_test.rs b/src/config/tui_config_test.rs index 5f8381b..821a40f 100644 --- a/src/config/tui_config_test.rs +++ b/src/config/tui_config_test.rs @@ -308,6 +308,7 @@ fn distinct_config() -> Config { }, shell: ShellConfig { program: Some("/bin/xyzsh".into()), + env: Vec::new(), }, terminal: TerminalConfig { scrollback_lines: 4097, diff --git a/src/drain_test.rs b/src/drain_test.rs index 073b886..fc2dd35 100644 --- a/src/drain_test.rs +++ b/src/drain_test.rs @@ -64,6 +64,7 @@ fn make_pane_entry() -> (PaneEntry, crossbeam_channel::Sender>) { pty_tx, "/bin/true", None, + &[], Box::new(|| {}), ) .expect("PTY spawn failed"); diff --git a/src/pane_ops.rs b/src/pane_ops.rs index 7f57df5..e4bc8e5 100644 --- a/src/pane_ops.rs +++ b/src/pane_ops.rs @@ -62,6 +62,7 @@ impl App { // Wakeup fires from the parser thread after each parsed batch. // Uses the app-level wakeup_pending flag (same Arc the main thread reads) // so the parser can cooperatively yield when the render thread is behind. + let env = self.state.config.shell.env.clone(); let proxy = self.proxy.clone(); let app_wakeup_pending = Arc::clone(&self.wakeup_pending); let wakeup = Box::new(move || { @@ -75,6 +76,7 @@ impl App { pty_tx, &shell, cwd.as_ref(), + &env, // PTY reader thread no longer calls wakeup; parser thread handles it. Box::new(|| {}), ) { diff --git a/src/pty/session.rs b/src/pty/session.rs index bb0e484..30d5c2c 100644 --- a/src/pty/session.rs +++ b/src/pty/session.rs @@ -58,7 +58,7 @@ impl PtySession { #[allow(dead_code)] pub fn spawn(cols: u16, rows: u16, output_tx: Sender>) -> anyhow::Result { let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); - Self::spawn_with_shell(cols, rows, output_tx, &shell, None, Box::new(|| {})) + Self::spawn_with_shell(cols, rows, output_tx, &shell, None, &[], Box::new(|| {})) } pub fn spawn_with_shell( @@ -67,6 +67,7 @@ impl PtySession { output_tx: Sender>, shell: &str, cwd: Option<&PathBuf>, + env: &[(String, String)], wakeup: Box, ) -> anyhow::Result { let pty_system = NativePtySystem::default(); @@ -81,6 +82,9 @@ impl PtySession { let mut cmd = CommandBuilder::new(shell); cmd.env("TERM", "xterm-256color"); + for (k, v) in env { + cmd.env(k, v); + } if let Some(dir) = cwd { cmd.cwd(dir); } diff --git a/src/pty/session_test.rs b/src/pty/session_test.rs index ed15f68..55711aa 100644 --- a/src/pty/session_test.rs +++ b/src/pty/session_test.rs @@ -33,7 +33,7 @@ fn libc_eio() -> i32 { #[test] fn spawn_with_shell_succeeds_with_bin_true() { let (tx, _rx) = unbounded(); - let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/true", None, Box::new(|| {})); + let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/true", None, &[], Box::new(|| {})); assert!( session.is_ok(), "spawn_with_shell failed: {:?}", @@ -44,8 +44,9 @@ fn spawn_with_shell_succeeds_with_bin_true() { #[test] fn write_bytes_after_spawn_does_not_panic() { let (tx, _rx) = unbounded(); - let mut session = PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", None, Box::new(|| {})) - .expect("spawn failed"); + let mut session = + PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", None, &[], Box::new(|| {})) + .expect("spawn failed"); // Writing to a live shell; ignore errors (shell may exit before write). let _ = session.write_input(b"exit\n"); } @@ -53,7 +54,7 @@ fn write_bytes_after_spawn_does_not_panic() { #[test] fn resize_after_spawn_does_not_panic() { let (tx, _rx) = unbounded(); - let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", None, Box::new(|| {})) + let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", None, &[], Box::new(|| {})) .expect("spawn failed"); let result = session.resize(120, 40); assert!(result.is_ok(), "resize failed: {:?}", result.err()); @@ -70,14 +71,15 @@ fn spawn_default_uses_env_shell() { fn spawn_with_cwd_sets_working_directory() { let (tx, _rx) = unbounded(); let cwd = std::path::PathBuf::from("/tmp"); - let result = PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", Some(&cwd), Box::new(|| {})); + let result = + PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", Some(&cwd), &[], Box::new(|| {})); assert!(result.is_ok(), "spawn with cwd failed: {:?}", result.err()); } #[test] fn cwd_returns_path_or_none_after_spawn() { let (tx, _rx) = unbounded(); - let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", None, Box::new(|| {})) + let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", None, &[], Box::new(|| {})) .expect("spawn failed"); // May return None on non-Linux; just assert no panic. let _ = session.cwd(); @@ -92,7 +94,7 @@ fn cwd_returns_path_or_none_after_spawn() { #[test] fn reader_thread_exits_on_pty_eof() { let (tx, rx) = unbounded::>(); - let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/true", None, Box::new(|| {})) + let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/true", None, &[], Box::new(|| {})) .expect("spawn failed"); // Keep the session (and thus the master) alive until the child has exited. let deadline = Instant::now() + Duration::from_secs(5); @@ -111,6 +113,48 @@ fn reader_thread_exits_on_pty_eof() { drop(session); } +/// An environment variable passed via `env` must be visible to the spawned +/// shell. We inject `MMTERM_TEST_ENV=hello`, then ask the shell to print it and +/// exit; the value must appear in the PTY output within the deadline. +#[test] +fn env_vars_are_injected_into_shell() { + let (tx, rx) = unbounded::>(); + let env = vec![("MMTERM_TEST_ENV".to_string(), "hello".to_string())]; + let mut session = + PtySession::spawn_with_shell(80, 24, tx, "/bin/sh", None, &env, Box::new(|| {})) + .expect("spawn failed"); + session + .write_input(b"printf '%s' \"$MMTERM_TEST_ENV\"; exit\n") + .expect("write failed"); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut output = Vec::new(); + loop { + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(bytes) => { + output.extend_from_slice(&bytes); + if String::from_utf8_lossy(&output).contains("hello") { + break; + } + } + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break, + Err(crossbeam_channel::RecvTimeoutError::Timeout) => {} + } + assert!( + Instant::now() < deadline, + "env var not observed in PTY output within 5s; got: {:?}", + String::from_utf8_lossy(&output) + ); + } + + assert!( + String::from_utf8_lossy(&output).contains("hello"), + "expected injected env var value 'hello' in output, got: {:?}", + String::from_utf8_lossy(&output) + ); + drop(session); +} + /// Verify that a child process does not become a zombie after it exits. /// /// Without the reaper thread in `spawn_with_shell`, every shell spawned for a @@ -126,7 +170,7 @@ fn reader_thread_exits_on_pty_eof() { #[cfg(target_os = "linux")] fn no_zombie_after_child_exits() { let (tx, _rx) = unbounded(); - let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/true", None, Box::new(|| {})) + let session = PtySession::spawn_with_shell(80, 24, tx, "/bin/true", None, &[], Box::new(|| {})) .expect("spawn failed"); let pid = match session.pid() { diff --git a/src/renderer/overlays_test.rs b/src/renderer/overlays_test.rs index 1a7782d..453f601 100644 --- a/src/renderer/overlays_test.rs +++ b/src/renderer/overlays_test.rs @@ -18,6 +18,7 @@ fn make_panel(value: &str, kind: FieldKind) -> ConfigPanel { status: None, collapsed: HashSet::new(), version: "", + shell_env: Vec::new(), } } @@ -331,6 +332,7 @@ fn make_section_panel(collapsed: bool) -> ConfigPanel { status: None, collapsed: c, version: "", + shell_env: Vec::new(), } }