From 51e1efb523b3710f98adfc374e27ac1d995a611f Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Tue, 8 Sep 2026 01:00:38 +0200 Subject: [PATCH] stop tab from navigating menus --- src/app.rs | 37 +++++++++++++++++++++++++++--- src/pane.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++--- src/pty.rs | 12 ++++++++-- src/tests.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 166 insertions(+), 9 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5f88da5..f967def 100644 --- a/src/app.rs +++ b/src/app.rs @@ -47,7 +47,7 @@ impl App { ) -> Result { cc.egui_ctx.set_visuals(egui::Visuals::dark()); cc.egui_ctx.set_fonts(fonts(&config)?); - let pane = Pane::new(&config, &cc.egui_ctx)?; + let pane = Pane::new(&config, &cc.egui_ctx, None)?; Ok(Self { config, config_path, @@ -65,7 +65,13 @@ impl App { self.panes.len() < self.config.multiplexer.max_panes, "Pane limit reached" ); - let pane = Pane::new(&self.config, ctx)?; + // A new pane starts where the pane it was spawned from currently sits. + let cwd = self + .tabs + .get(self.current) + .and_then(|tab| self.panes.get(&tab.active)) + .and_then(Pane::cwd); + let pane = Pane::new(&self.config, ctx, cwd.as_deref())?; let id = self.next_id; self.next_id += 1; self.panes.insert(id, pane); @@ -337,6 +343,7 @@ impl eframe::App for App { for pane in self.panes.values_mut() { pane.drain(ctx); } + grab_keys(ctx); if let Err(e) = self.keyboard(ctx) { self.error = Some(e.to_string()); } @@ -482,6 +489,30 @@ impl eframe::App for App { } } +/// Parks keyboard focus on a widget that never exists, with every navigation key +/// filtered out. Otherwise egui spends Tab, the arrows and Escape on moving focus +/// between the tab bar and the menu buttons instead of leaving them for the shell. +fn grab_keys(ctx: &egui::Context) { + let id = egui::Id::new("keys"); + // Registers the id for this pass so egui's dead-mans-switch keeps the focus. + ctx.check_for_id_clash(id, Rect::NOTHING, "key sink"); + ctx.memory_mut(|m| { + if m.focused() != Some(id) { + m.request_focus(id); + } + // Only applies once the sink has held focus for a full frame. + m.set_focus_lock_filter( + id, + egui::EventFilter { + tab: true, + horizontal_arrows: true, + vertical_arrows: true, + escape: true, + }, + ); + }); +} + fn show_pane( ui: &mut egui::Ui, id: PaneId, @@ -556,7 +587,7 @@ mod tests { shell: vec!["/bin/sh".into()], ..Default::default() }; - let pane = Pane::new(&config, ctx).unwrap(); + let pane = Pane::new(&config, ctx, None).unwrap(); App { config, config_path: PathBuf::new(), diff --git a/src/pane.rs b/src/pane.rs index 54f83b1..849ff04 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -11,7 +11,12 @@ use libghostty_vt::{ selection::{FormatOptions, Selection}, terminal::{self, Mode, Point, PointCoordinate, ScrollViewport}, }; -use std::{cell::RefCell, rc::Rc, sync::mpsc::TryRecvError}; +use std::{ + cell::RefCell, + path::{Path, PathBuf}, + rc::Rc, + sync::mpsc::TryRecvError, +}; pub struct Pane { pub terminal: Terminal<'static, 'static>, @@ -29,6 +34,47 @@ pub struct Pane { focused: bool, } +/// OSC 7 carries a `file://host/path` URL. Only a directory on this machine can be +/// handed to a new shell, so a remote host (an SSH session, say) is rejected here +/// and the caller falls back to the local process. +fn local_pwd(pwd: &str) -> Option { + if pwd.is_empty() { + return None; + } + // Some shells emit a bare path instead of a URL. + let Some(rest) = pwd.strip_prefix("file://") else { + return pwd.starts_with('/').then(|| PathBuf::from(pwd)); + }; + let (host, path) = rest.split_once('/')?; + let local = host.is_empty() + || host.eq_ignore_ascii_case("localhost") + || std::fs::read_to_string("/proc/sys/kernel/hostname") + .is_ok_and(|name| name.trim().eq_ignore_ascii_case(host)); + local.then(|| PathBuf::from(percent_decode(&format!("/{path}")))) +} + +fn percent_decode(path: &str) -> String { + let mut out = Vec::with_capacity(path.len()); + let mut bytes = path.bytes(); + while let Some(byte) = bytes.next() { + let decoded = (byte == b'%') + .then(|| { + let hex = [bytes.clone().next()?, bytes.clone().nth(1)?]; + let hex = std::str::from_utf8(&hex).ok()?; + u8::from_str_radix(hex, 16).ok() + }) + .flatten(); + match decoded { + Some(value) => { + bytes.nth(1); + out.push(value); + } + None => out.push(byte), + } + } + String::from_utf8_lossy(&out).into_owned() +} + pub fn terminal(config: &Config) -> Result> { let mut term = Terminal::new(TerminalOptions { cols: 80, @@ -68,12 +114,12 @@ pub fn apply_config(term: &mut Terminal<'_, '_>, config: &Config) -> Result<()> } impl Pane { - pub fn new(config: &Config, ctx: &egui::Context) -> Result { + pub fn new(config: &Config, ctx: &egui::Context, cwd: Option<&Path>) -> Result { let mut terminal = terminal(config)?; let renderer = Renderer::new()?; let mouse_encoder = mouse::Encoder::new()?; let mouse_event = mouse::Event::new()?; - let pty = Pty::spawn(config, ctx)?; + let pty = Pty::spawn(config, ctx, cwd)?; let input = pty.input.clone(); let callback_error = Rc::new(RefCell::new(None)); let errors = callback_error.clone(); @@ -170,6 +216,17 @@ impl Pane { .map(|b| String::from_utf8_lossy(&b).into_owned())) } + /// Where a pane spawned from this one should start: the directory the shell + /// reports through OSC 7, or the foreground process group's own directory. + pub fn cwd(&self) -> Option { + self.terminal + .pwd() + .ok() + .and_then(local_pwd) + .or_else(|| self.pty.cwd()) + .filter(|path| path.is_dir()) + } + pub fn focus(&mut self, focused: bool) -> Result<()> { if focused != self.focused { self.focused = focused; diff --git a/src/pty.rs b/src/pty.rs index d2c2afb..e80a594 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -3,6 +3,7 @@ use anyhow::{Context, Result}; use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; use std::{ io::{Read, Write}, + path::{Path, PathBuf}, sync::mpsc::{self, Receiver, Sender, SyncSender}, thread, }; @@ -18,7 +19,7 @@ pub struct Pty { } impl Pty { - pub fn spawn(config: &Config, ctx: &egui::Context) -> Result { + pub fn spawn(config: &Config, ctx: &egui::Context, cwd: Option<&Path>) -> Result { let pair = native_pty_system().openpty(PtySize { rows: 24, cols: 80, @@ -31,7 +32,7 @@ impl Pty { cmd.env("COLORTERM", "truecolor"); cmd.env("TERM_PROGRAM", "slerm"); cmd.env("TERM_PROGRAM_VERSION", env!("CARGO_PKG_VERSION")); - if let Some(dir) = &config.working_directory { + if let Some(dir) = cwd.or(config.working_directory.as_deref()) { cmd.cwd(dir); } let mut reader = pair.master.try_clone_reader()?; @@ -110,6 +111,13 @@ impl Pty { }) } + /// The working directory of the foreground process group, via /proc. Shells + /// that emit OSC 7 are reported by the terminal instead; this covers the rest. + pub fn cwd(&self) -> Option { + let pid = self.master.process_group_leader()?; + std::fs::read_link(format!("/proc/{pid}/cwd")).ok() + } + pub fn resize(&self, cols: u16, rows: u16, cw: u32, ch: u32) -> Result<()> { self.master.resize(PtySize { cols, diff --git a/src/tests.rs b/src/tests.rs index dfb354d..e482c57 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -173,7 +173,7 @@ fn pty_executes_input_and_bracketed_paste_is_sanitized() { ..Default::default() }; let ctx = egui::Context::default(); - let mut pane = pane::Pane::new(&config, &ctx).unwrap(); + let mut pane = pane::Pane::new(&config, &ctx, None).unwrap(); pane.send(b"printf '\\033[31mpty-%s\\033[0m\\n' 'success'\r".to_vec()) .unwrap(); let deadline = Instant::now() + Duration::from_secs(5); @@ -205,3 +205,64 @@ fn pty_executes_input_and_bracketed_paste_is_sanitized() { assert!(pane.dead); assert!(pane.send(b"after exit".to_vec()).is_err()); } + +#[test] +fn new_panes_start_in_the_directory_of_the_pane_they_came_from() { + let config = Config { + shell: vec!["/bin/sh".into()], + ..Default::default() + }; + let ctx = egui::Context::default(); + let dir = std::env::temp_dir().join(format!("slerm cwd {}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let dir = dir.canonicalize().unwrap(); + let mut pane = pane::Pane::new(&config, &ctx, None).unwrap(); + pane.send(format!("cd '{}'\r", dir.display()).into_bytes()) + .unwrap(); + // /bin/sh reports no OSC 7, so the directory has to come from the process. + let deadline = Instant::now() + Duration::from_secs(5); + while pane.cwd().as_deref() != Some(dir.as_path()) && Instant::now() < deadline { + pane.drain(&ctx); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(pane.cwd().as_deref(), Some(dir.as_path())); + + let mut child = pane::Pane::new(&config, &ctx, pane.cwd().as_deref()).unwrap(); + child + .send(b"printf 'at:%s\\n' \"$PWD\"\r".to_vec()) + .unwrap(); + let expected = format!("at:{}", dir.display()); + let deadline = Instant::now() + Duration::from_secs(5); + while !plain(&child.terminal).contains(&expected) && Instant::now() < deadline { + child.drain(&ctx); + std::thread::sleep(Duration::from_millis(10)); + } + assert!(plain(&child.terminal).contains(&expected)); + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn osc7_wins_over_the_process_directory_but_only_for_this_machine() { + let config = Config { + shell: vec!["/bin/sh".into()], + ..Default::default() + }; + let ctx = egui::Context::default(); + let reported = std::env::temp_dir().join(format!("slerm osc7 {}", std::process::id())); + std::fs::create_dir_all(&reported).unwrap(); + let reported = reported.canonicalize().unwrap(); + let mut pane = pane::Pane::new(&config, &ctx, None).unwrap(); + let process_cwd = pane.cwd().unwrap(); + assert_ne!(process_cwd, reported); + + let escaped = reported.display().to_string().replace(' ', "%20"); + pane.terminal + .vt_write(format!("\x1b]7;file://localhost{escaped}\x1b\\").as_bytes()); + assert_eq!(pane.cwd(), Some(reported.clone())); + + // A directory on another host cannot be entered here, so the process wins again. + pane.terminal + .vt_write(format!("\x1b]7;file://elsewhere{escaped}\x1b\\").as_bytes()); + assert_eq!(pane.cwd(), Some(process_cwd)); + std::fs::remove_dir_all(&reported).unwrap(); +}