Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions locales/en.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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}?"
Expand Down
5 changes: 5 additions & 0 deletions locales/zh.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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} 吗?"
Expand Down
2 changes: 1 addition & 1 deletion src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
16 changes: 16 additions & 0 deletions src/connection/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
224 changes: 207 additions & 17 deletions src/views/servers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<String>,
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<RefCell<TestConnectionResult>>,
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:
Expand All @@ -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<RefCell<TestConnectionResult>>,
}

impl ZedisServers {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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::<ZedisGlobalStore>().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::<u16>()
.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(),
]
}
})
Expand Down