aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-16 18:33:23 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-16 18:33:23 +0300
commit7b8f6990837ed2df63b533c7bbcd5bf6835efef7 (patch)
tree236232c2da60e011f3c2b1630f5a5e34fb7b022f /src
parent170d5674872fe030777ceb97e18b38a0e9ddbc2f (diff)
feat: add scrollback editor keybind
refs #122
Diffstat (limited to 'src')
-rw-r--r--src/app/api.rs4
-rw-r--r--src/app/input/navigate.rs193
-rw-r--r--src/app/mod.rs3
-rw-r--r--src/app/state.rs2
-rw-r--r--src/config/keybinds.rs32
-rw-r--r--src/config/model.rs3
-rw-r--r--src/main.rs1
-rw-r--r--src/ui/keybind_help.rs4
8 files changed, 229 insertions, 13 deletions
diff --git a/src/app/api.rs b/src/app/api.rs
index 8c929e9a..50a56624 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -149,6 +149,10 @@ impl App {
}
fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) {
+ for temp_file in &overlay.temp_files {
+ let _ = std::fs::remove_file(temp_file);
+ }
+
let Some(ws) = self.state.workspaces.get_mut(overlay.ws_idx) else {
return;
};
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 7675835b..7eb5b8d9 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -1,4 +1,9 @@
-use std::process::{Command, Stdio};
+use std::{
+ fs, io,
+ io::Write,
+ process::{Command, Stdio},
+ time::{SystemTime, UNIX_EPOCH},
+};
use bytes::Bytes;
use crossterm::event::{KeyCode, KeyEvent};
@@ -103,7 +108,11 @@ impl App {
}
if let Some(action) = navigate_action_for_key(&self.state, &key) {
- execute_navigate_action(&mut self.state, action);
+ if action == NavigateAction::EditScrollback {
+ self.launch_focused_scrollback_editor();
+ } else {
+ execute_navigate_action(&mut self.state, action);
+ }
return;
}
@@ -137,7 +146,9 @@ impl App {
let previous_toast = self.state.toast.clone();
let result = match binding.action {
crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding),
- crate::config::CustomCommandAction::Pane => self.spawn_pane_command(&binding.command),
+ crate::config::CustomCommandAction::Pane => {
+ self.spawn_pane_command(&binding.command, Vec::new())
+ }
};
match result {
Ok(()) => leave_navigate_mode(&mut self.state),
@@ -218,7 +229,67 @@ impl App {
Ok(())
}
- fn spawn_pane_command(&mut self, command: &str) -> std::io::Result<()> {
+ fn launch_focused_scrollback_editor(&mut self) {
+ let previous_toast = self.state.toast.clone();
+ match self.open_focused_scrollback_in_editor() {
+ Ok(()) => self.sync_toast_deadline(previous_toast),
+ Err(err) => {
+ self.state.toast = Some(crate::app::state::ToastNotification {
+ kind: crate::app::state::ToastKind::NeedsAttention,
+ title: "edit scrollback failed".to_string(),
+ context: err.to_string(),
+ target: None,
+ });
+ self.sync_toast_deadline(previous_toast);
+ }
+ }
+ }
+
+ fn open_focused_scrollback_in_editor(&mut self) -> std::io::Result<()> {
+ let ws_idx = self
+ .state
+ .active
+ .ok_or_else(|| std::io::Error::other("no active workspace"))?;
+ let ws = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
+ let pane_id = ws
+ .focused_pane_id()
+ .ok_or_else(|| std::io::Error::other("no focused pane"))?;
+ let scrollback = ws
+ .focused_runtime()
+ .ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))?
+ .recent_text(usize::MAX);
+
+ let path = write_scrollback_temp_file(&scrollback)?;
+
+ let quoted_path = shell_quote(&path.display().to_string());
+ let command = format!(
+ r#"scrollback_file={quoted_path}; eval "${{EDITOR:-vi}} \"\$scrollback_file\""; status=$?; rm -f "$scrollback_file"; exit $status"#
+ );
+ if let Err(err) = self.spawn_pane_command(&command, vec![path.clone()]) {
+ let _ = fs::remove_file(&path);
+ return Err(err);
+ }
+
+ if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) {
+ self.state.toast = Some(crate::app::state::ToastNotification {
+ kind: crate::app::state::ToastKind::Finished,
+ title: "opened scrollback".to_string(),
+ context: format!("focused pane {public_pane_id}"),
+ target: None,
+ });
+ }
+ Ok(())
+ }
+
+ fn spawn_pane_command(
+ &mut self,
+ command: &str,
+ temp_files: Vec<std::path::PathBuf>,
+ ) -> std::io::Result<()> {
let Some(ws_idx) = self.state.active else {
return Err(std::io::Error::other("no active workspace"));
};
@@ -264,6 +335,7 @@ impl App {
tab_idx,
previous_focus,
previous_zoomed,
+ temp_files,
},
);
self.state.mode = Mode::Terminal;
@@ -397,6 +469,7 @@ pub(crate) enum NavigateAction {
SplitVertical,
SplitHorizontal,
ClosePane,
+ EditScrollback,
Fullscreen,
EnterResizeMode,
ToggleSidebar,
@@ -506,6 +579,12 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateA
{
return Some(NavigateAction::RenamePane);
}
+ if kb
+ .edit_scrollback
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::EditScrollback);
+ }
if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) {
return Some(NavigateAction::SplitVertical);
}
@@ -642,6 +721,7 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
state.close_pane();
leave_navigate_mode(state);
}
+ NavigateAction::EditScrollback => {}
NavigateAction::Fullscreen => {
state.toggle_fullscreen();
leave_navigate_mode(state);
@@ -674,6 +754,65 @@ fn leave_navigate_mode(state: &mut AppState) {
}
}
+fn write_scrollback_temp_file(content: &str) -> io::Result<std::path::PathBuf> {
+ let mut last_collision = None;
+ for attempt in 0..16 {
+ let path = unique_scrollback_path(attempt);
+ let mut options = fs::OpenOptions::new();
+ options.write(true).create_new(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ options.mode(0o600);
+ }
+
+ match options.open(&path) {
+ Ok(mut file) => {
+ file.write_all(content.as_bytes())?;
+ return Ok(path);
+ }
+ Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
+ last_collision = Some(err);
+ }
+ Err(err) => return Err(err),
+ }
+ }
+
+ Err(last_collision.unwrap_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "failed to create unique scrollback temp file",
+ )
+ }))
+}
+
+fn unique_scrollback_path(attempt: u32) -> std::path::PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or(0);
+ std::env::temp_dir().join(format!(
+ "herdr-scrollback-{}-{nanos}-{attempt}.txt",
+ std::process::id()
+ ))
+}
+
+fn shell_quote(value: &str) -> String {
+ if !value.is_empty()
+ && value.chars().all(|ch| {
+ ch.is_ascii_alphanumeric()
+ || matches!(
+ ch,
+ '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-'
+ )
+ })
+ {
+ return value.to_string();
+ }
+
+ format!("'{}'", value.replace('\'', "'\\''"))
+}
+
#[cfg(test)]
mod tests {
use std::time::Duration;
@@ -979,6 +1118,52 @@ mod tests {
let _ = std::fs::remove_file(output_path);
}
+ #[tokio::test]
+ async fn edit_scrollback_key_opens_focused_runtime_scrollback_in_editor_pane() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ let mut workspace = Workspace::test_new("test");
+ let root_pane = workspace.tabs[0].root_pane;
+ workspace.tabs[0].runtimes.insert(
+ root_pane,
+ crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, b"alpha\nbeta\n"),
+ );
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Navigate;
+
+ let output_path = unique_temp_path("edit-scrollback");
+ let previous_editor = std::env::var_os("EDITOR");
+ std::env::set_var(
+ "EDITOR",
+ format!("sh -c 'cp \"$1\" {}' sh", output_path.display()),
+ );
+ app.state.keybinds.edit_scrollback = Some((KeyCode::Char('g'), KeyModifiers::empty()));
+ app.state.keybinds.edit_scrollback_label = Some("g".into());
+
+ app.handle_navigate_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()));
+
+ match previous_editor {
+ Some(value) => std::env::set_var("EDITOR", value),
+ None => std::env::remove_var("EDITOR"),
+ }
+
+ let content = wait_for_file(&output_path);
+ assert!(content.contains("alpha"));
+ assert!(content.contains("beta"));
+ assert_eq!(app.state.mode, Mode::Terminal);
+
+ let _ = std::fs::remove_file(output_path);
+ }
+
#[test]
fn fullscreen_action_exits_navigate_mode() {
let mut state = state_with_workspaces(&["test"]);
diff --git a/src/app/mod.rs b/src/app/mod.rs
index a332503f..644329ab 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -48,12 +48,13 @@ use crate::events::AppEvent;
pub use state::{AppState, Mode, ToastKind, ViewState};
/// Full application: AppState + runtime concerns (event channels, async I/O).
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone)]
pub(crate) struct OverlayPaneState {
ws_idx: usize,
tab_idx: usize,
previous_focus: crate::layout::PaneId,
previous_zoomed: bool,
+ temp_files: Vec<std::path::PathBuf>,
}
pub struct App {
diff --git a/src/app/state.rs b/src/app/state.rs
index d0d40c72..b51d500f 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1113,6 +1113,8 @@ impl AppState {
close_tab_label: None,
rename_pane: None,
rename_pane_label: None,
+ edit_scrollback: None,
+ edit_scrollback_label: None,
focus_pane_left: None,
focus_pane_left_label: None,
focus_pane_down: None,
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
index de4f03a9..9a1dc496 100644
--- a/src/config/keybinds.rs
+++ b/src/config/keybinds.rs
@@ -95,6 +95,8 @@ pub struct Keybinds {
pub close_tab_label: Option<String>,
pub rename_pane: Option<(KeyCode, KeyModifiers)>,
pub rename_pane_label: Option<String>,
+ pub edit_scrollback: Option<(KeyCode, KeyModifiers)>,
+ pub edit_scrollback_label: Option<String>,
pub focus_pane_left: Option<(KeyCode, KeyModifiers)>,
pub focus_pane_left_label: Option<String>,
pub focus_pane_down: Option<(KeyCode, KeyModifiers)>,
@@ -463,6 +465,12 @@ impl Config {
&mut diagnostics,
),
optional_binding(
+ navigate_scope(),
+ "keys.edit_scrollback",
+ &self.keys.edit_scrollback,
+ &mut diagnostics,
+ ),
+ optional_binding(
terminal_direct_scope(),
"keys.focus_pane_left",
&self.keys.focus_pane_left,
@@ -767,14 +775,16 @@ impl Config {
close_tab_label: optional_bindings[10].label.clone(),
rename_pane: optional_bindings[11].value,
rename_pane_label: optional_bindings[11].label.clone(),
- focus_pane_left: optional_bindings[12].value,
- focus_pane_left_label: optional_bindings[12].label.clone(),
- focus_pane_down: optional_bindings[13].value,
- focus_pane_down_label: optional_bindings[13].label.clone(),
- focus_pane_up: optional_bindings[14].value,
- focus_pane_up_label: optional_bindings[14].label.clone(),
- focus_pane_right: optional_bindings[15].value,
- focus_pane_right_label: optional_bindings[15].label.clone(),
+ edit_scrollback: optional_bindings[12].value,
+ edit_scrollback_label: optional_bindings[12].label.clone(),
+ focus_pane_left: optional_bindings[13].value,
+ focus_pane_left_label: optional_bindings[13].label.clone(),
+ focus_pane_down: optional_bindings[14].value,
+ focus_pane_down_label: optional_bindings[14].label.clone(),
+ focus_pane_up: optional_bindings[15].value,
+ focus_pane_up_label: optional_bindings[15].label.clone(),
+ focus_pane_right: optional_bindings[16].value,
+ focus_pane_right_label: optional_bindings[16].label.clone(),
split_vertical: bindings[4].value,
split_vertical_label: bindings[4].label.clone(),
split_horizontal: bindings[5].value,
@@ -1082,6 +1092,7 @@ mod tests {
assert_eq!(kb.split_vertical.0, KeyCode::Char('v'));
assert_eq!(kb.split_horizontal.0, KeyCode::Char('-'));
assert_eq!(kb.close_pane.0, KeyCode::Char('x'));
+ assert_eq!(kb.edit_scrollback, None);
assert_eq!(kb.fullscreen.0, KeyCode::Char('f'));
assert_eq!(kb.resize_mode.0, KeyCode::Char('r'));
assert_eq!(kb.toggle_sidebar.0, KeyCode::Char('b'));
@@ -1104,6 +1115,7 @@ resize_mode = "ctrl+r"
toggle_sidebar = "tab"
previous_agent = "alt+a"
next_agent = "alt+d"
+edit_scrollback = "e"
focus_pane_left = "alt+h"
focus_pane_right = "alt+right"
"#;
@@ -1140,6 +1152,10 @@ focus_pane_right = "alt+right"
);
assert_eq!(kb.next_agent, Some((KeyCode::Char('d'), KeyModifiers::ALT)));
assert_eq!(
+ kb.edit_scrollback,
+ Some((KeyCode::Char('e'), KeyModifiers::empty()))
+ );
+ assert_eq!(
kb.focus_pane_left,
Some((KeyCode::Char('h'), KeyModifiers::ALT))
);
diff --git a/src/config/model.rs b/src/config/model.rs
index ed8c4dd5..f389b18b 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -103,6 +103,8 @@ pub struct KeysConfig {
pub close_tab: String,
/// Rename the focused pane. Unset by default.
pub rename_pane: String,
+ /// Open the focused pane scrollback in $EDITOR. Unset by default.
+ pub edit_scrollback: String,
/// Focus the pane to the left in terminal mode. Unset by default.
pub focus_pane_left: String,
/// Focus the pane below in terminal mode. Unset by default.
@@ -198,6 +200,7 @@ impl Default for KeysConfig {
next_tab: "".into(),
close_tab: "".into(),
rename_pane: "".into(),
+ edit_scrollback: "".into(),
focus_pane_left: "".into(),
focus_pane_down: "".into(),
focus_pane_up: "".into(),
diff --git a/src/main.rs b/src/main.rs
index 16c11406..7d268a4d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -97,6 +97,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# next_tab = "" # optional, unset by default
# close_tab = "" # optional, unset by default
# rename_pane = "" # optional, unset by default
+# edit_scrollback = "" # optional, opens focused pane scrollback in $EDITOR
# focus_pane_left = "" # optional, unset by default
# focus_pane_down = "" # optional, unset by default
# focus_pane_up = "" # optional, unset by default
diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs
index ccc32c5c..7b524be8 100644
--- a/src/ui/keybind_help.rs
+++ b/src/ui/keybind_help.rs
@@ -104,6 +104,10 @@ pub(super) fn keybind_help_groups(
(kb.split_horizontal_label.clone(), "split horizontal"),
(kb.close_pane_label.clone(), "close pane"),
(optional_keybind_label(&kb.rename_pane_label), "rename pane"),
+ (
+ optional_keybind_label(&kb.edit_scrollback_label),
+ "edit scrollback",
+ ),
(kb.fullscreen_label.clone(), "fullscreen"),
(kb.resize_mode_label.clone(), "resize mode"),
(kb.toggle_sidebar_label.clone(), "toggle sidebar"),