diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-06 00:41:19 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-06 00:41:19 +0300 |
| commit | b7619db085eb4054a071b51159eda65d6b60f3fe (patch) | |
| tree | 19e6885b47b67dcad5beefa1ca487de170085dcd /src | |
| parent | 556b16b2df7ea2c3e9b8ad13db4ad7370c1ad838 (diff) | |
feat: expose pane zoom api
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 1 | ||||
| -rw-r--r-- | src/api/schema.rs | 360 | ||||
| -rw-r--r-- | src/api/schema/panes.rs | 394 | ||||
| -rw-r--r-- | src/api/server.rs | 1 | ||||
| -rw-r--r-- | src/app/actions.rs | 97 | ||||
| -rw-r--r-- | src/app/api.rs | 1 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 290 | ||||
| -rw-r--r-- | src/cli/pane.rs | 109 |
8 files changed, 888 insertions, 365 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs index f0cf42c7..7d2d5bc1 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -38,6 +38,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::AgentStart(_) | Method::PaneSplit(_) | Method::PaneSwap(_) + | Method::PaneZoom(_) | Method::PaneFocusDirection(_) | Method::PaneResize(_) | Method::PaneRename(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index ba2fd1e5..c0715758 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -2,6 +2,10 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +pub mod panes; + +pub use panes::*; + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Request { pub id: String, @@ -70,6 +74,8 @@ pub enum Method { PaneSplit(PaneSplitParams), #[serde(rename = "pane.swap")] PaneSwap(PaneSwapParams), + #[serde(rename = "pane.zoom")] + PaneZoom(PaneZoomParams), #[serde(rename = "pane.layout")] PaneLayout(PaneLayoutParams), #[serde(rename = "pane.neighbor")] @@ -274,118 +280,6 @@ pub struct AgentStartParams { pub argv: Vec<String>, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PaneSplitParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_pane_id: Option<String>, - pub direction: SplitDirection, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ratio: Option<f32>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option<String>, - #[serde(default)] - pub focus: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SplitDirection { - Right, - Down, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PaneDirection { - Left, - Right, - Up, - Down, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct PaneSwapParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pane_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub direction: Option<PaneDirection>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_pane_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_pane_id: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct PaneLayoutParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pane_id: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneNeighborParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pane_id: Option<String>, - pub direction: PaneDirection, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct PaneEdgesParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pane_id: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneFocusDirectionParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pane_id: Option<String>, - pub direction: PaneDirection, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PaneResizeParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pane_id: Option<String>, - pub direction: PaneDirection, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub amount: Option<f32>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct PaneListParams { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_id: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneRenameParams { - pub pane_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneSendTextParams { - pub pane_id: String, - pub text: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneSendKeysParams { - pub pane_id: String, - pub keys: Vec<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneSendInputParams { - pub pane_id: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub text: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub keys: Vec<String>, -} - #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ServerLiveHandoffParams { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -396,97 +290,6 @@ pub struct ServerLiveHandoffParams { pub expected_version: Option<String>, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneReadParams { - pub pane_id: String, - pub source: ReadSource, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub lines: Option<u32>, - #[serde(default)] - pub format: ReadFormat, - #[serde(default = "default_true")] - pub strip_ansi: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneReportAgentParams { - pub pane_id: String, - pub source: String, - pub agent: String, - pub state: PaneAgentState, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub message: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub seq: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_session_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_session_path: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneReportAgentSessionParams { - pub pane_id: String, - pub source: String, - pub agent: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub seq: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_session_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_session_path: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneReportMetadataParams { - pub pane_id: String, - pub source: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub applies_to_source: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub display_agent: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option<String>, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub state_labels: HashMap<String, String>, - #[serde(default)] - pub clear_title: bool, - #[serde(default)] - pub clear_display_agent: bool, - #[serde(default)] - pub clear_custom_status: bool, - #[serde(default)] - pub clear_state_labels: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub seq: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ttl_ms: Option<u64>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneClearAgentAuthorityParams { - pub pane_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub seq: Option<u64>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneReleaseAgentParams { - pub pane_id: String, - pub source: String, - pub agent: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub seq: Option<u64>, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ReadSource { @@ -793,6 +596,9 @@ pub enum ResponseResult { PaneSwap { swap: PaneSwapResult, }, + PaneZoom { + zoom: PaneZoomResult, + }, PaneLayout { layout: PaneLayoutSnapshot, }, @@ -924,142 +730,6 @@ pub struct AgentInfo { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneInfo { - pub pane_id: String, - pub terminal_id: String, - pub workspace_id: String, - pub tab_id: String, - pub focused: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub foreground_cwd: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub display_agent: Option<String>, - pub agent_status: AgentStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom_status: Option<String>, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub state_labels: HashMap<String, String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_session: Option<AgentSessionInfo>, - pub revision: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PaneSwapResult { - pub changed: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option<PaneSwapReason>, - pub source_pane_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_pane_id: Option<String>, - pub focused_pane_id: String, - pub layout: PaneLayoutSnapshot, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PaneSwapReason { - NoNeighbor, - SamePane, - NotFound, - CrossTab, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PaneLayoutSnapshot { - pub workspace_id: String, - pub tab_id: String, - pub zoomed: bool, - pub area: PaneLayoutRect, - pub focused_pane_id: String, - pub panes: Vec<PaneLayoutPane>, - pub splits: Vec<PaneLayoutSplit>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneLayoutRect { - pub x: u16, - pub y: u16, - pub width: u16, - pub height: u16, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneLayoutPane { - pub pane_id: String, - pub focused: bool, - pub rect: PaneLayoutRect, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PaneLayoutSplit { - pub id: String, - pub direction: SplitDirection, - pub ratio: f32, - pub rect: PaneLayoutRect, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PaneNeighborResult { - pub pane_id: String, - pub direction: PaneDirection, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub neighbor_pane_id: Option<String>, - pub layout: PaneLayoutSnapshot, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PaneEdgesResult { - pub pane_id: String, - pub left: bool, - pub right: bool, - pub up: bool, - pub down: bool, - pub layout: PaneLayoutSnapshot, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PaneFocusDirectionResult { - pub changed: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option<PaneFocusDirectionReason>, - pub source_pane_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub focused_pane_id: Option<String>, - pub layout: PaneLayoutSnapshot, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PaneFocusDirectionReason { - NoNeighbor, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PaneResizeResult { - pub changed: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reason: Option<PaneResizeReason>, - pub pane_id: String, - pub focused_pane_id: String, - pub layout: PaneLayoutSnapshot, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PaneResizeReason { - Unchanged, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentSessionInfo { pub source: String, pub agent: String, @@ -1068,18 +738,6 @@ pub struct AgentSessionInfo { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PaneReadResult { - pub pane_id: String, - pub workspace_id: String, - pub tab_id: String, - pub source: ReadSource, - pub format: ReadFormat, - pub text: String, - pub revision: u64, - pub truncated: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct IntegrationInstallResult { pub messages: Vec<String>, } diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs new file mode 100644 index 00000000..6a1827a2 --- /dev/null +++ b/src/api/schema/panes.rs @@ -0,0 +1,394 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::{AgentSessionInfo, AgentStatus, PaneAgentState, ReadFormat, ReadSource}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaneSplitParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_pane_id: Option<String>, + pub direction: SplitDirection, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ratio: Option<f32>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default)] + pub focus: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SplitDirection { + Right, + Down, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaneDirection { + Left, + Right, + Up, + Down, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PaneSwapParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direction: Option<PaneDirection>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_pane_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_pane_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PaneZoomParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, + #[serde(default)] + pub mode: PaneZoomMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PaneZoomMode { + #[default] + Toggle, + On, + Off, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PaneLayoutParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneNeighborParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, + pub direction: PaneDirection, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PaneEdgesParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneFocusDirectionParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, + pub direction: PaneDirection, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaneResizeParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option<String>, + pub direction: PaneDirection, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amount: Option<f32>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PaneListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneRenameParams { + pub pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSendTextParams { + pub pane_id: String, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSendKeysParams { + pub pane_id: String, + pub keys: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneSendInputParams { + pub pane_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub text: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub keys: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneReadParams { + pub pane_id: String, + pub source: ReadSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lines: Option<u32>, + #[serde(default)] + pub format: ReadFormat, + #[serde(default = "super::default_true")] + pub strip_ansi: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneReportAgentParams { + pub pane_id: String, + pub source: String, + pub agent: String, + pub state: PaneAgentState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_path: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneReportAgentSessionParams { + pub pane_id: String, + pub source: String, + pub agent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_path: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneReportMetadataParams { + pub pane_id: String, + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub applies_to_source: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub state_labels: HashMap<String, String>, + #[serde(default)] + pub clear_title: bool, + #[serde(default)] + pub clear_display_agent: bool, + #[serde(default)] + pub clear_custom_status: bool, + #[serde(default)] + pub clear_state_labels: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl_ms: Option<u64>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneClearAgentAuthorityParams { + pub pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option<u64>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneReleaseAgentParams { + pub pane_id: String, + pub source: String, + pub agent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option<u64>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneInfo { + pub pane_id: String, + pub terminal_id: String, + pub workspace_id: String, + pub tab_id: String, + pub focused: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub foreground_cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_agent: Option<String>, + pub agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option<String>, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub state_labels: HashMap<String, String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session: Option<AgentSessionInfo>, + pub revision: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaneSwapResult { + pub changed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option<PaneSwapReason>, + pub source_pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_pane_id: Option<String>, + pub focused_pane_id: String, + pub layout: PaneLayoutSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaneSwapReason { + NoNeighbor, + SamePane, + NotFound, + CrossTab, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaneZoomResult { + pub changed: bool, + pub zoom_changed: bool, + pub focus_changed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option<PaneZoomReason>, + pub pane_id: String, + pub focused_pane_id: String, + pub zoomed: bool, + pub layout: PaneLayoutSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaneZoomReason { + SinglePane, + AlreadyZoomed, + AlreadyUnzoomed, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaneLayoutSnapshot { + pub workspace_id: String, + pub tab_id: String, + pub zoomed: bool, + pub area: PaneLayoutRect, + pub focused_pane_id: String, + pub panes: Vec<PaneLayoutPane>, + pub splits: Vec<PaneLayoutSplit>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneLayoutRect { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneLayoutPane { + pub pane_id: String, + pub focused: bool, + pub rect: PaneLayoutRect, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaneLayoutSplit { + pub id: String, + pub direction: SplitDirection, + pub ratio: f32, + pub rect: PaneLayoutRect, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaneNeighborResult { + pub pane_id: String, + pub direction: PaneDirection, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub neighbor_pane_id: Option<String>, + pub layout: PaneLayoutSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaneEdgesResult { + pub pane_id: String, + pub left: bool, + pub right: bool, + pub up: bool, + pub down: bool, + pub layout: PaneLayoutSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaneFocusDirectionResult { + pub changed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option<PaneFocusDirectionReason>, + pub source_pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focused_pane_id: Option<String>, + pub layout: PaneLayoutSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaneFocusDirectionReason { + NoNeighbor, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaneResizeResult { + pub changed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option<PaneResizeReason>, + pub pane_id: String, + pub focused_pane_id: String, + pub layout: PaneLayoutSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaneResizeReason { + Unchanged, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneReadResult { + pub pane_id: String, + pub workspace_id: String, + pub tab_id: String, + pub source: ReadSource, + pub format: ReadFormat, + pub text: String, + pub revision: u64, + pub truncated: bool, +} diff --git a/src/api/server.rs b/src/api/server.rs index 44bb3097..7e4dc3b1 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -296,6 +296,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::AgentStart(_) => "agent.start", Method::PaneSplit(_) => "pane.split", Method::PaneSwap(_) => "pane.swap", + Method::PaneZoom(_) => "pane.zoom", Method::PaneLayout(_) => "pane.layout", Method::PaneNeighbor(_) => "pane.neighbor", Method::PaneEdges(_) => "pane.edges", diff --git a/src/app/actions.rs b/src/app/actions.rs index 8124be93..17daa6ff 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1350,6 +1350,28 @@ impl AppState { // Pane operations // --------------------------------------------------------------------------- +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PaneZoomCommand { + Toggle, + On, + Off, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PaneZoomNoopReason { + SinglePane, + AlreadyZoomed, + AlreadyUnzoomed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PaneZoomOutcome { + pub changed: bool, + pub focus_changed: bool, + pub reason: Option<PaneZoomNoopReason>, + pub zoomed: bool, +} + impl AppState { pub fn navigate_pane(&mut self, direction: NavDirection) { let Some(ws_idx) = self.active else { @@ -1468,17 +1490,72 @@ impl AppState { } } - pub fn toggle_zoom(&mut self) { - if let Some(tab) = self - .active - .and_then(|i| self.workspaces.get_mut(i)) - .and_then(|ws| ws.active_tab_mut()) - { - if tab.layout.pane_count() > 1 { - tab.zoomed = !tab.zoomed; - self.mark_session_dirty(); - } + pub(crate) fn apply_pane_zoom( + &mut self, + ws_idx: usize, + pane_id: PaneId, + command: PaneZoomCommand, + ) -> Option<PaneZoomOutcome> { + let tab_idx = self + .workspaces + .get(ws_idx)? + .find_tab_index_for_pane(pane_id)?; + let focus_changed = self.focus_pane_in_workspace(ws_idx, pane_id); + let tab = self + .workspaces + .get_mut(ws_idx) + .and_then(|ws| ws.tabs.get_mut(tab_idx))?; + if tab.layout.pane_count() <= 1 { + return Some(PaneZoomOutcome { + changed: false, + focus_changed, + reason: Some(PaneZoomNoopReason::SinglePane), + zoomed: tab.zoomed, + }); } + + let desired = match command { + PaneZoomCommand::Toggle => !tab.zoomed, + PaneZoomCommand::On => true, + PaneZoomCommand::Off => false, + }; + let reason = match (command, tab.zoomed) { + (PaneZoomCommand::On, true) => Some(PaneZoomNoopReason::AlreadyZoomed), + (PaneZoomCommand::Off, false) => Some(PaneZoomNoopReason::AlreadyUnzoomed), + _ => None, + }; + if reason.is_some() { + return Some(PaneZoomOutcome { + changed: false, + focus_changed, + reason, + zoomed: tab.zoomed, + }); + } + + tab.zoomed = desired; + let zoomed = tab.zoomed; + self.mark_session_dirty(); + Some(PaneZoomOutcome { + changed: true, + focus_changed, + reason: None, + zoomed, + }) + } + + pub fn toggle_zoom(&mut self) { + let Some(ws_idx) = self.active else { + return; + }; + let Some(pane_id) = self + .workspaces + .get(ws_idx) + .and_then(crate::workspace::Workspace::focused_pane_id) + else { + return; + }; + self.apply_pane_zoom(ws_idx, pane_id, PaneZoomCommand::Toggle); } pub(crate) fn workspace_close_would_close_worktree_group(&self, ws_idx: usize) -> bool { diff --git a/src/app/api.rs b/src/app/api.rs index 35994bff..592ee7da 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -556,6 +556,7 @@ impl App { Method::AgentSend(params) => return self.handle_agent_send(request.id, params), Method::PaneSplit(params) => return self.handle_pane_split(request.id, params), Method::PaneSwap(params) => return self.handle_pane_swap(request.id, params), + Method::PaneZoom(params) => return self.handle_pane_zoom(request.id, params), Method::PaneLayout(params) => return self.handle_pane_layout(request.id, params), Method::PaneNeighbor(params) => return self.handle_pane_neighbor(request.id, params), Method::PaneEdges(params) => return self.handle_pane_edges(request.id, params), diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 23272ffd..d824d26b 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -8,9 +8,10 @@ use crate::api::schema::{ PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, PaneResizeResult, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, - PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, ReadFormat, ReadSource, - ResponseResult, + PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, + PaneZoomReason, PaneZoomResult, ReadFormat, ReadSource, ResponseResult, }; +use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason}; use crate::app::{App, Mode}; use crate::layout::{find_in_direction, NavDirection, PaneId}; @@ -517,6 +518,57 @@ impl App { ) } + pub(super) fn handle_pane_zoom(&mut self, id: String, params: PaneZoomParams) -> String { + let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else { + return encode_error(id, "pane_not_found", "pane not found"); + }; + let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else { + return pane_not_found( + id, + &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(), + ); + }; + let Some(pane_public_id) = self.public_pane_id(ws_idx, pane_id) else { + return encode_error(id, "pane_not_found", "pane not found"); + }; + let command = match params.mode { + PaneZoomMode::Toggle => PaneZoomCommand::Toggle, + PaneZoomMode::On => PaneZoomCommand::On, + PaneZoomMode::Off => PaneZoomCommand::Off, + }; + let Some(outcome) = self.state.apply_pane_zoom(ws_idx, pane_id, command) else { + return pane_not_found(id, &pane_public_id); + }; + if outcome.changed || outcome.focus_changed { + self.schedule_session_save(); + } + self.state.mode = Mode::Terminal; + let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else { + return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); + }; + let focused_pane_id = layout.focused_pane_id.clone(); + + encode_success( + id, + ResponseResult::PaneZoom { + zoom: PaneZoomResult { + changed: outcome.changed || outcome.focus_changed, + zoom_changed: outcome.changed, + focus_changed: outcome.focus_changed, + reason: outcome.reason.map(|reason| match reason { + PaneZoomNoopReason::SinglePane => PaneZoomReason::SinglePane, + PaneZoomNoopReason::AlreadyZoomed => PaneZoomReason::AlreadyZoomed, + PaneZoomNoopReason::AlreadyUnzoomed => PaneZoomReason::AlreadyUnzoomed, + }), + pane_id: pane_public_id, + focused_pane_id, + zoomed: outcome.zoomed, + layout, + }, + }, + ) + } + pub(super) fn handle_pane_rename(&mut self, id: String, params: PaneRenameParams) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); @@ -1281,6 +1333,240 @@ mod tests { } #[test] + fn api_pane_zoom_current_toggles_zoom() { + let mut app = app_with_linked_worktree(); + app.state.active = Some(0); + app.state.selected = 0; + let root = app.state.workspaces[0].tabs[0].root_pane; + let _right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); + app.state.workspaces[0].tabs[0].layout.focus_pane(root); + let root_public = app.public_pane_id(0, root).unwrap(); + + let response = app.handle_pane_zoom("req".into(), PaneZoomParams::default()); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneZoom { zoom } = success.result else { + panic!("expected pane zoom response"); + }; + assert!(zoom.changed); + assert!(zoom.zoom_changed); + assert!(!zoom.focus_changed); + assert_eq!(zoom.reason, None); + assert_eq!(zoom.pane_id, root_public); + assert_eq!(zoom.focused_pane_id, zoom.pane_id); + assert!(zoom.zoomed); + assert!(zoom.layout.zoomed); + + let response = app.handle_pane_zoom("req".into(), PaneZoomParams::default()); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneZoom { zoom } = success.result else { + panic!("expected pane zoom response"); + }; + assert!(zoom.changed); + assert!(zoom.zoom_changed); + assert!(!zoom.focus_changed); + assert!(!zoom.zoomed); + assert!(!zoom.layout.zoomed); + } + + #[test] + fn api_pane_zoom_explicit_background_pane_updates_focus_history() { + let mut app = app_with_linked_worktree(); + app.state.workspaces.push(Workspace::test_new("other")); + let first = app.state.workspaces[0].tabs[0].root_pane; + let target = app.state.workspaces[1].tabs[0].root_pane; + let _other = app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal); + app.state.active = Some(0); + app.state.selected = 0; + app.state.workspaces[0].tabs[0].layout.focus_pane(first); + let target_public = app.public_pane_id(1, target).unwrap(); + + let response = app.handle_pane_zoom( + "req".into(), + PaneZoomParams { + pane_id: Some(target_public.clone()), + mode: PaneZoomMode::On, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneZoom { zoom } = success.result else { + panic!("expected pane zoom response"); + }; + assert!(zoom.changed); + assert!(zoom.zoom_changed); + assert!(zoom.focus_changed); + assert_eq!(zoom.pane_id, target_public); + assert_eq!(app.state.active, Some(1)); + assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target)); + assert!(app.state.workspaces[1].tabs[0].zoomed); + + app.state.last_pane(); + + assert_eq!(app.state.active, Some(0)); + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(first)); + } + + #[test] + fn api_pane_zoom_single_pane_returns_noop() { + let mut app = app_with_linked_worktree(); + app.state.active = Some(0); + app.state.selected = 0; + let root = app.state.workspaces[0].tabs[0].root_pane; + let root_public = app.public_pane_id(0, root).unwrap(); + + let response = app.handle_pane_zoom( + "req".into(), + PaneZoomParams { + pane_id: Some(root_public.clone()), + mode: PaneZoomMode::Toggle, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneZoom { zoom } = success.result else { + panic!("expected pane zoom response"); + }; + assert!(!zoom.changed); + assert!(!zoom.zoom_changed); + assert!(!zoom.focus_changed); + assert_eq!(zoom.reason, Some(PaneZoomReason::SinglePane)); + assert_eq!(zoom.pane_id, root_public); + assert!(!zoom.zoomed); + assert!(!app.state.workspaces[0].tabs[0].zoomed); + } + + #[test] + fn api_pane_zoom_on_and_off_are_idempotent() { + let mut app = app_with_linked_worktree(); + app.state.active = Some(0); + app.state.selected = 0; + let root = app.state.workspaces[0].tabs[0].root_pane; + let _right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); + app.state.workspaces[0].tabs[0].layout.focus_pane(root); + let root_public = app.public_pane_id(0, root).unwrap(); + + let response = app.handle_pane_zoom( + "req".into(), + PaneZoomParams { + pane_id: Some(root_public.clone()), + mode: PaneZoomMode::On, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneZoom { zoom } = success.result else { + panic!("expected pane zoom response"); + }; + assert!(zoom.changed); + assert!(zoom.zoom_changed); + assert!(!zoom.focus_changed); + assert!(zoom.zoomed); + + let response = app.handle_pane_zoom( + "req".into(), + PaneZoomParams { + pane_id: Some(root_public.clone()), + mode: PaneZoomMode::On, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneZoom { zoom } = success.result else { + panic!("expected pane zoom response"); + }; + assert!(!zoom.changed); + assert!(!zoom.zoom_changed); + assert!(!zoom.focus_changed); + assert_eq!(zoom.reason, Some(PaneZoomReason::AlreadyZoomed)); + assert!(zoom.zoomed); + + let response = app.handle_pane_zoom( + "req".into(), + PaneZoomParams { + pane_id: Some(root_public), + mode: PaneZoomMode::Off, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneZoom { zoom } = success.result else { + panic!("expected pane zoom response"); + }; + assert!(zoom.changed); + assert!(zoom.zoom_changed); + assert!(!zoom.focus_changed); + assert!(!zoom.zoomed); + + let response = app.handle_pane_zoom( + "req".into(), + PaneZoomParams { + pane_id: None, + mode: PaneZoomMode::Off, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneZoom { zoom } = success.result else { + panic!("expected pane zoom response"); + }; + assert!(!zoom.changed); + assert!(!zoom.zoom_changed); + assert!(!zoom.focus_changed); + assert_eq!(zoom.reason, Some(PaneZoomReason::AlreadyUnzoomed)); + assert!(!zoom.zoomed); + } + + #[test] + fn api_pane_zoom_idempotent_mode_reports_focus_change() { + let mut app = app_with_linked_worktree(); + app.state.active = Some(0); + app.state.selected = 0; + let root = app.state.workspaces[0].tabs[0].root_pane; + let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); + app.state.workspaces[0].tabs[0].layout.focus_pane(root); + app.state.workspaces[0].tabs[0].zoomed = true; + let right_public = app.public_pane_id(0, right).unwrap(); + + let response = app.handle_pane_zoom( + "req".into(), + PaneZoomParams { + pane_id: Some(right_public), + mode: PaneZoomMode::On, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneZoom { zoom } = success.result else { + panic!("expected pane zoom response"); + }; + assert!(zoom.changed); + assert!(!zoom.zoom_changed); + assert!(zoom.focus_changed); + assert_eq!(zoom.reason, Some(PaneZoomReason::AlreadyZoomed)); + assert!(zoom.zoomed); + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(right)); + } + + #[test] + fn api_pane_zoom_params_serialize_modes() { + let request = crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::PaneZoom(PaneZoomParams { + pane_id: Some("issue-1".into()), + mode: PaneZoomMode::On, + }), + }; + + let encoded = serde_json::to_string(&request).unwrap(); + assert!(encoded.contains("\"method\":\"pane.zoom\"")); + assert!(encoded.contains("\"mode\":\"on\"")); + + let decoded: crate::api::schema::Request = serde_json::from_str(&encoded).unwrap(); + let crate::api::schema::Method::PaneZoom(params) = decoded.method else { + panic!("expected pane zoom request"); + }; + assert_eq!(params.pane_id, Some("issue-1".into())); + assert_eq!(params.mode, PaneZoomMode::On); + } + + #[test] fn api_pane_layout_returns_public_ids_rects_and_splits() { let mut app = app_with_linked_worktree(); let root = app.state.workspaces[0].tabs[0].root_pane; diff --git a/src/cli/pane.rs b/src/cli/pane.rs index 1a9b20e1..3fb83248 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -2,8 +2,8 @@ use crate::api::schema::{ Method, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams, PaneLayoutParams, PaneListParams, PaneNeighborParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams, PaneReportMetadataParams, PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, - PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, ReadFormat, ReadSource, - Request, + PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, + ReadFormat, ReadSource, Request, }; pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { @@ -20,6 +20,7 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { "edges" => pane_edges(&args[1..]), "focus" => pane_focus(&args[1..]), "resize" => pane_resize(&args[1..]), + "zoom" => pane_zoom(&args[1..]), "read" => pane_read(&args[1..]), "rename" => pane_rename(&args[1..]), "split" => pane_split(&args[1..]), @@ -289,6 +290,78 @@ fn parse_pane_resize_args(args: &[String]) -> Result<PaneResizeParams, String> { }) } +fn pane_zoom(args: &[String]) -> std::io::Result<i32> { + let params = match parse_pane_zoom_args(args) { + Ok(params) => params, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; + + super::print_response(&super::send_request(&Request { + id: "cli:pane:zoom".into(), + method: Method::PaneZoom(params), + })?) +} + +fn parse_pane_zoom_args(args: &[String]) -> Result<PaneZoomParams, String> { + let mut pane_id = None; + let mut mode = PaneZoomMode::Toggle; + let mut mode_seen = false; + + let mut index = 0; + if args + .first() + .is_some_and(|arg| !arg.as_str().starts_with("--")) + { + pane_id = args.first().map(|arg| super::normalize_pane_id(arg)); + index = 1; + } + while index < args.len() { + match args[index].as_str() { + "--pane" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --pane".into()); + }; + pane_id = Some(super::normalize_pane_id(value)); + index += 2; + } + "--current" => { + pane_id = None; + index += 1; + } + "--toggle" => { + if mode_seen { + return Err("provide only one of --toggle, --on, or --off".into()); + } + mode = PaneZoomMode::Toggle; + mode_seen = true; + index += 1; + } + "--on" => { + if mode_seen { + return Err("provide only one of --toggle, --on, or --off".into()); + } + mode = PaneZoomMode::On; + mode_seen = true; + index += 1; + } + "--off" => { + if mode_seen { + return Err("provide only one of --toggle, --on, or --off".into()); + } + mode = PaneZoomMode::Off; + mode_seen = true; + index += 1; + } + other => return Err(format!("unknown option: {other}")), + } + } + + Ok(PaneZoomParams { pane_id, mode }) +} + fn pane_rename(args: &[String]) -> std::io::Result<i32> { let Some(raw_pane_id) = args.first() else { eprintln!("usage: herdr pane rename <pane_id> <label>|--clear"); @@ -953,6 +1026,7 @@ fn print_pane_help() { eprintln!( " herdr pane resize --direction left|right|up|down [--amount FLOAT] [--pane ID|--current]" ); + eprintln!(" herdr pane zoom [<pane_id>|--pane ID|--current] [--toggle|--on|--off]"); eprintln!(" herdr pane rename <pane_id> <label>|--clear"); eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); eprintln!( @@ -1050,6 +1124,37 @@ mod tests { } #[test] + fn parse_pane_zoom_args_defaults_to_current_toggle() { + let params = parse_pane_zoom_args(&args(&[])).unwrap(); + + assert_eq!(params.pane_id, None); + assert_eq!(params.mode, PaneZoomMode::Toggle); + } + + #[test] + fn parse_pane_zoom_args_accepts_positional_pane_and_on() { + let params = parse_pane_zoom_args(&args(&["issue-1", "--on"])).unwrap(); + + assert_eq!(params.pane_id, Some("issue-1".into())); + assert_eq!(params.mode, PaneZoomMode::On); + } + + #[test] + fn parse_pane_zoom_args_accepts_pane_option_and_off() { + let params = parse_pane_zoom_args(&args(&["--pane", "issue-2", "--off"])).unwrap(); + + assert_eq!(params.pane_id, Some("issue-2".into())); + assert_eq!(params.mode, PaneZoomMode::Off); + } + + #[test] + fn parse_pane_zoom_args_rejects_multiple_modes() { + let err = parse_pane_zoom_args(&args(&["--on", "--off"])).unwrap_err(); + + assert!(err.contains("provide only one")); + } + + #[test] fn parse_pane_neighbor_args_accepts_directional_current() { let params = parse_pane_neighbor_args(&args(&["--direction", "down"])).unwrap(); |
