diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-13 18:49:37 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-13 18:49:37 +0300 |
| commit | 1535a7117a47019f78d306ed4bba2300717357c1 (patch) | |
| tree | c7c73bbfb4ead927078a1e91885cd3929293c348 /src | |
| parent | 305d9577043291af271d16bdd2208f748e5949ba (diff) | |
feat add custom shell and pane keybind commands
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input.rs | 388 | ||||
| -rw-r--r-- | src/app/mod.rs | 41 | ||||
| -rw-r--r-- | src/app/state.rs | 1 | ||||
| -rw-r--r-- | src/config.rs | 531 | ||||
| -rw-r--r-- | src/pane.rs | 79 | ||||
| -rw-r--r-- | src/ui.rs | 10 | ||||
| -rw-r--r-- | src/workspace.rs | 83 |
7 files changed, 1028 insertions, 105 deletions
diff --git a/src/app/input.rs b/src/app/input.rs index 7cecfc8a..67f84108 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -1,5 +1,7 @@ //! Input handling — translates crossterm key/mouse events into state mutations. +use std::process::{Command, Stdio}; + use bytes::Bytes; use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; @@ -103,7 +105,7 @@ impl App { match self.state.mode { Mode::Onboarding => self.handle_onboarding_key(key), Mode::ReleaseNotes => self.handle_release_notes_key(key), - Mode::Navigate => handle_navigate_key(&mut self.state, key), + Mode::Navigate => self.handle_navigate_key(key), Mode::RenameWorkspace | Mode::RenameTab => { handle_rename_key(&mut self.state, key) } @@ -195,6 +197,164 @@ impl App { } } + fn handle_navigate_key(&mut self, key: KeyEvent) { + self.state.update_dismissed = true; + + if self.state.is_prefix(&key) || key.code == KeyCode::Esc { + leave_navigate_mode(&mut self.state); + return; + } + + if let Some(action) = navigate_action_for_key(&self.state, &key) { + execute_navigate_action(&mut self.state, action); + return; + } + + if handle_navigate_reserved_key(&mut self.state, key) { + return; + } + + if let Some(binding) = navigate_custom_command_for_key(&self.state, &key) { + self.launch_custom_command(binding); + } + } + + fn launch_custom_command(&mut self, binding: crate::config::CustomCommandKeybind) { + 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), + }; + match result { + Ok(()) => leave_navigate_mode(&mut self.state), + Err(err) => { + self.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::NeedsAttention, + title: "custom command failed".to_string(), + context: err.to_string(), + }); + self.sync_toast_deadline(previous_toast); + } + } + } + + fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) { + let mut env = vec![( + crate::api::SOCKET_PATH_ENV_VAR.to_string(), + crate::api::socket_path().display().to_string(), + )]; + if let Ok(current_exe) = std::env::current_exe() { + env.push(( + "HERDR_BIN_PATH".to_string(), + current_exe.display().to_string(), + )); + } + + let mut cwd = None; + if let Some(ws_idx) = self.state.active { + env.push(( + "HERDR_ACTIVE_WORKSPACE_ID".to_string(), + self.public_workspace_id(ws_idx), + )); + if let Some(workspace) = self.state.workspaces.get(ws_idx) { + let tab_idx = workspace.active_tab_index(); + if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) { + env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id)); + } + if let Some(pane_id) = workspace.focused_pane_id() { + if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { + env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id)); + } + if let Some(pane_cwd) = workspace + .active_tab() + .and_then(|tab| tab.cwd_for_pane(pane_id)) + { + env.push(( + "HERDR_ACTIVE_PANE_CWD".to_string(), + pane_cwd.display().to_string(), + )); + if pane_cwd.is_dir() { + cwd = Some(pane_cwd); + } + } + } + } + } + (env, cwd) + } + + fn spawn_custom_command( + &self, + binding: &crate::config::CustomCommandKeybind, + ) -> std::io::Result<()> { + let mut command = Command::new("/bin/sh"); + command + .arg("-lc") + .arg(&binding.command) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let (env, cwd) = self.custom_command_env(); + command.envs(env); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + command.spawn()?; + Ok(()) + } + + fn spawn_pane_command(&mut self, command: &str) -> std::io::Result<()> { + let Some(ws_idx) = self.state.active else { + return Err(std::io::Error::other("no active workspace")); + }; + let (rows, cols) = self.state.estimate_pane_size(); + let new_rows = rows.max(4); + let new_cols = cols.max(10); + let (env, _) = self.custom_command_env(); + + let ws = self + .state + .workspaces + .get_mut(ws_idx) + .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; + let tab_idx = ws.active_tab_index(); + let previous_focus = ws + .focused_pane_id() + .ok_or_else(|| std::io::Error::other("no focused pane"))?; + let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false); + let cwd = ws + .active_tab() + .and_then(|tab| tab.cwd_for_pane(previous_focus)); + let new_pane_id = ws.split_focused_command( + Direction::Horizontal, + new_rows, + new_cols, + cwd, + command, + &env, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + )?; + ws.active_tab_mut() + .expect("workspace must have an active tab") + .layout + .focus_pane(new_pane_id); + ws.active_tab_mut() + .expect("workspace must have an active tab") + .zoomed = true; + self.overlay_panes.insert( + new_pane_id, + super::OverlayPaneState { + ws_idx, + tab_idx, + previous_focus, + previous_zoomed, + }, + ); + self.state.mode = Mode::Terminal; + Ok(()) + } + pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) { if self.state.mode == Mode::ReleaseNotes { match mouse.kind { @@ -688,26 +848,30 @@ fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) { } } -fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { - state.update_dismissed = true; - - if state.is_prefix(&key) || key.code == KeyCode::Esc { - leave_navigate_mode(state); - return; - } - - if let Some(action) = navigate_action_for_key(state, &key) { - execute_navigate_action(state, action); - return; - } +fn navigate_custom_command_for_key( + state: &AppState, + key: &KeyEvent, +) -> Option<crate::config::CustomCommandKeybind> { + state + .keybinds + .custom_commands + .iter() + .find(|binding| key_matches(key, binding.key.0, binding.key.1)) + .cloned() +} +fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) -> bool { match key.code { - KeyCode::Char('q') => state.should_quit = true, + KeyCode::Char('q') => { + state.should_quit = true; + true + } KeyCode::Enter => { if !state.workspaces.is_empty() { state.switch_workspace(state.selected); leave_navigate_mode(state); } + true } KeyCode::Char(c @ '1'..='9') => { let idx = (c as usize) - ('1' as usize); @@ -715,31 +879,73 @@ fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { state.switch_workspace(idx); leave_navigate_mode(state); } + true } KeyCode::Char('s') => { open_settings(state); + true + } + KeyCode::Char('?') => { + open_keybind_help(state); + true } - KeyCode::Char('?') => open_keybind_help(state), KeyCode::Up => { if state.selected > 0 { state.selected -= 1; state.ensure_workspace_visible(state.selected); } + true } KeyCode::Down => { if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 { state.selected += 1; state.ensure_workspace_visible(state.selected); } + true } - KeyCode::Char('h') | KeyCode::Left => state.navigate_pane(NavDirection::Left), - KeyCode::Char('j') => state.navigate_pane(NavDirection::Down), - KeyCode::Char('k') => state.navigate_pane(NavDirection::Up), - KeyCode::Char('l') | KeyCode::Right => state.navigate_pane(NavDirection::Right), - KeyCode::Tab => state.cycle_pane(false), - KeyCode::BackTab => state.cycle_pane(true), - _ => {} + KeyCode::Char('h') | KeyCode::Left => { + state.navigate_pane(NavDirection::Left); + true + } + KeyCode::Char('j') => { + state.navigate_pane(NavDirection::Down); + true + } + KeyCode::Char('k') => { + state.navigate_pane(NavDirection::Up); + true + } + KeyCode::Char('l') | KeyCode::Right => { + state.navigate_pane(NavDirection::Right); + true + } + KeyCode::Tab => { + state.cycle_pane(false); + true + } + KeyCode::BackTab => { + state.cycle_pane(true); + true + } + _ => false, + } +} + +#[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key +fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { + state.update_dismissed = true; + + if state.is_prefix(&key) || key.code == KeyCode::Esc { + leave_navigate_mode(state); + return; + } + + if let Some(action) = navigate_action_for_key(state, &key) { + execute_navigate_action(state, action); + return; } + + let _ = handle_navigate_reserved_key(state, key); } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -3252,6 +3458,12 @@ impl AppState { #[cfg(test)] mod tests { use super::*; + use std::{ + fs, + path::Path, + time::{Duration, SystemTime, UNIX_EPOCH}, + }; + use crate::{config::Config, detect::Agent, workspace::Workspace}; use crossterm::event::{KeyModifiers, MouseEvent}; use ratatui::layout::Rect; @@ -3312,6 +3524,25 @@ mod tests { ) } + fn unique_temp_path(name: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id())) + } + + fn wait_for_file(path: &Path) -> String { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + if let Ok(content) = fs::read_to_string(path) { + return content; + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("timed out waiting for {}", path.display()); + } + fn root_layout_ratio(snapshot: &crate::persist::SessionSnapshot) -> Option<f32> { match &snapshot.workspaces.first()?.tabs.first()?.layout { crate::persist::LayoutSnapshot::Split { ratio, .. } => Some(*ratio), @@ -3439,6 +3670,119 @@ mod tests { assert_eq!(app.state.mode, Mode::Terminal); } + #[tokio::test] + async fn custom_command_runs_from_prefix_key_in_navigate_mode() { + 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(), + ); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let output_path = unique_temp_path("custom-command-keybind"); + let command = format!( + "printf '%s\\n%s\\n%s\\n' \"$HERDR_ACTIVE_WORKSPACE_ID\" \"$HERDR_ACTIVE_TAB_ID\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'", + output_path.display() + ); + app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { + key: (KeyCode::Char('g'), KeyModifiers::empty()), + label: "g".into(), + command, + action: crate::config::CustomCommandAction::Shell, + }]; + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + assert_eq!(app.state.mode, Mode::Navigate); + + app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) + .await; + + let content = wait_for_file(&output_path); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], app.state.workspaces[0].id); + assert_eq!(lines[1], format!("{}:1", app.state.workspaces[0].id)); + assert_eq!(lines[2], format!("{}-1", app.state.workspaces[0].id)); + assert_eq!(app.state.mode, Mode::Terminal); + + let _ = fs::remove_file(output_path); + } + + #[tokio::test] + async fn pane_overlay_command_opens_and_closes_after_exit() { + 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 workspace = Workspace::new( + std::env::current_dir().unwrap_or_else(|_| "/".into()), + 24, + 80, + app.state.pane_scrollback_limit_bytes, + app.state.host_terminal_theme, + app.event_tx.clone(), + app.render_notify.clone(), + app.render_dirty.clone(), + ) + .expect("workspace should spawn"); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let output_path = unique_temp_path("custom-pane-command"); + let command = format!("printf done > '{}'", output_path.display()); + app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind { + key: (KeyCode::Char('g'), KeyModifiers::empty()), + label: "g".into(), + command, + action: crate::config::CustomCommandAction::Pane, + }]; + + app.handle_key(TerminalKey::new( + app.state.prefix_code, + app.state.prefix_mods, + )) + .await; + app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())) + .await; + + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); + assert!(app.state.workspaces[0].tabs[0].zoomed); + + let _ = wait_for_file(&output_path); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < deadline { + if app.drain_internal_events() + && app.state.workspaces[0].tabs[0].layout.pane_count() == 1 + { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); + assert!(!app.state.workspaces[0].tabs[0].zoomed); + assert_eq!(app.state.mode, Mode::Terminal); + let _ = 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 985d2322..3b3cdbc3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,7 +8,7 @@ mod actions; mod input; pub mod state; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::future::pending; use std::io; use std::sync::atomic::{AtomicBool, Ordering}; @@ -36,6 +36,14 @@ use crate::workspace::Workspace; pub use state::{AppState, Mode, ToastKind, ViewState}; /// Full application: AppState + runtime concerns (event channels, async I/O). +#[derive(Debug, Clone, Copy)] +struct OverlayPaneState { + ws_idx: usize, + tab_idx: usize, + previous_focus: crate::layout::PaneId, + previous_zoomed: bool, +} + pub struct App { pub state: AppState, pub event_tx: mpsc::Sender<AppEvent>, @@ -58,6 +66,7 @@ pub struct App { suppressed_repeat_keys: HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, + overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>, } enum LoopEvent { @@ -355,6 +364,7 @@ impl App { last_terminal_size: terminal::size().ok(), render_notify, render_dirty, + overlay_panes: HashMap::new(), } } @@ -789,6 +799,12 @@ impl App { return; } + let overlay_state = if let AppEvent::PaneDied { pane_id } = &ev { + self.overlay_panes.remove(pane_id) + } else { + None + }; + if let AppEvent::PaneDied { pane_id } = &ev { if let Some((ws_idx, _)) = self.find_pane(*pane_id) { if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) { @@ -823,9 +839,32 @@ impl App { } } } + if let Some(overlay) = overlay_state { + self.restore_overlay_after_exit(overlay); + } self.sync_toast_deadline(previous_toast); } + fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) { + let Some(ws) = self.state.workspaces.get_mut(overlay.ws_idx) else { + return; + }; + if overlay.tab_idx >= ws.tabs.len() { + return; + } + + ws.active_tab = overlay.tab_idx; + let tab = &mut ws.tabs[overlay.tab_idx]; + if tab.panes.contains_key(&overlay.previous_focus) { + tab.layout.focus_pane(overlay.previous_focus); + } + tab.zoomed = overlay.previous_zoomed; + + if self.state.active == Some(overlay.ws_idx) { + self.state.mode = Mode::Terminal; + } + } + fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) { let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else { return; diff --git a/src/app/state.rs b/src/app/state.rs index c4b79cc3..00968c15 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -798,6 +798,7 @@ impl AppState { resize_mode_label: "r".into(), toggle_sidebar: (KeyCode::Char('b'), KeyModifiers::empty()), toggle_sidebar_label: "b".into(), + custom_commands: Vec::new(), }, spinner_tick: 0, palette: Palette::catppuccin(), diff --git a/src/config.rs b/src/config.rs index ce317d33..0e841b0d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -96,6 +96,36 @@ pub struct LiveKeybindConfig { pub keybinds: Keybinds, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum CommandKeybindType { + #[default] + Shell, + Pane, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct CommandKeybindConfig { + /// Navigate-mode key that runs a command after pressing the prefix key. + pub key: String, + /// Command executed either in the background shell or inside a pane. + pub command: String, + /// Command execution mode. Default: "shell". + #[serde(rename = "type")] + pub action_type: CommandKeybindType, +} + +impl Default for CommandKeybindConfig { + fn default() -> Self { + Self { + key: String::new(), + command: String::new(), + action_type: CommandKeybindType::Shell, + } + } +} + #[derive(Debug, Deserialize)] #[serde(default)] pub struct KeysConfig { @@ -141,6 +171,8 @@ pub struct KeysConfig { pub resize_mode: String, /// Toggle sidebar collapse. Default: "b" pub toggle_sidebar: String, + /// Prefix-mode custom command bindings. + pub command: Vec<CommandKeybindConfig>, } #[derive(Debug, Deserialize)] @@ -313,6 +345,7 @@ impl Default for KeysConfig { fullscreen: "f".into(), resize_mode: "r".into(), toggle_sidebar: "b".into(), + command: Vec::new(), } } } @@ -463,8 +496,15 @@ impl Config { Vec<String>, Keybinds, ) { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + enum BindingScope { + Navigate, + TerminalDirect, + } + #[derive(Clone)] - struct Binding<'a> { + struct RequiredBinding<'a> { + scope: BindingScope, field: &'a str, label: String, default_label: &'a str, @@ -472,23 +512,59 @@ impl Config { default: (KeyCode, KeyModifiers), } - let mut diagnostics = Vec::new(); - let (prefix, prefix_diag) = parse_key_combo_with_diagnostic( - &self.keys.prefix, - "keys.prefix", - (KeyCode::Char('b'), KeyModifiers::CONTROL), - ); - if let Some(diag) = &prefix_diag { - warn!(message = %diag, "config diagnostic"); + struct OptionalBinding { + scope: BindingScope, + field: &'static str, + value: Option<(KeyCode, KeyModifiers)>, + label: Option<String>, + } + + #[derive(Default)] + struct BindingRegistry { + seen: std::collections::HashMap<(BindingScope, KeyCode, KeyModifiers), String>, + } + + impl BindingRegistry { + fn register( + &mut self, + scope: BindingScope, + binding: (KeyCode, KeyModifiers), + field: &str, + ) -> Option<String> { + self.seen + .insert((scope, binding.0, binding.1), field.to_string()) + } + + fn conflict( + &self, + scope: BindingScope, + binding: (KeyCode, KeyModifiers), + ) -> Option<&str> { + self.seen + .get(&(scope, binding.0, binding.1)) + .map(String::as_str) + } + + fn reserve_if_unbound( + &mut self, + scope: BindingScope, + binding: (KeyCode, KeyModifiers), + field: &str, + ) { + self.seen + .entry((scope, binding.0, binding.1)) + .or_insert_with(|| field.to_string()); + } } - fn binding<'a>( + fn required_binding<'a>( + scope: BindingScope, field: &'a str, configured_label: &'a str, default_label: &'a str, default: (KeyCode, KeyModifiers), diagnostics: &mut Vec<String>, - ) -> Binding<'a> { + ) -> RequiredBinding<'a> { let (value, diag) = parse_key_combo_with_diagnostic(configured_label, field, default); let label = if let Some(diag) = diag { diagnostics.push(diag); @@ -496,7 +572,8 @@ impl Config { } else { configured_label.to_string() }; - Binding { + RequiredBinding { + scope, field, label, default_label, @@ -506,91 +583,128 @@ impl Config { } fn optional_binding( + scope: BindingScope, field: &'static str, configured_label: &str, diagnostics: &mut Vec<String>, - ) -> (Option<(KeyCode, KeyModifiers)>, Option<String>) { + ) -> OptionalBinding { if configured_label.trim().is_empty() { - return (None, None); + return OptionalBinding { + scope, + field, + value: None, + label: None, + }; } - let (value, diag) = parse_key_combo_with_diagnostic( - configured_label, - field, - (KeyCode::Null, KeyModifiers::empty()), - ); - if let Some(diag) = diag { - diagnostics.push(diag); - (None, None) - } else { - (Some(value), Some(configured_label.to_string())) + match parse_key_combo(configured_label) { + Some(value) => OptionalBinding { + scope, + field, + value: Some(value), + label: Some(configured_label.to_string()), + }, + None => { + let diag = format!( + "invalid keybinding: {field} = {:?}; disabling binding", + configured_label + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + OptionalBinding { + scope, + field, + value: None, + label: None, + } + } } } + let mut diagnostics = Vec::new(); + let (prefix, prefix_diag) = parse_key_combo_with_diagnostic( + &self.keys.prefix, + "keys.prefix", + (KeyCode::Char('b'), KeyModifiers::CONTROL), + ); + if let Some(diag) = &prefix_diag { + warn!(message = %diag, "config diagnostic"); + } + let mut bindings = vec![ - binding( + required_binding( + BindingScope::Navigate, "keys.new_workspace", &self.keys.new_workspace, "n", (KeyCode::Char('n'), KeyModifiers::empty()), &mut diagnostics, ), - binding( + required_binding( + BindingScope::Navigate, "keys.rename_workspace", &self.keys.rename_workspace, "shift+n", (KeyCode::Char('n'), KeyModifiers::SHIFT), &mut diagnostics, ), - binding( + required_binding( + BindingScope::Navigate, "keys.close_workspace", &self.keys.close_workspace, "d", (KeyCode::Char('d'), KeyModifiers::empty()), &mut diagnostics, ), - binding( + required_binding( + BindingScope::Navigate, "keys.new_tab", &self.keys.new_tab, "c", (KeyCode::Char('c'), KeyModifiers::empty()), &mut diagnostics, ), - binding( + required_binding( + BindingScope::Navigate, "keys.split_vertical", &self.keys.split_vertical, "v", (KeyCode::Char('v'), KeyModifiers::empty()), &mut diagnostics, ), - binding( + required_binding( + BindingScope::Navigate, "keys.split_horizontal", &self.keys.split_horizontal, "-", (KeyCode::Char('-'), KeyModifiers::empty()), &mut diagnostics, ), - binding( + required_binding( + BindingScope::Navigate, "keys.close_pane", &self.keys.close_pane, "x", (KeyCode::Char('x'), KeyModifiers::empty()), &mut diagnostics, ), - binding( + required_binding( + BindingScope::Navigate, "keys.fullscreen", &self.keys.fullscreen, "f", (KeyCode::Char('f'), KeyModifiers::empty()), &mut diagnostics, ), - binding( + required_binding( + BindingScope::Navigate, "keys.resize_mode", &self.keys.resize_mode, "r", (KeyCode::Char('r'), KeyModifiers::empty()), &mut diagnostics, ), - binding( + required_binding( + BindingScope::Navigate, "keys.toggle_sidebar", &self.keys.toggle_sidebar, "b", @@ -599,51 +713,72 @@ impl Config { ), ]; - let optional_bindings = [ + let mut optional_bindings = vec![ optional_binding( + BindingScope::Navigate, "keys.previous_workspace", &self.keys.previous_workspace, &mut diagnostics, ), optional_binding( + BindingScope::Navigate, "keys.next_workspace", &self.keys.next_workspace, &mut diagnostics, ), - optional_binding("keys.rename_tab", &self.keys.rename_tab, &mut diagnostics), optional_binding( + BindingScope::Navigate, + "keys.rename_tab", + &self.keys.rename_tab, + &mut diagnostics, + ), + optional_binding( + BindingScope::Navigate, "keys.previous_tab", &self.keys.previous_tab, &mut diagnostics, ), - optional_binding("keys.next_tab", &self.keys.next_tab, &mut diagnostics), - optional_binding("keys.close_tab", &self.keys.close_tab, &mut diagnostics), optional_binding( + BindingScope::Navigate, + "keys.next_tab", + &self.keys.next_tab, + &mut diagnostics, + ), + optional_binding( + BindingScope::Navigate, + "keys.close_tab", + &self.keys.close_tab, + &mut diagnostics, + ), + optional_binding( + BindingScope::TerminalDirect, "keys.focus_pane_left", &self.keys.focus_pane_left, &mut diagnostics, ), optional_binding( + BindingScope::TerminalDirect, "keys.focus_pane_down", &self.keys.focus_pane_down, &mut diagnostics, ), optional_binding( + BindingScope::TerminalDirect, "keys.focus_pane_up", &self.keys.focus_pane_up, &mut diagnostics, ), optional_binding( + BindingScope::TerminalDirect, "keys.focus_pane_right", &self.keys.focus_pane_right, &mut diagnostics, ), ]; - use std::collections::HashMap; - let mut seen: HashMap<(KeyCode, KeyModifiers), &str> = HashMap::new(); + let mut registry = BindingRegistry::default(); for binding in &mut bindings { - if let Some(first_field) = seen.get(&binding.value) { + if let Some(first_field) = registry.conflict(binding.scope, binding.value) { let diag = format!( "duplicate keybinding: {} conflicts with {}; using default {}", binding.field, first_field, binding.default_label @@ -652,9 +787,165 @@ impl Config { diagnostics.push(diag); binding.value = binding.default; binding.label = binding.default_label.to_string(); - } else { - seen.insert(binding.value, binding.field); } + registry.register(binding.scope, binding.value, binding.field); + } + + for binding in &mut optional_bindings { + let Some(value) = binding.value else { + continue; + }; + if let Some(first_field) = registry.conflict(binding.scope, value) { + let diag = format!( + "duplicate keybinding: {} conflicts with {}; disabling binding", + binding.field, first_field + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + binding.value = None; + binding.label = None; + continue; + } + registry.register(binding.scope, value, binding.field); + } + + registry.reserve_if_unbound(BindingScope::Navigate, prefix, "keys.prefix"); + for (field, binding) in [ + ("navigate.quit", (KeyCode::Char('q'), KeyModifiers::empty())), + ( + "navigate.open_workspace", + (KeyCode::Enter, KeyModifiers::empty()), + ), + ( + "navigate.settings", + (KeyCode::Char('s'), KeyModifiers::empty()), + ), + ( + "navigate.keybind_help", + (KeyCode::Char('?'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_up", + (KeyCode::Up, KeyModifiers::empty()), + ), + ( + "navigate.workspace_down", + (KeyCode::Down, KeyModifiers::empty()), + ), + ( + "navigate.focus_left", + (KeyCode::Char('h'), KeyModifiers::empty()), + ), + ( + "navigate.focus_down", + (KeyCode::Char('j'), KeyModifiers::empty()), + ), + ( + "navigate.focus_up", + (KeyCode::Char('k'), KeyModifiers::empty()), + ), + ( + "navigate.focus_right", + (KeyCode::Char('l'), KeyModifiers::empty()), + ), + ( + "navigate.arrow_left", + (KeyCode::Left, KeyModifiers::empty()), + ), + ( + "navigate.arrow_right", + (KeyCode::Right, KeyModifiers::empty()), + ), + ("navigate.tab_next", (KeyCode::Tab, KeyModifiers::empty())), + ( + "navigate.tab_prev", + (KeyCode::BackTab, KeyModifiers::empty()), + ), + ( + "navigate.workspace_1", + (KeyCode::Char('1'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_2", + (KeyCode::Char('2'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_3", + (KeyCode::Char('3'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_4", + (KeyCode::Char('4'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_5", + (KeyCode::Char('5'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_6", + (KeyCode::Char('6'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_7", + (KeyCode::Char('7'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_8", + (KeyCode::Char('8'), KeyModifiers::empty()), + ), + ( + "navigate.workspace_9", + (KeyCode::Char('9'), KeyModifiers::empty()), + ), + ("navigate.back", (KeyCode::Esc, KeyModifiers::empty())), + ] { + registry.reserve_if_unbound(BindingScope::Navigate, binding, field); + } + + let mut custom_commands = Vec::new(); + for (index, command) in self.keys.command.iter().enumerate() { + let key_field = format!("keys.command[{index}].key"); + let command_field = format!("keys.command[{index}].command"); + + if command.command.trim().is_empty() { + let diag = + format!("empty custom command: {command_field}; disabling custom command"); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + continue; + } + + let Some(binding) = parse_key_combo(&command.key) else { + let diag = format!( + "invalid keybinding: {} = {:?}; disabling custom command", + key_field, command.key + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + continue; + }; + + if let Some(first_field) = registry.conflict(BindingScope::Navigate, binding) { + let diag = format!( + "duplicate custom keybinding: {} conflicts with {}; disabling custom command", + key_field, first_field + ); + warn!(message = %diag, "config diagnostic"); + diagnostics.push(diag); + continue; + } + + registry.register(BindingScope::Navigate, binding, &key_field); + let action = match command.action_type { + CommandKeybindType::Shell => CustomCommandAction::Shell, + CommandKeybindType::Pane => CustomCommandAction::Pane, + }; + custom_commands.push(CustomCommandKeybind { + key: binding, + label: format_key_combo(binding), + command: command.command.clone(), + action, + }); } let keybinds = Keybinds { @@ -664,28 +955,28 @@ impl Config { rename_workspace_label: bindings[1].label.clone(), close_workspace: bindings[2].value, close_workspace_label: bindings[2].label.clone(), - previous_workspace: optional_bindings[0].0, - previous_workspace_label: optional_bindings[0].1.clone(), - next_workspace: optional_bindings[1].0, - next_workspace_label: optional_bindings[1].1.clone(), + previous_workspace: optional_bindings[0].value, + previous_workspace_label: optional_bindings[0].label.clone(), + next_workspace: optional_bindings[1].value, + next_workspace_label: optional_bindings[1].label.clone(), new_tab: bindings[3].value, new_tab_label: bindings[3].label.clone(), - rename_tab: optional_bindings[2].0, - rename_tab_label: optional_bindings[2].1.clone(), - previous_tab: optional_bindings[3].0, - previous_tab_label: optional_bindings[3].1.clone(), - next_tab: optional_bindings[4].0, - next_tab_label: optional_bindings[4].1.clone(), - close_tab: optional_bindings[5].0, - close_tab_label: optional_bindings[5].1.clone(), - focus_pane_left: optional_bindings[6].0, - focus_pane_left_label: optional_bindings[6].1.clone(), - focus_pane_down: optional_bindings[7].0, - focus_pane_down_label: optional_bindings[7].1.clone(), - focus_pane_up: optional_bindings[8].0, - focus_pane_up_label: optional_bindings[8].1.clone(), - focus_pane_right: optional_bindings[9].0, - focus_pane_right_label: optional_bindings[9].1.clone(), + rename_tab: optional_bindings[2].value, + rename_tab_label: optional_bindings[2].label.clone(), + previous_tab: optional_bindings[3].value, + previous_tab_label: optional_bindings[3].label.clone(), + next_tab: optional_bindings[4].value, + next_tab_label: optional_bindings[4].label.clone(), + close_tab: optional_bindings[5].value, + close_tab_label: optional_bindings[5].label.clone(), + focus_pane_left: optional_bindings[6].value, + focus_pane_left_label: optional_bindings[6].label.clone(), + focus_pane_down: optional_bindings[7].value, + focus_pane_down_label: optional_bindings[7].label.clone(), + focus_pane_up: optional_bindings[8].value, + focus_pane_up_label: optional_bindings[8].label.clone(), + focus_pane_right: optional_bindings[9].value, + focus_pane_right_label: optional_bindings[9].label.clone(), split_vertical: bindings[4].value, split_vertical_label: bindings[4].label.clone(), split_horizontal: bindings[5].value, @@ -698,12 +989,27 @@ impl Config { resize_mode_label: bindings[8].label.clone(), toggle_sidebar: bindings[9].value, toggle_sidebar_label: bindings[9].label.clone(), + custom_commands, }; (prefix_diag, prefix, diagnostics, keybinds) } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CustomCommandAction { + Shell, + Pane, +} + +#[derive(Debug, Clone)] +pub struct CustomCommandKeybind { + pub key: (KeyCode, KeyModifiers), + pub label: String, + pub command: String, + pub action: CustomCommandAction, +} + /// Parsed keybinds for navigate mode actions. #[derive(Debug, Clone)] pub struct Keybinds { @@ -747,6 +1053,7 @@ pub struct Keybinds { pub resize_mode_label: String, pub toggle_sidebar: (KeyCode, KeyModifiers), pub toggle_sidebar_label: String, + pub custom_commands: Vec<CustomCommandKeybind>, } /// Parse a color string into a ratatui Color. @@ -1171,6 +1478,7 @@ mod tests { 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')); + assert!(kb.custom_commands.is_empty()); } #[test] @@ -1326,6 +1634,103 @@ rename_workspace = "g" } #[test] + fn duplicate_optional_keybinding_is_disabled_with_diagnostic() { + let toml = r#" +[keys] +new_workspace = "g" +rename_tab = "g" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let diagnostics = config.collect_diagnostics(); + let kb = config.keybinds(); + + assert!(diagnostics + .iter() + .any(|d| d.contains("keys.rename_tab") && d.contains("disabling binding"))); + assert_eq!( + kb.new_workspace, + (KeyCode::Char('g'), KeyModifiers::empty()) + ); + assert_eq!(kb.rename_tab, None); + } + + #[test] + fn custom_command_keybinds_parse_from_toml() { + let toml = r#" +[[keys.command]] +key = "g" +command = "echo hi" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let kb = config.keybinds(); + + assert_eq!(kb.custom_commands.len(), 1); + assert_eq!( + kb.custom_commands[0].key, + (KeyCode::Char('g'), KeyModifiers::empty()) + ); + assert_eq!(kb.custom_commands[0].label, "g"); + assert_eq!(kb.custom_commands[0].command, "echo hi"); + assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Shell); + } + + #[test] + fn pane_custom_command_keybinds_parse_from_toml() { + let toml = r#" +[[keys.command]] +key = "g" +type = "pane" +command = "lazygit" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let kb = config.keybinds(); + + assert_eq!(kb.custom_commands.len(), 1); + assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Pane); + } + + #[test] + fn custom_command_conflicting_with_builtin_is_disabled_with_diagnostic() { + let toml = r#" +[keys] +new_workspace = "g" + +[[keys.command]] +key = "g" +command = "echo hi" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let diagnostics = config.collect_diagnostics(); + let kb = config.keybinds(); + + assert!(diagnostics.iter().any(|d| { + d.contains("duplicate custom keybinding") + && d.contains("keys.command[0].key") + && d.contains("keys.new_workspace") + })); + assert!(kb.custom_commands.is_empty()); + } + + #[test] + fn custom_command_conflicting_with_reserved_navigate_key_is_disabled_with_diagnostic() { + let toml = r#" +[[keys.command]] +key = "q" +command = "echo hi" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let diagnostics = config.collect_diagnostics(); + let kb = config.keybinds(); + + assert!(diagnostics.iter().any(|d| { + d.contains("duplicate custom keybinding") + && d.contains("keys.command[0].key") + && d.contains("navigate.quit") + })); + assert!(kb.custom_commands.is_empty()); + } + + #[test] fn live_keybinds_reject_invalid_keybinding() { let config: Config = toml::from_str( r#" diff --git a/src/pane.rs b/src/pane.rs index 7424638e..74fec4e4 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -1823,11 +1823,78 @@ impl PaneRuntime { cols: u16, cwd: std::path::PathBuf, scrollback_limit_bytes: usize, - _host_terminal_theme: crate::terminal_theme::TerminalTheme, + host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender<AppEvent>, render_notify: Arc<Notify>, render_dirty: Arc<AtomicBool>, ) -> std::io::Result<Self> { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); + let mut cmd = CommandBuilder::new(&shell); + cmd.cwd(cwd); + cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); + crate::integration::apply_pane_env(&mut cmd, pane_id); + Self::spawn_command_builder( + pane_id, + rows, + cols, + scrollback_limit_bytes, + host_terminal_theme, + events, + render_notify, + render_dirty, + cmd, + "failed to spawn shell", + ) + } + + pub fn spawn_shell_command( + pane_id: PaneId, + rows: u16, + cols: u16, + cwd: std::path::PathBuf, + command: &str, + extra_env: &[(String, String)], + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + events: mpsc::Sender<AppEvent>, + render_notify: Arc<Notify>, + render_dirty: Arc<AtomicBool>, + ) -> std::io::Result<Self> { + let mut cmd = CommandBuilder::new("/bin/sh"); + cmd.arg("-c"); + cmd.arg(command); + cmd.cwd(cwd); + cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); + crate::integration::apply_pane_env(&mut cmd, pane_id); + for (key, value) in extra_env { + cmd.env(key, value); + } + Self::spawn_command_builder( + pane_id, + rows, + cols, + scrollback_limit_bytes, + host_terminal_theme, + events, + render_notify, + render_dirty, + cmd, + "failed to spawn command pane", + ) + } + + fn spawn_command_builder( + pane_id: PaneId, + rows: u16, + cols: u16, + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + events: mpsc::Sender<AppEvent>, + render_notify: Arc<Notify>, + render_dirty: Arc<AtomicBool>, + cmd: CommandBuilder, + spawn_error_message: &'static str, + ) -> std::io::Result<Self> { let pty_system = native_pty_system(); let pair = pty_system .openpty(PtySize { @@ -1849,18 +1916,12 @@ impl PaneRuntime { let terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes) .map_err(|e| std::io::Error::other(e.to_string()))?; let pane_terminal = GhosttyPaneTerminal::new(terminal, input_tx.clone())?; - pane_terminal.apply_host_terminal_theme(_host_terminal_theme); + pane_terminal.apply_host_terminal_theme(host_terminal_theme); let terminal = Arc::new(PaneTerminal { ghostty: pane_terminal, }); let kitty_keyboard_flags = Arc::new(AtomicU16::new(0)); - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); - let mut cmd = CommandBuilder::new(&shell); - cmd.cwd(cwd); - cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE); - crate::integration::apply_pane_env(&mut cmd, pane_id); - let reader = pair .master .try_clone_reader() @@ -1889,7 +1950,7 @@ impl PaneRuntime { Err(e) => error!(pane = pane_id.raw(), err = %e, "child wait failed"), } } - Err(e) => error!(pane = pane_id.raw(), err = %e, "failed to spawn shell"), + Err(e) => error!(pane = pane_id.raw(), err = %e, "{spawn_error_message}"), } // Use blocking send — PaneDied is critical, must not be dropped if let Err(e) = rt.block_on(events.send(AppEvent::PaneDied { pane_id })) { @@ -2541,6 +2541,16 @@ fn keybind_help_groups(app: &AppState) -> Vec<(&'static str, Vec<(String, &'stat ]; groups.push(("panes", panes)); + if !kb.custom_commands.is_empty() { + groups.push(( + "custom", + kb.custom_commands + .iter() + .map(|binding| (binding.label.clone(), "custom command")) + .collect(), + )); + } + groups } diff --git a/src/workspace.rs b/src/workspace.rs index 09fa382d..23d9a8e1 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -122,20 +122,79 @@ impl Tab { scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, ) -> std::io::Result<PaneId> { - let new_id = self.layout.split_focused(direction); - let actual_cwd = - cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); - let runtime = PaneRuntime::spawn( - new_id, + self.split_focused_with_runtime( + direction, rows, cols, - actual_cwd.clone(), + cwd, scrollback_limit_bytes, host_terminal_theme, - self.events.clone(), - self.render_notify.clone(), - self.render_dirty.clone(), - )?; + None, + ) + } + + pub fn split_focused_command( + &mut self, + direction: Direction, + rows: u16, + cols: u16, + cwd: Option<PathBuf>, + command: &str, + extra_env: &[(String, String)], + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + ) -> std::io::Result<PaneId> { + self.split_focused_with_runtime( + direction, + rows, + cols, + cwd, + scrollback_limit_bytes, + host_terminal_theme, + Some((command, extra_env)), + ) + } + + fn split_focused_with_runtime( + &mut self, + direction: Direction, + rows: u16, + cols: u16, + cwd: Option<PathBuf>, + scrollback_limit_bytes: usize, + host_terminal_theme: crate::terminal_theme::TerminalTheme, + command: Option<(&str, &[(String, String)])>, + ) -> std::io::Result<PaneId> { + let new_id = self.layout.split_focused(direction); + let actual_cwd = + cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); + let runtime = if let Some((command, extra_env)) = command { + PaneRuntime::spawn_shell_command( + new_id, + rows, + cols, + actual_cwd.clone(), + command, + extra_env, + scrollback_limit_bytes, + host_terminal_theme, + self.events.clone(), + self.render_notify.clone(), + self.render_dirty.clone(), + )? + } else { + PaneRuntime::spawn( + new_id, + rows, + cols, + actual_cwd.clone(), + scrollback_limit_bytes, + host_terminal_theme, + self.events.clone(), + self.render_notify.clone(), + self.render_dirty.clone(), + )? + }; self.panes.insert(new_id, PaneState::new()); self.pane_cwds.insert(new_id, actual_cwd); self.runtimes.insert(new_id, runtime); @@ -325,6 +384,10 @@ impl Workspace { self.tabs.get(self.active_tab) } + pub fn active_tab_index(&self) -> usize { + self.active_tab + } + pub fn active_tab_mut(&mut self) -> Option<&mut Tab> { self.tabs.get_mut(self.active_tab) } |
