explicit scrollback editor command
This commit is contained in:
Generated
+1
@@ -2319,6 +2319,7 @@ dependencies = [
|
|||||||
"png",
|
"png",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
"serde",
|
"serde",
|
||||||
|
"shell-words",
|
||||||
"toml",
|
"toml",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ libghostty-vt = { version = "=0.2.1", features = ["png"] }
|
|||||||
png = "0.18"
|
png = "0.18"
|
||||||
portable-pty = "0.9"
|
portable-pty = "0.9"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
shell-words = "1"
|
||||||
toml = "0.9"
|
toml = "0.9"
|
||||||
wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] }
|
wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] }
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
# regular-only fonts retain synthetic bold/italic emphasis.
|
# regular-only fonts retain synthetic bold/italic emphasis.
|
||||||
font_size = 16
|
font_size = 16
|
||||||
scrollback = 10000
|
scrollback = 10000
|
||||||
|
# Command for opening scrollback; omit to use $EDITOR, or vi if unset.
|
||||||
|
# Supports quoted arguments; the scrollback file path is appended automatically.
|
||||||
|
# Runs directly without shell expansion. The editor should stay in the foreground.
|
||||||
|
# scrollback_editor = "nvim -R"
|
||||||
window_width = 1100
|
window_width = 1100
|
||||||
window_height = 720
|
window_height = 720
|
||||||
|
|
||||||
@@ -67,6 +71,8 @@ floating_max_height = 640
|
|||||||
"Alt+Shift+H" = "previous_tab"
|
"Alt+Shift+H" = "previous_tab"
|
||||||
"Alt+Shift+L" = "next_tab"
|
"Alt+Shift+L" = "next_tab"
|
||||||
"Alt+X" = "close_pane"
|
"Alt+X" = "close_pane"
|
||||||
|
# Open scrollback in scrollback_editor (or $EDITOR/vi), returning on exit.
|
||||||
|
"Alt+S" = "open_scrollback"
|
||||||
"Ctrl+Shift+Z" = "zoom_pane"
|
"Ctrl+Shift+Z" = "zoom_pane"
|
||||||
"Ctrl+Shift+R" = "reload_config"
|
"Ctrl+Shift+R" = "reload_config"
|
||||||
"Ctrl+Shift+C" = "copy"
|
"Ctrl+Shift+C" = "copy"
|
||||||
|
|||||||
@@ -207,6 +207,7 @@
|
|||||||
checkFlags = [
|
checkFlags = [
|
||||||
"--test-threads=1"
|
"--test-threads=1"
|
||||||
"--skip=app::tests::"
|
"--skip=app::tests::"
|
||||||
|
"--skip=pane::scrollback_tests::"
|
||||||
"--skip=tests::pty_executes_input_and_bracketed_paste_is_sanitized"
|
"--skip=tests::pty_executes_input_and_bracketed_paste_is_sanitized"
|
||||||
"--skip=tests::new_panes_start_in_the_directory_of_the_pane_they_came_from"
|
"--skip=tests::new_panes_start_in_the_directory_of_the_pane_they_came_from"
|
||||||
"--skip=tests::osc7_wins_over_the_process_directory_but_only_for_this_machine"
|
"--skip=tests::osc7_wins_over_the_process_directory_but_only_for_this_machine"
|
||||||
|
|||||||
@@ -235,6 +235,13 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::Paste => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
|
Action::Paste => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
|
||||||
|
Action::OpenScrollback => {
|
||||||
|
self.panes.get_mut(&active).unwrap().open_scrollback(
|
||||||
|
&self.config,
|
||||||
|
&self.theme,
|
||||||
|
ctx,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -425,6 +432,7 @@ impl eframe::App for App {
|
|||||||
("Zoom / restore", Action::ZoomPane),
|
("Zoom / restore", Action::ZoomPane),
|
||||||
("Next pane", Action::NextPane),
|
("Next pane", Action::NextPane),
|
||||||
("Close pane", Action::ClosePane),
|
("Close pane", Action::ClosePane),
|
||||||
|
("Open scrollback in editor", Action::OpenScrollback),
|
||||||
] {
|
] {
|
||||||
if ui.button(label).clicked() {
|
if ui.button(label).clicked() {
|
||||||
action = Some(a);
|
action = Some(a);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub struct Config {
|
|||||||
pub font_bold_italic_path: Option<PathBuf>,
|
pub font_bold_italic_path: Option<PathBuf>,
|
||||||
pub font_fallbacks: Vec<String>,
|
pub font_fallbacks: Vec<String>,
|
||||||
pub scrollback: usize,
|
pub scrollback: usize,
|
||||||
|
pub scrollback_editor: Option<String>,
|
||||||
pub window_width: f32,
|
pub window_width: f32,
|
||||||
pub window_height: f32,
|
pub window_height: f32,
|
||||||
pub theme: String,
|
pub theme: String,
|
||||||
@@ -77,6 +78,7 @@ pub enum Action {
|
|||||||
ReloadConfig,
|
ReloadConfig,
|
||||||
Copy,
|
Copy,
|
||||||
Paste,
|
Paste,
|
||||||
|
OpenScrollback,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Kitty {
|
impl Default for Kitty {
|
||||||
@@ -115,6 +117,7 @@ impl Default for Config {
|
|||||||
font_bold_italic_path: None,
|
font_bold_italic_path: None,
|
||||||
font_fallbacks: Vec::new(),
|
font_fallbacks: Vec::new(),
|
||||||
scrollback: 10000,
|
scrollback: 10000,
|
||||||
|
scrollback_editor: None,
|
||||||
window_width: 1100.0,
|
window_width: 1100.0,
|
||||||
window_height: 720.0,
|
window_height: 720.0,
|
||||||
theme: theme::DEFAULT_NAME.into(),
|
theme: theme::DEFAULT_NAME.into(),
|
||||||
@@ -135,6 +138,7 @@ impl Default for Config {
|
|||||||
("Alt+Shift+H", Action::PreviousTab),
|
("Alt+Shift+H", Action::PreviousTab),
|
||||||
("Alt+Shift+L", Action::NextTab),
|
("Alt+Shift+L", Action::NextTab),
|
||||||
("Alt+X", Action::ClosePane),
|
("Alt+X", Action::ClosePane),
|
||||||
|
("Alt+S", Action::OpenScrollback),
|
||||||
("Ctrl+Shift+Z", Action::ZoomPane),
|
("Ctrl+Shift+Z", Action::ZoomPane),
|
||||||
("Ctrl+Shift+R", Action::ReloadConfig),
|
("Ctrl+Shift+R", Action::ReloadConfig),
|
||||||
("Ctrl+Shift+C", Action::Copy),
|
("Ctrl+Shift+C", Action::Copy),
|
||||||
@@ -186,6 +190,14 @@ impl Config {
|
|||||||
!config.shell.is_empty() && !config.shell[0].is_empty(),
|
!config.shell.is_empty() && !config.shell[0].is_empty(),
|
||||||
"shell must contain an executable"
|
"shell must contain an executable"
|
||||||
);
|
);
|
||||||
|
if let Some(editor) = &config.scrollback_editor {
|
||||||
|
let command =
|
||||||
|
shell_words::split(editor).context("Invalid scrollback_editor command")?;
|
||||||
|
ensure!(
|
||||||
|
!command.is_empty() && !command[0].is_empty(),
|
||||||
|
"scrollback_editor must contain an executable"
|
||||||
|
);
|
||||||
|
}
|
||||||
ensure!(
|
ensure!(
|
||||||
(8.0..=48.0).contains(&config.font_size),
|
(8.0..=48.0).contains(&config.font_size),
|
||||||
"font_size must be 8..48"
|
"font_size must be 8..48"
|
||||||
@@ -307,6 +319,10 @@ mod tests {
|
|||||||
"font_fallbacks = [' ']",
|
"font_fallbacks = [' ']",
|
||||||
"font_family = 'Test'\nfont_path = '/font.ttf'",
|
"font_family = 'Test'\nfont_path = '/font.ttf'",
|
||||||
"shell = []",
|
"shell = []",
|
||||||
|
"scrollback_editor = ''",
|
||||||
|
"scrollback_editor = ' '",
|
||||||
|
"scrollback_editor = \"''\"",
|
||||||
|
"scrollback_editor = \"'\"",
|
||||||
"background = '#xx0000'",
|
"background = '#xx0000'",
|
||||||
"font_sze = 16",
|
"font_sze = 16",
|
||||||
"[keys]\n'Hyper+Q' = 'close_pane'",
|
"[keys]\n'Hyper+Q' = 'close_pane'",
|
||||||
@@ -330,6 +346,7 @@ mod tests {
|
|||||||
("Alt+Shift+H", Action::PreviousTab),
|
("Alt+Shift+H", Action::PreviousTab),
|
||||||
("Alt+Shift+L", Action::NextTab),
|
("Alt+Shift+L", Action::NextTab),
|
||||||
("Alt+X", Action::ClosePane),
|
("Alt+X", Action::ClosePane),
|
||||||
|
("Alt+S", Action::OpenScrollback),
|
||||||
] {
|
] {
|
||||||
assert_eq!(config.keys.get(binding), Some(&action));
|
assert_eq!(config.keys.get(binding), Some(&action));
|
||||||
assert!(shortcut(binding).unwrap().modifiers.alt);
|
assert!(shortcut(binding).unwrap().modifiers.alt);
|
||||||
|
|||||||
+193
-1
@@ -5,7 +5,7 @@ use crate::{
|
|||||||
render::{Grid, Renderer, cell_size},
|
render::{Grid, Renderer, cell_size},
|
||||||
theme::{self, Theme},
|
theme::{self, Theme},
|
||||||
};
|
};
|
||||||
use anyhow::{Result, ensure};
|
use anyhow::{Context, Result, ensure};
|
||||||
use egui::{FontId, Rect, Sense, vec2};
|
use egui::{FontId, Rect, Sense, vec2};
|
||||||
use libghostty_vt::{
|
use libghostty_vt::{
|
||||||
Terminal, TerminalOptions, mouse,
|
Terminal, TerminalOptions, mouse,
|
||||||
@@ -33,6 +33,134 @@ pub struct Pane {
|
|||||||
mouse_event: mouse::Event<'static>,
|
mouse_event: mouse::Event<'static>,
|
||||||
selection_start: Option<PointCoordinate>,
|
selection_start: Option<PointCoordinate>,
|
||||||
focused: bool,
|
focused: bool,
|
||||||
|
saved_session: Option<(Box<Pane>, ScrollbackFile)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScrollbackFile(PathBuf);
|
||||||
|
|
||||||
|
impl ScrollbackFile {
|
||||||
|
fn new(bytes: &[u8]) -> Result<Self> {
|
||||||
|
use std::{
|
||||||
|
fs::OpenOptions,
|
||||||
|
io::Write,
|
||||||
|
os::unix::fs::OpenOptionsExt,
|
||||||
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
|
};
|
||||||
|
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||||
|
loop {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"slerm-scrollback-{}-{}.txt",
|
||||||
|
std::process::id(),
|
||||||
|
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
match OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&path)
|
||||||
|
{
|
||||||
|
Ok(mut file) => {
|
||||||
|
let temporary = Self(path);
|
||||||
|
file.write_all(bytes)?;
|
||||||
|
return Ok(temporary);
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod scrollback_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_receives_history_and_restores_original_session_on_exit() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let config = Config {
|
||||||
|
shell: vec![
|
||||||
|
"/bin/sh".into(),
|
||||||
|
"-c".into(),
|
||||||
|
"read answer; printf ORIGINAL_ALIVE; read answer".into(),
|
||||||
|
],
|
||||||
|
scrollback_editor: Config::parse(
|
||||||
|
"scrollback_editor = \"/bin/sh -c 'printf EDITOR_READY; read answer' editor\"",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.scrollback_editor,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut pane = Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
|
||||||
|
pane.terminal.resize(12, 2, 8, 16).unwrap();
|
||||||
|
pane.terminal
|
||||||
|
.vt_write(b"history\r\n\x1b[31mred\x1b[0m\r\nlong line that wraps\r\nlast");
|
||||||
|
pane.open_scrollback(&config, &theme::DEFAULT, &ctx)
|
||||||
|
.unwrap();
|
||||||
|
let (original, file) = pane.saved_session.as_ref().unwrap();
|
||||||
|
assert!(!original.dead);
|
||||||
|
let path = file.0.clone();
|
||||||
|
let contents = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(
|
||||||
|
contents.contains("history\nred\nlong line that wraps\nlast"),
|
||||||
|
"{contents:?}"
|
||||||
|
);
|
||||||
|
assert!(!contents.contains('\x1b'));
|
||||||
|
pane.send(b"done\n".to_vec()).unwrap();
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while pane.saved_session.is_some() && Instant::now() < deadline {
|
||||||
|
pane.drain(&ctx);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
assert!(pane.saved_session.is_none());
|
||||||
|
assert!(!pane.dead);
|
||||||
|
assert!(!path.exists());
|
||||||
|
pane.send(b"continue\n".to_vec()).unwrap();
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
pane.drain(&ctx);
|
||||||
|
let text = libghostty_vt::fmt::Formatter::new(
|
||||||
|
&pane.terminal,
|
||||||
|
libghostty_vt::fmt::FormatterOptions::new().with_unwrap(true),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.format_alloc(None)
|
||||||
|
.unwrap();
|
||||||
|
if String::from_utf8_lossy(&text).contains("ORIGINAL_ALIVE") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"original process did not resume input"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_editor_leaves_the_original_pane_intact() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let config = Config {
|
||||||
|
shell: vec!["/bin/sh".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut pane = Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap();
|
||||||
|
for editor in ["", "'", "/no/such/slerm-editor"] {
|
||||||
|
assert!(
|
||||||
|
pane.open_scrollback_with_editor(&config, &theme::DEFAULT, &ctx, editor)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(pane.saved_session.is_none());
|
||||||
|
assert!(!pane.dead);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ScrollbackFile {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_file(&self.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OSC 7 carries a `file://host/path` URL. Only a directory on this machine can be
|
/// OSC 7 carries a `file://host/path` URL. Only a directory on this machine can be
|
||||||
@@ -181,10 +309,14 @@ impl Pane {
|
|||||||
mouse_event,
|
mouse_event,
|
||||||
selection_start: None,
|
selection_start: None,
|
||||||
focused: false,
|
focused: false,
|
||||||
|
saved_session: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn drain(&mut self, ctx: &egui::Context) {
|
pub fn drain(&mut self, ctx: &egui::Context) {
|
||||||
|
if let Some((session, _)) = &mut self.saved_session {
|
||||||
|
session.drain(ctx);
|
||||||
|
}
|
||||||
// Bound work per frame as well as queued bytes, so one busy PTY cannot starve others.
|
// Bound work per frame as well as queued bytes, so one busy PTY cannot starve others.
|
||||||
let mut drained = 0;
|
let mut drained = 0;
|
||||||
for _ in 0..64 {
|
for _ in 0..64 {
|
||||||
@@ -210,6 +342,66 @@ impl Pane {
|
|||||||
if drained == 64 {
|
if drained == 64 {
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
if self.dead
|
||||||
|
&& let Some((session, _file)) = self.saved_session.take()
|
||||||
|
{
|
||||||
|
*self = *session;
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_scrollback(
|
||||||
|
&mut self,
|
||||||
|
config: &Config,
|
||||||
|
theme: &Theme,
|
||||||
|
ctx: &egui::Context,
|
||||||
|
) -> Result<()> {
|
||||||
|
if self.saved_session.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let editor = config
|
||||||
|
.scrollback_editor
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| std::env::var("EDITOR").unwrap_or_else(|_| "vi".into()));
|
||||||
|
self.open_scrollback_with_editor(config, theme, ctx, &editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_scrollback_with_editor(
|
||||||
|
&mut self,
|
||||||
|
config: &Config,
|
||||||
|
theme: &Theme,
|
||||||
|
ctx: &egui::Context,
|
||||||
|
editor: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
use libghostty_vt::fmt::{Formatter, FormatterOptions};
|
||||||
|
let mut command =
|
||||||
|
shell_words::split(editor).context("Invalid scrollback editor command")?;
|
||||||
|
ensure!(
|
||||||
|
!command.is_empty() && !command[0].is_empty(),
|
||||||
|
"Scrollback editor command is empty"
|
||||||
|
);
|
||||||
|
self.drain(ctx);
|
||||||
|
let bytes = Formatter::new(
|
||||||
|
&self.terminal,
|
||||||
|
FormatterOptions::new().with_trim(true).with_unwrap(true),
|
||||||
|
)?
|
||||||
|
.format_alloc(None)?;
|
||||||
|
let file = ScrollbackFile::new(&bytes).context("Cannot save scrollback")?;
|
||||||
|
command.push(
|
||||||
|
file.0
|
||||||
|
.to_str()
|
||||||
|
.context("Scrollback path is not valid UTF-8")?
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
let mut editor_config = config.clone();
|
||||||
|
editor_config.shell = command;
|
||||||
|
let mut editor_pane = Self::new(&editor_config, theme, ctx, self.cwd().as_deref())
|
||||||
|
.context("Cannot open scrollback editor")?;
|
||||||
|
editor_pane.last_rect = self.last_rect;
|
||||||
|
let session = std::mem::replace(self, editor_pane);
|
||||||
|
self.saved_session = Some((Box::new(session), file));
|
||||||
|
ctx.request_repaint();
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send(&mut self, bytes: Vec<u8>) -> Result<()> {
|
pub fn send(&mut self, bytes: Vec<u8>) -> Result<()> {
|
||||||
|
|||||||
Reference in New Issue
Block a user