diff --git a/locales/en.toml b/locales/en.toml index 6c29a50..77adaa3 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -80,6 +80,11 @@ update_server_title = "Edit Server" add_server_description = "Configure connection details for a new Redis instance." update_tooltip = "Edit connection details" remove_tooltip = "Delete this server configuration" +test_connection = "Test Connection" +test_connection_tooltip = "Test connection to Redis server" +test_connection_success = "Connection successful!" +test_connection_failed = "Connection failed: %{error}" +testing_connection = "Testing..." [editor] delete_key_prompt = "Are you sure you want to delete this key: %{key}?" diff --git a/locales/zh.toml b/locales/zh.toml index 89287b0..dfd49d1 100644 --- a/locales/zh.toml +++ b/locales/zh.toml @@ -79,6 +79,11 @@ update_server_title = "编辑服务器" add_server_description = "配置新 Redis 实例的连接详情。" update_tooltip = "编辑连接详情" remove_tooltip = "删除此服务器配置" +test_connection = "测试连接" +test_connection_tooltip = "测试 Redis 服务器连接" +test_connection_success = "连接成功!" +test_connection_failed = "连接失败: %{error}" +testing_connection = "测试中..." [editor] delete_key_prompt = "您确定要删除此键 (Key): %{key} 吗?" diff --git a/src/connection.rs b/src/connection.rs index da3f870..f166ba3 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -18,4 +18,4 @@ mod manager; pub use async_connection::RedisAsyncConn; pub use config::{QueryMode, RedisServer, get_servers, save_servers}; -pub use manager::{RedisClientDescription, get_connection_manager}; +pub use manager::{RedisClientDescription, get_connection_manager, test_connection}; diff --git a/src/connection/manager.rs b/src/connection/manager.rs index 3c3b141..0d741f6 100644 --- a/src/connection/manager.rs +++ b/src/connection/manager.rs @@ -520,3 +520,19 @@ impl ConnectionManager { pub fn get_connection_manager() -> &'static ConnectionManager { &CONNECTION_MANAGER } + +/// Tests connection to a Redis server using the provided configuration. +/// Returns Ok(()) if connection is successful, or an error message if it fails. +/// Uses a 15-second timeout to prevent UI from hanging on unreachable servers. +pub async fn test_connection(server: &super::RedisServer) -> Result<()> { + let url = server.get_connection_url(); + let client = Client::open(url)?; + + // Use timeout to prevent hanging on unreachable servers + let conn_config = AsyncConnectionConfig::new().set_connection_timeout(Some(Duration::from_secs(15))); + let mut conn = client + .get_multiplexed_async_connection_with_config(&conn_config) + .await?; + let _: () = cmd("PING").query_async(&mut conn).await?; + Ok(()) +} diff --git a/src/views/servers.rs b/src/views/servers.rs index 037ea0e..9188540 100644 --- a/src/views/servers.rs +++ b/src/views/servers.rs @@ -14,19 +14,21 @@ use crate::assets::CustomIconName; use crate::components::Card; -use crate::connection::RedisServer; +use crate::connection::{test_connection, RedisServer}; use crate::helpers::{validate_common_string, validate_host, validate_long_string}; -use crate::states::{Route, ZedisGlobalStore, ZedisServerState, i18n_common, i18n_servers}; -use gpui::{App, Entity, Window, div, prelude::*, px}; +use crate::states::{i18n_common, i18n_servers, Route, ZedisGlobalStore, ZedisServerState}; +use gpui::{div, prelude::*, px, App, Entity, Window}; use gpui_component::{ - ActiveTheme, Colorize, Icon, IconName, WindowExt, button::{Button, ButtonVariants}, form::{field, v_form}, + h_flex, input::{Input, InputState, NumberInput}, label::Label, + notification::Notification, + ActiveTheme, Colorize, Icon, IconName, Sizable, WindowExt, }; use rust_i18n::t; -use std::{cell::Cell, rc::Rc}; +use std::{cell::{Cell, RefCell}, rc::Rc}; use substring::Substring; use tracing::info; @@ -38,6 +40,85 @@ const UPDATED_AT_SUBSTRING_LENGTH: usize = 10; // Length of date string to displ const THEME_LIGHTEN_AMOUNT_DARK: f32 = 1.0; const THEME_DARKEN_AMOUNT_LIGHT: f32 = 0.02; +/// Test connection state +#[derive(Clone, Copy, PartialEq, Default)] +enum TestConnectionState { + #[default] + Idle, + Testing, + Success, + Failed, +} + +/// Test connection result for status icon display +#[derive(Clone, Default)] +struct TestConnectionResult { + state: TestConnectionState, + error_message: Option, + notification_pending: bool, +} + +/// Build a RedisServer from input states for testing connection +fn build_server_from_inputs( + name: &str, + host: &str, + port: u16, + username: Option<&str>, + password: Option<&str>, + master_name: Option<&str>, +) -> RedisServer { + RedisServer { + id: String::new(), + name: name.to_string(), + host: host.to_string(), + port, + username: username.map(|s| s.to_string()), + password: password.map(|s| s.to_string()), + master_name: master_name.map(|s| s.to_string()), + description: None, + updated_at: None, + query_mode: None, + soft_wrap: None, + } +} + +/// Execute test connection and update result state +fn execute_test_connection( + server: RedisServer, + test_result: Rc>, + cx: &mut App, +) { + { + let mut result = test_result.borrow_mut(); + result.state = TestConnectionState::Testing; + result.notification_pending = false; + } + + cx.spawn(async move |cx| { + let result = cx + .background_spawn(async move { test_connection(&server).await }) + .await; + + cx.update(|cx| { + let mut test_result_ref = test_result.borrow_mut(); + match result { + Ok(_) => { + test_result_ref.state = TestConnectionState::Success; + test_result_ref.error_message = None; + } + Err(e) => { + test_result_ref.state = TestConnectionState::Failed; + test_result_ref.error_message = Some(e.to_string()); + } + } + test_result_ref.notification_pending = true; + cx.refresh_windows(); + }) + .ok(); + }) + .detach(); +} + /// Server management view component /// /// Displays a grid of server cards with: @@ -62,6 +143,9 @@ pub struct ZedisServers { /// Flag indicating if we're adding a new server (vs editing existing) server_id: String, + + /// Test connection result for UI feedback + test_result: Rc>, } impl ZedisServers { @@ -114,6 +198,7 @@ impl ZedisServers { master_name_state, description_state, server_id: String::new(), + test_result: Rc::new(RefCell::new(TestConnectionResult::default())), } } /// Fill input fields with server data for editing @@ -260,6 +345,10 @@ impl ZedisServers { true }); + // Reset test connection state when opening dialog + self.test_result.borrow_mut().state = TestConnectionState::Idle; + let test_result = self.test_result.clone(); + let focus_handle_done = Cell::new(false); window.open_dialog(cx, move |dialog, window, cx| { // Set dialog title based on add/update mode @@ -313,22 +402,123 @@ impl ZedisServers { }) .footer({ let handle = handle_submit.clone(); - move |_, _, _, cx| { + let name_state = name_state.clone(); + let host_state = host_state.clone(); + let port_state = port_state.clone(); + let username_state = username_state.clone(); + let password_state = password_state.clone(); + let master_name_state = master_name_state.clone(); + + // Use component-level test connection result + let test_result = test_result.clone(); + + move |_, _, window, cx| { let submit_label = i18n_common(cx, "submit"); let cancel_label = i18n_common(cx, "cancel"); + let test_label = i18n_servers(cx, "test_connection"); + let locale = cx.global::().read(cx).locale().to_string(); + + // Check for pending notification and push it + { + let mut test_result_ref = test_result.borrow_mut(); + if test_result_ref.notification_pending { + test_result_ref.notification_pending = false; + let notification = match test_result_ref.state { + TestConnectionState::Success => { + Notification::success(i18n_servers(cx, "test_connection_success")) + } + TestConnectionState::Failed => { + let error_msg = test_result_ref.error_message.clone().unwrap_or_default(); + let msg = t!("servers.test_connection_failed", error = error_msg, locale = locale).to_string(); + Notification::error(msg) + } + _ => return vec![], + }; + window.push_notification(notification, cx); + } + } + + let test_name_state = name_state.clone(); + let test_host_state = host_state.clone(); + let test_port_state = port_state.clone(); + let test_username_state = username_state.clone(); + let test_password_state = password_state.clone(); + let test_master_name_state = master_name_state.clone(); + + let current_state = test_result.borrow().state; + let is_testing = current_state == TestConnectionState::Testing; + let show_status_icon = current_state == TestConnectionState::Success + || current_state == TestConnectionState::Failed; + let is_success = current_state == TestConnectionState::Success; + + let test_button = Button::new("test") + .label(test_label) + .loading(is_testing) + .on_click({ + let test_result = test_result.clone(); + move |_, _window, cx| { + let host = test_host_state.read(cx).value(); + if host.is_empty() { + return; + } + let port = test_port_state + .read(cx) + .value() + .parse::() + .unwrap_or(DEFAULT_REDIS_PORT); + let password_val = test_password_state.read(cx).value(); + let username_val = test_username_state.read(cx).value(); + let master_name_val = test_master_name_state.read(cx).value(); + + let server = build_server_from_inputs( + &test_name_state.read(cx).value(), + &host, + port, + if username_val.is_empty() { None } else { Some(&username_val) }, + if password_val.is_empty() { None } else { Some(&password_val) }, + if master_name_val.is_empty() { None } else { Some(&master_name_val) }, + ); + + execute_test_connection(server, test_result.clone(), cx); + } + }); + + let left_side = h_flex() + .gap_2() + .items_center() + .child(test_button) + .when(show_status_icon, |this| { + let status_icon = if is_success { + Icon::new(CustomIconName::CircleCheckBig) + .small() + .text_color(cx.theme().success) + } else { + Icon::new(CustomIconName::X) + .small() + .text_color(cx.theme().danger) + }; + this.child(status_icon) + }); vec![ - // Submit button - validates and saves server configuration - Button::new("ok").primary().label(submit_label).on_click({ - let handle = handle.clone(); - move |_, window, cx| { - handle.clone()(window, cx); - } - }), - // Cancel button - closes dialog without saving - Button::new("cancel").label(cancel_label).on_click(|_, window, cx| { - window.close_dialog(cx); - }), + left_side.into_any_element(), + div().flex_grow().into_any_element(), + Button::new("ok") + .primary() + .label(submit_label) + .on_click({ + let handle = handle.clone(); + move |_, window, cx| { + handle.clone()(window, cx); + } + }) + .into_any_element(), + Button::new("cancel") + .label(cancel_label) + .on_click(|_, window, cx| { + window.close_dialog(cx); + }) + .into_any_element(), ] } })