clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 0c0da3d035ae
tarballunverified3 files changed+87-0
@@ -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.@@ -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<String>, } 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<Vec<u8>> { 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<String> {+ 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<u8> {+ 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<Vec<u8>> { if s.is_empty() || !s.len().is_multiple_of(2) {@@ -1069,6 +1115,18 @@ mod tests { } #[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); feed(&mut t, b"\x1b]0;hello\x07");@@ -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}"); } }