stop tab from navigating menus
This commit is contained in:
+34
-3
@@ -47,7 +47,7 @@ impl App {
|
|||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
cc.egui_ctx.set_visuals(egui::Visuals::dark());
|
cc.egui_ctx.set_visuals(egui::Visuals::dark());
|
||||||
cc.egui_ctx.set_fonts(fonts(&config)?);
|
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 {
|
Ok(Self {
|
||||||
config,
|
config,
|
||||||
config_path,
|
config_path,
|
||||||
@@ -65,7 +65,13 @@ impl App {
|
|||||||
self.panes.len() < self.config.multiplexer.max_panes,
|
self.panes.len() < self.config.multiplexer.max_panes,
|
||||||
"Pane limit reached"
|
"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;
|
let id = self.next_id;
|
||||||
self.next_id += 1;
|
self.next_id += 1;
|
||||||
self.panes.insert(id, pane);
|
self.panes.insert(id, pane);
|
||||||
@@ -337,6 +343,7 @@ impl eframe::App for App {
|
|||||||
for pane in self.panes.values_mut() {
|
for pane in self.panes.values_mut() {
|
||||||
pane.drain(ctx);
|
pane.drain(ctx);
|
||||||
}
|
}
|
||||||
|
grab_keys(ctx);
|
||||||
if let Err(e) = self.keyboard(ctx) {
|
if let Err(e) = self.keyboard(ctx) {
|
||||||
self.error = Some(e.to_string());
|
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(
|
fn show_pane(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
id: PaneId,
|
id: PaneId,
|
||||||
@@ -556,7 +587,7 @@ mod tests {
|
|||||||
shell: vec!["/bin/sh".into()],
|
shell: vec!["/bin/sh".into()],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let pane = Pane::new(&config, ctx).unwrap();
|
let pane = Pane::new(&config, ctx, None).unwrap();
|
||||||
App {
|
App {
|
||||||
config,
|
config,
|
||||||
config_path: PathBuf::new(),
|
config_path: PathBuf::new(),
|
||||||
|
|||||||
+60
-3
@@ -11,7 +11,12 @@ use libghostty_vt::{
|
|||||||
selection::{FormatOptions, Selection},
|
selection::{FormatOptions, Selection},
|
||||||
terminal::{self, Mode, Point, PointCoordinate, ScrollViewport},
|
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 struct Pane {
|
||||||
pub terminal: Terminal<'static, 'static>,
|
pub terminal: Terminal<'static, 'static>,
|
||||||
@@ -29,6 +34,47 @@ pub struct Pane {
|
|||||||
focused: bool,
|
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>> {
|
pub fn terminal(config: &Config) -> Result<Terminal<'static, 'static>> {
|
||||||
let mut term = Terminal::new(TerminalOptions {
|
let mut term = Terminal::new(TerminalOptions {
|
||||||
cols: 80,
|
cols: 80,
|
||||||
@@ -68,12 +114,12 @@ pub fn apply_config(term: &mut Terminal<'_, '_>, config: &Config) -> Result<()>
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Pane {
|
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 mut terminal = terminal(config)?;
|
||||||
let renderer = Renderer::new()?;
|
let renderer = Renderer::new()?;
|
||||||
let mouse_encoder = mouse::Encoder::new()?;
|
let mouse_encoder = mouse::Encoder::new()?;
|
||||||
let mouse_event = mouse::Event::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 input = pty.input.clone();
|
||||||
let callback_error = Rc::new(RefCell::new(None));
|
let callback_error = Rc::new(RefCell::new(None));
|
||||||
let errors = callback_error.clone();
|
let errors = callback_error.clone();
|
||||||
@@ -170,6 +216,17 @@ impl Pane {
|
|||||||
.map(|b| String::from_utf8_lossy(&b).into_owned()))
|
.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<()> {
|
pub fn focus(&mut self, focused: bool) -> Result<()> {
|
||||||
if focused != self.focused {
|
if focused != self.focused {
|
||||||
self.focused = focused;
|
self.focused = focused;
|
||||||
|
|||||||
+10
-2
@@ -3,6 +3,7 @@ use anyhow::{Context, Result};
|
|||||||
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
|
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||||||
use std::{
|
use std::{
|
||||||
io::{Read, Write},
|
io::{Read, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
sync::mpsc::{self, Receiver, Sender, SyncSender},
|
sync::mpsc::{self, Receiver, Sender, SyncSender},
|
||||||
thread,
|
thread,
|
||||||
};
|
};
|
||||||
@@ -18,7 +19,7 @@ pub struct Pty {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
let pair = native_pty_system().openpty(PtySize {
|
||||||
rows: 24,
|
rows: 24,
|
||||||
cols: 80,
|
cols: 80,
|
||||||
@@ -31,7 +32,7 @@ impl Pty {
|
|||||||
cmd.env("COLORTERM", "truecolor");
|
cmd.env("COLORTERM", "truecolor");
|
||||||
cmd.env("TERM_PROGRAM", "slerm");
|
cmd.env("TERM_PROGRAM", "slerm");
|
||||||
cmd.env("TERM_PROGRAM_VERSION", env!("CARGO_PKG_VERSION"));
|
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);
|
cmd.cwd(dir);
|
||||||
}
|
}
|
||||||
let mut reader = pair.master.try_clone_reader()?;
|
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<()> {
|
pub fn resize(&self, cols: u16, rows: u16, cw: u32, ch: u32) -> Result<()> {
|
||||||
self.master.resize(PtySize {
|
self.master.resize(PtySize {
|
||||||
cols,
|
cols,
|
||||||
|
|||||||
+62
-1
@@ -173,7 +173,7 @@ fn pty_executes_input_and_bracketed_paste_is_sanitized() {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let ctx = egui::Context::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())
|
pane.send(b"printf '\\033[31mpty-%s\\033[0m\\n' 'success'\r".to_vec())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
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.dead);
|
||||||
assert!(pane.send(b"after exit".to_vec()).is_err());
|
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