diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-07 00:07:24 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-07 16:29:44 +0300 |
| commit | 3c2b46d24aa6c8011a1e198143752f2aa414d585 (patch) | |
| tree | a52280bccc824ca656f8bc986a756f30901974e4 /src/pane | |
| parent | 5d7e56737ef30d9de5802b0684a9428c40131dda (diff) | |
feat: add native windows beta support
Diffstat (limited to 'src/pane')
| -rw-r--r-- | src/pane/kitty_keyboard.rs | 1 | ||||
| -rw-r--r-- | src/pane/osc.rs | 166 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 17 |
3 files changed, 183 insertions, 1 deletions
diff --git a/src/pane/kitty_keyboard.rs b/src/pane/kitty_keyboard.rs index 1b7d3274..89824ee3 100644 --- a/src/pane/kitty_keyboard.rs +++ b/src/pane/kitty_keyboard.rs @@ -81,6 +81,7 @@ impl KittyKeyboardTracker { } } + #[cfg(unix)] pub(crate) fn replay_ansi(&self) -> Option<String> { if self.stack.is_empty() { return (self.flags != 0).then(|| format!("\x1b[={}u", self.flags)); diff --git a/src/pane/osc.rs b/src/pane/osc.rs index 37407ade..1d37f1c0 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::path::PathBuf; use tracing::info; @@ -359,6 +360,145 @@ impl Osc52Forwarder { } } +/// Reconstructs cwd-reporting OSC sequences from child output. Shell +/// integrations commonly use OSC 7 (`file://...`), while Windows Terminal +/// documents OSC 9;9 for the same practical purpose. +#[derive(Debug, Default)] +pub(super) struct CwdOscTracker { + state: Osc52ForwarderState, + body: Vec<u8>, + pending: Vec<PathBuf>, +} + +impl CwdOscTracker { + pub(super) fn observe(&mut self, bytes: &[u8]) { + for &byte in bytes { + match self.state { + Osc52ForwarderState::Ground => { + if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } + } + Osc52ForwarderState::Escape => { + if byte == b']' { + self.body.clear(); + self.state = Osc52ForwarderState::OscBody; + } else if byte == 0x1b { + self.state = Osc52ForwarderState::Escape; + } else { + self.state = Osc52ForwarderState::Ground; + } + } + Osc52ForwarderState::OscBody => match byte { + 0x07 => { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } + 0x1b => self.state = Osc52ForwarderState::OscEscape, + _ => self.body.push(byte), + }, + Osc52ForwarderState::OscEscape => { + if byte == b'\\' { + self.finalize(); + self.state = Osc52ForwarderState::Ground; + } else { + self.body.push(0x1b); + self.body.push(byte); + self.state = Osc52ForwarderState::OscBody; + } + } + } + + if self.body.len() > 4096 { + self.body.clear(); + self.state = Osc52ForwarderState::Ground; + } + } + } + + fn finalize(&mut self) { + if let Some(cwd) = parse_cwd_osc(&self.body) { + self.pending.push(cwd); + } + self.body.clear(); + } + + pub(super) fn drain_latest(&mut self) -> Option<PathBuf> { + self.pending.drain(..).next_back() + } +} + +fn parse_cwd_osc(body: &[u8]) -> Option<PathBuf> { + let body = std::str::from_utf8(body).ok()?; + if let Some(uri) = body.strip_prefix("7;") { + return parse_file_uri_cwd(uri); + } + if let Some(path) = body.strip_prefix("9;9;") { + let path = path.trim().trim_matches('"'); + return (!path.is_empty()).then(|| PathBuf::from(path)); + } + None +} + +fn parse_file_uri_cwd(uri: &str) -> Option<PathBuf> { + let rest = uri.strip_prefix("file://")?; + let path = if rest.starts_with('/') { + rest + } else if let Some(slash) = rest.find('/') { + let host = &rest[..slash]; + if !(host.is_empty() || host.eq_ignore_ascii_case("localhost")) { + return None; + } + &rest[slash..] + } else { + rest + }; + let path = percent_decode_utf8(path)?; + + #[cfg(windows)] + { + let mut path = path; + if path.len() >= 3 + && path.as_bytes()[0] == b'/' + && path.as_bytes()[2] == b':' + && path.as_bytes()[1].is_ascii_alphabetic() + { + path.remove(0); + } + return Some(PathBuf::from(path.replace('/', "\\"))); + } + + #[cfg(not(windows))] + Some(PathBuf::from(path)) +} + +fn percent_decode_utf8(input: &str) -> Option<String> { + let bytes = input.as_bytes(); + let mut output = Vec::with_capacity(bytes.len()); + let mut idx = 0; + while idx < bytes.len() { + if bytes[idx] == b'%' { + let hi = *bytes.get(idx + 1)?; + let lo = *bytes.get(idx + 2)?; + output.push(hex_value(hi)? * 16 + hex_value(lo)?); + idx += 3; + } else { + output.push(bytes[idx]); + idx += 1; + } + } + String::from_utf8(output).ok() +} + +fn hex_value(byte: u8) -> Option<u8> { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + /// Accepts `52;c;<base64>` and `52;;<base64>`. /// Queries (`?`) are rejected because herdr has no reply path. /// The payload must decode as base64 before it is forwarded. @@ -601,6 +741,32 @@ mod tests { } #[test] + fn cwd_osc_tracker_detects_split_osc7_sequence() { + let mut tracker = CwdOscTracker::default(); + + tracker.observe(b"\x1b]7;file:///tmp/herdr%20repo"); + assert_eq!(tracker.drain_latest(), None); + tracker.observe(b"\x07"); + + assert_eq!( + tracker.drain_latest(), + Some(std::path::PathBuf::from("/tmp/herdr repo")) + ); + } + + #[test] + fn cwd_osc_tracker_detects_windows_terminal_cwd_sequence() { + let mut tracker = CwdOscTracker::default(); + + tracker.observe(b"\x1b]9;9;C:\\Users\\herdr\\src\\herdr\x1b\\"); + + assert_eq!( + tracker.drain_latest(), + Some(std::path::PathBuf::from("C:\\Users\\herdr\\src\\herdr")) + ); + } + + #[test] fn default_color_event_tracker_detects_queries_sets_and_resets() { let mut tracker = DefaultColorEventTracker::default(); diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index cf8506ba..9bfcf57f 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -23,7 +23,7 @@ use super::{ osc::{ contains_scrollback_clear_sequence, current_transient_default_color_owner, maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed, - write_host_terminal_theme, DefaultColorEvent, DefaultColorEventTracker, + write_host_terminal_theme, CwdOscTracker, DefaultColorEvent, DefaultColorEventTracker, DefaultColorOscTracker, DefaultColorQuery, DefaultColorTrackedEvent, Osc52Forwarder, }, xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse}, @@ -101,6 +101,7 @@ pub(crate) struct ProcessBytesResult { pub request_render: bool, pub render_delay: Option<Duration>, pub clipboard_writes: Vec<Vec<u8>>, + pub reported_cwd: Option<std::path::PathBuf>, pub terminal_responses: Vec<Bytes>, } @@ -123,6 +124,7 @@ pub(crate) struct GhosttyPaneCore { pub child_default_foreground_changed: bool, pub child_default_background_changed: bool, pub osc52_forwarder: Osc52Forwarder, + pub cwd_osc_tracker: CwdOscTracker, pub xtgettcap_query_tracker: XtgettcapQueryTracker, } @@ -264,6 +266,7 @@ impl PaneTerminal { self.ghostty.keyboard_protocol().unwrap_or(fallback) } + #[cfg(unix)] pub fn kitty_keyboard_state_ansi(&self) -> Option<String> { self.ghostty .kitty_keyboard_state_ansi() @@ -352,6 +355,7 @@ impl GhosttyPaneTerminal { child_default_foreground_changed: false, child_default_background_changed: false, osc52_forwarder: Osc52Forwarder::default(), + cwd_osc_tracker: CwdOscTracker::default(), xtgettcap_query_tracker: XtgettcapQueryTracker::default(), }), key_encoder: Mutex::new(key_encoder), @@ -419,6 +423,7 @@ impl GhosttyPaneTerminal { request_render: false, render_delay: None, clipboard_writes: Vec::new(), + reported_cwd: None, terminal_responses: Vec::new(), }; }; @@ -436,6 +441,8 @@ impl GhosttyPaneTerminal { core.osc52_forwarder.observe(bytes); let clipboard_writes = core.osc52_forwarder.drain_pending(); + core.cwd_osc_tracker.observe(bytes); + let reported_cwd = core.cwd_osc_tracker.drain_latest(); let alternate_screen = core .terminal @@ -507,6 +514,7 @@ impl GhosttyPaneTerminal { request_render, render_delay, clipboard_writes, + reported_cwd, terminal_responses, } } @@ -576,6 +584,7 @@ impl GhosttyPaneTerminal { } } + #[cfg(unix)] pub fn seed_handoff_input_state(&self, input_state: InputState) { let Ok(mut core) = self.core.lock() else { return; @@ -647,6 +656,7 @@ impl GhosttyPaneTerminal { } } + #[cfg(unix)] pub fn seed_keyboard_protocol_flags(&self, flags: u16) { if flags == 0 { return; @@ -654,6 +664,7 @@ impl GhosttyPaneTerminal { self.seed_keyboard_protocol_ansi(&format!("\x1b[>{flags}u")); } + #[cfg(unix)] pub fn seed_keyboard_protocol_ansi(&self, ansi: &str) { if ansi.is_empty() { return; @@ -785,6 +796,7 @@ impl GhosttyPaneTerminal { )) } + #[cfg(unix)] pub fn kitty_keyboard_state_ansi(&self) -> Option<String> { let core = self.core.lock().ok()?; core.kitty_keyboard.replay_ansi() @@ -2144,6 +2156,7 @@ mod tests { assert_eq!(encoded, b"\x1bOA"); } + #[cfg(unix)] #[test] fn ghostty_seed_handoff_input_state_restores_input_modes() { let (tx, _rx) = mpsc::channel(4); @@ -2254,6 +2267,7 @@ mod tests { assert_eq!(encoded, b"\x1b[13;2u"); } + #[cfg(unix)] #[test] fn ghostty_seed_keyboard_protocol_flags_restores_shift_enter_encoding() { let (tx, _rx) = mpsc::channel(4); @@ -2271,6 +2285,7 @@ mod tests { assert_eq!(encoded, b"\x1b[13;2u"); } + #[cfg(unix)] #[test] fn ghostty_keyboard_protocol_state_replays_nested_stack() { let (tx, _rx) = mpsc::channel(4); |
