From e76a96deb883f6b62dce9e8e7e358458dc904a91 Mon Sep 17 00:00:00 2001 From: "A.Shakhmatov" Date: Mon, 16 Feb 2026 09:04:12 +0300 Subject: [PATCH] feat: add unified Settings sheet, MCP indicator, and Docker host config - Add AppSettingsSheet (gear icon in Toolbar) with MCP, Docker, and AI sections - MCP Server: toggle on/off, port config, status badge, endpoint URL with copy - Docker: local/remote daemon selector with remote URL input - AI: moved Ollama settings into the unified sheet - MCP status probes actual TCP port for reliable running detection - Docker commands respect configurable docker host (-H flag) for remote daemons - MCP server supports graceful shutdown via tokio watch channel - Settings persisted to app_settings.json alongside existing config files - StatusBar shows MCP indicator (green/gray dot) with tooltip Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/docker.rs | 147 ++++++--- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/settings.rs | 116 +++++++ src-tauri/src/lib.rs | 53 +++- src-tauri/src/mcp/mod.rs | 11 +- src-tauri/src/models/mod.rs | 1 + src-tauri/src/models/settings.rs | 60 ++++ src-tauri/src/state.rs | 47 ++- src/components/layout/StatusBar.tsx | 22 ++ src/components/layout/Toolbar.tsx | 19 +- src/components/settings/AppSettingsSheet.tsx | 299 +++++++++++++++++++ src/hooks/use-settings.ts | 30 ++ src/lib/tauri.ts | 12 + src/types/index.ts | 24 ++ 14 files changed, 800 insertions(+), 42 deletions(-) create mode 100644 src-tauri/src/commands/settings.rs create mode 100644 src-tauri/src/models/settings.rs create mode 100644 src/components/settings/AppSettingsSheet.tsx create mode 100644 src/hooks/use-settings.ts diff --git a/src-tauri/src/commands/docker.rs b/src-tauri/src/commands/docker.rs index dabec3f..73010ac 100644 --- a/src-tauri/src/commands/docker.rs +++ b/src-tauri/src/commands/docker.rs @@ -9,6 +9,15 @@ use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager, State}; use tokio::process::Command; +async fn docker_cmd(state: &AppState) -> Command { + let host = state.docker_host.read().await; + let mut cmd = Command::new("docker"); + if let Some(ref h) = *host { + cmd.args(["-H", h]); + } + cmd +} + fn docker_err(msg: impl Into) -> TuskError { TuskError::Docker(msg.into()) } @@ -61,8 +70,9 @@ fn shell_escape(s: &str) -> String { } #[tauri::command] -pub async fn check_docker() -> TuskResult { - let output = Command::new("docker") +pub async fn check_docker(state: State<'_, Arc>) -> TuskResult { + let output = docker_cmd(&state) + .await .args(["version", "--format", "{{.Server.Version}}"]) .output() .await; @@ -99,8 +109,9 @@ pub async fn check_docker() -> TuskResult { } #[tauri::command] -pub async fn list_tusk_containers() -> TuskResult> { - let output = Command::new("docker") +pub async fn list_tusk_containers(state: State<'_, Arc>) -> TuskResult> { + let output = docker_cmd(&state) + .await .args([ "ps", "-a", @@ -189,15 +200,63 @@ pub async fn clone_to_docker( .map_err(|e| docker_err(format!("Clone task panicked: {}", e)))? } +/// Build a docker Command respecting the remote host setting +fn docker_cmd_sync(docker_host: &Option) -> Command { + let mut cmd = Command::new("docker"); + if let Some(ref h) = docker_host { + cmd.args(["-H", h]); + } + cmd +} + +async fn check_docker_internal(docker_host: &Option) -> TuskResult { + let output = docker_cmd_sync(docker_host) + .args(["version", "--format", "{{.Server.Version}}"]) + .output() + .await; + + match output { + Ok(out) => { + if out.status.success() { + let version = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Ok(DockerStatus { + installed: true, + daemon_running: true, + version: Some(version), + error: None, + }) + } else { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let daemon_running = !stderr.contains("Cannot connect") + && !stderr.contains("connection refused"); + Ok(DockerStatus { + installed: true, + daemon_running, + version: None, + error: Some(stderr), + }) + } + } + Err(_) => Ok(DockerStatus { + installed: false, + daemon_running: false, + version: None, + error: Some("Docker CLI not found. Please install Docker.".to_string()), + }), + } +} + async fn do_clone( app: &AppHandle, - _state: &Arc, + state: &Arc, params: &CloneToDockerParams, clone_id: &str, ) -> TuskResult { + let docker_host = state.docker_host.read().await.clone(); + // Step 1: Check Docker emit_progress(app, clone_id, "checking", 5, "Checking Docker availability...", None); - let status = check_docker().await?; + let status = check_docker_internal(&docker_host).await?; if !status.installed || !status.daemon_running { let msg = status .error @@ -219,7 +278,7 @@ async fn do_clone( let pg_password = params.postgres_password.as_deref().unwrap_or("tusk"); let image = format!("postgres:{}", params.pg_version); - let create_output = Command::new("docker") + let create_output = docker_cmd_sync(&docker_host) .args([ "run", "-d", "--name", ¶ms.container_name, @@ -245,12 +304,12 @@ async fn do_clone( // Step 4: Wait for PostgreSQL to be ready emit_progress(app, clone_id, "waiting", 30, "Waiting for PostgreSQL to be ready...", None); - wait_for_pg_ready(¶ms.container_name, 30).await?; + wait_for_pg_ready(&docker_host, ¶ms.container_name, 30).await?; emit_progress(app, clone_id, "waiting", 35, "PostgreSQL is ready", None); // Step 5: Create target database emit_progress(app, clone_id, "database", 35, &format!("Creating database '{}'...", params.source_database), None); - let create_db_output = Command::new("docker") + let create_db_output = docker_cmd_sync(&docker_host) .args([ "exec", ¶ms.container_name, "psql", "-U", "postgres", "-c", @@ -282,18 +341,18 @@ async fn do_clone( match params.clone_mode { CloneMode::SchemaOnly => { emit_progress(app, clone_id, "transfer", 45, "Dumping schema...", None); - transfer_schema_only(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version).await?; + transfer_schema_only(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version, &docker_host).await?; } CloneMode::FullClone => { emit_progress(app, clone_id, "transfer", 45, "Performing full database clone...", None); - transfer_full_clone(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version).await?; + transfer_full_clone(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version, &docker_host).await?; } CloneMode::SampleData => { emit_progress(app, clone_id, "transfer", 45, "Dumping schema...", None); - transfer_schema_only(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version).await?; + transfer_schema_only(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version, &docker_host).await?; emit_progress(app, clone_id, "transfer", 65, "Copying sample data...", None); let sample_rows = params.sample_rows.unwrap_or(1000); - transfer_sample_data(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version, sample_rows).await?; + transfer_sample_data(app, clone_id, &source_url, ¶ms.container_name, ¶ms.source_database, ¶ms.pg_version, sample_rows, &docker_host).await?; } } @@ -354,7 +413,7 @@ async fn find_free_port() -> TuskResult { Ok(port) } -async fn wait_for_pg_ready(container_name: &str, timeout_secs: u64) -> TuskResult<()> { +async fn wait_for_pg_ready(docker_host: &Option, container_name: &str, timeout_secs: u64) -> TuskResult<()> { let start = std::time::Instant::now(); let timeout = std::time::Duration::from_secs(timeout_secs); @@ -363,7 +422,7 @@ async fn wait_for_pg_ready(container_name: &str, timeout_secs: u64) -> TuskResul return Err(docker_err("PostgreSQL did not become ready in time")); } - let output = Command::new("docker") + let output = docker_cmd_sync(docker_host) .args(["exec", container_name, "pg_isready", "-U", "postgres"]) .output() .await; @@ -387,15 +446,24 @@ async fn try_local_pg_dump() -> bool { .unwrap_or(false) } +/// Build the docker host flag string for shell commands +fn docker_host_flag(docker_host: &Option) -> String { + match docker_host { + Some(h) => format!("-H '{}'", shell_escape(h)), + None => String::new(), + } +} + /// Build the pg_dump portion of a shell command -fn pg_dump_shell_cmd(has_local: bool, pg_version: &str, extra_args: &str, source_url: &str) -> String { +fn pg_dump_shell_cmd(has_local: bool, pg_version: &str, extra_args: &str, source_url: &str, docker_host: &Option) -> String { let escaped_url = shell_escape(source_url); if has_local { format!("pg_dump {} '{}'", extra_args, escaped_url) } else { + let host_flag = docker_host_flag(docker_host); format!( - "docker run --rm --network=host postgres:{} pg_dump {} '{}'", - pg_version, extra_args, escaped_url + "docker {} run --rm --network=host postgres:{} pg_dump {} '{}'", + host_flag, pg_version, extra_args, escaped_url ) } } @@ -468,16 +536,18 @@ async fn transfer_schema_only( container_name: &str, database: &str, pg_version: &str, + docker_host: &Option, ) -> TuskResult<()> { let has_local = try_local_pg_dump().await; let label = if has_local { "local pg_dump" } else { "Docker-based pg_dump" }; emit_progress(app, clone_id, "transfer", 48, &format!("Using {} for schema...", label), None); - let dump_cmd = pg_dump_shell_cmd(has_local, pg_version, "--schema-only --no-owner --no-acl", source_url); + let dump_cmd = pg_dump_shell_cmd(has_local, pg_version, "--schema-only --no-owner --no-acl", source_url, docker_host); let escaped_db = shell_escape(database); + let host_flag = docker_host_flag(docker_host); let pipe_cmd = format!( - "{} | docker exec -i '{}' psql -U postgres -d '{}'", - dump_cmd, shell_escape(container_name), escaped_db + "{} | docker {} exec -i '{}' psql -U postgres -d '{}'", + dump_cmd, host_flag, shell_escape(container_name), escaped_db ); run_pipe_cmd(app, clone_id, &pipe_cmd, "Schema transfer").await?; @@ -493,17 +563,19 @@ async fn transfer_full_clone( container_name: &str, database: &str, pg_version: &str, + docker_host: &Option, ) -> TuskResult<()> { let has_local = try_local_pg_dump().await; let label = if has_local { "local pg_dump" } else { "Docker-based pg_dump" }; emit_progress(app, clone_id, "transfer", 48, &format!("Using {} for full clone...", label), None); // Use plain text format piped to psql (more reliable than -Fc | pg_restore through docker exec) - let dump_cmd = pg_dump_shell_cmd(has_local, pg_version, "--no-owner --no-acl", source_url); + let dump_cmd = pg_dump_shell_cmd(has_local, pg_version, "--no-owner --no-acl", source_url, docker_host); let escaped_db = shell_escape(database); + let host_flag = docker_host_flag(docker_host); let pipe_cmd = format!( - "{} | docker exec -i '{}' psql -U postgres -d '{}'", - dump_cmd, shell_escape(container_name), escaped_db + "{} | docker {} exec -i '{}' psql -U postgres -d '{}'", + dump_cmd, host_flag, shell_escape(container_name), escaped_db ); run_pipe_cmd(app, clone_id, &pipe_cmd, "Full clone").await?; @@ -520,9 +592,10 @@ async fn transfer_sample_data( database: &str, pg_version: &str, sample_rows: u32, + docker_host: &Option, ) -> TuskResult<()> { // List tables from the target (schema already transferred) - let target_output = Command::new("docker") + let target_output = docker_cmd_sync(docker_host) .args([ "exec", container_name, "psql", "-U", "postgres", "-d", database, @@ -573,19 +646,20 @@ async fn transfer_sample_data( let escaped_container = shell_escape(container_name); let escaped_db = shell_escape(database); + let host_flag = docker_host_flag(docker_host); let source_cmd = if has_local { format!("psql '{}' -c \"{}\"", escaped_url, copy_out_sql) } else { let image = format!("postgres:{}", pg_version); format!( - "docker run --rm --network=host {} psql '{}' -c \"{}\"", - image, escaped_url, copy_out_sql + "docker {} run --rm --network=host {} psql '{}' -c \"{}\"", + host_flag, image, escaped_url, copy_out_sql ) }; let pipe_cmd = format!( - "set -o pipefail; {} | docker exec -i '{}' psql -U postgres -d '{}' -c \"{}\"", - source_cmd, escaped_container, escaped_db, copy_in_sql + "set -o pipefail; {} | docker {} exec -i '{}' psql -U postgres -d '{}' -c \"{}\"", + source_cmd, host_flag, escaped_container, escaped_db, copy_in_sql ); let output = Command::new("bash") @@ -635,8 +709,9 @@ fn save_connection_config(app: &AppHandle, config: &ConnectionConfig) -> TuskRes } #[tauri::command] -pub async fn start_container(name: String) -> TuskResult<()> { - let output = Command::new("docker") +pub async fn start_container(state: State<'_, Arc>, name: String) -> TuskResult<()> { + let output = docker_cmd(&state) + .await .args(["start", &name]) .output() .await @@ -651,8 +726,9 @@ pub async fn start_container(name: String) -> TuskResult<()> { } #[tauri::command] -pub async fn stop_container(name: String) -> TuskResult<()> { - let output = Command::new("docker") +pub async fn stop_container(state: State<'_, Arc>, name: String) -> TuskResult<()> { + let output = docker_cmd(&state) + .await .args(["stop", &name]) .output() .await @@ -667,8 +743,9 @@ pub async fn stop_container(name: String) -> TuskResult<()> { } #[tauri::command] -pub async fn remove_container(name: String) -> TuskResult<()> { - let output = Command::new("docker") +pub async fn remove_container(state: State<'_, Arc>, name: String) -> TuskResult<()> { + let output = docker_cmd(&state) + .await .args(["rm", "-f", &name]) .output() .await diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0e2df4e..7cf60e4 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,3 +9,4 @@ pub mod management; pub mod queries; pub mod saved_queries; pub mod schema; +pub mod settings; diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs new file mode 100644 index 0000000..94197a1 --- /dev/null +++ b/src-tauri/src/commands/settings.rs @@ -0,0 +1,116 @@ +use crate::error::{TuskError, TuskResult}; +use crate::mcp; +use crate::models::settings::{AppSettings, DockerHost, McpStatus}; +use crate::state::AppState; +use std::fs; +use std::sync::Arc; +use tauri::{AppHandle, Manager, State}; + +fn get_settings_path(app: &AppHandle) -> TuskResult { + let dir = app + .path() + .app_data_dir() + .map_err(|e| TuskError::Custom(e.to_string()))?; + fs::create_dir_all(&dir)?; + Ok(dir.join("app_settings.json")) +} + +#[tauri::command] +pub async fn get_app_settings(app: AppHandle) -> TuskResult { + let path = get_settings_path(&app)?; + if !path.exists() { + return Ok(AppSettings::default()); + } + let data = fs::read_to_string(&path)?; + let settings: AppSettings = serde_json::from_str(&data)?; + Ok(settings) +} + +#[tauri::command] +pub async fn save_app_settings( + app: AppHandle, + state: State<'_, Arc>, + settings: AppSettings, +) -> TuskResult<()> { + let path = get_settings_path(&app)?; + let data = serde_json::to_string_pretty(&settings)?; + fs::write(&path, data)?; + + // Apply docker host setting + { + let mut docker_host = state.docker_host.write().await; + *docker_host = match settings.docker.host { + DockerHost::Remote => settings.docker.remote_url.clone(), + DockerHost::Local => None, + }; + } + + // Apply MCP setting: restart or stop + let is_running = *state.mcp_running.read().await; + + if settings.mcp.enabled { + if is_running { + // Stop existing MCP server first + let _ = state.mcp_shutdown_tx.send(true); + // Give it a moment to shut down + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + *state.mcp_running.write().await = false; + } + + // Start new MCP server + let connections_path = app + .path() + .app_data_dir() + .map_err(|e| TuskError::Custom(e.to_string()))? + .join("connections.json"); + + let mcp_state = state.inner().clone(); + let port = settings.mcp.port; + let shutdown_rx = state.mcp_shutdown_tx.subscribe(); + + tokio::spawn(async move { + *mcp_state.mcp_running.write().await = true; + if let Err(e) = + mcp::start_mcp_server(mcp_state.clone(), connections_path, port, shutdown_rx).await + { + log::error!("MCP server error: {}", e); + } + *mcp_state.mcp_running.write().await = false; + }); + } else if is_running { + // Stop MCP server + let _ = state.mcp_shutdown_tx.send(true); + *state.mcp_running.write().await = false; + } + + Ok(()) +} + +#[tauri::command] +pub async fn get_mcp_status(app: AppHandle) -> TuskResult { + // Read settings from file for enabled/port + let settings = { + let path = get_settings_path(&app)?; + if path.exists() { + let data = fs::read_to_string(&path)?; + serde_json::from_str::(&data).unwrap_or_default() + } else { + AppSettings::default() + } + }; + + // Probe the actual port to determine if MCP is running + let running = tokio::time::timeout( + std::time::Duration::from_millis(500), + tokio::net::TcpStream::connect(format!("127.0.0.1:{}", settings.mcp.port)), + ) + .await + .map(|r| r.is_ok()) + .unwrap_or(false); + + Ok(McpStatus { + enabled: settings.mcp.enabled, + port: settings.mcp.port, + running, + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a64f27c..b2ed6a6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod models; mod state; mod utils; +use models::settings::{AppSettings, DockerHost}; use state::AppState; use std::sync::Arc; use tauri::Manager; @@ -24,12 +25,52 @@ pub fn run() { .expect("failed to resolve app data dir") .join("connections.json"); - tauri::async_runtime::spawn(async move { - if let Err(e) = mcp::start_mcp_server(state, connections_path, 9427).await { - log::error!("MCP server error: {}", e); - } + // Read app settings + let settings_path = app + .path() + .app_data_dir() + .expect("failed to resolve app data dir") + .join("app_settings.json"); + + let settings = if settings_path.exists() { + std::fs::read_to_string(&settings_path) + .ok() + .and_then(|data| serde_json::from_str::(&data).ok()) + .unwrap_or_default() + } else { + AppSettings::default() + }; + + // Apply docker host from settings + let docker_host = match settings.docker.host { + DockerHost::Remote => settings.docker.remote_url.clone(), + DockerHost::Local => None, + }; + + let mcp_enabled = settings.mcp.enabled; + let mcp_port = settings.mcp.port; + + // Set docker host synchronously (state is fresh, no contention) + let state_for_setup = state.clone(); + tauri::async_runtime::block_on(async { + *state_for_setup.docker_host.write().await = docker_host; }); + if mcp_enabled { + let shutdown_rx = state.mcp_shutdown_tx.subscribe(); + let mcp_state = state.clone(); + tauri::async_runtime::spawn(async move { + *mcp_state.mcp_running.write().await = true; + if let Err(e) = + mcp::start_mcp_server(mcp_state.clone(), connections_path, mcp_port, shutdown_rx) + .await + { + log::error!("MCP server error: {}", e); + } + *mcp_state.mcp_running.write().await = false; + }); + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -107,6 +148,10 @@ pub fn run() { commands::docker::start_container, commands::docker::stop_container, commands::docker::remove_container, + // settings + commands::settings::get_app_settings, + commands::settings::save_app_settings, + commands::settings::get_mcp_status, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index aaae5bb..d1d8301 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -13,6 +13,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::Arc; +use tokio::sync::watch; // --- Tool parameter types --- @@ -217,6 +218,7 @@ pub async fn start_mcp_server( state: Arc, connections_path: PathBuf, port: u16, + mut shutdown_rx: watch::Receiver, ) -> Result<(), Box> { let service = StreamableHttpService::new( move || Ok(TuskMcpServer::new(state.clone(), connections_path.clone())), @@ -230,7 +232,14 @@ pub async fn start_mcp_server( log::info!("MCP server listening on http://{}/mcp", addr); - axum::serve(listener, router).await?; + tokio::select! { + res = axum::serve(listener, router) => { + res?; + } + _ = shutdown_rx.changed() => { + log::info!("MCP server stopped by shutdown signal"); + } + } Ok(()) } diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index f114683..e0815a1 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -7,3 +7,4 @@ pub mod management; pub mod query_result; pub mod saved_queries; pub mod schema; +pub mod settings; diff --git a/src-tauri/src/models/settings.rs b/src-tauri/src/models/settings.rs new file mode 100644 index 0000000..52e3a7c --- /dev/null +++ b/src-tauri/src/models/settings.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppSettings { + pub mcp: McpSettings, + pub docker: DockerSettings, +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + mcp: McpSettings::default(), + docker: DockerSettings::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpSettings { + pub enabled: bool, + pub port: u16, +} + +impl Default for McpSettings { + fn default() -> Self { + Self { + enabled: true, + port: 9427, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DockerSettings { + pub host: DockerHost, + pub remote_url: Option, +} + +impl Default for DockerSettings { + fn default() -> Self { + Self { + host: DockerHost::Local, + remote_url: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DockerHost { + Local, + Remote, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpStatus { + pub enabled: bool, + pub port: u16, + pub running: bool, +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 94bfb15..afd1f00 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::collections::HashMap; use std::path::PathBuf; -use tokio::sync::RwLock; +use std::time::{Duration, Instant}; +use tokio::sync::{watch, RwLock}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -11,20 +12,37 @@ pub enum DbFlavor { Greenplum, } +#[derive(Clone)] +pub struct SchemaCacheEntry { + pub schema_text: String, + pub cached_at: Instant, +} + pub struct AppState { pub pools: RwLock>, pub config_path: RwLock>, pub read_only: RwLock>, pub db_flavors: RwLock>, + pub schema_cache: RwLock>, + pub mcp_shutdown_tx: watch::Sender, + pub mcp_running: RwLock, + pub docker_host: RwLock>, } +const SCHEMA_CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes + impl AppState { pub fn new() -> Self { + let (mcp_shutdown_tx, _) = watch::channel(false); Self { pools: RwLock::new(HashMap::new()), config_path: RwLock::new(None), read_only: RwLock::new(HashMap::new()), db_flavors: RwLock::new(HashMap::new()), + schema_cache: RwLock::new(HashMap::new()), + mcp_shutdown_tx, + mcp_running: RwLock::new(false), + docker_host: RwLock::new(None), } } @@ -37,4 +55,31 @@ impl AppState { let map = self.db_flavors.read().await; map.get(id).copied().unwrap_or(DbFlavor::PostgreSQL) } + + pub async fn get_schema_cache(&self, connection_id: &str) -> Option { + let cache = self.schema_cache.read().await; + cache.get(connection_id).and_then(|entry| { + if entry.cached_at.elapsed() < SCHEMA_CACHE_TTL { + Some(entry.schema_text.clone()) + } else { + None + } + }) + } + + pub async fn set_schema_cache(&self, connection_id: String, schema_text: String) { + let mut cache = self.schema_cache.write().await; + cache.insert( + connection_id, + SchemaCacheEntry { + schema_text, + cached_at: Instant::now(), + }, + ); + } + + pub async fn invalidate_schema_cache(&self, connection_id: &str) { + let mut cache = self.schema_cache.write().await; + cache.remove(connection_id); + } } diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index a8c84ec..3cfd803 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -1,7 +1,9 @@ import { useAppStore } from "@/stores/app-store"; import { useConnections } from "@/hooks/use-connections"; +import { useMcpStatus } from "@/hooks/use-settings"; import { Circle } from "lucide-react"; import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; function formatDbVersion(version: string): string { const gpMatch = version.match(/Greenplum Database ([\d.]+)/i); @@ -21,6 +23,7 @@ interface Props { export function StatusBar({ rowCount, executionTime }: Props) { const { activeConnectionId, connectedIds, readOnlyMap, pgVersion } = useAppStore(); const { data: connections } = useConnections(); + const { data: mcpStatus } = useMcpStatus(); const activeConn = connections?.find((c) => c.id === activeConnectionId); const isConnected = activeConnectionId @@ -62,6 +65,25 @@ export function StatusBar({ rowCount, executionTime }: Props) {
{rowCount != null && {rowCount.toLocaleString()} rows} {executionTime != null && {executionTime} ms} + + + + + MCP + + + +

+ MCP Server {mcpStatus?.running ? `running on :${mcpStatus.port}` : "stopped"} +

+
+
); diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index d67f1f0..8d192bb 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -8,13 +8,15 @@ import { ReadOnlyToggle } from "@/components/layout/ReadOnlyToggle"; import { useAppStore } from "@/stores/app-store"; import { useConnections, useReconnect } from "@/hooks/use-connections"; import { toast } from "sonner"; -import { Database, Plus, RefreshCw, Search } from "lucide-react"; +import { Database, Plus, RefreshCw, Search, Settings } from "lucide-react"; import type { ConnectionConfig, Tab } from "@/types"; import { getEnvironment } from "@/lib/environment"; +import { AppSettingsSheet } from "@/components/settings/AppSettingsSheet"; export function Toolbar() { const [listOpen, setListOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); const [editingConn, setEditingConn] = useState(null); const { activeConnectionId, currentDatabase, addTab } = useAppStore(); const { data: connections } = useConnections(); @@ -116,6 +118,16 @@ export function Toolbar() {
+ +
+ + ); } diff --git a/src/components/settings/AppSettingsSheet.tsx b/src/components/settings/AppSettingsSheet.tsx new file mode 100644 index 0000000..b7c5dba --- /dev/null +++ b/src/components/settings/AppSettingsSheet.tsx @@ -0,0 +1,299 @@ +import { useState, useEffect } from "react"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { useAppSettings, useSaveAppSettings, useMcpStatus } from "@/hooks/use-settings"; +import { useAiSettings, useSaveAiSettings, useOllamaModels } from "@/hooks/use-ai"; +import { RefreshCw, Loader2, Copy, Check } from "lucide-react"; +import { toast } from "sonner"; +import type { AppSettings, DockerHost } from "@/types"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AppSettingsSheet({ open, onOpenChange }: Props) { + const { data: appSettings } = useAppSettings(); + const { data: mcpStatus } = useMcpStatus(); + const saveAppMutation = useSaveAppSettings(); + + const { data: aiSettings } = useAiSettings(); + const saveAiMutation = useSaveAiSettings(); + + // MCP state + const [mcpEnabled, setMcpEnabled] = useState(true); + const [mcpPort, setMcpPort] = useState(9427); + + // Docker state + const [dockerHost, setDockerHost] = useState("local"); + const [dockerRemoteUrl, setDockerRemoteUrl] = useState(""); + + // AI state + const [ollamaUrl, setOllamaUrl] = useState("http://localhost:11434"); + const [aiModel, setAiModel] = useState(""); + + const [copied, setCopied] = useState(false); + + // Sync form with loaded settings + useEffect(() => { + if (appSettings) { + setMcpEnabled(appSettings.mcp.enabled); + setMcpPort(appSettings.mcp.port); + setDockerHost(appSettings.docker.host); + setDockerRemoteUrl(appSettings.docker.remote_url ?? ""); + } + }, [appSettings]); + + useEffect(() => { + if (aiSettings) { + setOllamaUrl(aiSettings.ollama_url); + setAiModel(aiSettings.model); + } + }, [aiSettings]); + + const { + data: models, + isLoading: modelsLoading, + isError: modelsError, + refetch: refetchModels, + } = useOllamaModels(ollamaUrl); + + const mcpEndpoint = `http://127.0.0.1:${mcpPort}/mcp`; + + const handleCopy = async () => { + await navigator.clipboard.writeText(mcpEndpoint); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleSave = () => { + const settings: AppSettings = { + mcp: { enabled: mcpEnabled, port: mcpPort }, + docker: { + host: dockerHost, + remote_url: dockerHost === "remote" ? dockerRemoteUrl || undefined : undefined, + }, + }; + + saveAppMutation.mutate(settings, { + onSuccess: () => { + toast.success("Settings saved"); + }, + onError: (err) => + toast.error("Failed to save settings", { description: String(err) }), + }); + + // Save AI settings separately + saveAiMutation.mutate( + { provider: "ollama", ollama_url: ollamaUrl, model: aiModel }, + { + onError: (err) => + toast.error("Failed to save AI settings", { description: String(err) }), + } + ); + }; + + return ( + + + + Settings + Application configuration + + +
+ {/* MCP Server */} +
+

MCP Server

+ +
+ Enabled + +
+ +
+ + setMcpPort(Number(e.target.value))} + className="h-8 text-xs" + min={1} + max={65535} + /> +
+ +
+ Status: + + + {mcpStatus?.running ? "Running" : "Stopped"} + +
+ +
+ +
+ + {mcpEndpoint} + + +
+
+
+ + + + {/* Docker */} +
+

Docker

+ +
+ + +
+ + {dockerHost === "remote" && ( +
+ + setDockerRemoteUrl(e.target.value)} + placeholder="tcp://192.168.1.100:2375" + className="h-8 text-xs" + /> +
+ )} +
+ + + + {/* AI */} +
+

AI

+ +
+ + +
+ +
+ + setOllamaUrl(e.target.value)} + placeholder="http://localhost:11434" + className="h-8 text-xs" + /> +
+ +
+
+ + +
+ {modelsError ? ( +

Cannot connect to Ollama

+ ) : ( + + )} +
+
+
+ + + + +
+
+ ); +} diff --git a/src/hooks/use-settings.ts b/src/hooks/use-settings.ts new file mode 100644 index 0000000..9718815 --- /dev/null +++ b/src/hooks/use-settings.ts @@ -0,0 +1,30 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { getAppSettings, saveAppSettings, getMcpStatus } from "@/lib/tauri"; +import type { AppSettings } from "@/types"; + +export function useAppSettings() { + return useQuery({ + queryKey: ["app-settings"], + queryFn: getAppSettings, + staleTime: Infinity, + }); +} + +export function useSaveAppSettings() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (settings: AppSettings) => saveAppSettings(settings), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["app-settings"] }); + queryClient.invalidateQueries({ queryKey: ["mcp-status"] }); + }, + }); +} + +export function useMcpStatus() { + return useQuery({ + queryKey: ["mcp-status"], + queryFn: getMcpStatus, + refetchInterval: 5000, + }); +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 7d2af45..6ed1f8b 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -33,6 +33,8 @@ import type { CloneProgress, CloneResult, TuskContainer, + AppSettings, + McpStatus, } from "@/types"; // Connections @@ -320,3 +322,13 @@ export const onCloneProgress = ( callback: (p: CloneProgress) => void ): Promise => listen("clone-progress", (e) => callback(e.payload)); + +// App Settings +export const getAppSettings = () => + invoke("get_app_settings"); + +export const saveAppSettings = (settings: AppSettings) => + invoke("save_app_settings", { settings }); + +export const getMcpStatus = () => + invoke("get_mcp_status"); diff --git a/src/types/index.ts b/src/types/index.ts index 7d0e80a..8226a63 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -323,6 +323,30 @@ export interface ErdData { relationships: ErdRelationship[]; } +// App Settings +export type DockerHost = "local" | "remote"; + +export interface McpSettings { + enabled: boolean; + port: number; +} + +export interface DockerSettings { + host: DockerHost; + remote_url?: string; +} + +export interface AppSettings { + mcp: McpSettings; + docker: DockerSettings; +} + +export interface McpStatus { + enabled: boolean; + port: number; + running: boolean; +} + // Docker export interface DockerStatus { installed: boolean;