stop tab from navigating menus
This commit is contained in:
+34
-3
@@ -47,7 +47,7 @@ impl App {
|
||||
) -> Result<Self> {
|
||||
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(),
|
||||
|
||||
+60
-3
@@ -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<PathBuf> {
|
||||
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<Terminal<'static, 'static>> {
|
||||
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<Self> {
|
||||
pub fn new(config: &Config, ctx: &egui::Context, cwd: Option<&Path>) -> Result<Self> {
|
||||
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<PathBuf> {
|
||||
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;
|
||||
|
||||
+10
-2
@@ -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<Self> {
|
||||
pub fn spawn(config: &Config, ctx: &egui::Context, cwd: Option<&Path>) -> Result<Self> {
|
||||
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<PathBuf> {
|
||||
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,
|
||||
|
||||
+62
-1
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user