diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index e69b544f03..53723c62ce 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -98,6 +98,8 @@ export const saveAiConfig = forward("saveAiConfig"); export const loadAiConfig = forward("loadAiConfig"); export const loadDesktopSettings = forward("loadDesktopSettings"); export const saveDesktopSettings = forward("saveDesktopSettings"); +export const loadPinnedTreeNodeIds = forward("loadPinnedTreeNodeIds"); +export const savePinnedTreeNodeIds = forward("savePinnedTreeNodeIds"); export const saveAiConversation = forward("saveAiConversation"); export const loadAiConversations = forward("loadAiConversations"); export const deleteAiConversation = forward("deleteAiConversation"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index f27786c449..78dd95203b 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -480,6 +480,14 @@ export async function saveDesktopSettings(_settings: DesktopSettings): Promise { + return get("/api/app-settings/pinned-tree-node-ids"); +} + +export async function savePinnedTreeNodeIds(_ids: string[]): Promise { + return post("/api/app-settings/pinned-tree-node-ids", { ids: _ids }); +} + // --- AI Conversations --- export async function saveAiConversation(conversation: AiConversation): Promise { diff --git a/apps/desktop/src/lib/pinnedTreeNodeIds.ts b/apps/desktop/src/lib/pinnedTreeNodeIds.ts new file mode 100644 index 0000000000..f2f267f442 --- /dev/null +++ b/apps/desktop/src/lib/pinnedTreeNodeIds.ts @@ -0,0 +1,10 @@ +export function prunePinnedTreeNodeIdsForConnection(ids: Set, connectionId: string): Set { + const next = new Set(); + const prefix = `${connectionId}:`; + for (const id of ids) { + if (id === connectionId) continue; + if (id.startsWith(prefix)) continue; + next.add(id); + } + return next; +} diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 83b4f62021..50d41cb944 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -130,6 +130,14 @@ export async function saveDesktopSettings(settings: DesktopSettings): Promise { + return invoke("load_pinned_tree_node_ids"); +} + +export async function savePinnedTreeNodeIds(ids: string[]): Promise { + return invoke("save_pinned_tree_node_ids", { ids }); +} + export async function listSystemFonts(): Promise { return invoke("list_system_fonts"); } diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index abde81fa0c..679522e29e 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -38,6 +38,7 @@ import { treeNodeSchemaCachePrefix, } from "@/lib/treeNodeContext"; import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/schemaTreeCache"; +import { prunePinnedTreeNodeIdsForConnection } from "@/lib/pinnedTreeNodeIds"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes"; @@ -71,7 +72,7 @@ export const useConnectionStore = defineStore("connection", () => { else localStorage.removeItem("dbx-active-connection"); }); const treeNodes = ref([]); - const pinnedTreeNodeIds = ref>(loadPinnedTreeNodeIds()); + const pinnedTreeNodeIds = ref>(new Set()); const connectedIds = ref>(new Set()); const loadedTreeNodeChildrenIds = ref>(new Set()); const connectionErrors = ref>({}); @@ -247,7 +248,7 @@ export const useConnectionStore = defineStore("connection", () => { }; } - function loadPinnedTreeNodeIds(): Set { + function loadPinnedTreeNodeIdsFromLocalStorage(): Set { try { if (typeof localStorage === "undefined") return new Set(); const saved = localStorage.getItem(PINNED_TREE_NODES_STORAGE_KEY); @@ -258,7 +259,28 @@ export const useConnectionStore = defineStore("connection", () => { } } + async function loadPinnedTreeNodeIds(): Promise> { + if (!isDesktop) return loadPinnedTreeNodeIdsFromLocalStorage(); + const ids = await api.loadPinnedTreeNodeIds().catch(() => []); + const valid = ids.filter((id) => typeof id === "string"); + if (valid.length > 0) return new Set(valid); + + // Migrate legacy localStorage values for existing desktop users. + const legacy = loadPinnedTreeNodeIdsFromLocalStorage(); + if (legacy.size > 0) { + await api.savePinnedTreeNodeIds([...legacy]).catch(() => undefined); + if (typeof localStorage !== "undefined") { + localStorage.removeItem(PINNED_TREE_NODES_STORAGE_KEY); + } + } + return legacy; + } + function persistPinnedTreeNodeIds() { + if (isDesktop) { + void api.savePinnedTreeNodeIds([...pinnedTreeNodeIds.value]).catch(() => undefined); + return; + } if (typeof localStorage === "undefined") return; localStorage.setItem(PINNED_TREE_NODES_STORAGE_KEY, JSON.stringify([...pinnedTreeNodeIds.value])); } @@ -477,6 +499,8 @@ export const useConnectionStore = defineStore("connection", () => { const nextConnections = connections.value.filter((c) => c.id !== id); await persistConnections(nextConnections); connections.value = nextConnections; + pinnedTreeNodeIds.value = prunePinnedTreeNodeIdsForConnection(pinnedTreeNodeIds.value, id); + persistPinnedTreeNodeIds(); clearConnectionError(id); sidebarLayout.value = removeConnectionFromSidebarLayout(sidebarLayout.value, id); rebuildTreeNodes(); @@ -1666,6 +1690,7 @@ export const useConnectionStore = defineStore("connection", () => { } async function initFromDisk() { + pinnedTreeNodeIds.value = await loadPinnedTreeNodeIds(); const saved = await api.loadConnections(); connections.value = saved.map(normalizeConnection); const savedLayout = await api.loadSidebarLayout(); diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index e1107c2760..95173fdc28 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -340,6 +340,24 @@ impl Storage { .unwrap_or_else(|| DesktopSettings::default().show_tray_icon), }) } + + pub async fn save_pinned_tree_node_ids(&self, ids: &[String]) -> Result<(), String> { + let mut settings = self.load_app_settings_json().await?; + let values = ids.iter().map(|id| serde_json::Value::String(id.clone())).collect::>(); + settings.insert("pinned_tree_node_ids".to_string(), serde_json::Value::Array(values)); + self.save_app_settings_json(&settings).await + } + + pub async fn load_pinned_tree_node_ids(&self) -> Result, String> { + let settings = self.load_app_settings_json().await?; + let Some(value) = settings.get("pinned_tree_node_ids") else { + return Ok(Vec::new()); + }; + let Some(array) = value.as_array() else { + return Ok(Vec::new()); + }; + Ok(array.iter().filter_map(|item| item.as_str().map(|value| value.to_string())).collect()) + } } // --------------------------------------------------------------------------- @@ -1041,4 +1059,27 @@ mod tests { assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-2".to_string())); assert_eq!(storage.load_desktop_settings().await.unwrap(), DesktopSettings { show_tray_icon: false }); } + + #[tokio::test] + async fn pinned_tree_node_ids_default_to_empty() { + let path = temp_db_path("pinned-tree-default"); + let storage = Storage::open(&path).await.unwrap(); + + assert_eq!(storage.load_pinned_tree_node_ids().await.unwrap(), Vec::::new()); + } + + #[tokio::test] + async fn pinned_tree_node_ids_roundtrip_and_preserve_password_hash() { + let path = temp_db_path("pinned-tree-roundtrip"); + let storage = Storage::open(&path).await.unwrap(); + + storage.save_password_hash("hash-3").await.unwrap(); + storage.save_pinned_tree_node_ids(&["conn-1".to_string(), "conn-1:db:main".to_string()]).await.unwrap(); + + assert_eq!( + storage.load_pinned_tree_node_ids().await.unwrap(), + vec!["conn-1".to_string(), "conn-1:db:main".to_string()] + ); + assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-3".to_string())); + } } diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 7e6e688646..d67ec72d1a 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -195,6 +195,11 @@ async fn main() { .route("/update/check", get(routes::update::check_for_updates)) // Layout .route("/layout/sidebar", post(routes::layout::save_sidebar_layout).get(routes::layout::load_sidebar_layout)) + // App settings + .route( + "/app-settings/pinned-tree-node-ids", + get(routes::app_settings::load_pinned_tree_node_ids).post(routes::app_settings::save_pinned_tree_node_ids), + ) .layer(middleware::from_fn_with_state(web_state.clone(), auth::auth_middleware)) .with_state(web_state.clone()); diff --git a/crates/dbx-web/src/routes/app_settings.rs b/crates/dbx-web/src/routes/app_settings.rs new file mode 100644 index 0000000000..b0345e4b9b --- /dev/null +++ b/crates/dbx-web/src/routes/app_settings.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use axum::extract::State; +use axum::Json; +use serde::Deserialize; + +use crate::error::AppError; +use crate::state::WebState; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SavePinnedTreeNodeIdsRequest { + pub ids: Vec, +} + +pub async fn load_pinned_tree_node_ids(State(state): State>) -> Result>, AppError> { + let ids = state.app.storage.load_pinned_tree_node_ids().await.map_err(AppError)?; + Ok(Json(ids)) +} + +pub async fn save_pinned_tree_node_ids( + State(state): State>, + Json(body): Json, +) -> Result, AppError> { + state.app.storage.save_pinned_tree_node_ids(&body.ids).await.map_err(AppError)?; + Ok(Json(())) +} diff --git a/crates/dbx-web/src/routes/mod.rs b/crates/dbx-web/src/routes/mod.rs index 46aca383eb..f2b385e3cb 100644 --- a/crates/dbx-web/src/routes/mod.rs +++ b/crates/dbx-web/src/routes/mod.rs @@ -1,5 +1,6 @@ pub mod agents; pub mod ai; +pub mod app_settings; pub mod connection; pub mod database_export; pub mod history; diff --git a/packages/app-tests/connectionStorePinnedCleanup.test.ts b/packages/app-tests/connectionStorePinnedCleanup.test.ts new file mode 100644 index 0000000000..af6b8f81b1 --- /dev/null +++ b/packages/app-tests/connectionStorePinnedCleanup.test.ts @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createPinia, setActivePinia } from "pinia"; +import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts"; +import type { ConnectionConfig } from "../../apps/desktop/src/types/database.ts"; + +function installMemoryStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + clear: () => values.clear(), + }, + }); + return { + values, + restore() { + if (original) Object.defineProperty(globalThis, "localStorage", original); + else Reflect.deleteProperty(globalThis, "localStorage"); + }, + }; +} + +function conn(id: string, name: string): ConnectionConfig { + return { + id, + name, + db_type: "postgres", + host: "localhost", + port: 5432, + username: "postgres", + password: "", + }; +} + +test("removeConnection prunes pinned ids and persists the pruned set", async () => { + const storage = installMemoryStorage({ + "dbx-pinned-tree-nodes": JSON.stringify(["conn-a", "conn-a:db:main", "conn-b:db:main"]), + }); + const originalFetch = globalThis.fetch; + const savedPayloads: unknown[] = []; + + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url === "/api/connection/list") { + return new Response(JSON.stringify([conn("conn-a", "A"), conn("conn-b", "B")]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "/api/layout/sidebar") { + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === "/api/connection/save") { + savedPayloads.push(JSON.parse(String(init?.body ?? "{}"))); + return new Response("null", { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } }); + }) as typeof fetch; + + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + await store.initFromDisk(); + + assert.equal(store.isTreeNodePinned("conn-a"), true); + assert.equal(store.isTreeNodePinned("conn-a:db:main"), true); + assert.equal(store.isTreeNodePinned("conn-b:db:main"), true); + + await store.removeConnection("conn-a"); + + assert.equal(store.isTreeNodePinned("conn-a"), false); + assert.equal(store.isTreeNodePinned("conn-a:db:main"), false); + assert.equal(store.isTreeNodePinned("conn-b:db:main"), true); + assert.deepEqual(JSON.parse(storage.values.get("dbx-pinned-tree-nodes") || "[]"), ["conn-b:db:main"]); + assert.equal(savedPayloads.length >= 1, true); + } finally { + globalThis.fetch = originalFetch; + storage.restore(); + } +}); + diff --git a/packages/app-tests/pinnedTreeNodeIds.test.ts b/packages/app-tests/pinnedTreeNodeIds.test.ts new file mode 100644 index 0000000000..00c4ecf3b0 --- /dev/null +++ b/packages/app-tests/pinnedTreeNodeIds.test.ts @@ -0,0 +1,12 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { prunePinnedTreeNodeIdsForConnection } from "../../apps/desktop/src/lib/pinnedTreeNodeIds.ts"; + +test("prunePinnedTreeNodeIdsForConnection removes pinned ids for a deleted connection", () => { + const ids = new Set(["conn-1", "conn-1:db:main", "conn-1:db:main:table:users", "conn-10:db:main", "other"]); + + const next = prunePinnedTreeNodeIdsForConnection(ids, "conn-1"); + + assert.deepEqual([...next], ["conn-10:db:main", "other"]); +}); + diff --git a/src-tauri/src/commands/app_settings.rs b/src-tauri/src/commands/app_settings.rs index e3a3bd1be5..28a003167c 100644 --- a/src-tauri/src/commands/app_settings.rs +++ b/src-tauri/src/commands/app_settings.rs @@ -23,3 +23,13 @@ pub async fn save_desktop_settings( } Ok(()) } + +#[tauri::command] +pub async fn load_pinned_tree_node_ids(state: State<'_, Arc>) -> Result, String> { + state.storage.load_pinned_tree_node_ids().await +} + +#[tauri::command] +pub async fn save_pinned_tree_node_ids(state: State<'_, Arc>, ids: Vec) -> Result<(), String> { + state.storage.save_pinned_tree_node_ids(&ids).await +}