From ca070e865fabfc6530b4f2f4009047826437186b Mon Sep 17 00:00:00 2001 From: Iskren Hadzhinedev Date: Tue, 16 Jun 2026 14:39:08 +0300 Subject: [PATCH 01/17] feat(k8s): add kubectl and kubeconfig override fields to models --- src-tauri/src/models.rs | 12 ++++++++++++ src-tauri/src/plugins/driver.rs | 2 ++ 2 files changed, 14 insertions(+) diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index eba9d080..c895d8c4 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -157,6 +157,10 @@ pub struct ConnectionParams { pub k8s_resource_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub k8s_port: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub k8s_kubectl_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub k8s_kubeconfig_path: Option, // Connection ID for stable pooling (not persisted, set at runtime) #[serde(skip_serializing_if = "Option::is_none")] pub connection_id: Option, @@ -221,6 +225,10 @@ pub struct K8sConnection { pub resource_type: String, // "service" or "pod" pub resource_name: String, pub port: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kubectl_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kubeconfig_path: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -231,6 +239,10 @@ pub struct K8sConnectionInput { pub resource_type: String, pub resource_name: String, pub port: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kubectl_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kubeconfig_path: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] diff --git a/src-tauri/src/plugins/driver.rs b/src-tauri/src/plugins/driver.rs index 70aa77b9..bed371ff 100644 --- a/src-tauri/src/plugins/driver.rs +++ b/src-tauri/src/plugins/driver.rs @@ -840,6 +840,8 @@ mod tests { k8s_resource_type: None, k8s_resource_name: None, k8s_port: None, + k8s_kubectl_path: None, + k8s_kubeconfig_path: None, connection_id: Some("conn-1".to_string()), } } From 474598ff7f2bbf29a631ae645c0d17d226836914 Mon Sep 17 00:00:00 2001 From: Iskren Hadzhinedev Date: Tue, 16 Jun 2026 14:39:33 +0300 Subject: [PATCH 02/17] feat(k8s): plumb kubectl and kubeconfig overrides through tunnel and commands --- src-tauri/src/commands.rs | 63 ++++- src-tauri/src/k8s_tunnel.rs | 472 +++++++++++++++++++++++------------- 2 files changed, 361 insertions(+), 174 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7651b4dc..8be0b26b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -239,8 +239,17 @@ fn resolve_k8s_params(params: &ConnectionParams) -> Result Result( resource_type: k8s.resource_type, resource_name: k8s.resource_name, port: k8s.port, + kubectl_path: k8s.kubectl_path, + kubeconfig_path: k8s.kubeconfig_path, }; connections.push(connection.clone()); @@ -1490,6 +1506,8 @@ pub async fn update_k8s_connection( resource_type: k8s.resource_type, resource_name: k8s.resource_name, port: k8s.port, + kubectl_path: k8s.kubectl_path, + kubeconfig_path: k8s.kubeconfig_path, }; connections[idx] = connection.clone(); @@ -1526,23 +1544,32 @@ pub async fn test_k8s_connection_cmd( _app: AppHandle, context: String, namespace: String, + kubectl_path: Option, + kubeconfig_path: Option, ) -> Result { - crate::k8s_tunnel::test_k8s_connection(&context, &namespace) + let options = crate::k8s_tunnel::K8sCommandOptions::new(kubectl_path, kubeconfig_path); + crate::k8s_tunnel::test_k8s_connection(&context, &namespace, &options) } #[tauri::command] pub async fn get_k8s_contexts_cmd( _app: AppHandle, + kubectl_path: Option, + kubeconfig_path: Option, ) -> Result, String> { - crate::k8s_tunnel::get_k8s_contexts() + let options = crate::k8s_tunnel::K8sCommandOptions::new(kubectl_path, kubeconfig_path); + crate::k8s_tunnel::get_k8s_contexts(&options) } #[tauri::command] pub async fn get_k8s_namespaces_cmd( _app: AppHandle, context: String, + kubectl_path: Option, + kubeconfig_path: Option, ) -> Result, String> { - crate::k8s_tunnel::get_k8s_namespaces(&context) + let options = crate::k8s_tunnel::K8sCommandOptions::new(kubectl_path, kubeconfig_path); + crate::k8s_tunnel::get_k8s_namespaces(&context, &options) } #[tauri::command] @@ -1551,8 +1578,11 @@ pub async fn get_k8s_resources_cmd( context: String, namespace: String, resource_type: String, + kubectl_path: Option, + kubeconfig_path: Option, ) -> Result, String> { - crate::k8s_tunnel::get_k8s_resources(&context, &namespace, &resource_type) + let options = crate::k8s_tunnel::K8sCommandOptions::new(kubectl_path, kubeconfig_path); + crate::k8s_tunnel::get_k8s_resources(&context, &namespace, &resource_type, &options) } #[tauri::command] @@ -1562,12 +1592,16 @@ pub async fn get_k8s_resource_ports_cmd( namespace: String, resource_type: String, resource_name: String, + kubectl_path: Option, + kubeconfig_path: Option, ) -> Result, String> { + let options = crate::k8s_tunnel::K8sCommandOptions::new(kubectl_path, kubeconfig_path); crate::k8s_tunnel::get_k8s_resource_ports( &context, &namespace, &resource_type, &resource_name, + &options, ) } @@ -1588,7 +1622,7 @@ pub async fn expand_k8s_connection_params( } // Resolve K8s params from saved connection if using connection_id - let (context, namespace, resource_type, resource_name, port) = + let (context, namespace, resource_type, resource_name, port, kubectl_path, kubeconfig_path) = if let Some(k8s_id) = ¶ms.k8s_connection_id { let k8s_conn = get_k8s_connection_by_id(app, k8s_id).await?; ( @@ -1597,6 +1631,8 @@ pub async fn expand_k8s_connection_params( k8s_conn.resource_type, k8s_conn.resource_name, k8s_conn.port, + k8s_conn.kubectl_path, + k8s_conn.kubeconfig_path, ) } else { let ctx = params @@ -1620,18 +1656,28 @@ pub async fn expand_k8s_connection_params( .ok_or("Missing K8s resource name")? .to_string(); let p = params.k8s_port.ok_or("Missing K8s port")?; - (ctx, ns, rt, rn, p) + ( + ctx, + ns, + rt, + rn, + p, + params.k8s_kubectl_path.clone(), + params.k8s_kubeconfig_path.clone(), + ) }; let _remote_host = params.host.as_deref().unwrap_or("localhost"); let _remote_port = params.port.unwrap_or(DEFAULT_MYSQL_PORT); + let options = crate::k8s_tunnel::K8sCommandOptions::new(kubectl_path, kubeconfig_path); let map_key = crate::k8s_tunnel::build_tunnel_key( &context, &namespace, &resource_type, &resource_name, port, + &options, ); // Check for existing tunnel @@ -1666,6 +1712,7 @@ pub async fn expand_k8s_connection_params( &resource_type, &resource_name, port, + &options, ) .map_err(|e| { eprintln!("[Connection Error] K8s Tunnel setup failed: {}", e); diff --git a/src-tauri/src/k8s_tunnel.rs b/src-tauri/src/k8s_tunnel.rs index 7781d43a..908a0df9 100644 --- a/src-tauri/src/k8s_tunnel.rs +++ b/src-tauri/src/k8s_tunnel.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; +use std::env; +use std::ffi::OsString; use std::io::{BufRead, BufReader}; use std::net::{TcpListener, TcpStream}; -use std::process::{Child, Command, Stdio}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; use std::sync::{Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant}; @@ -10,6 +13,7 @@ use std::time::{Duration, Instant}; const K8S_TUNNEL_TIMEOUT_SECS: u64 = 15; const K8S_CONNECT_RETRY_MS: u64 = 200; const LOG_BUFFER_INITIAL_CAPACITY: usize = 64; +const DEFAULT_KUBECTL: &str = "kubectl"; #[derive(Clone)] pub struct K8sTunnel { @@ -17,6 +21,41 @@ pub struct K8sTunnel { child: Arc>, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct K8sCommandOptions { + pub kubectl_path: Option, + pub kubeconfig_path: Option, +} + +impl K8sCommandOptions { + pub fn new(kubectl_path: Option, kubeconfig_path: Option) -> Self { + Self { + kubectl_path, + kubeconfig_path, + } + } + + fn kubectl_path(&self) -> String { + normalized_path_option(&self.kubectl_path).unwrap_or_else(|| DEFAULT_KUBECTL.to_string()) + } + + fn explicit_kubeconfig_path(&self) -> Option { + normalized_path_option(&self.kubeconfig_path) + } + + fn effective_kubeconfig_path(&self) -> Option { + self.explicit_kubeconfig_path() + .map(OsString::from) + .or_else(|| env::var_os("KUBECONFIG")) + } + + fn effective_kubeconfig_label(&self) -> String { + self.effective_kubeconfig_path() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "".to_string()) + } +} + pub static TUNNELS: OnceLock>> = OnceLock::new(); pub fn get_tunnels() -> &'static Mutex> { @@ -34,16 +73,17 @@ impl K8sTunnel { resource_type: &str, resource_name: &str, remote_port: u16, + options: &K8sCommandOptions, ) -> Result { println!( "[K8s Tunnel] New request: context={}, namespace={}, {}/{}:{}", context, namespace, resource_type, resource_name, remote_port ); - // Verify kubectl is available - Self::verify_kubectl()?; + // Verify kubectl is available before starting a long-lived tunnel process. + Self::verify_kubectl(options)?; - // Allocate a free local port + // Allocate a free local port. let local_port = { let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| { let err = format!("Failed to find free local port: {}", e); @@ -54,12 +94,9 @@ impl K8sTunnel { }; println!("[K8s Tunnel] Assigned local port: {}", local_port); - // Build the kubectl port-forward command let port_forward_spec = format!("{}:{}", local_port, remote_port); let resource = format!("{}/{}", resource_type, resource_name); - - let mut args = Vec::with_capacity(10); - args.extend([ + let args = [ "port-forward", "--context", context, @@ -67,25 +104,27 @@ impl K8sTunnel { namespace, &resource, &port_forward_spec, - ]); + ]; - println!("[K8s Tunnel] Executing: kubectl {:?}", args); + println!( + "[K8s Tunnel] Executing: {} {:?}", + options.kubectl_path(), + args + ); - let mut child = Command::new("kubectl") - .args(&args) + let mut command = kubectl_command(options)?; + let mut child = command + .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|e| { - let err = format!( - "Failed to launch kubectl: {}. Ensure 'kubectl' is in PATH.", - e - ); + let err = kubectl_spawn_error(options, &e); eprintln!("[K8s Tunnel Error] {}", err); err })?; - // Capture stdout/stderr in background threads + // Capture stdout/stderr in background threads. let stdout_log = Arc::new(Mutex::new(Vec::with_capacity(LOG_BUFFER_INITIAL_CAPACITY))); let stderr_log = Arc::new(Mutex::new(Vec::with_capacity(LOG_BUFFER_INITIAL_CAPACITY))); @@ -123,13 +162,13 @@ impl K8sTunnel { let child_arc = Arc::new(Mutex::new(child)); - // Wait for the tunnel to become ready + // Wait for the tunnel to become ready. let start = Instant::now(); let timeout = Duration::from_secs(K8S_TUNNEL_TIMEOUT_SECS); let mut ready = false; while start.elapsed() < timeout { - // Check if process is still alive + // Check if process is still alive. { let mut c = child_arc.lock().unwrap(); if let Ok(Some(status)) = c.try_wait() { @@ -144,7 +183,6 @@ impl K8sTunnel { } } - // Try connecting to the local port match TcpStream::connect(format!("127.0.0.1:{}", local_port)) { Ok(_) => { println!( @@ -182,37 +220,30 @@ impl K8sTunnel { pub fn stop(&self) { if let Ok(mut c) = self.child.lock() { let _ = c.kill(); - println!( - "[K8s Tunnel] Stopped tunnel on port {}", - self.local_port - ); + println!("[K8s Tunnel] Stopped tunnel on port {}", self.local_port); } } - /// Check that kubectl is available in PATH. - fn verify_kubectl() -> Result<(), String> { - let output = Command::new("kubectl") - .arg("version") - .arg("--client") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|e| { - format!( - "kubectl not found: {}. Please install kubectl and ensure it is in PATH.", - e - ) - })?; - - if !output.status.success() { - return Err("kubectl version check failed. Please verify your kubectl installation.".to_string()); + /// Check that kubectl is available. + fn verify_kubectl(options: &K8sCommandOptions) -> Result<(), String> { + let output = run_kubectl( + &["version", "--client"], + options, + "kubectl version check failed. Please verify your kubectl installation", + )?; + + if output.status.success() { + Ok(()) + } else { + Err( + "kubectl version check failed. Please verify your kubectl installation." + .to_string(), + ) } - - Ok(()) } } -/// Build a deterministic tunnel map key from K8s parameters. +/// Build a deterministic tunnel map key from K8s parameters and kubectl configuration. #[inline] pub fn build_tunnel_key( context: &str, @@ -220,10 +251,17 @@ pub fn build_tunnel_key( resource_type: &str, resource_name: &str, port: u16, + options: &K8sCommandOptions, ) -> String { format!( - "{}:{}:{}/{}:{}", - context, namespace, resource_type, resource_name, port + "{}:{}:{}/{}:{}:kubectl={}:kubeconfig={}", + context, + namespace, + resource_type, + resource_name, + port, + options.kubectl_path(), + options.effective_kubeconfig_label() ) } @@ -231,81 +269,67 @@ pub fn build_tunnel_key( pub fn test_k8s_connection( context: &str, namespace: &str, + options: &K8sCommandOptions, ) -> Result { println!( "[K8s Test] Testing connection: context={}, namespace={}", context, namespace ); - K8sTunnel::verify_kubectl()?; - - let output = Command::new("kubectl") - .args(["--context", context, "get", "namespace", namespace, "-o", "name"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|e| format!("Failed to execute kubectl: {}", e))?; + K8sTunnel::verify_kubectl(options)?; - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - println!("[K8s Test] Connection successful: {}", stdout); - Ok(format!( - "Kubernetes connection to context '{}' namespace '{}' verified successfully!", - context, namespace - )) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - let err = format!("K8s connection test failed: {}", stderr.trim()); - eprintln!("[K8s Test Error] {}", err); - Err(err) - } + let output = run_kubectl( + &[ + "--context", + context, + "get", + "namespace", + namespace, + "-o", + "name", + ], + options, + "K8s connection test failed", + )?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + println!("[K8s Test] Connection successful: {}", stdout); + Ok(format!( + "Kubernetes connection to context '{}' namespace '{}' verified successfully!", + context, namespace + )) } /// List available kubectl contexts from kubeconfig. -pub fn get_k8s_contexts() -> Result, String> { - let output = Command::new("kubectl") - .args(["config", "get-contexts", "-o", "name"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|e| format!("Failed to execute kubectl: {}", e))?; +pub fn get_k8s_contexts(options: &K8sCommandOptions) -> Result, String> { + let output = run_kubectl( + &["config", "get-contexts", "-o", "name"], + options, + "Failed to list K8s contexts", + )?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("Failed to list K8s contexts: {}", stderr.trim())); + let contexts = parse_lines(&String::from_utf8_lossy(&output.stdout)); + if contexts.is_empty() { + return Err(no_contexts_error(options)); } - let contexts = parse_lines(&String::from_utf8_lossy(&output.stdout)); println!("[K8s Discovery] Found {} contexts", contexts.len()); Ok(contexts) } /// List namespaces in a given kubectl context. -pub fn get_k8s_namespaces(context: &str) -> Result, String> { - let output = Command::new("kubectl") - .args([ - "--context", - context, - "get", - "namespaces", - "-o", - "name", - ]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|e| format!("Failed to execute kubectl: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "Failed to list namespaces in context '{}': {}", - context, - stderr.trim() - )); - } - - let namespaces = parse_lines_with_prefix(&String::from_utf8_lossy(&output.stdout), "namespace/"); +pub fn get_k8s_namespaces( + context: &str, + options: &K8sCommandOptions, +) -> Result, String> { + let output = run_kubectl( + &["--context", context, "get", "namespaces", "-o", "name"], + options, + &format!("Failed to list namespaces in context '{}'", context), + )?; + + let namespaces = + parse_lines_with_prefix(&String::from_utf8_lossy(&output.stdout), "namespace/"); println!( "[K8s Discovery] Found {} namespaces in context '{}'", namespaces.len(), @@ -319,8 +343,8 @@ pub fn get_k8s_resources( context: &str, namespace: &str, resource_type: &str, + options: &K8sCommandOptions, ) -> Result, String> { - // Validate resource type if resource_type != "service" && resource_type != "pod" { return Err(format!( "Unsupported resource type '{}'. Only 'service' and 'pod' are supported.", @@ -328,8 +352,8 @@ pub fn get_k8s_resources( )); } - let output = Command::new("kubectl") - .args([ + let output = run_kubectl( + &[ "--context", context, "--namespace", @@ -338,22 +362,13 @@ pub fn get_k8s_resources( resource_type, "-o", "name", - ]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(|e| format!("Failed to execute kubectl: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "Failed to list {} in context '{}' namespace '{}': {}", - resource_type, - context, - namespace, - stderr.trim() - )); - } + ], + options, + &format!( + "Failed to list {} in context '{}' namespace '{}'", + resource_type, context, namespace + ), + )?; let prefix = format!("{}/", resource_type); let resources = parse_lines_with_prefix(&String::from_utf8_lossy(&output.stdout), &prefix); @@ -373,6 +388,7 @@ pub fn get_k8s_resource_ports( namespace: &str, resource_type: &str, resource_name: &str, + options: &K8sCommandOptions, ) -> Result, String> { if resource_type != "service" { return Err(format!( @@ -381,8 +397,8 @@ pub fn get_k8s_resource_ports( )); } - let output = Command::new("kubectl") - .args([ + let output = run_kubectl( + &[ "--context", context, "--namespace", @@ -392,25 +408,125 @@ pub fn get_k8s_resource_ports( resource_name, "-o", "jsonpath={.spec.ports[*].port}", - ]) + ], + options, + &format!( + "Failed to list ports for {} '{}' in context '{}' namespace '{}'", + resource_type, resource_name, context, namespace + ), + )?; + + Ok(parse_resource_ports(&String::from_utf8_lossy( + &output.stdout, + ))) +} + +fn kubectl_command(options: &K8sCommandOptions) -> Result { + if let Some(kubeconfig_path) = options.explicit_kubeconfig_path() { + if !Path::new(&kubeconfig_path).exists() { + return Err(format!( + "Configured kubeconfig path does not exist: {}", + kubeconfig_path + )); + } + } + + let mut command = Command::new(options.kubectl_path()); + if let Some(kubeconfig_path) = options.effective_kubeconfig_path() { + command.env("KUBECONFIG", kubeconfig_path); + } + Ok(command) +} + +fn run_kubectl( + args: &[&str], + options: &K8sCommandOptions, + failure_context: &str, +) -> Result { + let mut command = kubectl_command(options)?; + let output = command + .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() - .map_err(|e| format!("Failed to execute kubectl: {}", e))?; + .map_err(|e| kubectl_spawn_error(options, &e))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "Failed to list ports for {} '{}' in context '{}' namespace '{}': {}", - resource_type, - resource_name, - context, - namespace, - stderr.trim() - )); + if output.status.success() { + return Ok(output); } - Ok(parse_resource_ports(&String::from_utf8_lossy(&output.stdout))) + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + Err(format!( + "{}: kubectl exited with status {}", + failure_context, output.status + )) + } else { + Err(format!("{}: {}", failure_context, stderr)) + } +} + +fn kubectl_spawn_error(options: &K8sCommandOptions, error: &std::io::Error) -> String { + if let Some(kubectl_path) = normalized_path_option(&options.kubectl_path) { + format!( + "kubectl executable was not found at '{}': {}. Set a valid kubectl path.", + kubectl_path, error + ) + } else { + format!( + "kubectl executable was not found: {}. Install kubectl or set a kubectl path.", + error + ) + } +} + +fn no_contexts_error(options: &K8sCommandOptions) -> String { + if let Some(kubeconfig_path) = options.explicit_kubeconfig_path() { + format!( + "kubectl did not find any Kubernetes contexts in configured kubeconfig '{}'. Choose a different kubeconfig path or verify the file contains contexts.", + kubeconfig_path + ) + } else if let Some(kubeconfig_path) = env::var_os("KUBECONFIG") { + format!( + "kubectl did not find any Kubernetes contexts using KUBECONFIG='{}'. Choose a kubeconfig path or verify the file contains contexts.", + kubeconfig_path.to_string_lossy() + ) + } else { + "kubectl did not find any Kubernetes contexts. Set KUBECONFIG or choose a kubeconfig path." + .to_string() + } +} + +fn normalized_option(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(String::from) +} + +fn normalized_path_option(value: &Option) -> Option { + normalized_option(value).map(|value| expand_home_path(&value)) +} + +fn expand_home_path(value: &str) -> String { + let Some(rest) = value + .strip_prefix("~/") + .or_else(|| value.strip_prefix("~\\")) + else { + return value.to_string(); + }; + + match home_dir() { + Some(home) => home.join(rest).to_string_lossy().into_owned(), + None => value.to_string(), + } +} + +fn home_dir() -> Option { + env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from)) } /// Parse newline-separated output into a list of trimmed, non-empty strings. @@ -449,50 +565,74 @@ mod tests { #[test] fn test_basic_key_format() { - let key = build_tunnel_key( - "my-cluster", - "default", - "service", - "my-db", - 3306, + let options = K8sCommandOptions::default(); + let key = build_tunnel_key("my-cluster", "default", "service", "my-db", 3306, &options); + assert_eq!( + key, + "my-cluster:default:service/my-db:3306:kubectl=kubectl:kubeconfig=" ); - assert_eq!(key, "my-cluster:default:service/my-db:3306"); } #[test] - fn test_pod_resource_type() { - let key = build_tunnel_key( - "prod-cluster", - "database", - "pod", - "mysql-0", - 5432, + fn test_key_includes_overrides() { + let options = K8sCommandOptions::new( + Some("/usr/local/bin/kubectl".to_string()), + Some("/tmp/kubeconfig".to_string()), + ); + let key = build_tunnel_key("prod", "database", "pod", "mysql-0", 5432, &options); + assert_eq!( + key, + "prod:database:pod/mysql-0:5432:kubectl=/usr/local/bin/kubectl:kubeconfig=/tmp/kubeconfig" ); - assert_eq!(key, "prod-cluster:database:pod/mysql-0:5432"); } #[test] fn test_empty_context() { - let key = build_tunnel_key("", "default", "service", "db", 80); - assert_eq!(key, ":default:service/db:80"); - } - - #[test] - fn test_special_characters() { - let key = build_tunnel_key( - "gke_project_us-central1_cluster", - "my-namespace", - "service", - "my-db-svc", - 3306, - ); + let options = K8sCommandOptions::default(); + let key = build_tunnel_key("", "default", "service", "db", 80, &options); assert_eq!( key, - "gke_project_us-central1_cluster:my-namespace:service/my-db-svc:3306" + ":default:service/db:80:kubectl=kubectl:kubeconfig=" ); } } + mod command_options_tests { + use super::*; + + #[test] + fn test_empty_overrides_are_ignored() { + let options = K8sCommandOptions::new(Some(" ".to_string()), Some("".to_string())); + assert_eq!(options.kubectl_path(), "kubectl"); + assert_eq!(options.explicit_kubeconfig_path(), None); + } + + #[test] + fn test_tilde_paths_are_expanded() { + if let Some(home) = home_dir() { + let options = K8sCommandOptions::new( + Some("~/bin/kubectl".to_string()), + Some("~/.kube.bak/config".to_string()), + ); + assert_eq!( + options.kubectl_path(), + home.join("bin/kubectl").to_string_lossy() + ); + assert_eq!( + options.explicit_kubeconfig_path().as_deref(), + Some(home.join(".kube.bak/config").to_string_lossy().as_ref()) + ); + } + } + + #[test] + fn test_no_contexts_error_mentions_explicit_kubeconfig() { + let options = K8sCommandOptions::new(None, Some("/tmp/missing-contexts".to_string())); + assert!(no_contexts_error(&options) + .contains("configured kubeconfig '/tmp/missing-contexts'")); + } + } + mod parse_lines_tests { use super::*; From 0580d2fd24d1f531da8db6e792845530cafd9c9d Mon Sep 17 00:00:00 2001 From: Iskren Hadzhinedev Date: Tue, 16 Jun 2026 14:40:10 +0300 Subject: [PATCH 03/17] feat(k8s): honor kubectl overrides in MCP connection expansion --- src-tauri/src/mcp/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 687062b2..85018da1 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -212,6 +212,8 @@ async fn expand_k8s_params_for_mcp( expanded.k8s_resource_type = Some(k8s.resource_type); expanded.k8s_resource_name = Some(k8s.resource_name); expanded.k8s_port = Some(k8s.port); + expanded.k8s_kubectl_path = k8s.kubectl_path; + expanded.k8s_kubeconfig_path = k8s.kubeconfig_path; Ok(expanded) } From 8a3f4c5c63e14d58e190601ff9eb1ec5143c3d53 Mon Sep 17 00:00:00 2001 From: Iskren Hadzhinedev Date: Tue, 16 Jun 2026 14:40:20 +0300 Subject: [PATCH 04/17] feat(k8s): pass kubectl overrides through frontend k8s utils --- src/utils/k8s.ts | 50 ++++++++++++++++--- .../modals/NewConnectionModal.test.tsx | 1 + tests/utils/k8s.test.ts | 38 +++++++++++++- 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/utils/k8s.ts b/src/utils/k8s.ts index c97c2783..25ba6ab0 100644 --- a/src/utils/k8s.ts +++ b/src/utils/k8s.ts @@ -13,6 +13,8 @@ export interface K8sConnection { resource_type: "service" | "pod"; resource_name: string; port: number; + kubectl_path?: string; + kubeconfig_path?: string; } export interface K8sConnectionInput { @@ -22,6 +24,23 @@ export interface K8sConnectionInput { resource_type: string; resource_name: string; port: number; + kubectl_path?: string; + kubeconfig_path?: string; +} + +export interface K8sCommandOptions { + kubectl_path?: string; + kubeconfig_path?: string; +} + +function toK8sCommandPayload(options?: K8sCommandOptions): { + kubectlPath?: string; + kubeconfigPath?: string; +} { + return { + kubectlPath: options?.kubectl_path, + kubeconfigPath: options?.kubeconfig_path, + }; } /** @@ -67,23 +86,36 @@ export async function deleteK8sConnection(id: string): Promise { */ export async function testK8sConnection( context: string, - namespace: string + namespace: string, + options?: K8sCommandOptions ): Promise { - return await invoke("test_k8s_connection_cmd", { context, namespace }); + return await invoke("test_k8s_connection_cmd", { + context, + namespace, + ...toK8sCommandPayload(options), + }); } /** * List available kubectl contexts */ -export async function getK8sContexts(): Promise { - return await invoke("get_k8s_contexts_cmd"); +export async function getK8sContexts( + options?: K8sCommandOptions +): Promise { + return await invoke("get_k8s_contexts_cmd", toK8sCommandPayload(options)); } /** * List namespaces in a kubectl context */ -export async function getK8sNamespaces(context: string): Promise { - return await invoke("get_k8s_namespaces_cmd", { context }); +export async function getK8sNamespaces( + context: string, + options?: K8sCommandOptions +): Promise { + return await invoke("get_k8s_namespaces_cmd", { + context, + ...toK8sCommandPayload(options), + }); } /** @@ -92,12 +124,14 @@ export async function getK8sNamespaces(context: string): Promise { export async function getK8sResources( context: string, namespace: string, - resourceType: string + resourceType: string, + options?: K8sCommandOptions ): Promise { return await invoke("get_k8s_resources_cmd", { context, namespace, resourceType, + ...toK8sCommandPayload(options), }); } @@ -109,12 +143,14 @@ export async function getK8sResourcePorts( namespace: string, resourceType: string, resourceName: string, + options?: K8sCommandOptions ): Promise { return await invoke("get_k8s_resource_ports_cmd", { context, namespace, resourceType, resourceName, + ...toK8sCommandPayload(options), }); } diff --git a/tests/components/modals/NewConnectionModal.test.tsx b/tests/components/modals/NewConnectionModal.test.tsx index 87103539..48cc295b 100644 --- a/tests/components/modals/NewConnectionModal.test.tsx +++ b/tests/components/modals/NewConnectionModal.test.tsx @@ -201,6 +201,7 @@ describe("NewConnectionModal K8s port defaults", () => { "db", "service", "mysql-svc", + expect.anything(), ); expect(portInput).toHaveValue(6543); }); diff --git a/tests/utils/k8s.test.ts b/tests/utils/k8s.test.ts index a8dc39e0..ded798f3 100644 --- a/tests/utils/k8s.test.ts +++ b/tests/utils/k8s.test.ts @@ -179,6 +179,8 @@ describe("k8s", () => { expect(invoke).toHaveBeenCalledWith("test_k8s_connection_cmd", { context: "my-context", namespace: "my-namespace", + kubectlPath: undefined, + kubeconfigPath: undefined, }); expect(result).toBe("K8s connection verified!"); }); @@ -191,6 +193,23 @@ describe("k8s", () => { testK8sConnection("bad-context", "default"), ).rejects.toThrow("Context not found"); }); + + it("should pass kubectl and kubeconfig overrides", async () => { + const { invoke } = await import("@tauri-apps/api/core"); + vi.mocked(invoke).mockResolvedValue("K8s connection verified!"); + + await testK8sConnection("my-context", "default", { + kubectl_path: "/opt/bin/kubectl", + kubeconfig_path: "/tmp/kubeconfig", + }); + + expect(invoke).toHaveBeenCalledWith("test_k8s_connection_cmd", { + context: "my-context", + namespace: "default", + kubectlPath: "/opt/bin/kubectl", + kubeconfigPath: "/tmp/kubeconfig", + }); + }); }); describe("getK8sContexts", () => { @@ -203,7 +222,10 @@ describe("k8s", () => { const result = await getK8sContexts(); - expect(invoke).toHaveBeenCalledWith("get_k8s_contexts_cmd"); + expect(invoke).toHaveBeenCalledWith("get_k8s_contexts_cmd", { + kubectlPath: undefined, + kubeconfigPath: undefined, + }); expect(result).toEqual(["minikube", "gke_project_cluster"]); }); }); @@ -217,6 +239,8 @@ describe("k8s", () => { expect(invoke).toHaveBeenCalledWith("get_k8s_namespaces_cmd", { context: "minikube", + kubectlPath: undefined, + kubeconfigPath: undefined, }); expect(result).toEqual(["default", "kube-system"]); }); @@ -237,6 +261,8 @@ describe("k8s", () => { context: "minikube", namespace: "database", resourceType: "service", + kubectlPath: undefined, + kubeconfigPath: undefined, }); expect(result).toEqual(["mysql-svc", "postgres-svc"]); }); @@ -259,6 +285,8 @@ describe("k8s", () => { namespace: "database", resourceType: "service", resourceName: "postgres-svc", + kubectlPath: undefined, + kubeconfigPath: undefined, }); expect(result).toEqual([5432]); }); @@ -285,6 +313,8 @@ describe("k8s", () => { resource_type: "service", resource_name: "db", port: 3306, + kubectl_path: "/opt/bin/kubectl", + kubeconfig_path: "/tmp/kubeconfig", }; vi.mocked(invoke).mockResolvedValue(saved); @@ -295,6 +325,8 @@ describe("k8s", () => { resource_type: "service", resource_name: "db", port: 3306, + kubectl_path: "/opt/bin/kubectl", + kubeconfig_path: "/tmp/kubeconfig", }; const result = await saveK8sConnection(input); @@ -317,6 +349,8 @@ describe("k8s", () => { resource_type: "pod", resource_name: "db-0", port: 5432, + kubectl_path: "/opt/bin/kubectl", + kubeconfig_path: "/tmp/kubeconfig", }; vi.mocked(invoke).mockResolvedValue(updated); @@ -327,6 +361,8 @@ describe("k8s", () => { resource_type: "pod", resource_name: "db-0", port: 5432, + kubectl_path: "/opt/bin/kubectl", + kubeconfig_path: "/tmp/kubeconfig", }; const result = await updateK8sConnection("abc-123", input); From 955cb91f364ecfdc3dcd390ab2b15a767b0808a9 Mon Sep 17 00:00:00 2001 From: Iskren Hadzhinedev Date: Tue, 16 Jun 2026 14:40:29 +0300 Subject: [PATCH 05/17] feat(k8s): add advanced kubectl settings to connection modals --- src/components/modals/K8sConnectionsModal.tsx | 204 ++++++++++++++-- src/components/modals/NewConnectionModal.tsx | 226 +++++++++++++++--- src/utils/k8s.ts | 2 + 3 files changed, 386 insertions(+), 46 deletions(-) diff --git a/src/components/modals/K8sConnectionsModal.tsx b/src/components/modals/K8sConnectionsModal.tsx index 5225f1d0..6e95df64 100644 --- a/src/components/modals/K8sConnectionsModal.tsx +++ b/src/components/modals/K8sConnectionsModal.tsx @@ -9,6 +9,8 @@ import { Loader2, Zap, XCircle, + AlertCircle, + ChevronDown, } from "lucide-react"; import { loadK8sConnections, @@ -59,11 +61,17 @@ export function K8sConnectionsModal({ const [port, setPort] = useState(undefined); const effectivePort = port ?? effectiveDefaultPort; const [isPortOverridden, setIsPortOverridden] = useState(false); + const [kubectlPath, setKubectlPath] = useState(""); + const [kubeconfigPath, setKubeconfigPath] = useState(""); + const [appliedKubectlPath, setAppliedKubectlPath] = useState(""); + const [appliedKubeconfigPath, setAppliedKubeconfigPath] = useState(""); + const [showAdvanced, setShowAdvanced] = useState(false); // Discovery state const [contexts, setContexts] = useState([]); const [namespaces, setNamespaces] = useState([]); const [resources, setResources] = useState([]); + const [discoveryError, setDiscoveryError] = useState(null); // Status const [testStatus, setTestStatus] = useState< @@ -77,14 +85,24 @@ export function K8sConnectionsModal({ setConnections(result); }, []); + const getCommandOptions = useCallback( + () => ({ + kubectl_path: appliedKubectlPath, + kubeconfig_path: appliedKubeconfigPath, + }), + [appliedKubectlPath, appliedKubeconfigPath], + ); + const loadContexts = useCallback(async () => { try { - const result = await getK8sContexts(); + const result = await getK8sContexts(getCommandOptions()); setContexts(result); - } catch { + setDiscoveryError(null); + } catch (error) { setContexts([]); + setDiscoveryError(toErrorMessage(error)); } - }, []); + }, [getCommandOptions]); useEffect(() => { if (!isOpen) return; @@ -103,12 +121,14 @@ export function K8sConnectionsModal({ } try { - setNamespaces(await getK8sNamespaces(context)); - } catch { + setNamespaces(await getK8sNamespaces(context, getCommandOptions())); + setDiscoveryError(null); + } catch (error) { setNamespaces([]); + setDiscoveryError(toErrorMessage(error)); } })(); - }, [context]); + }, [context, getCommandOptions]); useEffect(() => { void (async () => { @@ -118,12 +138,14 @@ export function K8sConnectionsModal({ } try { - setResources(await getK8sResources(context, namespace, resourceType)); - } catch { + setResources(await getK8sResources(context, namespace, resourceType, getCommandOptions())); + setDiscoveryError(null); + } catch (error) { setResources([]); + setDiscoveryError(toErrorMessage(error)); } })(); - }, [context, namespace, resourceType]); + }, [context, namespace, resourceType, getCommandOptions]); useEffect(() => { if ( @@ -144,6 +166,7 @@ export function K8sConnectionsModal({ namespace, resourceType, resourceName, + getCommandOptions(), ); if (!cancelled && ports.length === 1) { setPort((prev) => (prev === ports[0] ? prev : ports[0])); @@ -156,7 +179,7 @@ export function K8sConnectionsModal({ return () => { cancelled = true; }; - }, [context, namespace, resourceType, resourceName, isPortOverridden]); + }, [context, namespace, resourceType, resourceName, isPortOverridden, getCommandOptions]); const resetForm = () => { setName(""); @@ -166,9 +189,15 @@ export function K8sConnectionsModal({ setResourceName(""); setPort(undefined); setIsPortOverridden(false); + setKubectlPath(""); + setKubeconfigPath(""); + setAppliedKubectlPath(""); + setAppliedKubeconfigPath(""); + setShowAdvanced(false); setTestStatus("idle"); setTestMessage(""); setValidationError(null); + setDiscoveryError(null); }; const handleCreate = () => { @@ -185,11 +214,17 @@ export function K8sConnectionsModal({ setResourceName(conn.resource_name); setPort(conn.port); setIsPortOverridden(true); + setKubectlPath(conn.kubectl_path ?? ""); + setKubeconfigPath(conn.kubeconfig_path ?? ""); + setAppliedKubectlPath(conn.kubectl_path ?? ""); + setAppliedKubeconfigPath(conn.kubeconfig_path ?? ""); + setShowAdvanced(Boolean(conn.kubectl_path || conn.kubeconfig_path)); setEditingId(conn.id); setIsCreating(false); setTestStatus("idle"); setTestMessage(""); setValidationError(null); + setDiscoveryError(null); }; const handleCancel = () => { @@ -206,6 +241,8 @@ export function K8sConnectionsModal({ resource_type: resourceType, resource_name: resourceName, port: effectivePort, + kubectl_path: kubectlPath.trim() || undefined, + kubeconfig_path: kubeconfigPath.trim() || undefined, }); if (!validation.isValid) { setValidationError(t(validation.errorKey)); @@ -240,7 +277,7 @@ export function K8sConnectionsModal({ if (!context || !namespace) return; setTestStatus("testing"); try { - const result = await testK8sConnection(context, namespace); + const result = await testK8sConnection(context, namespace, getCommandOptions()); setTestStatus("success"); setTestMessage(result); } catch (error) { @@ -249,6 +286,43 @@ export function K8sConnectionsModal({ } }; + const handleContextChange = (value: string) => { + setContext(value); + setNamespace(""); + setResourceName(""); + setResources([]); + setDiscoveryError(null); + }; + + const handleNamespaceChange = (value: string) => { + setNamespace(value); + setResourceName(""); + setResources([]); + setDiscoveryError(null); + }; + + const applyAdvancedPaths = useCallback(() => { + const nextKubectlPath = kubectlPath.trim(); + const nextKubeconfigPath = kubeconfigPath.trim(); + if ( + nextKubectlPath === appliedKubectlPath && + nextKubeconfigPath === appliedKubeconfigPath + ) { + return; + } + + setAppliedKubectlPath(nextKubectlPath); + setAppliedKubeconfigPath(nextKubeconfigPath); + setContext(""); + setNamespace(""); + setResourceName(""); + setNamespaces([]); + setResources([]); + setDiscoveryError(null); + setTestStatus("idle"); + setTestMessage(""); + }, [appliedKubeconfigPath, appliedKubectlPath, kubeconfigPath, kubectlPath]); + return ( void; + kubeconfigPath: string; + setKubeconfigPath: (v: string) => void; + showAdvanced: boolean; + onAdvancedPathsBlur: () => void; + setShowAdvanced: (v: boolean) => void; + discoveryError: string | null; validationError: string | null; testStatus: "idle" | "testing" | "success" | "error"; testMessage: string; @@ -442,6 +540,14 @@ function EditForm({ contexts, namespaces, resources, + kubectlPath, + setKubectlPath, + kubeconfigPath, + setKubeconfigPath, + showAdvanced, + onAdvancedPathsBlur, + setShowAdvanced, + discoveryError, validationError, testStatus, testMessage, @@ -470,7 +576,9 @@ function EditForm({ onChange={(val) => setContext(val)} placeholder={t("k8sConnections.chooseContext")} searchPlaceholder={t("common.search")} - noResultsLabel={t("common.noResults")} + noResultsLabel={t("k8sConnections.noContexts", { + defaultValue: "No contexts available", + })} /> @@ -482,7 +590,9 @@ function EditForm({ onChange={(val) => setNamespace(val)} placeholder={t("k8sConnections.chooseNamespace")} searchPlaceholder={t("common.search")} - noResultsLabel={t("common.noResults")} + noResultsLabel={t("k8sConnections.noNamespaces", { + defaultValue: "No namespaces available", + })} /> @@ -498,7 +608,10 @@ function EditForm({ service: t("k8sConnections.resourceTypeService"), pod: t("k8sConnections.resourceTypePod"), }} - onChange={(val) => setResourceType(val)} + onChange={(val) => { + setResourceType(val); + setResourceName(""); + }} searchable={false} /> @@ -513,7 +626,9 @@ function EditForm({ onChange={(val) => setResourceName(val)} placeholder={t("k8sConnections.chooseResource")} searchPlaceholder={t("common.search")} - noResultsLabel={t("common.noResults")} + noResultsLabel={t("k8sConnections.noResources", { + defaultValue: "No resources available", + })} /> @@ -531,6 +646,46 @@ function EditForm({ /> + + +
+ + {showAdvanced && ( +
+
+ + setKubectlPath(e.target.value)} + onBlur={onAdvancedPathsBlur} + className={InputClass} + placeholder={t("k8sConnections.kubectlPathPlaceholder")} + /> +
+
+ + setKubeconfigPath(e.target.value)} + onBlur={onAdvancedPathsBlur} + className={InputClass} + placeholder={t("k8sConnections.kubeconfigPathPlaceholder")} + /> +
+
+ )} +
+ {/* Test result */} {testStatus !== "idle" && (
); } + +function DiscoveryError({ message }: { message: string | null }) { + if (!message) return null; + + return ( +
+ + {message} +
+ ); +} diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index 093a2d2e..5933e918 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -15,6 +15,7 @@ import { Info, Eye, EyeOff, + ChevronDown, } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import type { ConnectionAppearance } from "../../contexts/DatabaseContext"; @@ -76,6 +77,8 @@ interface ConnectionParams { k8s_resource_type?: string; k8s_resource_name?: string; k8s_port?: number; + k8s_kubectl_path?: string; + k8s_kubeconfig_path?: string; } interface SavedConnection { @@ -97,6 +100,7 @@ const FieldInput = ({ label, value, onChange, + onBlur, type = "text", placeholder, autoFocus, @@ -105,6 +109,7 @@ const FieldInput = ({ label: string; value: string | number | undefined; onChange: (v: string) => void; + onBlur?: () => void; type?: string; placeholder?: string; autoFocus?: boolean; @@ -124,6 +129,7 @@ const FieldInput = ({ value={value ?? ""} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} + onBlur={onBlur} autoFocus={autoFocus} autoCorrect="off" autoCapitalize="off" @@ -209,6 +215,10 @@ export const NewConnectionModal = ({ const [k8sContexts, setK8sContexts] = useState([]); const [k8sNamespaces, setK8sNamespaces] = useState([]); const [k8sResources, setK8sResources] = useState([]); + const [k8sDiscoveryError, setK8sDiscoveryError] = useState(null); + const [showK8sAdvanced, setShowK8sAdvanced] = useState(false); + const [k8sKubectlPathInput, setK8sKubectlPathInput] = useState(""); + const [k8sKubeconfigPathInput, setK8sKubeconfigPathInput] = useState(""); // ── databases ── const [availableDatabases, setAvailableDatabases] = useState([]); @@ -309,6 +319,13 @@ export const NewConnectionModal = ({ ? params.k8s_port ?? getK8sAutoPort(params) ?? k8sDefaultPort : params.k8s_port; const effectiveK8sPort = resolveK8sPort(formData); + const k8sCommandOptions = useMemo( + () => ({ + kubectl_path: formData.k8s_kubectl_path, + kubeconfig_path: formData.k8s_kubeconfig_path, + }), + [formData.k8s_kubectl_path, formData.k8s_kubeconfig_path], + ); const connectionStringEnabled = activeDriver?.capabilities?.connection_string ?? activeDriver?.capabilities?.connectionString ?? @@ -355,39 +372,71 @@ export const NewConnectionModal = ({ const loadK8sContextsList = async () => { try { - const result = await getK8sContexts(); + const result = await getK8sContexts(k8sCommandOptions); setK8sContexts(result); - } catch { + setK8sDiscoveryError(null); + } catch (error) { setK8sContexts([]); + setK8sDiscoveryError(toErrorMessage(error)); } }; - const loadK8sNamespacesList = async (context: string) => { - try { - const result = await getK8sNamespaces(context); - setK8sNamespaces(result); - } catch { - setK8sNamespaces([]); - } - }; + const loadK8sNamespacesList = useCallback( + async (context: string) => { + try { + const result = await getK8sNamespaces(context, k8sCommandOptions); + setK8sNamespaces(result); + setK8sDiscoveryError(null); + } catch (error) { + setK8sNamespaces([]); + setK8sDiscoveryError(toErrorMessage(error)); + } + }, + [k8sCommandOptions], + ); - const loadK8sResourcesList = async (context: string, namespace: string, resourceType: string) => { - try { - const result = await getK8sResources(context, namespace, resourceType); - setK8sResources(result); - } catch { - setK8sResources([]); - } - }; + const loadK8sResourcesList = useCallback( + async (context: string, namespace: string, resourceType: string) => { + try { + const result = await getK8sResources( + context, + namespace, + resourceType, + k8sCommandOptions, + ); + setK8sResources(result); + setK8sDiscoveryError(null); + } catch (error) { + setK8sResources([]); + setK8sDiscoveryError(toErrorMessage(error)); + } + }, + [k8sCommandOptions], + ); // ── K8s cascading dropdown loading ── + useEffect(() => { + if (!isOpen || !formData.k8s_enabled || k8sMode !== "inline") return; + + void (async () => { + try { + const result = await getK8sContexts(k8sCommandOptions); + setK8sContexts(result); + setK8sDiscoveryError(null); + } catch (error) { + setK8sContexts([]); + setK8sDiscoveryError(toErrorMessage(error)); + } + })(); + }, [isOpen, formData.k8s_enabled, k8sMode, k8sCommandOptions]); + useEffect(() => { if (formData.k8s_context) { loadK8sNamespacesList(formData.k8s_context); } else { setK8sNamespaces([]); } - }, [formData.k8s_context]); + }, [formData.k8s_context, loadK8sNamespacesList]); useEffect(() => { if (formData.k8s_context && formData.k8s_namespace && formData.k8s_resource_type) { @@ -399,7 +448,12 @@ export const NewConnectionModal = ({ } else { setK8sResources([]); } - }, [formData.k8s_context, formData.k8s_namespace, formData.k8s_resource_type]); + }, [ + formData.k8s_context, + formData.k8s_namespace, + formData.k8s_resource_type, + loadK8sResourcesList, + ]); useEffect(() => { const context = formData.k8s_context; @@ -426,6 +480,7 @@ export const NewConnectionModal = ({ namespace, resourceType, resourceName, + k8sCommandOptions, ); if (!cancelled) { setK8sAutoPort( @@ -450,6 +505,7 @@ export const NewConnectionModal = ({ formData.k8s_resource_name, isK8sPortOverridden, k8sMode, + k8sCommandOptions, ]); const updateField = ( @@ -459,6 +515,34 @@ export const NewConnectionModal = ({ setFormData((prev) => ({ ...prev, [field]: value })); }; + const applyK8sAdvancedFields = useCallback(() => { + const nextKubectlPath = k8sKubectlPathInput.trim() || undefined; + const nextKubeconfigPath = k8sKubeconfigPathInput.trim() || undefined; + if ( + formData.k8s_kubectl_path === nextKubectlPath && + formData.k8s_kubeconfig_path === nextKubeconfigPath + ) { + return; + } + + setFormData((prev) => ({ + ...prev, + k8s_kubectl_path: nextKubectlPath, + k8s_kubeconfig_path: nextKubeconfigPath, + k8s_context: undefined, + k8s_namespace: undefined, + k8s_resource_name: undefined, + })); + setK8sNamespaces([]); + setK8sResources([]); + setK8sDiscoveryError(null); + }, [ + formData.k8s_kubeconfig_path, + formData.k8s_kubectl_path, + k8sKubeconfigPathInput, + k8sKubectlPathInput, + ]); + const loadDatabases = async (overrides?: Partial) => { const effectiveDriver = overrides?.driver ?? driver; const targetDriver = drivers.find((d) => d.id === effectiveDriver); @@ -539,6 +623,8 @@ export const NewConnectionModal = ({ setConnectionStringError(null); setNameError(false); setDatabasesTabError(false); + setK8sDiscoveryError(null); + setShowK8sAdvanced(false); setIsK8sPortOverridden(false); if (initialConnection) { @@ -567,6 +653,9 @@ export const NewConnectionModal = ({ } setIsK8sPortOverridden(params.k8s_port != null); + setShowK8sAdvanced(Boolean(params.k8s_kubectl_path || params.k8s_kubeconfig_path)); + setK8sKubectlPathInput(params.k8s_kubectl_path ?? ""); + setK8sKubeconfigPathInput(params.k8s_kubeconfig_path ?? ""); if (Array.isArray(db)) { setSelectedDatabasesState(db); setFormData({ ...params, database: db[0] ?? "" }); @@ -593,11 +682,17 @@ export const NewConnectionModal = ({ ssh_enabled: false, ssh_port: 22, k8s_enabled: false, + k8s_kubectl_path: undefined, + k8s_kubeconfig_path: undefined, }); setSelectedDatabasesState([]); setSshMode("existing"); setK8sMode("existing"); setIsK8sPortOverridden(false); + setK8sDiscoveryError(null); + setShowK8sAdvanced(false); + setK8sKubectlPathInput(""); + setK8sKubeconfigPathInput(""); setDetectJsonInTextColumns(false); setAppearance({}); } @@ -636,8 +731,12 @@ export const NewConnectionModal = ({ k8s_resource_type: undefined, k8s_resource_name: undefined, k8s_port: undefined, + k8s_kubectl_path: undefined, + k8s_kubeconfig_path: undefined, }); setIsK8sPortOverridden(false); + setK8sKubectlPathInput(""); + setK8sKubeconfigPathInput(""); setSelectedDatabasesState([]); setDbSearchQuery(""); setAvailableDatabases([]); @@ -1677,6 +1776,12 @@ export const NewConnectionModal = ({ updateField("k8s_resource_type", undefined); updateField("k8s_resource_name", undefined); updateField("k8s_port", undefined); + updateField("k8s_kubectl_path", undefined); + updateField("k8s_kubeconfig_path", undefined); + setK8sDiscoveryError(null); + setShowK8sAdvanced(false); + setK8sKubectlPathInput(""); + setK8sKubeconfigPathInput(""); setIsK8sPortOverridden(false); } else { updateField("k8s_connection_id", undefined); @@ -1761,13 +1866,18 @@ export const NewConnectionModal = ({ options={k8sContexts} onChange={(val) => { updateField("k8s_context", val); + updateField("k8s_namespace", undefined); + updateField("k8s_resource_name", undefined); + setK8sDiscoveryError(null); }} searchPlaceholder={t("common.search")} - noResultsLabel={t("common.noResults")} + noResultsLabel={t("newConnection.noK8sContexts", { + defaultValue: "No contexts available", + })} placeholder={ k8sContexts.length === 0 ? t("newConnection.noK8sContexts", { - defaultValue: "No contexts found (is kubectl installed?)", + defaultValue: "No contexts available", }) : t("newConnection.chooseContext", { defaultValue: "Choose a context...", @@ -1787,9 +1897,13 @@ export const NewConnectionModal = ({ options={k8sNamespaces} onChange={(val) => { updateField("k8s_namespace", val); + updateField("k8s_resource_name", undefined); + setK8sDiscoveryError(null); }} searchPlaceholder={t("common.search")} - noResultsLabel={t("common.noResults")} + noResultsLabel={t("newConnection.noK8sNamespaces", { + defaultValue: "No namespaces available", + })} placeholder={ k8sNamespaces.length === 0 ? t("newConnection.selectContextFirst", { @@ -1822,6 +1936,7 @@ export const NewConnectionModal = ({ }} onChange={(val) => { updateField("k8s_resource_type", val); + updateField("k8s_resource_name", undefined); }} placeholder={t("newConnection.k8sSelectType", { defaultValue: "Select type...", @@ -1839,11 +1954,14 @@ export const NewConnectionModal = ({ setKubectlPath(e.target.value)} - onBlur={onAdvancedPathsBlur} - className={InputClass} - placeholder={t("k8sConnections.kubectlPathPlaceholder")} - /> +
+ setKubectlPath(e.target.value)} + onBlur={onKubectlPathBlur} + className={getPathInputClass(kubectlPathValidation.status)} + placeholder={t("k8sConnections.kubectlPathPlaceholder")} + aria-invalid={kubectlPathValidation.status === "invalid"} + /> + +
+
- setKubeconfigPath(e.target.value)} - onBlur={onAdvancedPathsBlur} - className={InputClass} - placeholder={t("k8sConnections.kubeconfigPathPlaceholder")} - /> +
+ setKubeconfigPath(e.target.value)} + onBlur={onKubeconfigPathBlur} + className={getPathInputClass(kubeconfigPathValidation.status)} + placeholder={t("k8sConnections.kubeconfigPathPlaceholder")} + aria-invalid={kubeconfigPathValidation.status === "invalid"} + /> + +
+
)} @@ -754,6 +925,36 @@ function EditForm({ ); } +function getPathInputClass(status: PathValidationStatus) { + return clsx( + InputClass, + "pr-8 transition-colors", + status === "validating" && "border-blue-500 focus:border-blue-500", + status === "valid" && "border-green-500 focus:border-green-500 focus:ring-1 focus:ring-green-500/40", + status === "invalid" && "border-red-500 focus:border-red-500 focus:ring-1 focus:ring-red-500/40", + ); +} + +function PathValidationAdornment({ status }: { status: PathValidationStatus }) { + if (status === "idle") return null; + + return ( + + {status === "validating" && ( + + )} + {status === "valid" && } + {status === "invalid" && } + + ); +} + +function PathValidationMessage({ validation }: { validation: PathValidationState }) { + if (!validation.message) return null; + + return

{validation.message}

; +} + function DiscoveryError({ message }: { message: string | null }) { if (!message) return null; diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index 5933e918..3b3d95f9 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -37,7 +37,9 @@ import { getK8sNamespaces, getK8sResources, getK8sResourcePorts, + validateK8sPath, type K8sConnection, + type K8sPathValidationKind, } from "../../utils/k8s"; import { isMultiDatabaseCapable } from "../../utils/database"; import { fetchConnectionWithCredentials } from "../../utils/credentials"; @@ -46,6 +48,7 @@ import { parseConnectionString, toConnectionParams, } from "../../utils/connectionStringParser"; +import { toErrorMessage } from "../../utils/errors"; interface ConnectionParams { driver: string; @@ -96,6 +99,38 @@ interface NewConnectionModalProps { initialConnection?: SavedConnection | null; } +type PathValidationStatus = "idle" | "validating" | "valid" | "invalid"; + +interface PathValidationState { + status: PathValidationStatus; + value: string; + message: string | null; +} + +const EmptyPathValidationState: PathValidationState = { + status: "idle", + value: "", + message: null, +}; + +function PathValidationAdornment({ status }: { status: PathValidationStatus }) { + return ( + + {status === "validating" && ( + + )} + {status === "valid" && } + {status === "invalid" && } + + ); +} + +function PathValidationMessage({ validation }: { validation: PathValidationState }) { + if (!validation.message) return null; + + return

{validation.message}

; +} + const FieldInput = ({ label, value, @@ -105,6 +140,7 @@ const FieldInput = ({ placeholder, autoFocus, className, + validation, }: { label: string; value: string | number | undefined; @@ -114,9 +150,12 @@ const FieldInput = ({ placeholder?: string; autoFocus?: boolean; className?: string; + validation?: PathValidationState; }) => { const [showPassword, setShowPassword] = useState(false); const isPassword = type === "password"; + const validationStatus = validation?.status ?? "idle"; + const hasValidationIcon = validationStatus !== "idle"; return (
@@ -137,9 +176,16 @@ const FieldInput = ({ spellCheck={false} className={clsx( "w-full px-3 py-2 bg-base border border-strong rounded-md text-sm text-primary placeholder:text-muted placeholder:italic focus:border-blue-500 focus:outline-none transition-colors", - isPassword && "pr-10" + (isPassword || hasValidationIcon) && "pr-10", + validationStatus === "validating" && "border-blue-500 focus:border-blue-500", + validationStatus === "valid" && "border-green-500 focus:border-green-500 focus:ring-1 focus:ring-green-500/40", + validationStatus === "invalid" && "border-red-500 focus:border-red-500 focus:ring-1 focus:ring-red-500/40", )} + aria-invalid={validationStatus === "invalid"} /> + {hasValidationIcon && !isPassword && ( + + )} {isPassword && (
+ {validation && } ); }; @@ -219,6 +266,8 @@ export const NewConnectionModal = ({ const [showK8sAdvanced, setShowK8sAdvanced] = useState(false); const [k8sKubectlPathInput, setK8sKubectlPathInput] = useState(""); const [k8sKubeconfigPathInput, setK8sKubeconfigPathInput] = useState(""); + const [k8sKubectlPathValidation, setK8sKubectlPathValidation] = useState(EmptyPathValidationState); + const [k8sKubeconfigPathValidation, setK8sKubeconfigPathValidation] = useState(EmptyPathValidationState); // ── databases ── const [availableDatabases, setAvailableDatabases] = useState([]); @@ -543,6 +592,144 @@ export const NewConnectionModal = ({ k8sKubectlPathInput, ]); + const isK8sPathReadyToApply = useCallback( + ( + kind: K8sPathValidationKind, + currentValue: string, + state: PathValidationState, + nextValidPath?: { kind: K8sPathValidationKind; value: string }, + ) => { + if (!currentValue) return true; + if (nextValidPath?.kind === kind && nextValidPath.value === currentValue) { + return true; + } + + return state.status === "valid" && state.value === currentValue; + }, + [], + ); + + const areK8sAdvancedPathsReadyToApply = useCallback( + (nextValidPath?: { kind: K8sPathValidationKind; value: string }) => { + const nextKubectlPath = k8sKubectlPathInput.trim(); + const nextKubeconfigPath = k8sKubeconfigPathInput.trim(); + + return ( + isK8sPathReadyToApply( + "kubectl", + nextKubectlPath, + k8sKubectlPathValidation, + nextValidPath, + ) && + isK8sPathReadyToApply( + "kubeconfig", + nextKubeconfigPath, + k8sKubeconfigPathValidation, + nextValidPath, + ) + ); + }, + [ + isK8sPathReadyToApply, + k8sKubeconfigPathInput, + k8sKubeconfigPathValidation, + k8sKubectlPathInput, + k8sKubectlPathValidation, + ], + ); + + const validateK8sAdvancedPath = async ( + kind: K8sPathValidationKind, + applyOnSuccess = true, + ) => { + const path = kind === "kubectl" ? k8sKubectlPathInput : k8sKubeconfigPathInput; + const trimmedPath = path.trim(); + const setPathValidation = + kind === "kubectl" + ? setK8sKubectlPathValidation + : setK8sKubeconfigPathValidation; + + if (!trimmedPath) { + setPathValidation(EmptyPathValidationState); + if (applyOnSuccess && areK8sAdvancedPathsReadyToApply({ kind, value: "" })) { + applyK8sAdvancedFields(); + } + return true; + } + + setPathValidation({ status: "validating", value: trimmedPath, message: null }); + try { + await validateK8sPath(trimmedPath, kind); + setPathValidation({ status: "valid", value: trimmedPath, message: null }); + if (applyOnSuccess && areK8sAdvancedPathsReadyToApply({ kind, value: trimmedPath })) { + applyK8sAdvancedFields(); + } + return true; + } catch (error) { + setPathValidation({ + status: "invalid", + value: trimmedPath, + message: toErrorMessage(error), + }); + return false; + } + }; + + const validateK8sAdvancedPathsForSave = async () => { + if (!formData.k8s_enabled || k8sMode !== "inline") { + return true; + } + + const kubectlPathValid = await validateK8sAdvancedPath("kubectl", false); + const kubeconfigPathValid = await validateK8sAdvancedPath("kubeconfig", false); + + return kubectlPathValid && kubeconfigPathValid; + }; + + const getK8sAdvancedPathParams = useCallback((): Partial => { + if (!formData.k8s_enabled || k8sMode !== "inline") { + return {}; + } + + return { + k8s_kubectl_path: k8sKubectlPathInput.trim() || undefined, + k8s_kubeconfig_path: k8sKubeconfigPathInput.trim() || undefined, + }; + }, [ + formData.k8s_enabled, + k8sKubeconfigPathInput, + k8sKubectlPathInput, + k8sMode, + ]); + + const haveK8sAdvancedPathInputsChanged = useCallback(() => { + if (!formData.k8s_enabled || k8sMode !== "inline") { + return false; + } + + const currentPaths = getK8sAdvancedPathParams(); + return ( + formData.k8s_kubectl_path !== currentPaths.k8s_kubectl_path || + formData.k8s_kubeconfig_path !== currentPaths.k8s_kubeconfig_path + ); + }, [ + formData.k8s_enabled, + formData.k8s_kubeconfig_path, + formData.k8s_kubectl_path, + getK8sAdvancedPathParams, + k8sMode, + ]); + + const handleK8sKubectlPathInputChange = (value: string) => { + setK8sKubectlPathInput(value); + setK8sKubectlPathValidation(EmptyPathValidationState); + }; + + const handleK8sKubeconfigPathInputChange = (value: string) => { + setK8sKubeconfigPathInput(value); + setK8sKubeconfigPathValidation(EmptyPathValidationState); + }; + const loadDatabases = async (overrides?: Partial) => { const effectiveDriver = overrides?.driver ?? driver; const targetDriver = drivers.find((d) => d.id === effectiveDriver); @@ -737,6 +924,8 @@ export const NewConnectionModal = ({ setIsK8sPortOverridden(false); setK8sKubectlPathInput(""); setK8sKubeconfigPathInput(""); + setK8sKubectlPathValidation(EmptyPathValidationState); + setK8sKubeconfigPathValidation(EmptyPathValidationState); setSelectedDatabasesState([]); setDbSearchQuery(""); setAvailableDatabases([]); @@ -751,6 +940,33 @@ export const NewConnectionModal = ({ }; const testConnection = async () => { + const k8sAdvancedPathsValid = await validateK8sAdvancedPathsForSave(); + if (!k8sAdvancedPathsValid) { + setStatus("error"); + setMessage( + t("newConnection.k8sPathValidationFailed", { + defaultValue: "Fix invalid Kubernetes advanced path fields before saving.", + }), + ); + setTestResult("error"); + setActiveTab("k8s"); + return false; + } + + if (haveK8sAdvancedPathInputsChanged()) { + applyK8sAdvancedFields(); + setStatus("error"); + setMessage( + t("newConnection.k8sPathSelectionReset", { + defaultValue: + "Advanced Kubernetes paths changed. Choose the context, namespace, and resource again.", + }), + ); + setTestResult("error"); + setActiveTab("k8s"); + return false; + } + setStatus("testing"); setMessage(""); setTestResult(null); @@ -758,6 +974,7 @@ export const NewConnectionModal = ({ const testParams: Partial = { driver, ...formData, + ...getK8sAdvancedPathParams(), port: formData.port != null ? Number(formData.port) : undefined, k8s_port: effectiveK8sPort, database: isMultiDb @@ -826,6 +1043,34 @@ export const NewConnectionModal = ({ setTestResult("error"); return; } + + const k8sAdvancedPathsValid = await validateK8sAdvancedPathsForSave(); + if (!k8sAdvancedPathsValid) { + setStatus("error"); + setMessage( + t("newConnection.k8sPathValidationFailed", { + defaultValue: "Fix invalid Kubernetes advanced path fields before saving.", + }), + ); + setTestResult("error"); + setActiveTab("k8s"); + return; + } + + if (haveK8sAdvancedPathInputsChanged()) { + applyK8sAdvancedFields(); + setStatus("error"); + setMessage( + t("newConnection.k8sPathSelectionReset", { + defaultValue: + "Advanced Kubernetes paths changed. Choose the context, namespace, and resource again.", + }), + ); + setTestResult("error"); + setActiveTab("k8s"); + return; + } + setStatus("saving"); setMessage(""); setTestResult(null); @@ -833,6 +1078,7 @@ export const NewConnectionModal = ({ const params: Partial = { driver, ...formData, + ...getK8sAdvancedPathParams(), port: formData.port != null ? Number(formData.port) : undefined, k8s_port: effectiveK8sPort, database: isMultiDb @@ -1782,6 +2028,8 @@ export const NewConnectionModal = ({ setShowK8sAdvanced(false); setK8sKubectlPathInput(""); setK8sKubeconfigPathInput(""); + setK8sKubectlPathValidation(EmptyPathValidationState); + setK8sKubeconfigPathValidation(EmptyPathValidationState); setIsK8sPortOverridden(false); } else { updateField("k8s_connection_id", undefined); @@ -2021,22 +2269,24 @@ export const NewConnectionModal = ({ defaultValue: "kubectl Path", })} value={k8sKubectlPathInput} - onChange={setK8sKubectlPathInput} - onBlur={applyK8sAdvancedFields} + onChange={handleK8sKubectlPathInputChange} + onBlur={() => void validateK8sAdvancedPath("kubectl")} placeholder={t("newConnection.kubectlPathPlaceholder", { defaultValue: "kubectl", })} + validation={k8sKubectlPathValidation} /> void validateK8sAdvancedPath("kubeconfig")} placeholder={t("newConnection.kubeconfigPathPlaceholder", { defaultValue: "Path to kubeconfig", })} + validation={k8sKubeconfigPathValidation} /> )} From f6203af4b41cd6d9b5a7ceef8cab438c06689cae Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Tue, 23 Jun 2026 16:49:08 +0200 Subject: [PATCH 10/17] docs(sponsors): add Vercel and Tolgee, update DevGlobe --- README.md | 4 +++- SPONSORS.md | 42 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 105fbc6f..e9f8c26f 100644 --- a/README.md +++ b/README.md @@ -395,8 +395,10 @@ Contributions are welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md). Good pla - turboSMTP **[turboSMTP](https://www.serversmtp.com/?utm_source=tabularis&utm_medium=referral&utm_campaign=sponsor)** — Professional SMTP relay — your emails delivered straight to the inbox, never to spam - Kilo Code **[Kilo Code](https://www.kilo.ai/?utm_source=tabularis&utm_medium=referral&utm_campaign=sponsor)** — Open source AI coding agent — build, ship, and iterate faster with 500+ models - DigitalOcean **[DigitalOcean](https://m.do.co/c/f6ab3d158275?utm_source=tabularis&utm_medium=referral&utm_campaign=sponsor)** — Simple, predictable cloud infrastructure for developers and growing teams. +- Vercel **[Vercel](https://vercel.com/?utm_source=tabularis&utm_medium=referral&utm_campaign=sponsor)** — The platform for the modern web — ship, preview, and scale frontend apps with zero config. - Usero **[Usero](https://usero.io/?utm_source=tabularis&utm_medium=referral&utm_campaign=sponsor)** — Feedback becomes code. Automatically. -- DevGlobe **[DevGlobe](https://devglobe.xyz/?utm_source=tabularis&utm_medium=referral&utm_campaign=sponsor)** — Connect your IDE, show up on the globe, and showcase your projects to a community of builders. +- DevGlobe **[DevGlobe](https://devglobe.app/?utm_source=tabularis&utm_medium=referral&utm_campaign=sponsor)** — Connect your IDE, show up on the globe, and showcase your projects to a community of builders. +- Tolgee **[Tolgee](https://tolgee.io/?utm_source=tabularis&utm_medium=referral&utm_campaign=sponsor)** — Open-source localization platform — translate your app in context, without the spreadsheet chaos. _[Become a sponsor →](https://tabularis.dev/sponsors)_ diff --git a/SPONSORS.md b/SPONSORS.md index 2355092a..32196ccc 100644 --- a/SPONSORS.md +++ b/SPONSORS.md @@ -62,6 +62,25 @@ Interested in sponsoring? [Get in touch →](https://tabularis.dev/sponsors) --- +## Vercel + +Vercel + +**The platform for the modern web — ship, preview, and scale frontend apps with zero config.** + +🔗 [https://vercel.com](https://vercel.com/?utm_source=tabularis&utm_medium=referral&utm_campaign=sponsor) + +- ▲ Git-based deploys — a live preview URL for every push +- ⚡ Global edge network — fast everywhere, by default +- 🧩 First-class Next.js — framework and platform, one team +- 🔍 Preview deployments — review changes before they go live +- ❤️ Open Source Program — a year of support for maintainers + +> **Start building for free** +> Vercel's Hobby tier is free for personal projects and open source — deploy a Next.js app in minutes, no credit card required. + +--- + ## Usero Usero @@ -83,11 +102,11 @@ Interested in sponsoring? [Get in touch →](https://tabularis.dev/sponsors) ## DevGlobe -DevGlobe +DevGlobe **Connect your IDE, show up on the globe, and showcase your projects to a community of builders.** -🔗 [https://devglobe.xyz](https://devglobe.xyz/?utm_source=tabularis&utm_medium=referral&utm_campaign=sponsor) +🔗 [https://devglobe.app](https://devglobe.app/?utm_source=tabularis&utm_medium=referral&utm_campaign=sponsor) - 🌍 Connect your IDE and appear live on the globe - 🚀 Ship your project — get discovered by the community @@ -100,4 +119,23 @@ Interested in sponsoring? [Get in touch →](https://tabularis.dev/sponsors) --- +## Tolgee + +Tolgee + +**Open-source localization platform — translate your app in context, without the spreadsheet chaos.** + +🔗 [https://tolgee.io](https://tolgee.io/?utm_source=tabularis&utm_medium=referral&utm_campaign=sponsor) + +- 🌍 In-context editing — translate directly inside your running app +- 🤖 AI-powered translations, machine translation & translation memory +- 🔓 Open source — self-host it or use the managed cloud +- 🧩 SDKs & integrations for React, Vue, Angular, Next.js and more +- ⚡ One-click translation updates without redeploying + +> **Free tier for open source & small teams** +> Get started with Tolgee for free — self-host the platform or use the cloud free tier to localize your project from day one. + +--- + _This file is auto-generated by `scripts/sync-sponsors.js`. Do not edit manually._ \ No newline at end of file From 19b5dc3b2bb9b159efe0ff3729724acecbcd03fc Mon Sep 17 00:00:00 2001 From: Emiliano Maldonado Garza <68057133+Wilovy09@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:38:43 -0600 Subject: [PATCH 11/17] Add function to include headers in CSV output (#356) * feat: add rowsToCSVWithHeaders function to include headers in CSV output * feat: enhance copy functionality to include headers in CSV output * feat: enhance copy functionality to handle errors and include headers in clipboard output * feat: add option to toggle CSV headers when copying Add a csvIncludeHeaders setting (persisted in config.json) and a toolbar toggle in the copy controls to choose whether copied CSV output includes the column header row. Defaults to true. Threaded through MultiResultPanel, StackedResultItem and ResultEntryContent down to DataGrid, with i18n keys added across all 8 locales. --------- Co-authored-by: Andrea Debernardi --- src-tauri/src/config.rs | 5 ++++ src/components/settings/GeneralTab.tsx | 14 +++++++++++ src/components/ui/DataGrid.tsx | 31 +++++++++++++++++++++--- src/components/ui/MultiResultPanel.tsx | 4 +++ src/components/ui/ResultEntryContent.tsx | 4 +++ src/components/ui/StackedResultItem.tsx | 4 +++ src/contexts/SettingsContext.ts | 3 +++ src/i18n/locales/de.json | 3 +++ src/i18n/locales/en.json | 3 +++ src/i18n/locales/es.json | 3 +++ src/i18n/locales/fr.json | 3 +++ src/i18n/locales/it.json | 3 +++ src/i18n/locales/ja.json | 3 +++ src/i18n/locales/ru.json | 3 +++ src/i18n/locales/zh.json | 3 +++ src/pages/Editor.tsx | 23 ++++++++++++++++++ src/utils/clipboard.ts | 6 +++++ 17 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 5ba6419e..1a26feb9 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -48,6 +48,8 @@ pub struct AppConfig { pub max_blob_size: Option, pub copy_format: Option, pub csv_delimiter: Option, + /// Whether copied CSV output includes a header row. Default: true. + pub csv_include_headers: Option, pub active_external_drivers: Option>, pub custom_registry_url: Option, pub plugins: Option>, @@ -287,6 +289,9 @@ pub fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> { if config.csv_delimiter.is_some() { existing_config.csv_delimiter = config.csv_delimiter; } + if config.csv_include_headers.is_some() { + existing_config.csv_include_headers = config.csv_include_headers; + } if config.active_external_drivers.is_some() { existing_config.active_external_drivers = config.active_external_drivers; } diff --git a/src/components/settings/GeneralTab.tsx b/src/components/settings/GeneralTab.tsx index e4084293..cf327288 100644 --- a/src/components/settings/GeneralTab.tsx +++ b/src/components/settings/GeneralTab.tsx @@ -94,6 +94,20 @@ export function GeneralTab() { /> + + updateSetting("csvIncludeHeaders", v)} + /> + + diff --git a/src/components/ui/DataGrid.tsx b/src/components/ui/DataGrid.tsx index a3f7f526..a348adc2 100644 --- a/src/components/ui/DataGrid.tsx +++ b/src/components/ui/DataGrid.tsx @@ -61,6 +61,7 @@ import { RowEditorSidebar } from "./RowEditorSidebar"; import { useDatabase } from "../../hooks/useDatabase"; import { rowsToCSV, + rowsToCSVWithHeaders, rowsToJSON, rowsToSqlInsert, getSelectedRows, @@ -109,6 +110,7 @@ interface DataGridProps { onSelectionChange?: (indices: Set) => void; copyFormat?: "csv" | "json" | "sql-insert"; csvDelimiter?: string; + csvIncludeHeaders?: boolean; sortClause?: string; onSort?: (colName: string) => void; readonly?: boolean; @@ -144,6 +146,7 @@ export const DataGrid = React.memo( onSelectionChange, copyFormat, csvDelimiter = ",", + csvIncludeHeaders = true, sortClause, onSort, readonly: readonlyProp, @@ -442,12 +445,30 @@ export const DataGrid = React.memo( } else { const allIndices = new Set(mergedRows.map((_, i) => i)); updateSelection(allIndices); + const allRows = mergedRows.map((r) => r.rowData); + const text = copyFormat === "json" + ? rowsToJSON(allRows, columns) + : copyFormat === "sql-insert" + ? rowsToSqlInsert(allRows, columns, tableName ?? "table") + : csvIncludeHeaders + ? rowsToCSVWithHeaders(allRows, columns, "null", csvDelimiter) + : rowsToCSV(allRows, "null", csvDelimiter); + copyTextToClipboard(text).catch((e) => { + showAlert(t("common.error") + ": " + e, { title: t("common.error"), kind: "error" }); + }); } }, [ selectedRowIndices.size, mergedRows, updateSelection, onForeignKeyHidePanel, + columns, + copyFormat, + csvDelimiter, + csvIncludeHeaders, + tableName, + showAlert, + t, ]); useEffect(() => { @@ -1104,13 +1125,15 @@ export const DataGrid = React.memo( ); const formatRows = useCallback( - (rows: unknown[][]) => { + (rows: unknown[][], withHeaders = false) => { if (copyFormat === "json") return rowsToJSON(rows, columns); if (copyFormat === "sql-insert") return rowsToSqlInsert(rows, columns, tableName ?? "table"); + if (withHeaders && csvIncludeHeaders) + return rowsToCSVWithHeaders(rows, columns, "null", csvDelimiter); return rowsToCSV(rows, "null", csvDelimiter); }, - [columns, copyFormat, csvDelimiter, tableName], + [columns, copyFormat, csvDelimiter, csvIncludeHeaders, tableName], ); const copySelectedOrContextRow = useCallback(async () => { @@ -1121,7 +1144,7 @@ export const DataGrid = React.memo( ? getSelectedRows(data, selectedRowIndices) : [contextMenu.row]; - await copyToClipboard(formatRows(rows)); + await copyToClipboard(formatRows(rows, true)); }, [contextMenu, selectedRowIndices, data, formatRows, copyToClipboard]); const copyHeaderName = useCallback(async () => { @@ -1146,7 +1169,7 @@ export const DataGrid = React.memo( const copySelectedCells = useCallback(async () => { if (selectedRowIndices.size === 0) return; await copyToClipboard( - formatRows(getSelectedRows(data, selectedRowIndices)), + formatRows(getSelectedRows(data, selectedRowIndices), true), ); }, [selectedRowIndices, data, formatRows, copyToClipboard]); diff --git a/src/components/ui/MultiResultPanel.tsx b/src/components/ui/MultiResultPanel.tsx index b4c19eec..f7f74fe2 100644 --- a/src/components/ui/MultiResultPanel.tsx +++ b/src/components/ui/MultiResultPanel.tsx @@ -46,6 +46,7 @@ interface MultiResultPanelProps { connectionId: string | null; copyFormat: "csv" | "json" | "sql-insert"; csvDelimiter: string; + csvIncludeHeaders: boolean; onSelectResult: (entryId: string) => void; onRerunEntry: (entryId: string) => void; onPageChange: (entryId: string, page: number) => void; @@ -234,6 +235,7 @@ export function MultiResultPanel({ connectionId, copyFormat, csvDelimiter, + csvIncludeHeaders, onSelectResult, onRerunEntry, onPageChange, @@ -456,6 +458,7 @@ export function MultiResultPanel({ connectionId={connectionId} copyFormat={copyFormat} csvDelimiter={csvDelimiter} + csvIncludeHeaders={csvIncludeHeaders} onPageChange={(page) => onPageChange(activeEntry.id, page)} /> @@ -507,6 +510,7 @@ export function MultiResultPanel({ connectionId={connectionId} copyFormat={copyFormat} csvDelimiter={csvDelimiter} + csvIncludeHeaders={csvIncludeHeaders} collapsed={collapsedIds.has(entry.id)} aiEnabled={aiEnabled} aiRenaming={aiRenamingEntryId === entry.id} diff --git a/src/components/ui/ResultEntryContent.tsx b/src/components/ui/ResultEntryContent.tsx index 7338fe5a..ec3de8be 100644 --- a/src/components/ui/ResultEntryContent.tsx +++ b/src/components/ui/ResultEntryContent.tsx @@ -11,6 +11,7 @@ interface ResultEntryContentProps { connectionId: string | null; copyFormat: "csv" | "json" | "sql-insert"; csvDelimiter: string; + csvIncludeHeaders: boolean; onPageChange: (page: number) => void; compact?: boolean; } @@ -20,6 +21,7 @@ export function ResultEntryContent({ connectionId, copyFormat, csvDelimiter, + csvIncludeHeaders, onPageChange, compact, }: ResultEntryContentProps) { @@ -74,6 +76,7 @@ export function ResultEntryContent({ onSelectionChange={() => {}} copyFormat={copyFormat} csvDelimiter={csvDelimiter} + csvIncludeHeaders={csvIncludeHeaders} readonly={true} /> @@ -121,6 +124,7 @@ export function ResultEntryContent({ onSelectionChange={() => {}} copyFormat={copyFormat} csvDelimiter={csvDelimiter} + csvIncludeHeaders={csvIncludeHeaders} readonly={true} /> diff --git a/src/components/ui/StackedResultItem.tsx b/src/components/ui/StackedResultItem.tsx index 412c7563..d0e5b79a 100644 --- a/src/components/ui/StackedResultItem.tsx +++ b/src/components/ui/StackedResultItem.tsx @@ -25,6 +25,7 @@ interface StackedResultItemProps { connectionId: string | null; copyFormat: "csv" | "json" | "sql-insert"; csvDelimiter: string; + csvIncludeHeaders: boolean; collapsed: boolean; aiEnabled: boolean; aiRenaming: boolean; @@ -41,6 +42,7 @@ export function StackedResultItem({ connectionId, copyFormat, csvDelimiter, + csvIncludeHeaders, collapsed, aiEnabled, aiRenaming, @@ -293,6 +295,7 @@ export function StackedResultItem({ connectionId={connectionId} copyFormat={copyFormat} csvDelimiter={csvDelimiter} + csvIncludeHeaders={csvIncludeHeaders} onPageChange={onPageChange} compact /> @@ -305,6 +308,7 @@ export function StackedResultItem({ connectionId={connectionId} copyFormat={copyFormat} csvDelimiter={csvDelimiter} + csvIncludeHeaders={csvIncludeHeaders} onPageChange={onPageChange} compact /> diff --git a/src/contexts/SettingsContext.ts b/src/contexts/SettingsContext.ts index d1269873..a2d0e155 100644 --- a/src/contexts/SettingsContext.ts +++ b/src/contexts/SettingsContext.ts @@ -41,6 +41,8 @@ export interface Settings { erDiagramDefaultLayout?: ERDiagramLayout; copyFormat?: CopyFormat; csvDelimiter?: string; + /** Whether copied CSV output includes a header row. Default: true. */ + csvIncludeHeaders?: boolean; activeExternalDrivers?: string[]; plugins?: Record; editorTheme?: string; @@ -105,6 +107,7 @@ export const DEFAULT_SETTINGS: Settings = { maxLogEntries: 1000, copyFormat: "csv", csvDelimiter: ",", + csvIncludeHeaders: true, erDiagramDefaultLayout: "LR", editorFontFamily: "JetBrains Mono", editorFontSize: 14, diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 1da05fd4..3cdf9f11 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -287,6 +287,9 @@ "delimiterSemicolon": "Semikolon (;)", "delimiterTab": "Tabulator", "delimiterPipe": "Pipe (|)", + "csvIncludeHeaders": "CSV-Kopfzeile einschließen", + "csvIncludeHeadersDesc": "Beim Kopieren von Zeilen als CSV eine Kopfzeile mit den Spaltennamen einfügen.", + "csvHeaders": "Spaltennamen exportieren", "detectJsonInTextColumns": "JSON in Text-Spalten erkennen", "detectJsonInTextColumnsDesc": "Zeigt JSON-Viewer-Bedienelemente, wenn eine nicht typisierte Text-Zelle ein JSON-Objekt oder -Array enthält. Verursacht kleine Parse-Kosten pro Zelle.", "appearance": "Darstellung", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 94081d0a..3fbfdd2b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -305,6 +305,9 @@ "delimiterSemicolon": "Semicolon (;)", "delimiterTab": "Tab", "delimiterPipe": "Pipe (|)", + "csvIncludeHeaders": "Include CSV headers", + "csvIncludeHeadersDesc": "Include a header row with column names when copying rows as CSV.", + "csvHeaders": "Export column names", "detectJsonInTextColumns": "Detect JSON in text columns", "detectJsonInTextColumnsDesc": "Show the JSON viewer affordance when a non-typed text cell contains a JSON object or array. Adds a small per-cell parse cost.", "appearance": "Appearance", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 09d899dd..93e34a8f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -292,6 +292,9 @@ "delimiterSemicolon": "Punto y coma (;)", "delimiterTab": "Tab", "delimiterPipe": "Pipe (|)", + "csvIncludeHeaders": "Incluir encabezados CSV", + "csvIncludeHeadersDesc": "Incluir una fila de encabezado con los nombres de las columnas al copiar filas como CSV.", + "csvHeaders": "Exportar nombres de columnas", "detectJsonInTextColumns": "Detectar JSON en columnas de texto", "detectJsonInTextColumnsDesc": "Muestra el visor de JSON cuando una celda de texto sin tipar contiene un objeto o array JSON. Añade un pequeño coste de análisis por celda.", "appearance": "Apariencia", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a89b22b6..e666b8ae 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -287,6 +287,9 @@ "delimiterSemicolon": "Point-virgule (;)", "delimiterTab": "Tabulation", "delimiterPipe": "Barre verticale (|)", + "csvIncludeHeaders": "Inclure les en-têtes CSV", + "csvIncludeHeadersDesc": "Inclure une ligne d'en-tête avec les noms des colonnes lors de la copie des lignes au format CSV.", + "csvHeaders": "Exporter les noms de colonnes", "detectJsonInTextColumns": "Détecter le JSON dans les colonnes texte", "detectJsonInTextColumnsDesc": "Affiche le visualiseur JSON lorsqu'une cellule texte non typée contient un objet ou un tableau JSON. Ajoute un petit coût d'analyse par cellule.", "appearance": "Apparence", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4491bf6f..ddaf7015 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -292,6 +292,9 @@ "delimiterSemicolon": "Punto e virgola (;)", "delimiterTab": "Tab", "delimiterPipe": "Pipe (|)", + "csvIncludeHeaders": "Includi intestazioni CSV", + "csvIncludeHeadersDesc": "Includi una riga di intestazione con i nomi delle colonne quando copi le righe come CSV.", + "csvHeaders": "Esporta nomi colonne", "detectJsonInTextColumns": "Rileva JSON nelle colonne di testo", "detectJsonInTextColumnsDesc": "Mostra il visualizzatore JSON quando una cella di testo non tipizzata contiene un oggetto o un array JSON. Aggiunge un piccolo costo di analisi per cella.", "appearance": "Aspetto", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 8b985c17..d8dca06f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -300,6 +300,9 @@ "delimiterSemicolon": "セミコロン (;)", "delimiterTab": "タブ", "delimiterPipe": "パイプ (|)", + "csvIncludeHeaders": "CSVヘッダーを含める", + "csvIncludeHeadersDesc": "行をCSVとしてコピーするときに、列名のヘッダー行を含めます。", + "csvHeaders": "列名をエクスポート", "appearance": "外観", "localization": "ローカライズ", "themeSelection": "テーマ選択", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 0ffac881..9d308b0a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -282,6 +282,9 @@ "delimiterSemicolon": "Точка с запятой (;)", "delimiterTab": "Табуляция", "delimiterPipe": "Вертикальная черта (|)", + "csvIncludeHeaders": "Включать заголовки CSV", + "csvIncludeHeadersDesc": "Добавлять строку заголовка с именами столбцов при копировании строк в формате CSV.", + "csvHeaders": "Экспортировать имена столбцов", "detectJsonInTextColumns": "Определять JSON в текстовых столбцах", "detectJsonInTextColumnsDesc": "Показывать просмотрщик JSON, когда нетипизированная текстовая ячейка содержит объект или массив JSON. Создаёт небольшие издержки на разбор каждой ячейки.", "appearance": "Внешний вид", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 5f6a0b82..b195685d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -286,6 +286,9 @@ "delimiterSemicolon": "分号 (;)", "delimiterTab": "制表符", "delimiterPipe": "管道符 (|)", + "csvIncludeHeaders": "包含 CSV 表头", + "csvIncludeHeadersDesc": "以 CSV 格式复制行时包含含有列名的表头行。", + "csvHeaders": "导出列名", "detectJsonInTextColumns": "在文本列中检测 JSON", "detectJsonInTextColumnsDesc": "当未带类型的文本单元格包含 JSON 对象或数组时显示 JSON 查看器入口。每单元格会增加少量解析开销。", "appearance": "外观", diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 036a6b7d..ce5015fd 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -328,6 +328,9 @@ export const Editor = () => { const [csvDelimiter, setCsvDelimiter] = useState( settings.csvDelimiter ?? ",", ); + const [csvIncludeHeaders, setCsvIncludeHeaders] = useState( + settings.csvIncludeHeaders ?? true, + ); const activeTabType = activeTab?.type; const activeTabQuery = activeTab?.query; @@ -2995,6 +2998,7 @@ export const Editor = () => { connectionId={activeConnectionId} copyFormat={copyFormat} csvDelimiter={csvDelimiter} + csvIncludeHeaders={csvIncludeHeaders} onSelectResult={(entryId) => updateTab(activeTab.id, { activeResultId: entryId }) } @@ -3312,6 +3316,24 @@ export const Editor = () => { )} + {copyFormat === "csv" && ( + + )} {/* Separator */} @@ -3396,6 +3418,7 @@ export const Editor = () => { onSelectionChange={handleSelectionChange} copyFormat={copyFormat} csvDelimiter={csvDelimiter} + csvIncludeHeaders={csvIncludeHeaders} sortClause={activeTab.sortClause} onSort={ activeTab.type === "table" && diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts index 5f6f6267..ad6e6215 100644 --- a/src/utils/clipboard.ts +++ b/src/utils/clipboard.ts @@ -12,6 +12,12 @@ export function rowsToCSV(rows: unknown[][], nullLabel: string = "null", delimit .join("\n"); } +export function rowsToCSVWithHeaders(rows: unknown[][], columns: string[], nullLabel: string = "null", delimiter: string = ","): string { + const header = columns.join(delimiter); + const body = rowsToCSV(rows, nullLabel, delimiter); + return body ? `${header}\n${body}` : header; +} + export function rowToJSON(row: unknown[], columns: string[]): string { const obj: Record = {}; columns.forEach((col, i) => { From fac4c9ee7b619a3bc02c5090b82f88d4e52a97fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20No=C3=A9=20N=C3=BA=C3=B1ez=20L=C3=B3pez?= Date: Sat, 20 Jun 2026 18:26:00 -0700 Subject: [PATCH 12/17] Add IBM Informix plugin to registry --- plugins/registry.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/plugins/registry.json b/plugins/registry.json index 9033cca6..2d0cac17 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -236,6 +236,23 @@ } } ] + }, + { + "id": "informix", + "name": "IBM Informix", + "description": "IBM Informix 11.70+ driver plugin for Tabularis (ODBC; requires the Informix Client SDK)", + "author": "Daniel Núñez ", + "homepage": "https://github.com/danielnuld/tabularis-informix-plugin", + "latest_version": "0.1.0", + "releases": [ + { + "version": "0.1.0", + "min_tabularis_version": "0.9.17", + "assets": { + "win-x64": "https://github.com/danielnuld/tabularis-informix-plugin/releases/download/v0.1.0/tabularis-informix-plugin-win-x64-v0.1.0.zip" + } + } + ] } ] } From c52ca436eeca390f9cb8464d32a417fb99f5e632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20No=C3=A9=20N=C3=BA=C3=B1ez=20L=C3=B3pez?= Date: Mon, 22 Jun 2026 09:45:54 -0700 Subject: [PATCH 13/17] Bump Informix plugin to 0.1.1 (hidden console window) --- plugins/registry.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/registry.json b/plugins/registry.json index 2d0cac17..8c3727d7 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -243,13 +243,13 @@ "description": "IBM Informix 11.70+ driver plugin for Tabularis (ODBC; requires the Informix Client SDK)", "author": "Daniel Núñez ", "homepage": "https://github.com/danielnuld/tabularis-informix-plugin", - "latest_version": "0.1.0", + "latest_version": "0.1.1", "releases": [ { - "version": "0.1.0", + "version": "0.1.1", "min_tabularis_version": "0.9.17", "assets": { - "win-x64": "https://github.com/danielnuld/tabularis-informix-plugin/releases/download/v0.1.0/tabularis-informix-plugin-win-x64-v0.1.0.zip" + "win-x64": "https://github.com/danielnuld/tabularis-informix-plugin/releases/download/v0.1.1/tabularis-informix-plugin-win-x64-v0.1.1.zip" } } ] From 927f6eef69ce9f45c680d114c3febf4e8444fc2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20No=C3=A9=20N=C3=BA=C3=B1ez=20L=C3=B3pez?= Date: Tue, 23 Jun 2026 10:16:54 -0700 Subject: [PATCH 14/17] Informix plugin 0.1.2: add linux-x64 asset --- plugins/registry.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/registry.json b/plugins/registry.json index 8c3727d7..b274840d 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -243,13 +243,14 @@ "description": "IBM Informix 11.70+ driver plugin for Tabularis (ODBC; requires the Informix Client SDK)", "author": "Daniel Núñez ", "homepage": "https://github.com/danielnuld/tabularis-informix-plugin", - "latest_version": "0.1.1", + "latest_version": "0.1.2", "releases": [ { - "version": "0.1.1", + "version": "0.1.2", "min_tabularis_version": "0.9.17", "assets": { - "win-x64": "https://github.com/danielnuld/tabularis-informix-plugin/releases/download/v0.1.1/tabularis-informix-plugin-win-x64-v0.1.1.zip" + "linux-x64": "https://github.com/danielnuld/tabularis-informix-plugin/releases/download/v0.1.2/tabularis-informix-plugin-linux-x64-v0.1.2.zip", + "win-x64": "https://github.com/danielnuld/tabularis-informix-plugin/releases/download/v0.1.2/tabularis-informix-plugin-win-x64-v0.1.2.zip" } } ] From ddb27fe144c813791aaf6bde0c266f68bedee76f Mon Sep 17 00:00:00 2001 From: Iskren Hadzhinedev Date: Wed, 24 Jun 2026 09:43:42 +0300 Subject: [PATCH 15/17] feat(hooks): add useLatestAsync guard for cancellable async --- src/hooks/useLatestAsync.ts | 53 +++++++++++++ tests/hooks/useLatestAsync.test.ts | 123 +++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/hooks/useLatestAsync.ts create mode 100644 tests/hooks/useLatestAsync.test.ts diff --git a/src/hooks/useLatestAsync.ts b/src/hooks/useLatestAsync.ts new file mode 100644 index 00000000..3e80dd9b --- /dev/null +++ b/src/hooks/useLatestAsync.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useRef } from "react"; + +/** + * "Latest-wins" guard for fire-and-forget async work in components. + * + * Returns a stable `run(key, fn)` function. Only the most recent call for a + * given `key` is considered active; earlier in-flight calls for that same key + * observe `isLatest() === false` once a newer call starts, and should skip + * their state writes. Calls under different keys are fully independent, so + * e.g. fetching namespaces does not cancel an in-flight path validation. + * + * All keys are invalidated on unmount (the ref map is dropped), preventing + * state writes after the component is gone. + * + * @example + * const run = useLatestAsync(); + * useEffect(() => { + * if (!context) { setNamespaces([]); return; } + * void run("namespaces", async (isLatest) => { + * try { + * const result = await getK8sNamespaces(context); + * if (!isLatest()) return; + * setNamespaces(result); + * } catch (error) { + * if (!isLatest()) return; + * setDiscoveryError(toErrorMessage(error)); + * } + * }); + * }, [context, run]); + */ +export function useLatestAsync() { + const tokensRef = useRef>({}); + + // Invalidate every key on unmount so late resolves skip state writes. + useEffect( + () => () => { + tokensRef.current = {}; + }, + [], + ); + + return useCallback( + ( + key: string, + fn: (isLatest: () => boolean) => Promise, + ): Promise => { + const token = (tokensRef.current[key] ?? 0) + 1; + tokensRef.current[key] = token; + return fn(() => tokensRef.current[key] === token); + }, + [], + ); +} diff --git a/tests/hooks/useLatestAsync.test.ts b/tests/hooks/useLatestAsync.test.ts new file mode 100644 index 00000000..0aecec31 --- /dev/null +++ b/tests/hooks/useLatestAsync.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useLatestAsync } from "../../src/hooks/useLatestAsync"; + +// Helper: a deferred promise we can resolve on demand. +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +describe("useLatestAsync", () => { + it("marks a prior call for the same key as stale when a newer call starts", async () => { + const { result } = renderHook(() => useLatestAsync()); + const run = result.current; + + let firstIsLatest: (() => boolean) | null = null; + let secondIsLatest: (() => boolean) | null = null; + const first = deferred(); + const second = deferred(); + + void run("k", async (isLatest) => { + firstIsLatest = isLatest; + return first.promise; + }); + void run("k", async (isLatest) => { + secondIsLatest = isLatest; + return second.promise; + }); + + expect(firstIsLatest).not.toBeNull(); + expect(secondIsLatest).not.toBeNull(); + // Second call bumped the token: first is now stale, second is current. + expect(firstIsLatest!()).toBe(false); + expect(secondIsLatest!()).toBe(true); + + // Resolving the stale call must not throw; it still returns its value. + await act(async () => { + first.resolve("first"); + await Promise.resolve(); + }); + await act(async () => { + second.resolve("second"); + await Promise.resolve(); + }); + + // Guards remain stable references reflecting the latest token. + expect(firstIsLatest!()).toBe(false); + expect(secondIsLatest!()).toBe(true); + }); + + it("keeps different keys independent", async () => { + const { result } = renderHook(() => useLatestAsync()); + const run = result.current; + + let aIsLatest: (() => boolean) | null = null; + let bIsLatest: (() => boolean) | null = null; + + void run("a", async (isLatest) => { + aIsLatest = isLatest; + await Promise.resolve(); + return "a"; + }); + void run("b", async (isLatest) => { + bIsLatest = isLatest; + await Promise.resolve(); + return "b"; + }); + + // Calling key "b" must not invalidate key "a". + expect(aIsLatest!()).toBe(true); + expect(bIsLatest!()).toBe(true); + + // A second call under "b" invalidates only the first "b", not "a". + let b2IsLatest: (() => boolean) | null = null; + void run("b", async (isLatest) => { + b2IsLatest = isLatest; + await Promise.resolve(); + return "b2"; + }); + + expect(aIsLatest!()).toBe(true); + expect(bIsLatest!()).toBe(false); + expect(b2IsLatest!()).toBe(true); + }); + + it("preserves the natural return value of the callback", async () => { + const { result } = renderHook(() => useLatestAsync()); + const run = result.current; + + let resolved: string | undefined; + await act(async () => { + resolved = await run("once", async () => { + await Promise.resolve(); + return "done"; + }); + }); + + expect(resolved).toBe("done"); + }); + + it("invalidates all keys on unmount", async () => { + const { result, unmount } = renderHook(() => useLatestAsync()); + const run = result.current; + + let guard: (() => boolean) | null = null; + const pending = deferred(); + + void run("ns", async (isLatest) => { + guard = isLatest; + return pending.promise; + }); + + expect(guard!()).toBe(true); + + unmount(); + + // After unmount the guard must report stale so late resolves skip writes. + expect(guard!()).toBe(false); + }); +}); From 13b6039cc47e72ae268290aa8163058451e92012 Mon Sep 17 00:00:00 2001 From: Iskren Hadzhinedev Date: Wed, 24 Jun 2026 09:43:43 +0300 Subject: [PATCH 16/17] fix(k8s): cancel stale async in K8s connections modal --- src/components/modals/K8sConnectionsModal.tsx | 85 +++++++++++-------- .../modals/K8sConnectionsModal.test.tsx | 69 ++++++++++++++- 2 files changed, 119 insertions(+), 35 deletions(-) diff --git a/src/components/modals/K8sConnectionsModal.tsx b/src/components/modals/K8sConnectionsModal.tsx index 8c1b4cd5..08bf1820 100644 --- a/src/components/modals/K8sConnectionsModal.tsx +++ b/src/components/modals/K8sConnectionsModal.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; +import { useLatestAsync } from "../../hooks/useLatestAsync"; import { X, Plus, @@ -98,6 +99,9 @@ export function K8sConnectionsModal({ const [testMessage, setTestMessage] = useState(""); const [validationError, setValidationError] = useState(null); + // Guards fire-and-forget async so only the latest call per key can write state. + const run = useLatestAsync(); + const loadConnections = useCallback(async () => { const result = await loadK8sConnections(); setConnections(result); @@ -112,15 +116,19 @@ export function K8sConnectionsModal({ ); const loadContexts = useCallback(async () => { - try { - const result = await getK8sContexts(getCommandOptions()); - setContexts(result); - setDiscoveryError(null); - } catch (error) { - setContexts([]); - setDiscoveryError(toErrorMessage(error)); - } - }, [getCommandOptions]); + await run("contexts", async (isLatest) => { + try { + const result = await getK8sContexts(getCommandOptions()); + if (!isLatest()) return; + setContexts(result); + setDiscoveryError(null); + } catch (error) { + if (!isLatest()) return; + setContexts([]); + setDiscoveryError(toErrorMessage(error)); + } + }); + }, [getCommandOptions, run]); useEffect(() => { if (!isOpen) return; @@ -132,38 +140,42 @@ export function K8sConnectionsModal({ }, [isOpen, loadConnections, loadContexts]); useEffect(() => { - void (async () => { + void run("namespaces", async (isLatest) => { if (!context) { setNamespaces([]); return; } - try { - setNamespaces(await getK8sNamespaces(context, getCommandOptions())); + const result = await getK8sNamespaces(context, getCommandOptions()); + if (!isLatest()) return; + setNamespaces(result); setDiscoveryError(null); } catch (error) { + if (!isLatest()) return; setNamespaces([]); setDiscoveryError(toErrorMessage(error)); } - })(); - }, [context, getCommandOptions]); + }); + }, [context, getCommandOptions, run]); useEffect(() => { - void (async () => { + void run("resources", async (isLatest) => { if (!context || !namespace || !resourceType) { setResources([]); return; } - try { - setResources(await getK8sResources(context, namespace, resourceType, getCommandOptions())); + const result = await getK8sResources(context, namespace, resourceType, getCommandOptions()); + if (!isLatest()) return; + setResources(result); setDiscoveryError(null); } catch (error) { + if (!isLatest()) return; setResources([]); setDiscoveryError(toErrorMessage(error)); } - })(); - }, [context, namespace, resourceType, getCommandOptions]); + }); + }, [context, namespace, resourceType, getCommandOptions, run]); useEffect(() => { if ( @@ -430,22 +442,27 @@ export function K8sConnectionsModal({ } setPathValidation({ status: "validating", value: trimmedPath, message: null }); - try { - await validateK8sPath(trimmedPath, kind); - setPathValidation({ status: "valid", value: trimmedPath, message: null }); - setValidationError(null); - if (applyOnSuccess && areAdvancedPathsReadyToApply({ kind, value: trimmedPath })) { - applyAdvancedPaths(); + return run(kind, async (isLatest) => { + try { + await validateK8sPath(trimmedPath, kind); + if (isLatest()) { + setPathValidation({ status: "valid", value: trimmedPath, message: null }); + } + if (isLatest() && applyOnSuccess && areAdvancedPathsReadyToApply({ kind, value: trimmedPath })) { + applyAdvancedPaths(); + } + return true; + } catch (error) { + if (isLatest()) { + setPathValidation({ + status: "invalid", + value: trimmedPath, + message: toErrorMessage(error), + }); + } + return false; } - return true; - } catch (error) { - setPathValidation({ - status: "invalid", - value: trimmedPath, - message: toErrorMessage(error), - }); - return false; - } + }); }; const haveAdvancedPathInputsChanged = useCallback(() => { diff --git a/tests/components/modals/K8sConnectionsModal.test.tsx b/tests/components/modals/K8sConnectionsModal.test.tsx index 007dac00..9b10ffa2 100644 --- a/tests/components/modals/K8sConnectionsModal.test.tsx +++ b/tests/components/modals/K8sConnectionsModal.test.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { K8sConnectionsModal } from "../../../src/components/modals/K8sConnectionsModal"; interface MockSelectProps { @@ -180,3 +180,70 @@ describe("K8sConnectionsModal port defaults", () => { }); }); }); + +describe("K8sConnectionsModal async cancellation", () => { + beforeEach(() => { + vi.clearAllMocks(); + k8sMocks.loadK8sConnections.mockResolvedValue([]); + k8sMocks.saveK8sConnection.mockResolvedValue({ id: "new" }); + k8sMocks.getK8sResourcePorts.mockResolvedValue([]); + k8sMocks.validateK8sConnection.mockImplementation( + (input: K8sValidationInput) => + input.port != null && input.port >= 1 && input.port <= 65535 + ? { + isValid: true, + value: { + name: input.name ?? "", + context: input.context ?? "", + namespace: input.namespace ?? "", + resource_type: input.resource_type ?? "service", + resource_name: input.resource_name ?? "", + port: input.port, + }, + } + : { isValid: false, errorKey: "k8sConnections.errors.portInvalid" }, + ); + }); + + it("ignores stale namespace fetches when context changes rapidly", async () => { + k8sMocks.getK8sContexts.mockResolvedValue(["ctx-a", "ctx-b"]); + let resolveFirst: (value: string[]) => void = () => {}; + k8sMocks.getK8sNamespaces.mockImplementation((ctx: string) => { + if (ctx === "ctx-a") { + return new Promise((res) => { + resolveFirst = res; + }); + } + return Promise.resolve(["ns-b-1", "ns-b-2"]); + }); + + renderModal(null); + fireEvent.click(screen.getByText("k8sConnections.add")); + + await waitFor(() => + expect(screen.getByRole("option", { name: "ctx-a" })).toBeInTheDocument(), + ); + + fireEvent.change(screen.getByLabelText("k8sConnections.chooseContext"), { + target: { value: "ctx-a" }, + }); + // Switch immediately to ctx-b so its (fast) fetch supersedes ctx-a's slow one. + fireEvent.change(screen.getByLabelText("k8sConnections.chooseContext"), { + target: { value: "ctx-b" }, + }); + + await waitFor(() => + expect(screen.getByRole("option", { name: "ns-b-1" })).toBeInTheDocument(), + ); + + // Resolve the stale ctx-a fetch with outdated namespaces. + await act(async () => { + resolveFirst(["ns-a-1", "ns-a-2"]); + await Promise.resolve(); + }); + + // Stale namespaces must not leak into the dropdown; ctx-b's list stays. + expect(screen.queryByRole("option", { name: "ns-a-1" })).not.toBeInTheDocument(); + expect(screen.getByRole("option", { name: "ns-b-2" })).toBeInTheDocument(); + }); +}); From 4331d0ae4eb583c8fdf26db9c9e37ea212a595f8 Mon Sep 17 00:00:00 2001 From: Iskren Hadzhinedev Date: Wed, 24 Jun 2026 09:43:45 +0300 Subject: [PATCH 17/17] fix(k8s): cancel stale async and dedupe context fetch in new connection modal --- src/components/modals/NewConnectionModal.tsx | 112 ++++++++++-------- .../modals/NewConnectionModal.test.tsx | 77 +++++++++++- 2 files changed, 136 insertions(+), 53 deletions(-) diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index 3b3d95f9..204de1ae 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -24,6 +24,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import clsx from "clsx"; import { SshConnectionsModal } from "./SshConnectionsModal"; import { K8sConnectionsModal } from "./K8sConnectionsModal"; +import { useLatestAsync } from "../../hooks/useLatestAsync"; import { Select } from "../ui/Select"; import { SlotAnchor } from "../ui/SlotAnchor"; import { useDrivers } from "../../hooks/useDrivers"; @@ -409,6 +410,9 @@ export const NewConnectionModal = ({ ).length > 0; // ── helpers ── + // Guards fire-and-forget async so only the latest call per key can write state. + const run = useLatestAsync(); + const loadSshConnectionsList = async () => { const result = await loadSshConnections(); setSshConnections(result); @@ -419,65 +423,64 @@ export const NewConnectionModal = ({ setK8sConnections(result); }; - const loadK8sContextsList = async () => { - try { - const result = await getK8sContexts(k8sCommandOptions); - setK8sContexts(result); - setK8sDiscoveryError(null); - } catch (error) { - setK8sContexts([]); - setK8sDiscoveryError(toErrorMessage(error)); - } - }; - const loadK8sNamespacesList = useCallback( async (context: string) => { - try { - const result = await getK8sNamespaces(context, k8sCommandOptions); - setK8sNamespaces(result); - setK8sDiscoveryError(null); - } catch (error) { - setK8sNamespaces([]); - setK8sDiscoveryError(toErrorMessage(error)); - } + await run("namespaces", async (isLatest) => { + try { + const result = await getK8sNamespaces(context, k8sCommandOptions); + if (!isLatest()) return; + setK8sNamespaces(result); + setK8sDiscoveryError(null); + } catch (error) { + if (!isLatest()) return; + setK8sNamespaces([]); + setK8sDiscoveryError(toErrorMessage(error)); + } + }); }, - [k8sCommandOptions], + [k8sCommandOptions, run], ); const loadK8sResourcesList = useCallback( async (context: string, namespace: string, resourceType: string) => { - try { - const result = await getK8sResources( - context, - namespace, - resourceType, - k8sCommandOptions, - ); - setK8sResources(result); - setK8sDiscoveryError(null); - } catch (error) { - setK8sResources([]); - setK8sDiscoveryError(toErrorMessage(error)); - } + await run("resources", async (isLatest) => { + try { + const result = await getK8sResources( + context, + namespace, + resourceType, + k8sCommandOptions, + ); + if (!isLatest()) return; + setK8sResources(result); + setK8sDiscoveryError(null); + } catch (error) { + if (!isLatest()) return; + setK8sResources([]); + setK8sDiscoveryError(toErrorMessage(error)); + } + }); }, - [k8sCommandOptions], + [k8sCommandOptions, run], ); // ── K8s cascading dropdown loading ── useEffect(() => { if (!isOpen || !formData.k8s_enabled || k8sMode !== "inline") return; - void (async () => { + void run("contexts", async (isLatest) => { try { const result = await getK8sContexts(k8sCommandOptions); + if (!isLatest()) return; setK8sContexts(result); setK8sDiscoveryError(null); } catch (error) { + if (!isLatest()) return; setK8sContexts([]); setK8sDiscoveryError(toErrorMessage(error)); } - })(); - }, [isOpen, formData.k8s_enabled, k8sMode, k8sCommandOptions]); + }); + }, [isOpen, formData.k8s_enabled, k8sMode, k8sCommandOptions, run]); useEffect(() => { if (formData.k8s_context) { @@ -658,21 +661,27 @@ export const NewConnectionModal = ({ } setPathValidation({ status: "validating", value: trimmedPath, message: null }); - try { - await validateK8sPath(trimmedPath, kind); - setPathValidation({ status: "valid", value: trimmedPath, message: null }); - if (applyOnSuccess && areK8sAdvancedPathsReadyToApply({ kind, value: trimmedPath })) { - applyK8sAdvancedFields(); + return run(kind, async (isLatest) => { + try { + await validateK8sPath(trimmedPath, kind); + if (isLatest()) { + setPathValidation({ status: "valid", value: trimmedPath, message: null }); + } + if (isLatest() && applyOnSuccess && areK8sAdvancedPathsReadyToApply({ kind, value: trimmedPath })) { + applyK8sAdvancedFields(); + } + return true; + } catch (error) { + if (isLatest()) { + setPathValidation({ + status: "invalid", + value: trimmedPath, + message: toErrorMessage(error), + }); + } + return false; } - return true; - } catch (error) { - setPathValidation({ - status: "invalid", - value: trimmedPath, - message: toErrorMessage(error), - }); - return false; - } + }); }; const validateK8sAdvancedPathsForSave = async () => { @@ -886,7 +895,6 @@ export const NewConnectionModal = ({ await loadSshConnectionsList(); await loadK8sConnectionsList(); - await loadK8sContextsList(); }; void init(); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/tests/components/modals/NewConnectionModal.test.tsx b/tests/components/modals/NewConnectionModal.test.tsx index 48cc295b..c938fb36 100644 --- a/tests/components/modals/NewConnectionModal.test.tsx +++ b/tests/components/modals/NewConnectionModal.test.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { invoke } from "@tauri-apps/api/core"; import { NewConnectionModal } from "../../../src/components/modals/NewConnectionModal"; @@ -222,3 +222,78 @@ describe("NewConnectionModal K8s port defaults", () => { }); }); }); + +describe("NewConnectionModal K8s async cancellation", () => { + beforeEach(() => { + vi.clearAllMocks(); + driverState.defaultPort = 15432; + vi.mocked(invoke).mockResolvedValue("ok"); + sshMocks.loadSshConnections.mockResolvedValue([]); + k8sMocks.loadK8sConnections.mockResolvedValue([]); + k8sMocks.getK8sResources.mockResolvedValue([]); + k8sMocks.getK8sResourcePorts.mockResolvedValue([]); + }); + + it("fetches k8s contexts once when entering inline mode (no duplicate fetch)", async () => { + k8sMocks.getK8sContexts.mockResolvedValue(["ctx"]); + k8sMocks.getK8sNamespaces.mockResolvedValue(["db"]); + + renderModal(); + fireEvent.click(screen.getByText("Kubernetes")); + fireEvent.click(screen.getByLabelText("newConnection.useK8s")); + fireEvent.click(screen.getByText("newConnection.createInlineK8s")); + + await waitFor(() => + expect(screen.getByRole("option", { name: "ctx" })).toBeInTheDocument(), + ); + // Flush any duplicate call that the old init-based path would have produced. + await act(async () => { + await Promise.resolve(); + }); + + expect(k8sMocks.getK8sContexts).toHaveBeenCalledTimes(1); + }); + + it("ignores stale namespace fetches when context changes rapidly", async () => { + k8sMocks.getK8sContexts.mockResolvedValue(["ctx-a", "ctx-b"]); + let resolveFirst: (value: string[]) => void = () => {}; + k8sMocks.getK8sNamespaces.mockImplementation((ctx: string) => { + if (ctx === "ctx-a") { + return new Promise((res) => { + resolveFirst = res; + }); + } + return Promise.resolve(["ns-b-1", "ns-b-2"]); + }); + + renderModal(); + fireEvent.click(screen.getByText("Kubernetes")); + fireEvent.click(screen.getByLabelText("newConnection.useK8s")); + fireEvent.click(screen.getByText("newConnection.createInlineK8s")); + + await waitFor(() => + expect(screen.getByRole("option", { name: "ctx-a" })).toBeInTheDocument(), + ); + + fireEvent.change(screen.getByLabelText("newConnection.chooseContext"), { + target: { value: "ctx-a" }, + }); + fireEvent.change(screen.getByLabelText("newConnection.chooseContext"), { + target: { value: "ctx-b" }, + }); + + await waitFor(() => + expect(screen.getByRole("option", { name: "ns-b-1" })).toBeInTheDocument(), + ); + + await act(async () => { + resolveFirst(["ns-a-1", "ns-a-2"]); + await Promise.resolve(); + }); + + expect( + screen.queryByRole("option", { name: "ns-a-1" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("option", { name: "ns-b-2" })).toBeInTheDocument(); + }); +});