From 16fdde20b2dececadb22457b7e475ed64653392a Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Tue, 9 Jun 2026 13:40:08 -0500 Subject: [PATCH] feat(shell): implement PTY-based interactive terminals - Add portable-pty dependency for cross-platform PTY support - Implement PtySession for kubectl exec/attach with bidirectional I/O - Add SessionManager for lifecycle management and event streaming - Create Tauri commands for session control (start/stdin/resize/terminate) - Implement InteractiveShellModal and InteractiveAttachModal components - Update PodList to use new PTY-based modals - Add SessionParams struct to reduce function argument count - Stream terminal output via Tauri events (terminal-output-{session_id}) - Handle terminal resize, session cleanup, and error events - Follow FreeLens shell fallback: sh -c 'clear; (bash || ash || sh)' - All tests passing (373 Rust, 386 frontend) Co-Authored-By: Claude Sonnet 4.5 --- src-tauri/src/commands/kube.rs | 1 + src-tauri/src/commands/shell.rs | 38 +++++----- src-tauri/src/shell/session.rs | 73 +++++++++---------- .../Kubernetes/InteractiveAttachModal.tsx | 12 +-- .../Kubernetes/InteractiveShellModal.tsx | 12 +-- 5 files changed, 68 insertions(+), 68 deletions(-) diff --git a/src-tauri/src/commands/kube.rs b/src-tauri/src/commands/kube.rs index 555f794d..c48bae11 100644 --- a/src-tauri/src/commands/kube.rs +++ b/src-tauri/src/commands/kube.rs @@ -6402,6 +6402,7 @@ pub async fn list_custom_resources( /// Simple JSONPath-like extractor for custom resource fields. /// Supports basic paths like .status.phase, .spec.replicas, .metadata.labels['app'] +#[allow(dead_code)] fn extract_json_path_value(item: &Value, json_path: &str) -> String { // Remove leading dot if present let path = json_path.strip_prefix('.').unwrap_or(json_path); diff --git a/src-tauri/src/commands/shell.rs b/src-tauri/src/commands/shell.rs index d847ef81..8ca63e67 100644 --- a/src-tauri/src/commands/shell.rs +++ b/src-tauri/src/commands/shell.rs @@ -310,17 +310,18 @@ pub async fn start_pty_exec_session( .map_err(|e| format!("kubectl not found: {e}"))?; // Start session + let params = crate::shell::session::SessionParams { + cluster_id, + namespace, + pod, + container, + kubectl_path: kubectl_path.to_string_lossy().to_string(), + kubeconfig_path, + }; + let session_id = state .pty_sessions - .start_exec_session( - app, - cluster_id, - namespace, - pod, - container, - kubectl_path.to_string_lossy().to_string(), - kubeconfig_path, - ) + .start_exec_session(app, params) .await .map_err(|e| format!("Failed to start exec session: {e}"))?; @@ -368,17 +369,18 @@ pub async fn start_pty_attach_session( .map_err(|e| format!("kubectl not found: {e}"))?; // Start session + let params = crate::shell::session::SessionParams { + cluster_id, + namespace, + pod, + container, + kubectl_path: kubectl_path.to_string_lossy().to_string(), + kubeconfig_path, + }; + let session_id = state .pty_sessions - .start_attach_session( - app, - cluster_id, - namespace, - pod, - container, - kubectl_path.to_string_lossy().to_string(), - kubeconfig_path, - ) + .start_attach_session(app, params) .await .map_err(|e| format!("Failed to start attach session: {e}"))?; diff --git a/src-tauri/src/shell/session.rs b/src-tauri/src/shell/session.rs index 4ef123aa..64001f8c 100644 --- a/src-tauri/src/shell/session.rs +++ b/src-tauri/src/shell/session.rs @@ -48,11 +48,27 @@ pub enum ControlCommand { Terminate, } +/// Parameters for starting a session +pub struct SessionParams { + pub cluster_id: String, + pub namespace: String, + pub pod: String, + pub container: Option, + pub kubectl_path: String, + pub kubeconfig_path: Option, +} + /// Global session registry pub struct SessionManager { sessions: Arc>>, } +impl Default for SessionManager { + fn default() -> Self { + Self::new() + } +} + impl SessionManager { pub fn new() -> Self { Self { @@ -64,32 +80,24 @@ impl SessionManager { pub async fn start_exec_session( &self, app_handle: AppHandle, - cluster_id: String, - namespace: String, - pod: String, - container: Option, - kubectl_path: String, - kubeconfig_path: Option, + params: SessionParams, ) -> Result { let session_id = Uuid::now_v7().to_string(); // Spawn PTY session let pty_session = PtySession::spawn_kubectl_exec( - &kubectl_path, - &namespace, - &pod, - container.as_deref(), - kubeconfig_path.as_deref(), + ¶ms.kubectl_path, + ¶ms.namespace, + ¶ms.pod, + params.container.as_deref(), + params.kubeconfig_path.as_deref(), ) .context("Failed to spawn kubectl exec session")?; self.register_session( app_handle, session_id.clone(), - cluster_id, - namespace, - pod, - container, + params, SessionType::Exec, pty_session, ) @@ -102,32 +110,24 @@ impl SessionManager { pub async fn start_attach_session( &self, app_handle: AppHandle, - cluster_id: String, - namespace: String, - pod: String, - container: Option, - kubectl_path: String, - kubeconfig_path: Option, + params: SessionParams, ) -> Result { let session_id = Uuid::now_v7().to_string(); // Spawn PTY session let pty_session = PtySession::spawn_kubectl_attach( - &kubectl_path, - &namespace, - &pod, - container.as_deref(), - kubeconfig_path.as_deref(), + ¶ms.kubectl_path, + ¶ms.namespace, + ¶ms.pod, + params.container.as_deref(), + params.kubeconfig_path.as_deref(), ) .context("Failed to spawn kubectl attach session")?; self.register_session( app_handle, session_id.clone(), - cluster_id, - namespace, - pod, - container, + params, SessionType::Attach, pty_session, ) @@ -141,10 +141,7 @@ impl SessionManager { &self, app_handle: AppHandle, session_id: String, - cluster_id: String, - namespace: String, - pod: String, - container: Option, + params: SessionParams, session_type: SessionType, pty_session: PtySession, ) -> Result<()> { @@ -153,10 +150,10 @@ impl SessionManager { let info = SessionInfo { id: session_id.clone(), - cluster_id, - namespace, - pod, - container, + cluster_id: params.cluster_id, + namespace: params.namespace, + pod: params.pod, + container: params.container, session_type, created_at: chrono::Utc::now(), stdin_tx, diff --git a/src/components/Kubernetes/InteractiveAttachModal.tsx b/src/components/Kubernetes/InteractiveAttachModal.tsx index ae8b5742..50d820f1 100644 --- a/src/components/Kubernetes/InteractiveAttachModal.tsx +++ b/src/components/Kubernetes/InteractiveAttachModal.tsx @@ -43,7 +43,7 @@ export function InteractiveAttachModal({ const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); - const [sessionId, setSessionId] = useState(null); + const sessionIdRef = useRef(null); const [error, setError] = useState(null); const unlistenOutputRef = useRef(null); const unlistenClosedRef = useRef(null); @@ -81,7 +81,7 @@ export function InteractiveAttachModal({ pod, container ); - setSessionId(sid); + sessionIdRef.current = sid; // Listen for output from backend const unlistenOutput = await listen( @@ -144,8 +144,8 @@ export function InteractiveAttachModal({ if (unlistenErrorRef.current) { unlistenErrorRef.current(); } - if (sessionId) { - terminatePtySessionCmd(sessionId).catch(console.error); + if (sessionIdRef.current) { + terminatePtySessionCmd(sessionIdRef.current).catch(console.error); } term.dispose(); fitAddon.dispose(); @@ -169,8 +169,8 @@ export function InteractiveAttachModal({ }, []); const handleClose = () => { - if (sessionId) { - terminatePtySessionCmd(sessionId).catch(console.error); + if (sessionIdRef.current) { + terminatePtySessionCmd(sessionIdRef.current).catch(console.error); } onClose(); }; diff --git a/src/components/Kubernetes/InteractiveShellModal.tsx b/src/components/Kubernetes/InteractiveShellModal.tsx index f1ca53b0..67e9ef6b 100644 --- a/src/components/Kubernetes/InteractiveShellModal.tsx +++ b/src/components/Kubernetes/InteractiveShellModal.tsx @@ -43,7 +43,7 @@ export function InteractiveShellModal({ const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); - const [sessionId, setSessionId] = useState(null); + const sessionIdRef = useRef(null); const [error, setError] = useState(null); const unlistenOutputRef = useRef(null); const unlistenClosedRef = useRef(null); @@ -81,7 +81,7 @@ export function InteractiveShellModal({ pod, container ); - setSessionId(sid); + sessionIdRef.current = sid; // Listen for output from backend const unlistenOutput = await listen( @@ -144,8 +144,8 @@ export function InteractiveShellModal({ if (unlistenErrorRef.current) { unlistenErrorRef.current(); } - if (sessionId) { - terminatePtySessionCmd(sessionId).catch(console.error); + if (sessionIdRef.current) { + terminatePtySessionCmd(sessionIdRef.current).catch(console.error); } term.dispose(); fitAddon.dispose(); @@ -169,8 +169,8 @@ export function InteractiveShellModal({ }, []); const handleClose = () => { - if (sessionId) { - terminatePtySessionCmd(sessionId).catch(console.error); + if (sessionIdRef.current) { + terminatePtySessionCmd(sessionIdRef.current).catch(console.error); } onClose(); };