//! Definition of the actions that can be bound to keys. pub use super::command::{OpenFilePayload, RunCommandAction}; use super::layout::{ FloatingPaneLayout, Layout, PluginAlias, RunPlugin, RunPluginLocation, RunPluginOrAlias, SwapFloatingLayout, SwapTiledLayout, TabLayoutInfo, TiledPaneLayout, }; use crate::cli::CliAction; use crate::data::{ CommandOrPlugin, Direction, KeyWithModifier, LayoutInfo, NewPanePlacement, OriginatingPlugin, PaneId, Resize, UnblockCondition, }; use crate::data::{FloatingPaneCoordinates, InputMode}; use crate::home::{find_default_config_dir, get_layout_dir}; use crate::input::config::{Config, ConfigError, KdlError}; use crate::input::mouse::MouseEvent; use crate::input::options::{OnForceClose, PaneFrameStyle}; use miette::{NamedSource, Report}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use uuid::Uuid; use std::path::PathBuf; use std::str::FromStr; use crate::position::Position; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub enum ResizeDirection { Left, Right, Up, Down, Increase, Decrease, } impl FromStr for ResizeDirection { type Err = String; fn from_str(s: &str) -> Result { match s { "Left" | "left" => Ok(ResizeDirection::Left), "Right" | "right" => Ok(ResizeDirection::Right), "Up" | "up" => Ok(ResizeDirection::Up), "Down" | "down" => Ok(ResizeDirection::Down), "Increase" | "increase" | "+" => Ok(ResizeDirection::Increase), "Decrease" | "decrease" | "-" => Ok(ResizeDirection::Decrease), _ => Err(format!( "Failed to parse ResizeDirection. Unknown ResizeDirection: {}", s )), } } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] pub enum SearchDirection { Down, Up, } impl FromStr for SearchDirection { type Err = String; fn from_str(s: &str) -> Result { match s { "Down" | "down" => Ok(SearchDirection::Down), "Up" | "up" => Ok(SearchDirection::Up), _ => Err(format!( "Failed to parse SearchDirection. Unknown SearchDirection: {}", s )), } } } #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub enum SearchOption { CaseSensitivity, WholeWord, Wrap, } impl FromStr for SearchOption { type Err = String; fn from_str(s: &str) -> Result { match s { "CaseSensitivity" | "casesensitivity" | "Casesensitivity" => { Ok(SearchOption::CaseSensitivity) }, "WholeWord" | "wholeword" | "Wholeword" => Ok(SearchOption::WholeWord), "Wrap" | "wrap" => Ok(SearchOption::Wrap), _ => Err(format!( "Failed to parse SearchOption. Unknown SearchOption: {}", s )), } } } // As these actions are bound to the default config, please // do take care when refactoring - or renaming. // They might need to be adjusted in the default config // as well `../../assets/config/default.yaml` /// Actions that can be bound to keys. #[derive( Clone, Debug, PartialEq, Eq, Deserialize, Serialize, strum_macros::Display, strum_macros::EnumString, strum_macros::EnumIter, )] #[strum(ascii_case_insensitive)] pub enum Action { /// Quit Zellij. Quit, /// Write to the terminal. Write { key_with_modifier: Option, bytes: Vec, is_kitty_keyboard_protocol: bool, }, /// Write Characters to the terminal. WriteChars { chars: String, }, /// Write to a specific pane by ID. WriteToPaneId { bytes: Vec, pane_id: PaneId, }, /// Write Characters to a specific pane by ID. WriteCharsToPaneId { chars: String, pane_id: PaneId, }, /// Paste text using bracketed paste mode, optionally to a specific pane. Paste { chars: String, pane_id: Option, }, /// Switch to the specified input mode. SwitchToMode { input_mode: InputMode, }, /// Switch all connected clients to the specified input mode. SwitchModeForAllClients { input_mode: InputMode, }, /// Shrink/enlarge focused pane at specified border Resize { resize: Resize, direction: Option, }, /// Switch focus to next pane in specified direction. FocusNextPane, FocusPreviousPane, /// Switch focus to the last focused pane. FocusLastPane, /// Move the focus pane in specified direction. SwitchFocus, MoveFocus { direction: Direction, }, /// Tries to move the focus pane in specified direction. /// If there is no pane in the direction, move to previous/next Tab. MoveFocusOrTab { direction: Direction, }, MovePane { direction: Option, }, MovePaneBackwards, /// Clear all buffers of a current screen ClearScreen, /// Dumps the screen to a file or STDOUT DumpScreen { file_path: Option, include_scrollback: bool, pane_id: Option, ansi: bool, }, /// Dumps DumpLayout, /// Save the current session state to disk SaveSession, EditScrollback { ansi: bool, }, /// Scroll up in focus pane. ScrollUp, /// Scroll up at point ScrollUpAt { position: Position, }, /// Scroll down in focus pane. ScrollDown, /// Scroll down at point ScrollDownAt { position: Position, }, /// Scroll down to bottom in focus pane. ScrollToBottom, /// Scroll up to top in focus pane. ScrollToTop, /// Scroll up one page in focus pane. PageScrollUp, /// Scroll down one page in focus pane. PageScrollDown, /// Scroll up half page in focus pane. HalfPageScrollUp, /// Scroll down half page in focus pane. HalfPageScrollDown, /// Toggle between fullscreen focus pane and normal layout. ToggleFocusFullscreen, ToggleFocusNoUiFullscreen, /// Toggle frames around panes in the UI TogglePaneFrames, SetPaneFrameStyle(PaneFrameStyle), /// Toggle between sending text commands to all panes on the current tab and normal mode. ToggleActiveSyncTab, /// Open a new pane in the specified direction (relative to focus). /// If no direction is specified, will try to use the biggest available space. NewPane { direction: Option, pane_name: Option, start_suppressed: bool, }, /// Returns: Created pane ID (format: terminal_) NewBlockingPane { placement: NewPanePlacement, pane_name: Option, command: Option, unblock_condition: Option, near_current_pane: bool, no_focus: bool, tab_id: Option, /// Opaque agent-facing hint; zellij never interprets it. purpose: Option, }, /// Open the file in a new pane using the default editor /// Returns: Created pane ID (format: terminal_) EditFile { payload: OpenFilePayload, direction: Option, floating: bool, in_place: bool, close_replaced_pane: bool, start_suppressed: bool, coordinates: Option, near_current_pane: bool, no_focus: bool, tab_id: Option, }, /// Open a new floating pane /// Returns: Created pane ID (format: terminal_ or plugin_) NewFloatingPane { command: Option, pane_name: Option, coordinates: Option, near_current_pane: bool, no_focus: bool, tab_id: Option, /// Opaque agent-facing hint; zellij never interprets it. purpose: Option, }, /// Open a new tiled (embedded, non-floating) pane /// Returns: Created pane ID (format: terminal_ or plugin_) NewTiledPane { direction: Option, command: Option, pane_name: Option, near_current_pane: bool, no_focus: bool, borderless: Option, tab_id: Option, /// Opaque agent-facing hint; zellij never interprets it. purpose: Option, }, /// Open a new pane in place of the focused one, suppressing it instead /// Returns: Created pane ID (format: terminal_ or plugin_) NewInPlacePane { command: Option, pane_name: Option, near_current_pane: bool, no_focus: bool, pane_id_to_replace: Option, close_replaced_pane: bool, tab_id: Option, }, /// Returns: Created pane ID (format: terminal_ or plugin_) NewStackedPane { command: Option, pane_name: Option, near_current_pane: bool, no_focus: bool, tab_id: Option, /// Opaque agent-facing hint; zellij never interprets it. purpose: Option, }, /// Embed focused pane in tab if floating or float focused pane if embedded TogglePaneEmbedOrFloating, /// Toggle the visibility of all floating panes (if any) in the current Tab ToggleFloatingPanes, /// Show all floating panes in the specified tab (or active tab if tab_id is None) ShowFloatingPanes { tab_id: Option, }, /// Hide all floating panes in the specified tab (or active tab if tab_id is None) HideFloatingPanes { tab_id: Option, }, /// Check if floating panes are visible in the specified tab (or active tab if tab_id is None) AreFloatingPanesVisible { tab_id: Option, }, /// Close the focus pane. CloseFocus, PaneNameInput { input: Vec, }, UndoRenamePane, /// Create a new tab, optionally with a specified tab layout. NewTab { tiled_layout: Option, floating_layouts: Vec, swap_tiled_layouts: Option>, swap_floating_layouts: Option>, tab_name: Option, should_change_focus_to_new_tab: bool, cwd: Option, initial_panes: Option>, first_pane_unblock_condition: Option, }, /// Do nothing. NoOp, /// Go to the next tab. GoToNextTab, /// Go to the previous tab. GoToPreviousTab, /// Close the current tab. CloseTab, GoToTab { index: u32, }, GoToTabName { name: String, create: bool, }, ToggleTab, TabNameInput { input: Vec, }, UndoRenameTab, MoveTab { direction: Direction, }, /// Run specified command in new pane. Run { command: RunCommandAction, near_current_pane: bool, no_focus: bool, }, /// Set pane default foreground/background color SetPaneColor { pane_id: PaneId, fg: Option, bg: Option, }, /// Detach session and exit Detach, /// Switch the host-terminal theme mode to dark (uses configured `theme_dark`). SetDarkTheme, /// Switch the host-terminal theme mode to light (uses configured `theme_light`). SetLightTheme, /// Toggle between dark and light host-terminal theme modes. ToggleTheme, /// Switch to a different session SwitchSession { name: String, tab_position: Option, pane_id: Option<(u32, bool)>, // (id, is_plugin) layout: Option, cwd: Option, }, /// Returns: Plugin pane ID (format: plugin_) when creating or focusing plugin LaunchOrFocusPlugin { plugin: RunPluginOrAlias, should_float: bool, move_to_focused_tab: bool, should_open_in_place: bool, close_replaced_pane: bool, skip_cache: bool, tab_id: Option, /// Close the plugin instead of focusing it when it is ALREADY the focused /// pane, so one chord both opens and dismisses it. /// /// Needed because a plugin cannot do this itself: keybindings are /// resolved into actions before a key can reach a pane, so the chord that /// launched a plugin never arrives at it -- not even through /// intercept_key_presses, which only sees keys already destined for a /// pane. close_if_focused: bool, }, /// Returns: Plugin pane ID (format: plugin_) LaunchPlugin { plugin: RunPluginOrAlias, should_float: bool, should_open_in_place: bool, close_replaced_pane: bool, skip_cache: bool, cwd: Option, no_focus: bool, tab_id: Option, }, MouseEvent { event: MouseEvent, }, Copy, /// Confirm a prompt Confirm, /// Deny a prompt Deny, /// Confirm an action that invokes a prompt automatically SkipConfirm { action: Box, }, /// Search for String SearchInput { input: Vec, }, /// Search for something Search { direction: SearchDirection, }, /// Toggle case sensitivity of search SearchToggleOption { option: SearchOption, }, ToggleMouseMode, ToggleMobileMode, PreviousSwapLayout, NextSwapLayout, /// Override the layout of the active tab OverrideLayout { tabs: Vec, retain_existing_terminal_panes: bool, retain_existing_plugin_panes: bool, apply_only_to_active_tab: bool, }, /// Query all tab names QueryTabNames, /// Open a new tiled (embedded, non-floating) plugin pane /// Returns: Created pane ID (format: plugin_) NewTiledPluginPane { plugin: RunPluginOrAlias, pane_name: Option, skip_cache: bool, cwd: Option, no_focus: bool, tab_id: Option, }, /// Returns: Created pane ID (format: plugin_) NewFloatingPluginPane { plugin: RunPluginOrAlias, pane_name: Option, skip_cache: bool, cwd: Option, coordinates: Option, no_focus: bool, tab_id: Option, }, /// Returns: Created pane ID (format: plugin_) NewInPlacePluginPane { plugin: RunPluginOrAlias, pane_name: Option, skip_cache: bool, close_replaced_pane: bool, no_focus: bool, tab_id: Option, }, StartOrReloadPlugin { plugin: RunPluginOrAlias, }, CloseTerminalPane { pane_id: u32, }, ClosePluginPane { pane_id: u32, }, FocusTerminalPaneWithId { pane_id: u32, should_float_if_hidden: bool, should_be_in_place_if_hidden: bool, }, FocusPluginPaneWithId { pane_id: u32, should_float_if_hidden: bool, should_be_in_place_if_hidden: bool, }, RenameTerminalPane { pane_id: u32, name: Vec, }, RenamePluginPane { pane_id: u32, name: Vec, }, RenameTab { tab_index: u32, name: Vec, }, GoToTabById { id: u64, }, CloseTabById { id: u64, }, RenameTabById { id: u64, name: String, }, BreakPane, BreakPaneRight, BreakPaneLeft, FocusHostSession, FocusGuestSession, ToggleHostFullscreen, RenameSession { name: String, }, CliPipe { pipe_id: String, name: Option, payload: Option, args: Option>, plugin: Option, configuration: Option>, launch_new: bool, skip_cache: bool, floating: Option, in_place: Option, cwd: Option, pane_title: Option, }, KeybindPipe { name: Option, payload: Option, args: Option>, plugin: Option, plugin_id: Option, // supercedes plugin if present configuration: Option>, launch_new: bool, skip_cache: bool, floating: Option, in_place: Option, cwd: Option, pane_title: Option, }, ListClients, ListPanes { show_tab: bool, show_command: bool, show_state: bool, show_geometry: bool, show_all: bool, output_json: bool, }, ListTabs { show_state: bool, show_dimensions: bool, show_panes: bool, show_layout: bool, show_all: bool, output_json: bool, }, CurrentTabInfo { output_json: bool, }, TogglePanePinned, StackPanes { pane_ids: Vec, }, ChangeFloatingPaneCoordinates { pane_id: PaneId, coordinates: FloatingPaneCoordinates, }, TogglePaneBorderless { pane_id: PaneId, }, SetPaneBorderless { pane_id: PaneId, borderless: bool, }, TogglePaneInGroup, ToggleGroupMarking, // Pane-targeting CLI-only variants ScrollUpByPaneId { pane_id: PaneId, }, ScrollDownByPaneId { pane_id: PaneId, }, ScrollToTopByPaneId { pane_id: PaneId, }, ScrollToBottomByPaneId { pane_id: PaneId, }, PageScrollUpByPaneId { pane_id: PaneId, }, PageScrollDownByPaneId { pane_id: PaneId, }, HalfPageScrollUpByPaneId { pane_id: PaneId, }, HalfPageScrollDownByPaneId { pane_id: PaneId, }, ResizeByPaneId { pane_id: PaneId, resize: Resize, direction: Option, }, MovePaneByPaneId { pane_id: PaneId, direction: Option, }, MovePaneBackwardsByPaneId { pane_id: PaneId, }, ClearScreenByPaneId { pane_id: PaneId, }, EditScrollbackByPaneId { pane_id: PaneId, ansi: bool, }, ToggleFocusFullscreenByPaneId { pane_id: PaneId, }, ToggleFocusNoUiFullscreenByPaneId { pane_id: PaneId, }, TogglePaneEmbedOrFloatingByPaneId { pane_id: PaneId, }, CloseFocusByPaneId { pane_id: PaneId, }, RenamePaneByPaneId { pane_id: Option, name: Vec, }, UndoRenamePaneByPaneId { pane_id: PaneId, }, TogglePanePinnedByPaneId { pane_id: PaneId, }, FocusPaneByPaneId { pane_id: PaneId, }, // Tab-targeting CLI-only variants UndoRenameTabByTabId { id: u64, }, ToggleActiveSyncTabByTabId { id: u64, }, ToggleFloatingPanesByTabId { id: u64, }, PreviousSwapLayoutByTabId { id: u64, }, NextSwapLayoutByTabId { id: u64, }, MoveTabByTabId { id: u64, direction: Direction, }, } impl Default for Action { fn default() -> Self { Action::NoOp } } impl Default for SearchDirection { fn default() -> Self { SearchDirection::Down } } impl Default for SearchOption { fn default() -> Self { SearchOption::CaseSensitivity } } impl Action { /// Checks that two Action are match except their mutable attributes. pub fn shallow_eq(&self, other_action: &Action) -> bool { match (self, other_action) { (Action::NewTab { .. }, Action::NewTab { .. }) => true, (Action::LaunchOrFocusPlugin { .. }, Action::LaunchOrFocusPlugin { .. }) => true, (Action::LaunchPlugin { .. }, Action::LaunchPlugin { .. }) => true, (Action::OverrideLayout { .. }, Action::OverrideLayout { .. }) => true, _ => self == other_action, } } pub fn actions_from_cli( cli_action: CliAction, get_current_dir: Box PathBuf>, config: Option, ) -> Result, String> { match cli_action { CliAction::Write { bytes, pane_id } => match pane_id { Some(pane_id_str) => { let parsed_pane_id = PaneId::from_str(&pane_id_str); match parsed_pane_id { Ok(parsed_pane_id) => { Ok(vec![Action::WriteToPaneId { bytes, pane_id: parsed_pane_id, }]) }, Err(_e) => { Err(format!( "Malformed pane id: {}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)", pane_id_str )) } } }, None => Ok(vec![Action::Write { key_with_modifier: None, bytes, is_kitty_keyboard_protocol: false, }]), }, CliAction::WriteChars { chars, pane_id } => match pane_id { Some(pane_id_str) => { let parsed_pane_id = PaneId::from_str(&pane_id_str); match parsed_pane_id { Ok(parsed_pane_id) => { Ok(vec![Action::WriteCharsToPaneId { chars, pane_id: parsed_pane_id, }]) }, Err(_e) => { Err(format!( "Malformed pane id: {}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)", pane_id_str )) } } }, None => Ok(vec![Action::WriteChars { chars }]), }, CliAction::Paste { chars, pane_id } => match pane_id { Some(pane_id_str) => { let parsed_pane_id = PaneId::from_str(&pane_id_str); match parsed_pane_id { Ok(parsed_pane_id) => { Ok(vec![Action::Paste { chars, pane_id: Some(parsed_pane_id), }]) }, Err(_e) => { Err(format!( "Malformed pane id: {}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)", pane_id_str )) } } }, None => Ok(vec![Action::Paste { chars, pane_id: None, }]), }, CliAction::SendKeys { keys, pane_id } => { let mut actions = Vec::new(); for (index, key_str) in keys.iter().enumerate() { let key = KeyWithModifier::from_str(key_str).map_err(|e| { let suggestion = suggest_key_fix(key_str); format!( "Invalid key at position {}: \"{}\"\n Error: {}\n{}", index + 1, key_str, e, suggestion ) })?; #[cfg(not(target_family = "wasm"))] let bytes = key .serialize_kitty() .map(|s| s.into_bytes()) .unwrap_or_else(Vec::new); #[cfg(target_family = "wasm")] let bytes = vec![]; match &pane_id { Some(pane_id_str) => { let parsed_pane_id = PaneId::from_str(pane_id_str) .map_err(|_| format!( "Malformed pane id: {}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)", pane_id_str ))?; actions.push(Action::WriteToPaneId { bytes, pane_id: parsed_pane_id, }); }, None => { actions.push(Action::Write { key_with_modifier: Some(key), bytes, is_kitty_keyboard_protocol: true, }); }, } } Ok(actions) }, CliAction::Resize { resize, direction, pane_id, } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::ResizeByPaneId { pane_id, resize, direction, }]) }, None => Ok(vec![Action::Resize { resize, direction }]), }, CliAction::FocusNextPane => Ok(vec![Action::FocusNextPane]), CliAction::FocusPreviousPane => Ok(vec![Action::FocusPreviousPane]), CliAction::FocusPaneId { pane_id } => { let pane_id = PaneId::from_str(&pane_id) .map_err(|_| format!( "Malformed pane id: {pane_id}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::FocusPaneByPaneId { pane_id }]) }, CliAction::FocusLastPane => Ok(vec![Action::FocusLastPane]), CliAction::MoveFocus { direction } => Ok(vec![Action::MoveFocus { direction }]), CliAction::MoveFocusOrTab { direction } => { Ok(vec![Action::MoveFocusOrTab { direction }]) }, CliAction::MovePane { direction, pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::MovePaneByPaneId { pane_id, direction }]) }, None => Ok(vec![Action::MovePane { direction }]), }, CliAction::MovePaneBackwards { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::MovePaneBackwardsByPaneId { pane_id }]) }, None => Ok(vec![Action::MovePaneBackwards]), }, CliAction::MoveTab { direction, tab_id } => match tab_id { Some(id) => Ok(vec![Action::MoveTabByTabId { id: id as u64, direction, }]), None => Ok(vec![Action::MoveTab { direction }]), }, CliAction::Clear { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::ClearScreenByPaneId { pane_id }]) }, None => Ok(vec![Action::ClearScreen]), }, CliAction::DumpScreen { path, full, pane_id, ansi, } => match pane_id { Some(pane_id_str) => { let parsed_pane_id = PaneId::from_str(&pane_id_str); match parsed_pane_id { Ok(parsed_pane_id) => { Ok(vec![Action::DumpScreen { file_path: path.map(|p| p.as_os_str().to_string_lossy().into()), include_scrollback: full, pane_id: Some(parsed_pane_id), ansi, }]) }, Err(_e) => { Err(format!( "Malformed pane id: {}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)", pane_id_str )) } } }, None => Ok(vec![Action::DumpScreen { file_path: path.map(|p| p.as_os_str().to_string_lossy().into()), include_scrollback: full, pane_id: None, ansi, }]), }, CliAction::DumpLayout => Ok(vec![Action::DumpLayout]), CliAction::SaveSession => Ok(vec![Action::SaveSession]), CliAction::EditScrollback { pane_id, ansi } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::EditScrollbackByPaneId { pane_id, ansi }]) }, None => Ok(vec![Action::EditScrollback { ansi }]), }, CliAction::ScrollUp { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::ScrollUpByPaneId { pane_id }]) }, None => Ok(vec![Action::ScrollUp]), }, CliAction::ScrollDown { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::ScrollDownByPaneId { pane_id }]) }, None => Ok(vec![Action::ScrollDown]), }, CliAction::ScrollToBottom { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::ScrollToBottomByPaneId { pane_id }]) }, None => Ok(vec![Action::ScrollToBottom]), }, CliAction::ScrollToTop { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::ScrollToTopByPaneId { pane_id }]) }, None => Ok(vec![Action::ScrollToTop]), }, CliAction::PageScrollUp { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::PageScrollUpByPaneId { pane_id }]) }, None => Ok(vec![Action::PageScrollUp]), }, CliAction::PageScrollDown { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::PageScrollDownByPaneId { pane_id }]) }, None => Ok(vec![Action::PageScrollDown]), }, CliAction::HalfPageScrollUp { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::HalfPageScrollUpByPaneId { pane_id }]) }, None => Ok(vec![Action::HalfPageScrollUp]), }, CliAction::HalfPageScrollDown { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::HalfPageScrollDownByPaneId { pane_id }]) }, None => Ok(vec![Action::HalfPageScrollDown]), }, CliAction::ToggleFullscreen { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::ToggleFocusFullscreenByPaneId { pane_id }]) }, None => Ok(vec![Action::ToggleFocusFullscreen]), }, CliAction::ToggleNoUiFullscreen { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::ToggleFocusNoUiFullscreenByPaneId { pane_id }]) }, None => Ok(vec![Action::ToggleFocusNoUiFullscreen]), }, CliAction::TogglePaneFrames => Ok(vec![Action::TogglePaneFrames]), CliAction::SetPaneFrameStyle { style } => Ok(vec![Action::SetPaneFrameStyle(style)]), CliAction::ToggleActiveSyncTab { tab_id } => match tab_id { Some(id) => Ok(vec![Action::ToggleActiveSyncTabByTabId { id: id as u64 }]), None => Ok(vec![Action::ToggleActiveSyncTab]), }, CliAction::NewPane { direction, command, plugin, cwd, floating, in_place, close_replaced_pane, pane_id, name, close_on_exit, start_suspended, configuration, skip_plugin_cache, x, y, width, height, pinned, stacked, blocking, block_until_exit_success, block_until_exit_failure, block_until_exit, unblock_condition, near_current_pane, no_focus, borderless, tab_id, purpose, } => { let pane_id_to_replace = match pane_id { Some(pane_id_str) => match PaneId::from_str(&pane_id_str) { Ok(parsed_pane_id) => Some(parsed_pane_id), Err(_e) => { return Err(format!( "Malformed pane id: {}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)", pane_id_str )) }, }, None => None, }; let current_dir = get_current_dir(); // cwd should only be specified in a plugin alias if it was explicitly given to us, // otherwise the current_dir might override a cwd defined in the alias itself let alias_cwd = cwd.clone().map(|cwd| current_dir.join(cwd)); let cwd = cwd .map(|cwd| current_dir.join(cwd)) .or_else(|| Some(current_dir.clone())); let unblock_condition = unblock_condition.or_else(|| { if block_until_exit_success { Some(UnblockCondition::OnExitSuccess) } else if block_until_exit_failure { Some(UnblockCondition::OnExitFailure) } else if block_until_exit { Some(UnblockCondition::OnAnyExit) } else { None } }); if blocking || unblock_condition.is_some() { // For blocking panes, we don't support plugins if plugin.is_some() { return Err("Blocking panes do not support plugin variants".to_string()); } let command = if !command.is_empty() { let mut command = command.clone(); let (command, args) = (PathBuf::from(command.remove(0)), command); let hold_on_start = start_suspended; let hold_on_close = !close_on_exit; Some(RunCommandAction { command, args, cwd, direction, hold_on_close, hold_on_start, ..Default::default() }) } else { None }; let placement = if floating { NewPanePlacement::Floating(FloatingPaneCoordinates::new( x, y, width, height, pinned, borderless, )) } else if in_place { NewPanePlacement::InPlace { pane_id_to_replace, close_replaced_pane, borderless, } } else if stacked { NewPanePlacement::Stacked { pane_id_to_stack_under: None, borderless, } } else { NewPanePlacement::Tiled { direction, borderless, } }; Ok(vec![Action::NewBlockingPane { purpose: purpose.clone(), placement, pane_name: name, command, unblock_condition, near_current_pane, no_focus, tab_id, }]) } else if let Some(plugin) = plugin { let plugin = match RunPluginLocation::parse(&plugin, cwd.clone()) { Ok(location) => { let user_configuration = configuration.unwrap_or_default(); RunPluginOrAlias::RunPlugin(RunPlugin { _allow_exec_host_cmd: false, location, configuration: user_configuration, initial_cwd: cwd.clone(), }) }, Err(_) => { let mut plugin_alias = PluginAlias::new( &plugin, &configuration.map(|c| c.inner().clone()), alias_cwd, ); plugin_alias.set_caller_cwd_if_not_set(Some(current_dir)); RunPluginOrAlias::Alias(plugin_alias) }, }; if floating { Ok(vec![Action::NewFloatingPluginPane { plugin, pane_name: name, skip_cache: skip_plugin_cache, cwd, coordinates: FloatingPaneCoordinates::new( x, y, width, height, pinned, borderless, ), no_focus, tab_id, }]) } else if in_place { Ok(vec![Action::NewInPlacePluginPane { plugin, pane_name: name, skip_cache: skip_plugin_cache, close_replaced_pane, no_focus, tab_id, }]) } else { // it is intentional that a new tiled plugin pane cannot include a // direction // this is because the cli client opening a tiled plugin pane is a // different client than the one opening the pane, and this can potentially // create very confusing races if the client changes focus while the plugin // is being loaded // this is not the case with terminal panes for historical reasons of // backwards compatibility to a time before we had auto layouts Ok(vec![Action::NewTiledPluginPane { plugin, pane_name: name, skip_cache: skip_plugin_cache, cwd, no_focus, tab_id, }]) } } else if !command.is_empty() { let mut command = command.clone(); let (command, args) = (PathBuf::from(command.remove(0)), command); let hold_on_start = start_suspended; let hold_on_close = !close_on_exit; let run_command_action = RunCommandAction { command, args, cwd, direction, hold_on_close, hold_on_start, ..Default::default() }; if floating { Ok(vec![Action::NewFloatingPane { purpose: purpose.clone(), command: Some(run_command_action), pane_name: name, coordinates: FloatingPaneCoordinates::new( x, y, width, height, pinned, borderless, ), near_current_pane, no_focus, tab_id, }]) } else if in_place { Ok(vec![Action::NewInPlacePane { command: Some(run_command_action), pane_name: name, near_current_pane, no_focus, pane_id_to_replace, close_replaced_pane, tab_id, }]) } else if stacked { Ok(vec![Action::NewStackedPane { purpose: purpose.clone(), command: Some(run_command_action), pane_name: name, near_current_pane, no_focus, tab_id, }]) } else { Ok(vec![Action::NewTiledPane { purpose: purpose.clone(), direction, command: Some(run_command_action), pane_name: name, near_current_pane, no_focus, borderless, tab_id, }]) } } else { if floating { Ok(vec![Action::NewFloatingPane { purpose: purpose.clone(), command: None, pane_name: name, coordinates: FloatingPaneCoordinates::new( x, y, width, height, pinned, borderless, ), near_current_pane, no_focus, tab_id, }]) } else if in_place { Ok(vec![Action::NewInPlacePane { command: None, pane_name: name, near_current_pane, no_focus, pane_id_to_replace, close_replaced_pane, tab_id, }]) } else if stacked { Ok(vec![Action::NewStackedPane { purpose: purpose.clone(), command: None, pane_name: name, near_current_pane, no_focus, tab_id, }]) } else { Ok(vec![Action::NewTiledPane { purpose: purpose.clone(), direction, command: None, pane_name: name, near_current_pane, no_focus, borderless, tab_id, }]) } } }, CliAction::Edit { direction, file, line_number, floating, in_place, close_replaced_pane, cwd, x, y, width, height, pinned, near_current_pane, no_focus, borderless, tab_id, } => { let mut file = file; let current_dir = get_current_dir(); let cwd = cwd .map(|cwd| current_dir.join(cwd)) .or_else(|| Some(current_dir)); if file.is_relative() { if let Some(cwd) = cwd.as_ref() { file = cwd.join(file); } } let start_suppressed = false; Ok(vec![Action::EditFile { payload: OpenFilePayload::new(file, line_number, cwd), direction, floating, in_place, close_replaced_pane, start_suppressed, coordinates: FloatingPaneCoordinates::new( x, y, width, height, pinned, borderless, ), near_current_pane, no_focus, tab_id, }]) }, CliAction::SwitchMode { input_mode } => Ok(vec![Action::SwitchToMode { input_mode }]), CliAction::TogglePaneEmbedOrFloating { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::TogglePaneEmbedOrFloatingByPaneId { pane_id }]) }, None => Ok(vec![Action::TogglePaneEmbedOrFloating]), }, CliAction::ToggleFloatingPanes { tab_id } => match tab_id { Some(id) => Ok(vec![Action::ToggleFloatingPanesByTabId { id: id as u64 }]), None => Ok(vec![Action::ToggleFloatingPanes]), }, CliAction::ShowFloatingPanes { tab_id } => { Ok(vec![Action::ShowFloatingPanes { tab_id }]) }, CliAction::HideFloatingPanes { tab_id } => { Ok(vec![Action::HideFloatingPanes { tab_id }]) }, CliAction::AreFloatingPanesVisible { tab_id } => { Ok(vec![Action::AreFloatingPanesVisible { tab_id }]) }, CliAction::ClosePane { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::CloseFocusByPaneId { pane_id }]) }, None => Ok(vec![Action::CloseFocus]), }, CliAction::RenamePane { name, pane_id } => { let pane_id = match pane_id { Some(pane_id_str) => Some( PaneId::from_str(&pane_id_str).map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?, ), None => None, }; Ok(vec![Action::RenamePaneByPaneId { pane_id, name: name.as_bytes().to_vec(), }]) }, CliAction::UndoRenamePane { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::UndoRenamePaneByPaneId { pane_id }]) }, None => Ok(vec![Action::UndoRenamePane]), }, CliAction::GoToNextTab => Ok(vec![Action::GoToNextTab]), CliAction::GoToPreviousTab => Ok(vec![Action::GoToPreviousTab]), CliAction::CloseTab { tab_id } => match tab_id { Some(id) => Ok(vec![Action::CloseTabById { id: id as u64 }]), None => Ok(vec![Action::CloseTab]), }, CliAction::GoToTab { index } => Ok(vec![Action::GoToTab { index }]), CliAction::GoToTabName { name, create } => { Ok(vec![Action::GoToTabName { name, create }]) }, CliAction::RenameTab { name, tab_id } => match tab_id { Some(id) => Ok(vec![Action::RenameTabById { id: id as u64, name, }]), None => Ok(vec![ Action::TabNameInput { input: vec![0] }, Action::TabNameInput { input: name.as_bytes().to_vec(), }, ]), }, CliAction::UndoRenameTab { tab_id } => match tab_id { Some(id) => Ok(vec![Action::UndoRenameTabByTabId { id: id as u64 }]), None => Ok(vec![Action::UndoRenameTab]), }, CliAction::GoToTabById { id } => Ok(vec![Action::GoToTabById { id }]), CliAction::CloseTabById { id } => Ok(vec![Action::CloseTabById { id }]), CliAction::RenameTabById { id, name } => Ok(vec![Action::RenameTabById { id, name }]), CliAction::NewTab { name, layout, layout_string, layout_dir, cwd, initial_command, initial_plugin, close_on_exit, start_suspended, block_until_exit_success, block_until_exit_failure, block_until_exit, no_focus, } => { let current_dir = get_current_dir(); let cwd = cwd .map(|cwd| current_dir.join(cwd)) .or_else(|| Some(current_dir.clone())); // Map CLI flags to UnblockCondition let first_pane_unblock_condition = if block_until_exit_success { Some(UnblockCondition::OnExitSuccess) } else if block_until_exit_failure { Some(UnblockCondition::OnExitFailure) } else if block_until_exit { Some(UnblockCondition::OnAnyExit) } else { None }; // Parse initial_panes from initial_command or initial_plugin let initial_panes = if let Some(plugin_url) = initial_plugin { let plugin = match RunPluginLocation::parse(&plugin_url, cwd.clone()) { Ok(location) => RunPluginOrAlias::RunPlugin(RunPlugin { _allow_exec_host_cmd: false, location, configuration: Default::default(), initial_cwd: cwd.clone(), }), Err(_) => { let mut plugin_alias = PluginAlias::new(&plugin_url, &None, cwd.clone()); plugin_alias.set_caller_cwd_if_not_set(Some(current_dir.clone())); RunPluginOrAlias::Alias(plugin_alias) }, }; Some(vec![CommandOrPlugin::Plugin(plugin)]) } else if !initial_command.is_empty() { let mut command: Vec = initial_command.clone(); let (command, args) = ( PathBuf::from(command.remove(0)), command.into_iter().collect(), ); let hold_on_close = !close_on_exit; let hold_on_start = start_suspended; let run_command_action = RunCommandAction { command, args, cwd: cwd.clone(), direction: None, hold_on_close, hold_on_start, ..Default::default() }; Some(vec![CommandOrPlugin::Command(run_command_action)]) } else { None }; if let Some(raw_layout) = layout_string { let layout_source_name = "layout-string".to_owned(); let path_to_raw_layout = layout_source_name.clone(); let swap_layouts: Option<(String, String)> = None; let should_start_layout_commands_suspended = false; let raw_layout_for_error = raw_layout.clone(); let mut layout = Layout::from_str(&raw_layout, path_to_raw_layout, swap_layouts.as_ref().map(|(f, p)| (f.as_str(), p.as_str())), cwd).map_err(|e| { let stringified_error = match e { ConfigError::KdlError(kdl_error) => { let error = kdl_error.add_src(layout_source_name.clone(), raw_layout_for_error); let report: Report = error.into(); format!("{:?}", report) } ConfigError::KdlDeserializationError(kdl_error) => { let error_message = match kdl_error.kind { kdl::KdlErrorKind::Context("valid node terminator") => { format!("Failed to deserialize KDL node. \nPossible reasons:\n{}\n{}\n{}\n{}", "- Missing `;` after a node name, eg. { node; another_node; }", "- Missing quotations (\") around an argument node eg. { first_node \"argument_node\"; }", "- Missing an equal sign (=) between node arguments on a title line. eg. argument=\"value\"", "- Found an extraneous equal sign (=) between node child arguments and their values. eg. { argument=\"value\" }") }, _ => String::from(kdl_error.help.unwrap_or("Kdl Deserialization Error")), }; let kdl_error = KdlError { error_message, src: Some(NamedSource::new(layout_source_name.clone(), raw_layout_for_error)), offset: Some(kdl_error.span.offset()), len: Some(kdl_error.span.len()), help_message: None, }; let report: Report = kdl_error.into(); format!("{:?}", report) }, e => format!("{}", e) }; stringified_error })?; if should_start_layout_commands_suspended { layout.recursively_add_start_suspended_including_template(Some(true)); } let mut tabs = layout.tabs(); if !tabs.is_empty() { let swap_tiled_layouts = Some(layout.swap_tiled_layouts.clone()); let swap_floating_layouts = Some(layout.swap_floating_layouts.clone()); let mut new_tab_actions = vec![]; let mut has_focused_tab = tabs .iter() .any(|(_, layout, _)| layout.focus.unwrap_or(false)); for (tab_name, layout, floating_panes_layout) in tabs.drain(..) { let name = tab_name.or_else(|| name.clone()); let should_change_focus_to_new_tab = !no_focus && layout.focus.unwrap_or_else(|| { if !has_focused_tab { has_focused_tab = true; true } else { false } }); new_tab_actions.push(Action::NewTab { tiled_layout: Some(layout), floating_layouts: floating_panes_layout, swap_tiled_layouts: swap_tiled_layouts.clone(), swap_floating_layouts: swap_floating_layouts.clone(), tab_name: name, should_change_focus_to_new_tab, cwd: None, initial_panes: initial_panes.clone(), first_pane_unblock_condition, }); } Ok(new_tab_actions) } else { let swap_tiled_layouts = Some(layout.swap_tiled_layouts.clone()); let swap_floating_layouts = Some(layout.swap_floating_layouts.clone()); let (layout, floating_panes_layout) = layout.new_tab(); let should_change_focus_to_new_tab = !no_focus; Ok(vec![Action::NewTab { tiled_layout: Some(layout), floating_layouts: floating_panes_layout, swap_tiled_layouts, swap_floating_layouts, tab_name: name, should_change_focus_to_new_tab, cwd: None, initial_panes, first_pane_unblock_condition, }]) } } else if let Some(layout_path) = layout { let layout_dir = layout_dir .or_else(|| config.and_then(|c| c.options.layout_dir)) .or_else(|| get_layout_dir(find_default_config_dir())); let mut should_start_layout_commands_suspended = false; let layout_source_name; let (path_to_raw_layout, raw_layout, swap_layouts) = if let Some(layout_url) = layout_path.to_str().and_then(|l| { if l.starts_with("http://") || l.starts_with("https://") { Some(l) } else { None } }) { should_start_layout_commands_suspended = true; layout_source_name = layout_url.to_owned(); ( layout_url.to_owned(), Layout::stringified_from_url(layout_url) .map_err(|e| format!("Failed to load layout: {}", e))?, None, ) } else { layout_source_name = layout_path .as_path() .as_os_str() .to_string_lossy() .to_string(); Layout::stringified_from_path_or_default(Some(&layout_path), layout_dir) .map_err(|e| format!("Failed to load layout: {}", e))? }; let mut layout = Layout::from_str(&raw_layout, path_to_raw_layout, swap_layouts.as_ref().map(|(f, p)| (f.as_str(), p.as_str())), cwd).map_err(|e| { let stringified_error = match e { ConfigError::KdlError(kdl_error) => { let error = kdl_error.add_src(layout_source_name.clone(), String::from(raw_layout)); let report: Report = error.into(); format!("{:?}", report) } ConfigError::KdlDeserializationError(kdl_error) => { let error_message = match kdl_error.kind { kdl::KdlErrorKind::Context("valid node terminator") => { format!("Failed to deserialize KDL node. \nPossible reasons:\n{}\n{}\n{}\n{}", "- Missing `;` after a node name, eg. { node; another_node; }", "- Missing quotations (\") around an argument node eg. { first_node \"argument_node\"; }", "- Missing an equal sign (=) between node arguments on a title line. eg. argument=\"value\"", "- Found an extraneous equal sign (=) between node child arguments and their values. eg. { argument=\"value\" }") }, _ => String::from(kdl_error.help.unwrap_or("Kdl Deserialization Error")), }; let kdl_error = KdlError { error_message, src: Some(NamedSource::new(layout_source_name.clone(), String::from(raw_layout))), offset: Some(kdl_error.span.offset()), len: Some(kdl_error.span.len()), help_message: None, }; let report: Report = kdl_error.into(); format!("{:?}", report) }, e => format!("{}", e) }; stringified_error })?; if should_start_layout_commands_suspended { layout.recursively_add_start_suspended_including_template(Some(true)); } let mut tabs = layout.tabs(); if !tabs.is_empty() { let swap_tiled_layouts = Some(layout.swap_tiled_layouts.clone()); let swap_floating_layouts = Some(layout.swap_floating_layouts.clone()); let mut new_tab_actions = vec![]; let mut has_focused_tab = tabs .iter() .any(|(_, layout, _)| layout.focus.unwrap_or(false)); for (tab_name, layout, floating_panes_layout) in tabs.drain(..) { let name = tab_name.or_else(|| name.clone()); let should_change_focus_to_new_tab = !no_focus && layout.focus.unwrap_or_else(|| { if !has_focused_tab { has_focused_tab = true; true } else { false } }); new_tab_actions.push(Action::NewTab { tiled_layout: Some(layout), floating_layouts: floating_panes_layout, swap_tiled_layouts: swap_tiled_layouts.clone(), swap_floating_layouts: swap_floating_layouts.clone(), tab_name: name, should_change_focus_to_new_tab, cwd: None, // the cwd is done through the layout initial_panes: initial_panes.clone(), first_pane_unblock_condition, }); } Ok(new_tab_actions) } else { let swap_tiled_layouts = Some(layout.swap_tiled_layouts.clone()); let swap_floating_layouts = Some(layout.swap_floating_layouts.clone()); let (layout, floating_panes_layout) = layout.new_tab(); let should_change_focus_to_new_tab = !no_focus; Ok(vec![Action::NewTab { tiled_layout: Some(layout), floating_layouts: floating_panes_layout, swap_tiled_layouts, swap_floating_layouts, tab_name: name, should_change_focus_to_new_tab, cwd: None, // the cwd is done through the layout initial_panes, first_pane_unblock_condition, }]) } } else { let should_change_focus_to_new_tab = !no_focus; Ok(vec![Action::NewTab { tiled_layout: None, floating_layouts: vec![], swap_tiled_layouts: None, swap_floating_layouts: None, tab_name: name, should_change_focus_to_new_tab, cwd, initial_panes, first_pane_unblock_condition, }]) } }, CliAction::PreviousSwapLayout { tab_id } => match tab_id { Some(id) => Ok(vec![Action::PreviousSwapLayoutByTabId { id: id as u64 }]), None => Ok(vec![Action::PreviousSwapLayout]), }, CliAction::NextSwapLayout { tab_id } => match tab_id { Some(id) => Ok(vec![Action::NextSwapLayoutByTabId { id: id as u64 }]), None => Ok(vec![Action::NextSwapLayout]), }, CliAction::OverrideLayout { layout, layout_string, layout_dir, retain_existing_terminal_panes, retain_existing_plugin_panes, apply_only_to_active_tab, } => { // Determine layout_dir: CLI arg > config > default let layout_dir = layout_dir .or_else(|| config.and_then(|c| c.options.layout_dir)) .or_else(|| get_layout_dir(find_default_config_dir())); // Load layout from string, URL, or file path let layout_source_name; let (path_to_raw_layout, raw_layout, swap_layouts) = if let Some(raw) = layout_string { layout_source_name = "layout-string".to_owned(); (layout_source_name.clone(), raw, None) } else if let Some(layout_path) = &layout { if let Some(layout_url) = layout_path.to_str().and_then(|l| { if l.starts_with("http://") || l.starts_with("https://") { Some(l) } else { None } }) { layout_source_name = layout_url.to_owned(); ( layout_url.to_owned(), Layout::stringified_from_url(layout_url) .map_err(|e| format!("Failed to load layout from URL: {}", e))?, None, ) } else { layout_source_name = layout_path .as_path() .as_os_str() .to_string_lossy() .to_string(); Layout::stringified_from_path_or_default(Some(layout_path), layout_dir) .map_err(|e| format!("Failed to load layout: {}", e))? } } else { return Err("Either layout or layout-string must be provided".to_string()); }; // Parse KDL layout let layout = Layout::from_str( &raw_layout, path_to_raw_layout, swap_layouts.as_ref().map(|(f, p)| (f.as_str(), p.as_str())), None, // cwd ) .map_err(|e| { let stringified_error = match e { ConfigError::KdlError(kdl_error) => { let error = kdl_error .add_src(layout_source_name.clone(), String::from(raw_layout)); let report: Report = error.into(); format!("{:?}", report) }, ConfigError::KdlDeserializationError(kdl_error) => { let error_message = kdl_error.to_string(); format!("Failed to deserialize KDL layout: {}", error_message) }, e => format!("{}", e), }; stringified_error })?; // Convert all tabs to Vec let tabs: Vec = layout .tabs .iter() .enumerate() .map(|(index, (tab_name, tiled, floating))| TabLayoutInfo { tab_index: index, tab_name: tab_name.clone(), tiled_layout: tiled.clone(), floating_layouts: floating.clone(), swap_tiled_layouts: Some(layout.swap_tiled_layouts.clone()), swap_floating_layouts: Some(layout.swap_floating_layouts.clone()), }) .collect(); // If no tabs, create default tab let tabs = if tabs.is_empty() { let (tiled, floating) = layout.new_tab(); vec![TabLayoutInfo { tab_index: 0, tab_name: None, tiled_layout: tiled, floating_layouts: floating, swap_tiled_layouts: Some(layout.swap_tiled_layouts), swap_floating_layouts: Some(layout.swap_floating_layouts), }] } else { tabs }; Ok(vec![Action::OverrideLayout { tabs, retain_existing_terminal_panes, retain_existing_plugin_panes, apply_only_to_active_tab, }]) }, CliAction::QueryTabNames => Ok(vec![Action::QueryTabNames]), CliAction::StartOrReloadPlugin { url, configuration } => { let current_dir = get_current_dir(); let run_plugin_or_alias = RunPluginOrAlias::from_url( &url, &configuration.map(|c| c.inner().clone()), None, Some(current_dir), )?; Ok(vec![Action::StartOrReloadPlugin { plugin: run_plugin_or_alias, }]) }, CliAction::LaunchOrFocusPlugin { url, floating, in_place, close_replaced_pane, move_to_focused_tab, configuration, skip_plugin_cache, tab_id, } => { let current_dir = get_current_dir(); let run_plugin_or_alias = RunPluginOrAlias::from_url( url.as_str(), &configuration.map(|c| c.inner().clone()), None, Some(current_dir), )?; Ok(vec![Action::LaunchOrFocusPlugin { plugin: run_plugin_or_alias, should_float: floating, move_to_focused_tab, should_open_in_place: in_place, close_replaced_pane, skip_cache: skip_plugin_cache, tab_id, // a one-shot CLI invocation has nothing to toggle close_if_focused: false, }]) }, CliAction::LaunchPlugin { url, floating, in_place, close_replaced_pane, configuration, skip_plugin_cache, no_focus, tab_id, } => { let current_dir = get_current_dir(); let run_plugin_or_alias = RunPluginOrAlias::from_url( &url.as_str(), &configuration.map(|c| c.inner().clone()), None, Some(current_dir.clone()), )?; Ok(vec![Action::LaunchPlugin { plugin: run_plugin_or_alias, should_float: floating, should_open_in_place: in_place, close_replaced_pane, skip_cache: skip_plugin_cache, cwd: Some(current_dir), no_focus, tab_id, }]) }, CliAction::RenameSession { name } => Ok(vec![Action::RenameSession { name }]), CliAction::Pipe { name, payload, args, plugin, plugin_configuration, force_launch_plugin, skip_plugin_cache, floating_plugin, in_place_plugin, plugin_cwd, plugin_title, } => { let current_dir = get_current_dir(); let cwd = plugin_cwd .map(|cwd| current_dir.join(cwd)) .or_else(|| Some(current_dir)); let skip_cache = skip_plugin_cache; let pipe_id = Uuid::new_v4().to_string(); Ok(vec![Action::CliPipe { pipe_id, name, payload, args: args.map(|a| a.inner().clone()), // TODO: no clone somehow plugin, configuration: plugin_configuration.map(|a| a.inner().clone()), // TODO: no clone // somehow launch_new: force_launch_plugin, floating: floating_plugin, in_place: in_place_plugin, cwd, pane_title: plugin_title, skip_cache, }]) }, CliAction::ListClients => Ok(vec![Action::ListClients]), CliAction::ListPanes { tab, command, state, geometry, all, json, } => Ok(vec![Action::ListPanes { show_tab: tab, show_command: command, show_state: state, show_geometry: geometry, show_all: all, output_json: json, }]), CliAction::ListTabs { state, dimensions, panes, layout, all, json, } => Ok(vec![Action::ListTabs { show_state: state, show_dimensions: dimensions, show_panes: panes, show_layout: layout, show_all: all, output_json: json, }]), CliAction::CurrentTabInfo { json } => { Ok(vec![Action::CurrentTabInfo { output_json: json }]) }, CliAction::TogglePanePinned { pane_id } => match pane_id { Some(pane_id_str) => { let pane_id = PaneId::from_str(&pane_id_str) .map_err(|_| format!( "Malformed pane id: {pane_id_str}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)" ))?; Ok(vec![Action::TogglePanePinnedByPaneId { pane_id }]) }, None => Ok(vec![Action::TogglePanePinned]), }, CliAction::StackPanes { pane_ids } => { let mut malformed_ids = vec![]; let pane_ids = pane_ids .iter() .filter_map( |stringified_pane_id| match PaneId::from_str(stringified_pane_id) { Ok(pane_id) => Some(pane_id), Err(_e) => { malformed_ids.push(stringified_pane_id.to_owned()); None }, }, ) .collect(); if !malformed_ids.is_empty() { Err( format!( "Malformed pane ids: {}, expecting a space separated list of either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)", malformed_ids.join(", ") ) ) } else { Ok(vec![Action::StackPanes { pane_ids }]) } }, CliAction::ChangeFloatingPaneCoordinates { pane_id, x, y, width, height, pinned, borderless, } => { let Some(coordinates) = FloatingPaneCoordinates::new(x, y, width, height, pinned, borderless) else { return Err(format!("Failed to parse floating pane coordinates")); }; let parsed_pane_id = PaneId::from_str(&pane_id); match parsed_pane_id { Ok(parsed_pane_id) => { Ok(vec![Action::ChangeFloatingPaneCoordinates { pane_id: parsed_pane_id, coordinates, }]) }, Err(_e) => { Err(format!( "Malformed pane id: {}, expecting a space separated list of either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)", pane_id )) } } }, CliAction::TogglePaneBorderless { pane_id } => { let parsed_pane_id = PaneId::from_str(&pane_id); match parsed_pane_id { Ok(parsed_pane_id) => { Ok(vec![Action::TogglePaneBorderless { pane_id: parsed_pane_id, }]) }, Err(_e) => { Err(format!( "Malformed pane id: {}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)", pane_id )) } } }, CliAction::SetPaneBorderless { pane_id, borderless, } => { let parsed_pane_id = PaneId::from_str(&pane_id); match parsed_pane_id { Ok(parsed_pane_id) => { Ok(vec![Action::SetPaneBorderless { pane_id: parsed_pane_id, borderless, }]) }, Err(_e) => { Err(format!( "Malformed pane id: {}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)", pane_id )) } } }, CliAction::SetPaneColor { pane_id, fg, bg, reset, } => { let pane_id_str = match pane_id { Some(id) => id, None => std::env::var("ZELLIJ_PANE_ID").map_err(|_| { "No --pane-id provided and ZELLIJ_PANE_ID is not set".to_string() })?, }; let parsed_pane_id = PaneId::from_str(&pane_id_str); match parsed_pane_id { Ok(parsed_pane_id) => { let (fg, bg) = if reset { (None, None) } else { (fg, bg) }; Ok(vec![Action::SetPaneColor { pane_id: parsed_pane_id, fg, bg, }]) }, Err(_e) => Err(format!( "Malformed pane id: {}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)", pane_id_str )), } }, CliAction::Detach => Ok(vec![Action::Detach]), CliAction::SetDarkTheme => Ok(vec![Action::SetDarkTheme]), CliAction::SetLightTheme => Ok(vec![Action::SetLightTheme]), CliAction::ToggleTheme => Ok(vec![Action::ToggleTheme]), CliAction::SwitchSession { name, tab_position, pane_id, layout, layout_string, layout_dir, cwd, } => { let pane_id = match pane_id { Some(stringified_pane_id) => match PaneId::from_str(&stringified_pane_id) { Ok(PaneId::Terminal(id)) => Some((id, false)), Ok(PaneId::Plugin(id)) => Some((id, true)), Err(_e) => { return Err(format!( "Malformed pane id: {}, expecting either a bare integer (eg. 1), a terminal pane id (eg. terminal_1) or a plugin pane id (eg. plugin_1)", stringified_pane_id )); }, }, None => None, }; let cwd = cwd.map(|cwd| { let current_dir = get_current_dir(); current_dir.join(cwd) }); let layout_dir = layout_dir.map(|layout_dir| { let current_dir = get_current_dir(); current_dir.join(layout_dir) }); let layout_info = if let Some(layout_string) = layout_string { // validate the layout string before sending it to the target session let layout_source_name = "layout-string".to_owned(); let raw_layout_for_error = layout_string.clone(); Layout::from_str(&layout_string, layout_source_name.clone(), None, None) .map_err(|e| { match e { ConfigError::KdlError(kdl_error) => { let error = kdl_error.add_src(layout_source_name, raw_layout_for_error); let report: Report = error.into(); format!("{:?}", report) }, ConfigError::KdlDeserializationError(kdl_error) => { let error_message = match kdl_error.kind { kdl::KdlErrorKind::Context("valid node terminator") => { format!("Failed to deserialize KDL node. \nPossible reasons:\n{}\n{}\n{}\n{}", "- Missing `;` after a node name, eg. {{ node; another_node; }}", "- Missing quotations (\") around an argument node eg. {{ first_node \"argument_node\"; }}", "- Missing an equal sign (=) between node arguments on a title line. eg. argument=\"value\"", "- Found an extraneous equal sign (=) between node child arguments and their values. eg. {{ argument=\"value\" }}") }, _ => String::from(kdl_error.help.unwrap_or("Kdl Deserialization Error")), }; let kdl_error = KdlError { error_message, src: Some(NamedSource::new(layout_source_name, raw_layout_for_error)), offset: Some(kdl_error.span.offset()), len: Some(kdl_error.span.len()), help_message: None, }; let report: Report = kdl_error.into(); format!("{:?}", report) }, e => format!("{}", e), } })?; Some(LayoutInfo::Stringified(layout_string)) } else if let Some(layout_path) = layout { let layout_dir = layout_dir .or_else(|| config.and_then(|c| c.options.layout_dir.clone())) .or_else(|| get_layout_dir(find_default_config_dir())); // validate the layout file before sending it to the target session let layout_source_name = layout_path.display().to_string(); Layout::from_path_or_default_without_config( Some(&layout_path), layout_dir.clone(), ) .map_err(|e| { match e { ConfigError::KdlError(kdl_error) => { let report: Report = kdl_error.into(); format!("{:?}", report) }, ConfigError::KdlDeserializationError(kdl_error) => { let error_message = match kdl_error.kind { kdl::KdlErrorKind::Context("valid node terminator") => { format!("Failed to deserialize KDL node. \nPossible reasons:\n{}\n{}\n{}\n{}", "- Missing `;` after a node name, eg. {{ node; another_node; }}", "- Missing quotations (\") around an argument node eg. {{ first_node \"argument_node\"; }}", "- Missing an equal sign (=) between node arguments on a title line. eg. argument=\"value\"", "- Found an extraneous equal sign (=) between node child arguments and their values. eg. {{ argument=\"value\" }}") }, _ => String::from(kdl_error.help.unwrap_or("Kdl Deserialization Error")), }; let kdl_error = KdlError { error_message, src: Some(NamedSource::new(layout_source_name, String::new())), offset: Some(kdl_error.span.offset()), len: Some(kdl_error.span.len()), help_message: None, }; let report: Report = kdl_error.into(); format!("{:?}", report) }, e => format!("{}", e), } })?; LayoutInfo::from_config(&layout_dir, &Some(layout_path)) } else { None }; Ok(vec![Action::SwitchSession { name: name.clone(), tab_position: tab_position.clone(), pane_id, layout: layout_info, cwd, }]) }, } } pub fn populate_originating_plugin(&mut self, originating_plugin: OriginatingPlugin) { match self { Action::NewBlockingPane { command, .. } | Action::NewFloatingPane { command, .. } | Action::NewTiledPane { command, .. } | Action::NewInPlacePane { command, .. } | Action::NewStackedPane { command, .. } => { command .as_mut() .map(|c| c.populate_originating_plugin(originating_plugin)); }, Action::Run { command, .. } => { command.populate_originating_plugin(originating_plugin); }, Action::EditFile { payload, .. } => { payload.originating_plugin = Some(originating_plugin); }, Action::NewTab { initial_panes, .. } => { if let Some(initial_panes) = initial_panes.as_mut() { for pane in initial_panes.iter_mut() { match pane { CommandOrPlugin::Command(run_command) => { run_command.populate_originating_plugin(originating_plugin.clone()); }, _ => {}, } } } }, _ => {}, } } pub fn launches_plugin(&self, plugin_url: &str) -> bool { match self { Action::LaunchPlugin { plugin, .. } => &plugin.location_string() == plugin_url, Action::LaunchOrFocusPlugin { plugin, .. } => &plugin.location_string() == plugin_url, _ => false, } } pub fn is_mouse_action(&self) -> bool { if let Action::MouseEvent { .. } = self { return true; } false } } fn suggest_key_fix(key_str: &str) -> String { if key_str.contains('-') { return " Hint: Use spaces instead of hyphens (e.g., \"Ctrl a\" not \"Ctrl-a\")" .to_string(); } if key_str.trim().is_empty() { return " Hint: Key string cannot be empty".to_string(); } let parts: Vec<&str> = key_str.split_whitespace().collect(); if parts.len() > 1 { for part in &parts[..parts.len() - 1] { let lower = part.to_ascii_lowercase(); if lower.starts_with("ctr") && lower != "ctrl" { return format!(" Hint: Did you mean \"Ctrl\" instead of \"{}\"?", part); } if !matches!(lower.as_str(), "ctrl" | "alt" | "shift" | "super") { return " Hint: Valid modifiers are: Ctrl, Alt, Shift, Super".to_string(); } } } " Hint: Use format like \"Ctrl a\", \"Alt Shift F1\", or \"Enter\"".to_string() } impl From for Action { fn from(ofc: OnForceClose) -> Action { match ofc { OnForceClose::Quit => Action::Quit, OnForceClose::Detach => Action::Detach, } } } #[cfg(test)] mod tests { use super::*; use crate::data::BareKey; use crate::data::KeyModifier; use std::path::PathBuf; #[test] fn test_send_keys_single_key() { let cli_action = CliAction::SendKeys { keys: vec!["Enter".to_string()], pane_id: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::Write { key_with_modifier, bytes, is_kitty_keyboard_protocol, } => { assert!(key_with_modifier.is_some()); let key = key_with_modifier.as_ref().unwrap(); assert_eq!(key.bare_key, BareKey::Enter); assert!(key.key_modifiers.is_empty()); assert!(!bytes.is_empty()); assert_eq!(*is_kitty_keyboard_protocol, true); }, _ => panic!("Expected Write action"), } } #[test] fn test_send_keys_with_modifier() { let cli_action = CliAction::SendKeys { keys: vec!["Ctrl a".to_string()], pane_id: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::Write { key_with_modifier, is_kitty_keyboard_protocol, .. } => { assert!(key_with_modifier.is_some()); let key = key_with_modifier.as_ref().unwrap(); assert_eq!(key.bare_key, BareKey::Char('a')); assert!(key.key_modifiers.contains(&KeyModifier::Ctrl)); assert_eq!(*is_kitty_keyboard_protocol, true); }, _ => panic!("Expected Write action"), } } #[test] fn test_send_keys_multiple_keys() { let cli_action = CliAction::SendKeys { keys: vec!["Ctrl a".to_string(), "F1".to_string(), "Enter".to_string()], pane_id: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 3); for action in &actions { match action { Action::Write { is_kitty_keyboard_protocol, .. } => { assert_eq!(*is_kitty_keyboard_protocol, true); }, _ => panic!("Expected Write action"), } } } #[test] fn test_send_keys_error_hyphen_syntax() { let cli_action = CliAction::SendKeys { keys: vec!["Ctrl-a".to_string()], pane_id: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.contains("Use spaces instead of hyphens")); } #[test] fn test_send_keys_error_typo() { let cli_action = CliAction::SendKeys { keys: vec!["Ctrll a".to_string()], pane_id: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.contains("Ctrl") || err.contains("modifier")); } #[test] fn test_send_keys_with_pane_id() { let cli_action = CliAction::SendKeys { keys: vec!["a".to_string()], pane_id: Some("terminal_1".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::WriteToPaneId { pane_id, bytes } => { assert!(matches!(pane_id, PaneId::Terminal(1))); assert!(!bytes.is_empty()); }, _ => panic!("Expected WriteToPaneId action"), } } #[test] fn test_send_keys_error_invalid_pane_id() { let cli_action = CliAction::SendKeys { keys: vec!["a".to_string()], pane_id: Some("invalid_id".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.contains("Malformed pane id")); } // ============================================= // Category 1: Pane-targeting tests // ============================================= // 1. ScrollUp #[test] fn test_scroll_up_with_pane_id() { let cli_action = CliAction::ScrollUp { pane_id: Some("terminal_5".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::ScrollUpByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(5))); }, _ => panic!("Expected ScrollUpByPaneId action"), } } #[test] fn test_scroll_up_without_pane_id() { let cli_action = CliAction::ScrollUp { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::ScrollUp)); } // 2. ScrollDown #[test] fn test_scroll_down_with_pane_id() { let cli_action = CliAction::ScrollDown { pane_id: Some("terminal_2".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::ScrollDownByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(2))); }, _ => panic!("Expected ScrollDownByPaneId action"), } } #[test] fn test_scroll_down_without_pane_id() { let cli_action = CliAction::ScrollDown { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::ScrollDown)); } // 3. ScrollToTop #[test] fn test_scroll_to_top_with_pane_id() { let cli_action = CliAction::ScrollToTop { pane_id: Some("terminal_1".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::ScrollToTopByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(1))); }, _ => panic!("Expected ScrollToTopByPaneId action"), } } #[test] fn test_scroll_to_top_without_pane_id() { let cli_action = CliAction::ScrollToTop { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::ScrollToTop)); } // 4. ScrollToBottom #[test] fn test_scroll_to_bottom_with_pane_id() { let cli_action = CliAction::ScrollToBottom { pane_id: Some("terminal_4".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::ScrollToBottomByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(4))); }, _ => panic!("Expected ScrollToBottomByPaneId action"), } } #[test] fn test_scroll_to_bottom_without_pane_id() { let cli_action = CliAction::ScrollToBottom { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::ScrollToBottom)); } // 5. PageScrollUp #[test] fn test_page_scroll_up_with_pane_id() { let cli_action = CliAction::PageScrollUp { pane_id: Some("terminal_6".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::PageScrollUpByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(6))); }, _ => panic!("Expected PageScrollUpByPaneId action"), } } #[test] fn test_page_scroll_up_without_pane_id() { let cli_action = CliAction::PageScrollUp { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::PageScrollUp)); } // 6. PageScrollDown #[test] fn test_page_scroll_down_with_pane_id() { let cli_action = CliAction::PageScrollDown { pane_id: Some("terminal_8".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::PageScrollDownByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(8))); }, _ => panic!("Expected PageScrollDownByPaneId action"), } } #[test] fn test_page_scroll_down_without_pane_id() { let cli_action = CliAction::PageScrollDown { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::PageScrollDown)); } // 7. HalfPageScrollUp #[test] fn test_half_page_scroll_up_with_pane_id() { let cli_action = CliAction::HalfPageScrollUp { pane_id: Some("terminal_10".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::HalfPageScrollUpByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(10))); }, _ => panic!("Expected HalfPageScrollUpByPaneId action"), } } #[test] fn test_half_page_scroll_up_without_pane_id() { let cli_action = CliAction::HalfPageScrollUp { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::HalfPageScrollUp)); } // 8. HalfPageScrollDown #[test] fn test_half_page_scroll_down_with_pane_id() { let cli_action = CliAction::HalfPageScrollDown { pane_id: Some("terminal_12".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::HalfPageScrollDownByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(12))); }, _ => panic!("Expected HalfPageScrollDownByPaneId action"), } } #[test] fn test_half_page_scroll_down_without_pane_id() { let cli_action = CliAction::HalfPageScrollDown { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::HalfPageScrollDown)); } // 9. Resize #[test] fn test_resize_with_pane_id() { let cli_action = CliAction::Resize { resize: Resize::Increase, direction: Some(Direction::Left), pane_id: Some("terminal_3".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::ResizeByPaneId { pane_id, resize, direction, } => { assert!(matches!(pane_id, PaneId::Terminal(3))); assert!(matches!(resize, Resize::Increase)); assert!(matches!(direction, Some(Direction::Left))); }, _ => panic!("Expected ResizeByPaneId action"), } } #[test] fn test_resize_without_pane_id() { let cli_action = CliAction::Resize { resize: Resize::Increase, direction: Some(Direction::Left), pane_id: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::Resize { resize, direction } => { assert!(matches!(resize, Resize::Increase)); assert!(matches!(direction, Some(Direction::Left))); }, _ => panic!("Expected Resize action"), } } // 10. MovePane #[test] fn test_move_pane_with_pane_id() { let cli_action = CliAction::MovePane { direction: Some(Direction::Right), pane_id: Some("terminal_9".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::MovePaneByPaneId { pane_id, direction } => { assert!(matches!(pane_id, PaneId::Terminal(9))); assert!(matches!(direction, Some(Direction::Right))); }, _ => panic!("Expected MovePaneByPaneId action"), } } #[test] fn test_move_pane_without_pane_id() { let cli_action = CliAction::MovePane { direction: Some(Direction::Right), pane_id: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::MovePane { direction } => { assert!(matches!(direction, Some(Direction::Right))); }, _ => panic!("Expected MovePane action"), } } // 11. MovePaneBackwards #[test] fn test_move_pane_backwards_with_pane_id() { let cli_action = CliAction::MovePaneBackwards { pane_id: Some("terminal_11".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::MovePaneBackwardsByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(11))); }, _ => panic!("Expected MovePaneBackwardsByPaneId action"), } } #[test] fn test_move_pane_backwards_without_pane_id() { let cli_action = CliAction::MovePaneBackwards { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::MovePaneBackwards)); } // 12. Clear #[test] fn test_clear_with_pane_id() { let cli_action = CliAction::Clear { pane_id: Some("terminal_14".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::ClearScreenByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(14))); }, _ => panic!("Expected ClearScreenByPaneId action"), } } #[test] fn test_clear_without_pane_id() { let cli_action = CliAction::Clear { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::ClearScreen)); } // 13. EditScrollback #[test] fn test_edit_scrollback_with_pane_id() { let cli_action = CliAction::EditScrollback { pane_id: Some("terminal_15".to_string()), ansi: false, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::EditScrollbackByPaneId { pane_id, ansi } => { assert!(matches!(pane_id, PaneId::Terminal(15))); assert!(!ansi); }, _ => panic!("Expected EditScrollbackByPaneId action"), } } #[test] fn test_edit_scrollback_without_pane_id() { let cli_action = CliAction::EditScrollback { pane_id: None, ansi: false, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::EditScrollback { ansi: false })); } // 14. ToggleFullscreen #[test] fn test_toggle_fullscreen_with_pane_id() { let cli_action = CliAction::ToggleFullscreen { pane_id: Some("terminal_16".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::ToggleFocusFullscreenByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(16))); }, _ => panic!("Expected ToggleFocusFullscreenByPaneId action"), } } #[test] fn test_toggle_fullscreen_without_pane_id() { let cli_action = CliAction::ToggleFullscreen { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::ToggleFocusFullscreen)); } #[test] fn test_toggle_no_ui_fullscreen_with_pane_id() { let cli_action = CliAction::ToggleNoUiFullscreen { pane_id: Some("terminal_16".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::ToggleFocusNoUiFullscreenByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(16))); }, _ => panic!("Expected ToggleFocusNoUiFullscreenByPaneId action"), } } #[test] fn test_toggle_no_ui_fullscreen_without_pane_id() { let cli_action = CliAction::ToggleNoUiFullscreen { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::ToggleFocusNoUiFullscreen)); } // 15. TogglePaneEmbedOrFloating #[test] fn test_toggle_pane_embed_or_floating_with_pane_id() { let cli_action = CliAction::TogglePaneEmbedOrFloating { pane_id: Some("terminal_17".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::TogglePaneEmbedOrFloatingByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(17))); }, _ => panic!("Expected TogglePaneEmbedOrFloatingByPaneId action"), } } #[test] fn test_toggle_pane_embed_or_floating_without_pane_id() { let cli_action = CliAction::TogglePaneEmbedOrFloating { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::TogglePaneEmbedOrFloating)); } // 16. ClosePane #[test] fn test_close_pane_with_pane_id() { let cli_action = CliAction::ClosePane { pane_id: Some("terminal_18".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::CloseFocusByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(18))); }, _ => panic!("Expected CloseFocusByPaneId action"), } } #[test] fn test_close_pane_without_pane_id() { let cli_action = CliAction::ClosePane { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::CloseFocus)); } // 17. RenamePane #[test] fn test_rename_pane_with_pane_id() { let cli_action = CliAction::RenamePane { name: "my-pane".to_string(), pane_id: Some("terminal_19".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::RenamePaneByPaneId { pane_id, name } => { assert!(matches!(pane_id, Some(PaneId::Terminal(19)))); assert_eq!(name, &"my-pane".as_bytes().to_vec()); }, _ => panic!("Expected RenamePaneByPaneId action"), } } #[test] fn test_rename_pane_without_pane_id() { let cli_action = CliAction::RenamePane { name: "my-pane".to_string(), pane_id: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::RenamePaneByPaneId { pane_id, name } => { assert!(pane_id.is_none()); assert_eq!(name, &"my-pane".as_bytes().to_vec()); }, _ => panic!("Expected RenamePaneByPaneId action"), } } // 18. UndoRenamePane #[test] fn test_undo_rename_pane_with_pane_id() { let cli_action = CliAction::UndoRenamePane { pane_id: Some("terminal_20".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::UndoRenamePaneByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(20))); }, _ => panic!("Expected UndoRenamePaneByPaneId action"), } } #[test] fn test_undo_rename_pane_without_pane_id() { let cli_action = CliAction::UndoRenamePane { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::UndoRenamePane)); } // 19. TogglePanePinned #[test] fn test_toggle_pane_pinned_with_pane_id() { let cli_action = CliAction::TogglePanePinned { pane_id: Some("terminal_21".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::TogglePanePinnedByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(21))); }, _ => panic!("Expected TogglePanePinnedByPaneId action"), } } #[test] fn test_toggle_pane_pinned_without_pane_id() { let cli_action = CliAction::TogglePanePinned { pane_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::TogglePanePinned)); } // Extra pane tests #[test] fn test_scroll_up_with_plugin_pane_id() { let cli_action = CliAction::ScrollUp { pane_id: Some("plugin_3".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::ScrollUpByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Plugin(3))); }, _ => panic!("Expected ScrollUpByPaneId action with plugin pane id"), } } #[test] fn test_scroll_up_with_bare_integer_pane_id() { let cli_action = CliAction::ScrollUp { pane_id: Some("7".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::ScrollUpByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(7))); }, _ => panic!("Expected ScrollUpByPaneId action with bare integer pane id"), } } #[test] fn test_scroll_up_with_invalid_pane_id() { let cli_action = CliAction::ScrollUp { pane_id: Some("invalid_id".to_string()), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.contains("Malformed pane id")); } // ============================================= // Category 1: Tab-targeting tests // ============================================= // 20. CloseTab #[test] fn test_close_tab_with_tab_id() { let cli_action = CliAction::CloseTab { tab_id: Some(5) }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::CloseTabById { id } => { assert_eq!(*id, 5u64); }, _ => panic!("Expected CloseTabById action"), } } #[test] fn test_close_tab_without_tab_id() { let cli_action = CliAction::CloseTab { tab_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::CloseTab)); } #[test] fn test_set_dark_theme_cli_to_action() { let result = Action::actions_from_cli( CliAction::SetDarkTheme, Box::new(|| PathBuf::from("/tmp")), None, ); let actions = result.expect("SetDarkTheme conversion should succeed"); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::SetDarkTheme)); } #[test] fn test_set_light_theme_cli_to_action() { let result = Action::actions_from_cli( CliAction::SetLightTheme, Box::new(|| PathBuf::from("/tmp")), None, ); let actions = result.expect("SetLightTheme conversion should succeed"); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::SetLightTheme)); } #[test] fn test_toggle_theme_cli_to_action() { let result = Action::actions_from_cli( CliAction::ToggleTheme, Box::new(|| PathBuf::from("/tmp")), None, ); let actions = result.expect("ToggleTheme conversion should succeed"); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::ToggleTheme)); } // 21. RenameTab #[test] fn test_rename_tab_with_tab_id() { let cli_action = CliAction::RenameTab { name: "my-tab".to_string(), tab_id: Some(3), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::RenameTabById { id, name } => { assert_eq!(*id, 3u64); assert_eq!(name, "my-tab"); }, _ => panic!("Expected RenameTabById action"), } } #[test] fn test_rename_tab_without_tab_id() { let cli_action = CliAction::RenameTab { name: "my-tab".to_string(), tab_id: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 2); assert!(matches!(actions[0], Action::TabNameInput { .. })); assert!(matches!(actions[1], Action::TabNameInput { .. })); } // 22. UndoRenameTab #[test] fn test_undo_rename_tab_with_tab_id() { let cli_action = CliAction::UndoRenameTab { tab_id: Some(7) }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::UndoRenameTabByTabId { id } => { assert_eq!(*id, 7u64); }, _ => panic!("Expected UndoRenameTabByTabId action"), } } #[test] fn test_undo_rename_tab_without_tab_id() { let cli_action = CliAction::UndoRenameTab { tab_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::UndoRenameTab)); } // 23. ToggleActiveSyncTab #[test] fn test_toggle_active_sync_tab_with_tab_id() { let cli_action = CliAction::ToggleActiveSyncTab { tab_id: Some(2) }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::ToggleActiveSyncTabByTabId { id } => { assert_eq!(*id, 2u64); }, _ => panic!("Expected ToggleActiveSyncTabByTabId action"), } } #[test] fn test_toggle_active_sync_tab_without_tab_id() { let cli_action = CliAction::ToggleActiveSyncTab { tab_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::ToggleActiveSyncTab)); } // 24. ToggleFloatingPanes #[test] fn test_toggle_floating_panes_with_tab_id() { let cli_action = CliAction::ToggleFloatingPanes { tab_id: Some(4) }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::ToggleFloatingPanesByTabId { id } => { assert_eq!(*id, 4u64); }, _ => panic!("Expected ToggleFloatingPanesByTabId action"), } } #[test] fn test_toggle_floating_panes_without_tab_id() { let cli_action = CliAction::ToggleFloatingPanes { tab_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::ToggleFloatingPanes)); } // 25. PreviousSwapLayout #[test] fn test_previous_swap_layout_with_tab_id() { let cli_action = CliAction::PreviousSwapLayout { tab_id: Some(6) }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::PreviousSwapLayoutByTabId { id } => { assert_eq!(*id, 6u64); }, _ => panic!("Expected PreviousSwapLayoutByTabId action"), } } #[test] fn test_previous_swap_layout_without_tab_id() { let cli_action = CliAction::PreviousSwapLayout { tab_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::PreviousSwapLayout)); } // 26. NextSwapLayout #[test] fn test_next_swap_layout_with_tab_id() { let cli_action = CliAction::NextSwapLayout { tab_id: Some(8) }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::NextSwapLayoutByTabId { id } => { assert_eq!(*id, 8u64); }, _ => panic!("Expected NextSwapLayoutByTabId action"), } } #[test] fn test_next_swap_layout_without_tab_id() { let cli_action = CliAction::NextSwapLayout { tab_id: None }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::NextSwapLayout)); } // 27. MoveTab #[test] fn test_move_tab_with_tab_id() { let cli_action = CliAction::MoveTab { direction: Direction::Right, tab_id: Some(10), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::MoveTabByTabId { id, direction } => { assert_eq!(*id, 10u64); assert!(matches!(direction, Direction::Right)); }, _ => panic!("Expected MoveTabByTabId action"), } } #[test] fn test_move_tab_without_tab_id() { let cli_action = CliAction::MoveTab { direction: Direction::Right, tab_id: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::MoveTab { direction } => { assert!(matches!(direction, Direction::Right)); }, _ => panic!("Expected MoveTab action"), } } // 28. ANSI flag tests #[test] fn test_edit_scrollback_with_ansi_flag() { let cli_action = CliAction::EditScrollback { pane_id: None, ansi: true, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); assert!(matches!(actions[0], Action::EditScrollback { ansi: true })); } #[test] fn test_edit_scrollback_with_pane_id_and_ansi() { let cli_action = CliAction::EditScrollback { pane_id: Some("terminal_15".to_string()), ansi: true, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::EditScrollbackByPaneId { pane_id, ansi } => { assert_eq!(*pane_id, PaneId::Terminal(15)); assert!(*ansi); }, _ => panic!("Expected EditScrollbackByPaneId action"), } } #[test] fn test_dump_screen_with_ansi_flag() { let cli_action = CliAction::DumpScreen { path: Some(PathBuf::from("/tmp/test")), full: true, pane_id: None, ansi: true, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::DumpScreen { ansi, include_scrollback, .. } => { assert!(*ansi); assert!(*include_scrollback); }, _ => panic!("Expected DumpScreen action"), } } #[test] fn test_dump_screen_with_pane_id_and_ansi() { let cli_action = CliAction::DumpScreen { path: None, full: false, pane_id: Some("terminal_5".to_string()), ansi: true, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::DumpScreen { pane_id, ansi, .. } => { assert_eq!(*pane_id, Some(PaneId::Terminal(5))); assert!(*ansi); }, _ => panic!("Expected DumpScreen action"), } } #[test] fn test_focus_pane_id() { let cli_action = CliAction::FocusPaneId { pane_id: "terminal_7".to_string(), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::FocusPaneByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(7))); }, _ => panic!("Expected FocusPaneByPaneId action"), } } #[test] fn test_focus_pane_id_bare_int() { let cli_action = CliAction::FocusPaneId { pane_id: "3".to_string(), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::FocusPaneByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Terminal(3))); }, _ => panic!("Expected FocusPaneByPaneId action"), } } #[test] fn test_focus_pane_id_plugin() { let cli_action = CliAction::FocusPaneId { pane_id: "plugin_2".to_string(), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::FocusPaneByPaneId { pane_id } => { assert!(matches!(pane_id, PaneId::Plugin(2))); }, _ => panic!("Expected FocusPaneByPaneId action"), } } #[test] fn test_focus_pane_id_malformed() { let cli_action = CliAction::FocusPaneId { pane_id: "invalid_id".to_string(), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_err()); } #[test] fn test_new_tab_with_layout_string() { let cli_action = CliAction::NewTab { name: None, layout: None, layout_string: Some("layout {\n pane\n pane\n}\n".into()), layout_dir: None, cwd: None, initial_command: vec![], initial_plugin: None, close_on_exit: Default::default(), start_suspended: Default::default(), block_until_exit: false, block_until_exit_success: false, block_until_exit_failure: false, no_focus: false, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::NewTab { tiled_layout, floating_layouts, .. } => { assert!(tiled_layout.is_some()); let layout = tiled_layout.as_ref().unwrap(); // layout { pane; pane } produces a layout with 2 children assert_eq!(layout.children.len(), 2); assert!(floating_layouts.is_empty()); }, _ => panic!("Expected NewTab action"), } } #[test] fn test_new_tab_with_invalid_layout_string() { let cli_action = CliAction::NewTab { name: None, layout: None, layout_string: Some("invalid { kdl".into()), layout_dir: None, cwd: None, initial_command: vec![], initial_plugin: None, close_on_exit: Default::default(), start_suspended: Default::default(), block_until_exit: false, block_until_exit_success: false, block_until_exit_failure: false, no_focus: false, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_err()); } #[test] fn test_override_layout_with_layout_string() { let cli_action = CliAction::OverrideLayout { layout: None, layout_string: Some("layout {\n pane\n pane\n}\n".into()), layout_dir: None, retain_existing_terminal_panes: false, retain_existing_plugin_panes: false, apply_only_to_active_tab: false, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::OverrideLayout { tabs, .. } => { assert!(!tabs.is_empty()); }, _ => panic!("Expected OverrideLayout action"), } } #[test] fn test_switch_session_with_layout_string() { let cli_action = CliAction::SwitchSession { name: "test-session".into(), tab_position: None, pane_id: None, layout: None, layout_string: Some("layout {\n pane\n}\n".into()), layout_dir: None, cwd: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::SwitchSession { layout, .. } => { assert!(matches!( layout, Some(crate::data::LayoutInfo::Stringified(_)) )); }, _ => panic!("Expected SwitchSession action"), } } #[test] fn test_switch_session_with_invalid_layout_string() { let cli_action = CliAction::SwitchSession { name: "test-session".into(), tab_position: None, pane_id: None, layout: None, layout_string: Some("invalid { kdl".into()), layout_dir: None, cwd: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_err()); } // Tab-targeting for pane creation commands #[test] fn test_new_pane_tiled_with_tab_id() { let cli_action = CliAction::NewPane { purpose: None, direction: Some(Direction::Right), command: vec![], plugin: None, cwd: None, floating: false, in_place: false, close_replaced_pane: false, pane_id: None, name: None, close_on_exit: false, start_suspended: false, configuration: None, skip_plugin_cache: false, x: None, y: None, width: None, height: None, pinned: None, stacked: false, blocking: false, block_until_exit_success: false, block_until_exit_failure: false, block_until_exit: false, unblock_condition: None, near_current_pane: false, no_focus: false, borderless: None, tab_id: Some(3), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::NewTiledPane { tab_id, .. } => { assert_eq!(*tab_id, Some(3)); }, _ => panic!("Expected NewTiledPane action"), } } #[test] fn test_new_pane_tiled_without_tab_id() { let cli_action = CliAction::NewPane { purpose: None, direction: None, command: vec![], plugin: None, cwd: None, floating: false, in_place: false, close_replaced_pane: false, pane_id: None, name: None, close_on_exit: false, start_suspended: false, configuration: None, skip_plugin_cache: false, x: None, y: None, width: None, height: None, pinned: None, stacked: false, blocking: false, block_until_exit_success: false, block_until_exit_failure: false, block_until_exit: false, unblock_condition: None, near_current_pane: false, no_focus: false, borderless: None, tab_id: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::NewTiledPane { tab_id, .. } => { assert_eq!(*tab_id, None); }, _ => panic!("Expected NewTiledPane action"), } } #[test] fn test_new_in_place_pane_with_pane_id_to_replace() { let cli_action = CliAction::NewPane { purpose: None, direction: None, command: vec![], plugin: None, cwd: None, floating: false, in_place: true, close_replaced_pane: true, pane_id: Some("terminal_4".to_string()), name: None, close_on_exit: false, start_suspended: false, configuration: None, skip_plugin_cache: false, x: None, y: None, width: None, height: None, pinned: None, stacked: false, blocking: false, block_until_exit_success: false, block_until_exit_failure: false, block_until_exit: false, unblock_condition: None, near_current_pane: false, no_focus: false, borderless: None, tab_id: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::NewInPlacePane { pane_id_to_replace, .. } => { assert_eq!(*pane_id_to_replace, Some(PaneId::Terminal(4))); }, _ => panic!("Expected NewInPlacePane action"), } } #[test] fn test_new_in_place_pane_with_malformed_pane_id() { let cli_action = CliAction::NewPane { purpose: None, direction: None, command: vec![], plugin: None, cwd: None, floating: false, in_place: true, close_replaced_pane: false, pane_id: Some("not_a_pane".to_string()), name: None, close_on_exit: false, start_suspended: false, configuration: None, skip_plugin_cache: false, x: None, y: None, width: None, height: None, pinned: None, stacked: false, blocking: false, block_until_exit_success: false, block_until_exit_failure: false, block_until_exit: false, unblock_condition: None, near_current_pane: false, no_focus: false, borderless: None, tab_id: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_err()); assert!(result.unwrap_err().contains("Malformed pane id")); } #[test] fn test_new_pane_floating_with_tab_id() { let cli_action = CliAction::NewPane { purpose: None, direction: None, command: vec![], plugin: None, cwd: None, floating: true, in_place: false, close_replaced_pane: false, pane_id: None, name: None, close_on_exit: false, start_suspended: false, configuration: None, skip_plugin_cache: false, x: None, y: None, width: None, height: None, pinned: None, stacked: false, blocking: false, block_until_exit_success: false, block_until_exit_failure: false, block_until_exit: false, unblock_condition: None, near_current_pane: false, no_focus: false, borderless: None, tab_id: Some(5), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::NewFloatingPane { tab_id, .. } => { assert_eq!(*tab_id, Some(5)); }, _ => panic!("Expected NewFloatingPane action"), } } #[test] fn test_new_pane_stacked_with_tab_id() { let cli_action = CliAction::NewPane { purpose: None, direction: None, command: vec!["ls".into()], plugin: None, cwd: None, floating: false, in_place: false, close_replaced_pane: false, pane_id: None, name: None, close_on_exit: false, start_suspended: false, configuration: None, skip_plugin_cache: false, x: None, y: None, width: None, height: None, pinned: None, stacked: true, blocking: false, block_until_exit_success: false, block_until_exit_failure: false, block_until_exit: false, unblock_condition: None, near_current_pane: false, no_focus: false, borderless: None, tab_id: Some(1), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::NewStackedPane { tab_id, .. } => { assert_eq!(*tab_id, Some(1)); }, _ => panic!("Expected NewStackedPane action"), } } #[test] fn test_new_pane_blocking_with_tab_id() { let cli_action = CliAction::NewPane { purpose: None, direction: None, command: vec!["ls".into()], plugin: None, cwd: None, floating: false, in_place: false, close_replaced_pane: false, pane_id: None, name: None, close_on_exit: false, start_suspended: false, configuration: None, skip_plugin_cache: false, x: None, y: None, width: None, height: None, pinned: None, stacked: false, blocking: true, block_until_exit_success: false, block_until_exit_failure: false, block_until_exit: false, unblock_condition: None, near_current_pane: false, no_focus: false, borderless: None, tab_id: Some(2), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::NewBlockingPane { tab_id, .. } => { assert_eq!(*tab_id, Some(2)); }, _ => panic!("Expected NewBlockingPane action"), } } #[test] fn test_edit_with_tab_id() { let cli_action = CliAction::Edit { file: PathBuf::from("/tmp/test.rs"), direction: None, line_number: None, floating: false, in_place: false, close_replaced_pane: false, cwd: None, x: None, y: None, width: None, height: None, pinned: None, near_current_pane: false, no_focus: false, borderless: None, tab_id: Some(4), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::EditFile { tab_id, .. } => { assert_eq!(*tab_id, Some(4)); }, _ => panic!("Expected EditFile action"), } } #[test] fn test_edit_without_tab_id() { let cli_action = CliAction::Edit { file: PathBuf::from("/tmp/test.rs"), direction: None, line_number: None, floating: false, in_place: false, close_replaced_pane: false, cwd: None, x: None, y: None, width: None, height: None, pinned: None, near_current_pane: false, no_focus: false, borderless: None, tab_id: None, }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::EditFile { tab_id, .. } => { assert_eq!(*tab_id, None); }, _ => panic!("Expected EditFile action"), } } #[test] fn test_new_pane_plugin_tiled_with_tab_id() { let cli_action = CliAction::NewPane { purpose: None, direction: None, command: vec![], plugin: Some("zellij:strider".into()), cwd: None, floating: false, in_place: false, close_replaced_pane: false, pane_id: None, name: None, close_on_exit: false, start_suspended: false, configuration: None, skip_plugin_cache: false, x: None, y: None, width: None, height: None, pinned: None, stacked: false, blocking: false, block_until_exit_success: false, block_until_exit_failure: false, block_until_exit: false, unblock_condition: None, near_current_pane: false, no_focus: false, borderless: None, tab_id: Some(2), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::NewTiledPluginPane { tab_id, .. } => { assert_eq!(*tab_id, Some(2)); }, _ => panic!("Expected NewTiledPluginPane action"), } } #[test] fn test_new_pane_plugin_floating_with_tab_id() { let cli_action = CliAction::NewPane { purpose: None, direction: None, command: vec![], plugin: Some("zellij:strider".into()), cwd: None, floating: true, in_place: false, close_replaced_pane: false, pane_id: None, name: None, close_on_exit: false, start_suspended: false, configuration: None, skip_plugin_cache: false, x: None, y: None, width: None, height: None, pinned: None, stacked: false, blocking: false, block_until_exit_success: false, block_until_exit_failure: false, block_until_exit: false, unblock_condition: None, near_current_pane: false, no_focus: false, borderless: None, tab_id: Some(1), }; let result = Action::actions_from_cli(cli_action, Box::new(|| PathBuf::from("/tmp")), None); assert!(result.is_ok()); let actions = result.unwrap(); assert_eq!(actions.len(), 1); match &actions[0] { Action::NewFloatingPluginPane { tab_id, .. } => { assert_eq!(*tab_id, Some(1)); }, _ => panic!("Expected NewFloatingPluginPane action"), } } } #[cfg(test)] mod sl0p_purpose_wire_tests { use super::*; /// A CLI flag has to CROSS the client->server protobuf boundary, unlike a /// field that merely exists on both sides. Defaulting it in the conversion /// (which is what "no protoc" usually forces) silently drops it, and the flag /// then parses and does nothing at all -- which is exactly what happened. #[test] fn sl0p_purpose_survives_the_protobuf_round_trip() { use crate::plugin_api::action::ProtobufAction; use std::convert::TryInto; let action = Action::NewTiledPane { direction: None, command: Some(RunCommandAction { command: std::path::PathBuf::from("sleep"), ..Default::default() }), pane_name: None, near_current_pane: false, no_focus: false, borderless: None, tab_id: None, purpose: Some("dev-server".to_string()), }; let proto: ProtobufAction = action.clone().try_into().expect("to protobuf"); let back: Action = proto.try_into().expect("from protobuf"); match back { Action::NewTiledPane { purpose, .. } => assert_eq!( purpose.as_deref(), Some("dev-server"), "purpose must survive the wire, or --purpose is a no-op" ), other => panic!("expected NewTiledPane, got {:?}", other), } } } #[cfg(test)] mod sl0p_purpose_ipc_wire_tests { use super::*; /// There are TWO protobuf boundaries: the plugin API and the client->server /// IPC contract. `--purpose` parsed fine and did nothing for a whole round of /// testing because only the first was wired, and a field with no wire /// representation is silently dropped in transit rather than failing to /// compile. This is the boundary a CLI flag actually crosses. #[test] fn sl0p_purpose_survives_the_client_server_ipc_round_trip() { use crate::client_server_contract::client_server_contract::Action as ProtoIpcAction; use std::convert::TryInto; for action in [ Action::NewTiledPane { direction: None, command: None, pane_name: None, near_current_pane: false, no_focus: false, borderless: None, tab_id: None, purpose: Some("dev-server".to_string()), }, Action::NewFloatingPane { command: None, pane_name: None, coordinates: None, near_current_pane: false, no_focus: false, tab_id: None, purpose: Some("dev-server".to_string()), }, ] { let proto: ProtoIpcAction = action.clone().into(); let back: Action = proto.try_into().expect("from ipc protobuf"); let purpose = match back { Action::NewTiledPane { purpose, .. } | Action::NewFloatingPane { purpose, .. } => { purpose }, other => panic!("unexpected action back from the wire: {:?}", other), }; assert_eq!( purpose.as_deref(), Some("dev-server"), "purpose must cross the IPC boundary, or --purpose is a silent no-op" ); } } }