Files
gridline/src-tauri/src/store/mod.rs
T
adrianbonpin 80962d7d11 DB viewer + query editor enhancements (home-screen-ux-query-editor) (#4)
* feat: add query_history table migration (v5) (Task 1)

* feat: add isDestructiveQuery utility (Task 2)

* feat: add tabType discriminator and openQueryTab to dbViewerStore (Task 3)

* feat: add execute_query command with pagination and query history (Task 4)

* feat: add typed wrappers for executeQuery, getQueryHistory, clearQueryHistory (Task 5)

* fix: global search bypasses folder scope when filters active (Task 6)

* feat: add TagFilterDropdown with checkboxes and empty state (Task 7)

* feat: add DbTypeFilterDropdown with checkboxes and clear all (Task 8)

* feat: wire TagFilterDropdown/DbTypeFilterDropdown into ActionRow, add inline tag creation (Task 9)

* feat: add Name input to GeneralTab for connection editing (Task 10)

* feat: add QueryEditor Monaco wrapper with SQL mode and Cmd+Enter (Task 11)

* feat: add DestructiveQueryDialog with SQL preview and confirmation (Task 12)

* feat: integrate query tabs, Monaco editor, destructive guard into DbViewerScreen (Task 13)

* fix: harden moveConnection against race conditions on rapid drags (Task 14)

* docs: update AGENTS.md implementation status for Home Screen UX + Query Editor (Task 15)

* feat: switch tag filter to OR semantics, add environment filter (F-T16)

* feat: add activeEnvironment filter state to uiStore and useFilteredConnections (F-T17)

* feat: add environment filter select to Filters dropdown (F-T18)

* feat: filter folder cards by tag match or contained connections (F-T19)

* fix: keep grid header width to content, border last column

* fix: hide select-all checkbox and empty-state when no table open

* fix: filter folder cards by any active filter, show global search results (F-T20)

* feat: show 'Showing Search Results' breadcrumb with clear button (F-T21)

* docs: update README + AGENTS.md for Query Editor, filters, and planned AI integration (BYOK)

* feat: refresh indicator with spinning icon and pulse, defer auto-refresh on tab switch

* feat: smart default schema selection, refresh schemas on database switch

* fix: auto-refresh waits for in-flight refresh to complete before next tick

* style: shrink db viewer sidebar nav icons from 20px to 16px

* style: shrink db viewer sidebar nav buttons to 32px (8px padding)

* style: make Tables panel title xs, regular weight, muted

* style: bump Tables panel title back to sm, keep regular weight and muted

* feat: export schema diagram as PNG/JPEG/SVG (entire schema or viewport)

* chore: lockfile for html-to-image

* fix: raise schema visualizer toolbar above legend so export menu isn't hidden

* feat: schema export via save dialog, transparent background option, save notification

* fix: render nothing in tab bar when no tabs are open

* style: reduce tab bar height from 40px to 36px

* style: reduce tab bar height to 32px

* style: revert tab bar height to 36px

* feat: split tab bar with fixed +Query and Changes actions on the right

* style: blue play-icon Query button in tab bar

* refactor: remove sidebar New Query button (now in tab bar)

* style: conditional bottom padding in sidebar toolbar when nothing is below

* feat: distinguish table and query tabs with icons

* style: tab icons follow active/inactive state, muted colors

* feat: query tab toolbar (run/format/dialect badge) + bare transparent editor

* style: blue rounded Run Query button in query toolbar

* feat: smart platform-aware shortcut tooltip on Run Query (⌘+⏎ / Ctrl+Enter)

* style: show only the shortcut in the Run Query tooltip

* fix: Cmd+Enter keybinding stale closure; add run pulse to query toolbar; bundle monaco locally (offline)

* feat: show placeholder text in empty query editor

* feat: SQL autocomplete — keywords + table names from active schema

* feat: per-table column autocomplete on 'table.' + docs update

* feat: query-variant result toolbar — export/refresh/columns left, smart-unit execution time right

* fix: populate execution_time_ms on query results so the toolbar can show time taken

* fix: re-measure monaco fonts after async font load to stop cursor drift

* feat: resizable + collapsible query results panel

* refactor: move results caret onto the resize handle (centered), bottom caret when collapsed

* style: thin drag strip with caret on its own centered pill

* refactor: remove Queue button from table toolbar (Changes lives in tab bar)

* style: changes button becomes bordered rounded icon with count badge

* docs: mark tab-bar Changes queue button in AGENTS.md and README
2026-08-01 05:56:47 +08:00

819 lines
31 KiB
Rust

pub mod migrations;
use rusqlite::params;
use rusqlite::Connection as SqliteConnection;
use std::collections::HashMap;
use std::sync::Mutex;
use crate::models::{Connection, ConnectionInput, Folder, FolderInput, Settings, Tag, TagInput};
pub struct Store {
conn: Mutex<SqliteConnection>,
}
impl Store {
pub fn from_connection(conn: SqliteConnection) -> Self {
Self {
conn: Mutex::new(conn),
}
}
pub fn open(path: &str) -> Result<Self, String> {
let conn = SqliteConnection::open(path).map_err(|e| e.to_string())?;
migrations::run_migrations(&conn).map_err(|e| e.to_string())?;
Ok(Self::from_connection(conn))
}
fn now() -> String {
chrono::Utc::now().to_rfc3339()
}
pub fn get_folders(&self) -> Result<Vec<Folder>, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT id, name, parent_id, created_at, updated_at FROM folders ORDER BY name")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok(Folder {
id: row.get(0)?,
name: row.get(1)?,
parent_id: row.get(2)?,
tag_ids: vec![],
created_at: row.get(3)?,
updated_at: row.get(4)?,
})
})
.map_err(|e| e.to_string())?;
let mut folders: Vec<Folder> = rows.filter_map(|r| r.ok()).collect();
// Load tags for each folder
for f in folders.iter_mut() {
let mut tag_stmt = conn
.prepare("SELECT tag_id FROM folder_tags WHERE folder_id = ?1")
.map_err(|e| e.to_string())?;
let tag_rows = tag_stmt
.query_map(params![f.id], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?;
f.tag_ids = tag_rows.filter_map(|r| r.ok()).collect();
}
Ok(folders)
}
pub fn create_folder(&self, input: FolderInput) -> Result<Folder, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let id = uuid::Uuid::new_v4().to_string();
let now = Self::now();
conn.execute(
"INSERT INTO folders (id, name, parent_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![id, input.name, input.parent_id, now, now],
)
.map_err(|e| e.to_string())?;
let tag_ids = input.tag_ids.unwrap_or_default();
for tag_id in &tag_ids {
conn.execute(
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
params![id, tag_id],
)
.map_err(|e| e.to_string())?;
}
Ok(Folder {
id,
name: input.name,
parent_id: input.parent_id,
tag_ids,
created_at: now.clone(),
updated_at: now,
})
}
pub fn update_folder(&self, id: &str, input: FolderInput) -> Result<Folder, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let now = Self::now();
conn.execute(
"UPDATE folders SET name = ?1, parent_id = ?2, updated_at = ?3 WHERE id = ?4",
params![input.name, input.parent_id, now, id],
)
.map_err(|e| e.to_string())?;
// Replace all tags: clear existing, insert new
conn.execute("DELETE FROM folder_tags WHERE folder_id = ?1", params![id])
.map_err(|e| e.to_string())?;
let tag_ids = input.tag_ids.unwrap_or_default();
for tag_id in &tag_ids {
conn.execute(
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
params![id, tag_id],
)
.map_err(|e| e.to_string())?;
}
Ok(Folder {
id: id.to_string(),
name: input.name,
parent_id: input.parent_id,
tag_ids,
created_at: now.clone(),
updated_at: now,
})
}
pub fn delete_folder(&self, id: &str) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
// Get the folder's parent_id to reparent children
let parent_id: Option<String> = conn
.query_row(
"SELECT parent_id FROM folders WHERE id = ?1",
params![id],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
// Move child folders to the parent
conn.execute(
"UPDATE folders SET parent_id = ?1 WHERE parent_id = ?2",
params![parent_id, id],
)
.map_err(|e| e.to_string())?;
// Move child connections to the parent
conn.execute(
"UPDATE connections SET folder_id = ?1 WHERE folder_id = ?2",
params![parent_id, id],
)
.map_err(|e| e.to_string())?;
// Delete the folder
conn.execute("DELETE FROM folders WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn add_folder_tags(&self, folder_id: &str, tag_ids: &[String]) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
for tag_id in tag_ids {
conn.execute(
"INSERT OR IGNORE INTO folder_tags (folder_id, tag_id) VALUES (?1, ?2)",
params![folder_id, tag_id],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn add_connection_tags(&self, conn_id: &str, tag_ids: &[String]) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
for tag_id in tag_ids {
conn.execute(
"INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?1, ?2)",
params![conn_id, tag_id],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn get_tags(&self) -> Result<Vec<Tag>, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT id, name, color, created_at FROM tags ORDER BY name")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok(Tag {
id: row.get(0)?,
name: row.get(1)?,
color: row.get(2)?,
created_at: row.get(3)?,
})
})
.map_err(|e| e.to_string())?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn create_tag(&self, input: TagInput) -> Result<Tag, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let id = uuid::Uuid::new_v4().to_string();
let now = Self::now();
conn.execute(
"INSERT INTO tags (id, name, color, created_at) VALUES (?1, ?2, ?3, ?4)",
params![id, input.name, input.color, now],
)
.map_err(|e| e.to_string())?;
Ok(Tag {
id,
name: input.name,
color: input.color,
created_at: now,
})
}
pub fn delete_tag(&self, id: &str) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM tags WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn update_tag(&self, id: &str, input: TagInput) -> Result<Tag, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let now = Self::now();
conn.execute(
"UPDATE tags SET name = ?1, color = ?2 WHERE id = ?3",
params![input.name, input.color, id],
)
.map_err(|e| e.to_string())?;
Ok(Tag {
id: id.to_string(),
name: input.name,
color: input.color,
created_at: now,
})
}
pub fn get_connections(&self) -> Result<Vec<Connection>, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at FROM connections ORDER BY name",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok(Connection {
id: row.get(0)?,
name: row.get(1)?,
db_type: row.get(2)?,
host: row.get(3)?,
port: row.get(4)?,
username: row.get(5)?,
database: row.get(6)?,
folder_id: row.get(7)?,
keychain_ref: row.get(8)?,
ssh_host: row.get(9)?,
ssh_port: row.get(10)?,
ssh_user: row.get(11)?,
ssh_auth_method: row.get(12)?,
ssh_private_key_path: row.get(13)?,
ssl_mode: row.get(14)?,
ssl_ca_path: row.get(15)?,
ssl_cert_path: row.get(16)?,
ssl_key_path: row.get(17)?,
environment: row.get(18)?,
tag_ids: vec![],
created_at: row.get(19)?,
updated_at: row.get(20)?,
})
})
.map_err(|e| e.to_string())?;
let mut conns: Vec<Connection> = rows.filter_map(|r| r.ok()).collect();
// Load tags for each connection
for c in conns.iter_mut() {
let mut tag_stmt = conn
.prepare("SELECT tag_id FROM connection_tags WHERE connection_id = ?1")
.map_err(|e| e.to_string())?;
let tag_rows = tag_stmt
.query_map(params![c.id], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?;
c.tag_ids = tag_rows.filter_map(|r| r.ok()).collect();
}
Ok(conns)
}
pub fn create_connection(&self, input: ConnectionInput) -> Result<Connection, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let id = uuid::Uuid::new_v4().to_string();
let now = Self::now();
conn.execute(
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
params![id, input.name, input.db_type, input.host, input.port, input.username, input.database, input.folder_id, input.ssh_host, input.ssh_port, input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path, input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path, input.environment, now, now],
)
.map_err(|e| e.to_string())?;
for tag_id in &input.tag_ids {
conn.execute(
"INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?1, ?2)",
params![id, tag_id],
)
.map_err(|e| e.to_string())?;
}
Ok(Connection {
id,
name: input.name,
db_type: input.db_type,
host: input.host,
port: input.port,
username: input.username,
folder_id: input.folder_id,
database: input.database,
keychain_ref: None,
environment: input.environment,
ssh_host: input.ssh_host,
ssh_port: input.ssh_port,
ssh_user: input.ssh_user,
ssh_auth_method: input.ssh_auth_method,
ssh_private_key_path: input.ssh_private_key_path,
ssl_mode: input.ssl_mode,
ssl_ca_path: input.ssl_ca_path,
ssl_cert_path: input.ssl_cert_path,
ssl_key_path: input.ssl_key_path,
tag_ids: input.tag_ids,
created_at: now.clone(),
updated_at: now,
})
}
pub fn delete_connection(&self, id: &str) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM connections WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn update_connection(&self, id: &str, input: ConnectionInput) -> Result<Connection, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let now = Self::now();
conn.execute(
"UPDATE connections SET name=?1, db_type=?2, host=?3, port=?4, username=?5, database=?6, folder_id=?7, ssh_host=?8, ssh_port=?9, ssh_user=?10, ssh_auth_method=?11, ssh_private_key_path=?12, ssl_mode=?13, ssl_ca_path=?14, ssl_cert_path=?15, ssl_key_path=?16, environment=?17, updated_at=?18 WHERE id=?19",
params![
input.name, input.db_type, input.host, input.port, input.username,
input.database, input.folder_id, input.ssh_host, input.ssh_port,
input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path,
input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path,
input.environment, now, id
],
).map_err(|e| e.to_string())?;
// Update tags
conn.execute("DELETE FROM connection_tags WHERE connection_id = ?1", params![id])
.map_err(|e| e.to_string())?;
for tag_id in &input.tag_ids {
conn.execute(
"INSERT OR IGNORE INTO connection_tags (connection_id, tag_id) VALUES (?1, ?2)",
params![id, tag_id],
).map_err(|e| e.to_string())?;
}
Ok(Connection {
id: id.to_string(),
name: input.name,
db_type: input.db_type,
host: input.host,
port: input.port,
username: input.username,
database: input.database,
folder_id: input.folder_id,
keychain_ref: None,
environment: input.environment,
ssh_host: input.ssh_host,
ssh_port: input.ssh_port,
ssh_user: input.ssh_user,
ssh_auth_method: input.ssh_auth_method,
ssh_private_key_path: input.ssh_private_key_path,
ssl_mode: input.ssl_mode,
ssl_ca_path: input.ssl_ca_path,
ssl_cert_path: input.ssl_cert_path,
ssl_key_path: input.ssl_key_path,
tag_ids: input.tag_ids.clone(),
created_at: String::new(), // not updated
updated_at: now,
})
}
pub fn get_settings(&self) -> Result<Settings, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let mut map: HashMap<String, String> = HashMap::new();
let mut stmt = conn
.prepare("SELECT key, value FROM settings")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
))
})
.map_err(|e| e.to_string())?;
for r in rows.filter_map(|r| r.ok()) {
map.insert(r.0, r.1);
}
let theme = map
.get("theme")
.cloned()
.unwrap_or_else(|| "system".to_string());
let font_size = map
.get("font_size")
.cloned()
.unwrap_or_else(|| "medium".to_string());
let confirm = map
.get("confirm_before_delete")
.map(|v| v == "true")
.unwrap_or(true);
let default_folder_id = map
.get("default_folder_id")
.filter(|v| v.as_str() != "null")
.cloned();
let mut default_ports = HashMap::new();
default_ports.insert("postgresql".to_string(), Some(5432i64));
default_ports.insert("mysql".to_string(), Some(3306i64));
default_ports.insert("redis".to_string(), Some(6379i64));
default_ports.insert("sqlite".to_string(), None);
if let Some(ports_json) = map.get("default_ports") {
if let Ok(parsed) =
serde_json::from_str::<HashMap<String, Option<i64>>>(ports_json)
{
default_ports = parsed;
}
}
Ok(Settings {
confirm_before_delete: confirm,
default_folder_id,
theme,
font_size,
default_ports,
tag_order: map.get("tag_order").cloned(),
table_refresh_rate: map
.get("table_refresh_rate")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
table_page_size: map
.get("table_page_size")
.and_then(|v| v.parse().ok())
.unwrap_or(50),
shortcuts: map
.get("shortcuts")
.and_then(|v| serde_json::from_str(v).ok())
.unwrap_or_default(),
})
}
pub fn update_setting(&self, key: &str, value: &str) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// Insert a row into the `query_history` table.
pub fn insert_query_history(
&self,
id: &str,
connection_id: &str,
query_text: &str,
execution_time_ms: Option<i64>,
row_count: Option<i64>,
status: &str,
error_message: Option<&str>,
) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let now = Self::now();
conn.execute(
"INSERT INTO query_history (id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![id, connection_id, query_text, execution_time_ms, row_count, status, error_message, now],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// Fetch query history rows, optionally filtered by `connection_id`.
/// Returns results ordered by `executed_at DESC`.
pub fn get_query_history(
&self,
connection_id: Option<&str>,
limit: i64,
offset: i64,
) -> Result<Vec<crate::commands::query::QueryHistoryEntry>, String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
let (sql, params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
if let Some(cid) = connection_id {
(
"SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at FROM query_history WHERE connection_id = ?1 ORDER BY executed_at DESC LIMIT ?2 OFFSET ?3".to_string(),
vec![Box::new(cid.to_string()), Box::new(limit), Box::new(offset)],
)
} else {
(
"SELECT id, connection_id, query_text, execution_time_ms, row_count, status, error_message, executed_at FROM query_history ORDER BY executed_at DESC LIMIT ?1 OFFSET ?2".to_string(),
vec![Box::new(limit), Box::new(offset)],
)
};
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
let refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt
.query_map(rusqlite::params_from_iter(&refs), |row| {
Ok(crate::commands::query::QueryHistoryEntry {
id: row.get(0)?,
connection_id: row.get(1)?,
query_text: row.get(2)?,
execution_time_ms: row.get(3)?,
row_count: row.get(4)?,
status: row.get(5)?,
error_message: row.get(6)?,
executed_at: row.get(7)?,
})
})
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
/// Delete all query history rows, optionally filtered by `connection_id`.
pub fn clear_query_history(&self, connection_id: Option<&str>) -> Result<(), String> {
let conn = self.conn.lock().map_err(|e| e.to_string())?;
if let Some(cid) = connection_id {
conn.execute(
"DELETE FROM query_history WHERE connection_id = ?1",
params![cid],
)
.map_err(|e| e.to_string())?;
} else {
conn.execute("DELETE FROM query_history", [])
.map_err(|e| e.to_string())?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{ConnectionInput, FolderInput, TagInput};
fn fresh_store() -> Store {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::migrations::run_migrations(&conn).unwrap();
Store::from_connection(conn)
}
#[test]
fn create_and_get_folder() {
let store = fresh_store();
let folder = store
.create_folder(FolderInput { tag_ids: None,
name: "Work".into(),
parent_id: None,
})
.unwrap();
let got = store.get_folders().unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].name, "Work");
assert_eq!(got[0].id, folder.id);
assert!(got[0].parent_id.is_none());
}
#[test]
fn create_nested_folders() {
let store = fresh_store();
let parent = store
.create_folder(FolderInput { tag_ids: None,
name: "root".into(),
parent_id: None,
})
.unwrap();
let child = store
.create_folder(FolderInput { tag_ids: None,
name: "child".into(),
parent_id: Some(parent.id.clone()),
})
.unwrap();
assert_eq!(child.parent_id, Some(parent.id));
}
#[test]
fn create_and_get_tag() {
let store = fresh_store();
let tag = store
.create_tag(TagInput {
name: "production".into(),
color: "#ef4444".into(),
})
.unwrap();
let got = store.get_tags().unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].name, "production");
assert_eq!(got[0].color, "#ef4444");
assert_eq!(got[0].id, tag.id);
}
#[test]
fn create_and_get_connection() {
let store = fresh_store();
let conn = store
.create_connection(ConnectionInput {
name: "Prod".into(),
db_type: "postgresql".into(),
host: "prod.example.com".into(),
port: Some(5432),
username: Some("admin".into()),
folder_id: None,
password: None,
database: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
environment: None,
tag_ids: vec![],
})
.unwrap();
let got = store.get_connections().unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].name, "Prod");
assert_eq!(got[0].port, Some(5432));
assert!(got[0].tag_ids.is_empty());
assert_eq!(got[0].id, conn.id);
}
#[test]
fn connection_with_tags_persists_join() {
let store = fresh_store();
let t1 = store
.create_tag(TagInput {
name: "prod".into(),
color: "#ef4444".into(),
})
.unwrap();
let t2 = store
.create_tag(TagInput {
name: "primary".into(),
color: "#3b82f6".into(),
})
.unwrap();
store
.create_connection(ConnectionInput {
name: "Prod".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: None,
password: None,
database: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
environment: None,
tag_ids: vec![t1.id.clone(), t2.id.clone()],
})
.unwrap();
let got = store.get_connections().unwrap();
assert_eq!(got[0].tag_ids.len(), 2);
assert!(got[0].tag_ids.contains(&t1.id));
assert!(got[0].tag_ids.contains(&t2.id));
}
#[test]
fn delete_folder_sets_connection_folder_null() {
let store = fresh_store();
let folder = store
.create_folder(FolderInput { tag_ids: None,
name: "f".into(),
parent_id: None,
})
.unwrap();
store
.create_connection(ConnectionInput {
name: "C".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: Some(folder.id.clone()),
password: None,
database: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
environment: None,
tag_ids: vec![],
})
.unwrap();
store.delete_folder(&folder.id).unwrap();
let conns = store.get_connections().unwrap();
assert!(conns[0].folder_id.is_none());
}
#[test]
fn delete_tag_removes_from_connection() {
let store = fresh_store();
let tag = store
.create_tag(TagInput {
name: "prod".into(),
color: "#ef4444".into(),
})
.unwrap();
store
.create_connection(ConnectionInput {
name: "C".into(),
db_type: "postgresql".into(),
host: "h".into(),
port: Some(5432),
username: None,
folder_id: None,
password: None,
database: None,
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
environment: None,
tag_ids: vec![tag.id.clone()],
})
.unwrap();
store.delete_tag(&tag.id).unwrap();
let conns = store.get_connections().unwrap();
assert!(conns[0].tag_ids.is_empty());
}
#[test]
fn settings_get_returns_defaults_when_empty() {
let store = fresh_store();
let settings = store.get_settings().unwrap();
assert_eq!(settings.theme, "system");
assert_eq!(settings.font_size, "medium");
assert!(settings.confirm_before_delete);
assert_eq!(
settings.default_ports.get("postgresql"),
Some(&Some(5432))
);
}
#[test]
fn settings_update_persists() {
let store = fresh_store();
store.update_setting("theme", "light").unwrap();
let settings = store.get_settings().unwrap();
assert_eq!(settings.theme, "light");
}
#[test]
fn ssh_ssl_fields_persist_and_retrieve() {
let store = fresh_store();
let _conn = store
.create_connection(ConnectionInput {
name: "SSH-Tunnel-DB".into(),
db_type: "postgresql".into(),
host: "localhost".into(),
port: Some(5432),
username: Some("dbuser".into()),
folder_id: None,
password: None,
database: Some("analytics".into()),
ssh_host: Some("jumphost.example.com".into()),
ssh_port: Some(2222),
ssh_user: Some("tunneluser".into()),
ssh_auth_method: Some("Key".into()),
ssh_private_key_path: Some("/home/user/.ssh/id_rsa".into()),
ssh_passphrase: None,
ssl_mode: Some("verify-full".into()),
ssl_ca_path: Some("/etc/ssl/certs/ca.pem".into()),
ssl_cert_path: Some("/etc/ssl/certs/client-cert.pem".into()),
ssl_key_path: Some("/etc/ssl/private/client-key.pem".into()),
environment: None,
tag_ids: vec![],
})
.unwrap();
let got = store.get_connections().unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].database.as_deref(), Some("analytics"));
assert_eq!(got[0].ssh_host.as_deref(), Some("jumphost.example.com"));
assert_eq!(got[0].ssh_port, Some(2222));
assert_eq!(got[0].ssh_user.as_deref(), Some("tunneluser"));
assert_eq!(got[0].ssh_auth_method.as_deref(), Some("Key"));
assert_eq!(
got[0].ssh_private_key_path.as_deref(),
Some("/home/user/.ssh/id_rsa")
);
assert_eq!(got[0].ssl_mode.as_deref(), Some("verify-full"));
assert_eq!(
got[0].ssl_ca_path.as_deref(),
Some("/etc/ssl/certs/ca.pem")
);
assert_eq!(
got[0].ssl_cert_path.as_deref(),
Some("/etc/ssl/certs/client-cert.pem")
);
assert_eq!(
got[0].ssl_key_path.as_deref(),
Some("/etc/ssl/private/client-key.pem")
);
}
}