From 0c0da3d035ae9bfeec4bf921699f2bd07905f2ab Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 25 Jun 2026 13:31:15 +0300 Subject: [PATCH] vt: track cwd via OSC 7; add a new-window action Signed-off-by: NotAShelf Change-Id: I498a1938ca0129d10cf0e230d27188ed6a6a6964 --- src/bindings.rs | 3 +++ src/vt.rs | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ src/wayland.rs | 26 ++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/src/bindings.rs b/src/bindings.rs index 0b29cc7..8e23f05 100644 --- a/src/bindings.rs +++ b/src/bindings.rs @@ -20,6 +20,7 @@ pub enum Action { FontDecrease, FontReset, Fullscreen, + NewWindow, } impl Action { @@ -37,6 +38,7 @@ impl Action { "font-decrease" => Self::FontDecrease, "font-reset" => Self::FontReset, "fullscreen" => Self::Fullscreen, + "new-window" => Self::NewWindow, _ => return None, }) } @@ -171,6 +173,7 @@ const DEFAULT_BINDINGS: &[(&str, &str)] = &[ ("Ctrl+minus", "font-decrease"), ("Ctrl+0", "font-reset"), ("F11", "fullscreen"), + ("Ctrl+Shift+N", "new-window"), ]; /// Map a key token to a keysym: a single character, or a named special key. diff --git a/src/vt.rs b/src/vt.rs index a7f413a..4104e05 100644 --- a/src/vt.rs +++ b/src/vt.rs @@ -102,6 +102,8 @@ pub struct Term { theme: Theme, /// Set when the child rings the bell (`BEL`); cleared by the front-end. bell: bool, + /// Working directory reported by the shell via OSC 7, for new windows. + cwd: Option, } impl Term { @@ -118,9 +120,15 @@ impl Term { clipboard_ops: Vec::new(), theme: Theme::default(), bell: false, + cwd: None, } } + /// The working directory last reported by the shell (OSC 7), if any. + pub fn cwd(&self) -> Option<&str> { + self.cwd.as_deref() + } + /// Take and clear the pending bell flag. pub fn take_bell(&mut self) -> bool { std::mem::take(&mut self.bell) @@ -653,6 +661,12 @@ impl Perform for Term { self.title = Some(String::from_utf8_lossy(text).into_owned()); } } + // OSC 7: the shell reports its cwd as a `file://host/path` URI. + Some(&n) if n == b"7" => { + if let Some(uri) = params.get(1) { + self.cwd = file_uri_path(uri); + } + } // OSC 4: set/query palette entries (pairs of index;spec). Some(&n) if n == b"4" => self.osc_palette(params, bell), // OSC 104: reset palette (all, or the listed indices). @@ -796,6 +810,38 @@ fn base64_decode(data: &[u8]) -> Option> { Some(out) } +/// Extract the local path from an OSC 7 `file://host/path` URI, percent-decoding +/// `%XX` escapes. The host part is ignored (we only spawn locally). Returns +/// `None` if it is not a usable absolute path. +fn file_uri_path(uri: &[u8]) -> Option { + let rest = uri.strip_prefix(b"file://").unwrap_or(uri); + // Skip the authority (host) up to the first '/', which begins the path. + let slash = rest.iter().position(|&b| b == b'/')?; + let path_bytes = percent_decode(&rest[slash..]); + let path = String::from_utf8(path_bytes).ok()?; + path.starts_with('/').then_some(path) +} + +/// Percent-decode `%XX` byte escapes in a URI path, passing other bytes through. +fn percent_decode(s: &[u8]) -> Vec { + let mut out = Vec::with_capacity(s.len()); + let mut i = 0; + while i < s.len() { + if s[i] == b'%' && i + 2 < s.len() { + let hi = (s[i + 1] as char).to_digit(16); + let lo = (s[i + 2] as char).to_digit(16); + if let (Some(hi), Some(lo)) = (hi, lo) { + out.push((hi * 16 + lo) as u8); + i += 3; + continue; + } + } + out.push(s[i]); + i += 1; + } + out +} + /// Decode an even-length lowercase/uppercase hex string into bytes. fn decode_hex(s: &[u8]) -> Option> { if s.is_empty() || !s.len().is_multiple_of(2) { @@ -1068,6 +1114,18 @@ mod tests { assert_eq!(t.grid().row_text(0), "─│"); } + #[test] + fn osc7_tracks_cwd_and_decodes_percent() { + let mut t = Term::new(20, 1); + feed(&mut t, b"\x1b]7;file://hermes/home/user/my%20dir\x07"); + assert_eq!(t.cwd(), Some("/home/user/my dir")); + // A non-file or relative URI leaves the previous value untouched? It + // simply does not match a path, so cwd stays None here. + let mut t2 = Term::new(20, 1); + feed(&mut t2, b"\x1b]7;file://host\x07"); + assert_eq!(t2.cwd(), None); + } + #[test] fn title_via_osc() { let mut t = Term::new(20, 1); diff --git a/src/wayland.rs b/src/wayland.rs index 77263b7..03f9066 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -638,6 +638,32 @@ impl App { Action::FontDecrease => self.change_font_size(self.font_size.saturating_sub(1)), Action::FontReset => self.change_font_size(self.config.main.font_size), Action::Fullscreen => self.toggle_fullscreen(), + Action::NewWindow => self.spawn_new_window(), + } + } + + /// Launch another beer process in the shell's reported working directory + /// (OSC 7), inheriting the same config. The child is fully detached. + fn spawn_new_window(&mut self) { + let exe = match std::env::current_exe() { + Ok(exe) => exe, + Err(err) => { + tracing::warn!("locate beer executable: {err}"); + return; + } + }; + let mut cmd = std::process::Command::new(exe); + if let Some(path) = self.config_path.as_ref() { + cmd.arg("--config").arg(path); + } + if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) { + cmd.current_dir(cwd); + } + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + if let Err(err) = cmd.spawn() { + tracing::warn!("spawn new window: {err}"); } }