import { create } from "zustand"; import type { Connection, ConnectionInput, Folder, FolderInput, Tag, TagInput } from "../lib/types"; import * as cmd from "../lib/commands"; import { persistDbPassword } from "../lib/keychain"; // In-memory passwords for connections with use_keychain=false (never persisted). const sessionPasswords = new Map(); interface ConnectionState { connections: Connection[]; folders: Folder[]; tags: Tag[]; tagOrder: string[]; recent: Connection[]; loading: boolean; error: string | null; loadAll: () => Promise; loadRecent: () => Promise; recordRecent: (id: string) => Promise; toggleFavorite: (id: string) => Promise; loadTagOrder: () => Promise; setTagOrder: (order: string[]) => Promise; createConnection: (input: ConnectionInput) => Promise; deleteConnection: (id: string) => Promise; duplicateConnection: (id: string) => Promise; createFolder: (input: FolderInput) => Promise; updateFolder: (id: string, input: FolderInput) => Promise; deleteFolder: (id: string) => Promise; createTag: (input: TagInput) => Promise; updateTag: (id: string, input: TagInput) => Promise; deleteTag: (id: string) => Promise; addTagToItems: (tagId: string, folderIds: string[], connectionIds: string[]) => Promise; moveConnection: (connectionId: string, newFolderId: string | null) => Promise; moveSelectionToFolder: (selectedIds: string[], targetFolderId: string | null) => Promise; cachePassword: (connectionId: string, password: string) => Promise; getConnectionPassword: (connectionId: string) => Promise; setSessionPassword: (connectionId: string, password: string) => void; clearSessionPassword: (connectionId: string) => void; } export const useConnectionStore = create((set, get) => ({ connections: [], folders: [], tags: [], tagOrder: [], recent: [], loading: false, error: null, loadAll: async () => { set({ loading: true, error: null }); try { const [connections, folders, tags] = await Promise.all([cmd.getConnections(), cmd.getFolders(), cmd.getTags()]); set({ connections, folders, tags, loading: false }); // Sort favorites first (stable sort preserves name order within groups) set((s) => ({ connections: [...s.connections].sort((a, b) => Number(b.favorite) - Number(a.favorite)) })); // Also load tag order get().loadTagOrder(); // Refresh recents once connections are known so the recent strip is not // computed against an empty list (avoids a startup race with loadRecent). get().loadRecent().catch(() => {}); } catch (e) { set({ loading: false, error: e instanceof Error ? e.message : String(e) }); } }, loadRecent: async () => { try { const recent = await cmd.getRecentConnections(8); const map = new Map(get().connections.map((c) => [c.id, c])); set({ recent: recent.map((r) => map.get(r.connection_id)).filter(Boolean) as Connection[] }); } catch { /* best-effort: recent list is non-critical */ } }, recordRecent: async (id) => { try { await cmd.recordRecentConnection(id); } catch { /* best-effort */ } }, toggleFavorite: async (id) => { const prev = get().connections; const next = prev.map((c) => c.id === id ? { ...c, favorite: !c.favorite } : c); set({ connections: next }); const conn = next.find((c) => c.id === id); try { await cmd.setConnectionFavorite(id, conn?.favorite ?? false); } catch (e) { set({ connections: prev }); // rollback throw e; } }, loadTagOrder: async () => { try { const settings = await cmd.getSettings(); if (settings.tag_order) { try { const order = JSON.parse(settings.tag_order); if (Array.isArray(order)) set({ tagOrder: order }); } catch {} } } catch {} }, setTagOrder: async (order) => { await cmd.updateSetting("tag_order", JSON.stringify(order)); set({ tagOrder: order }); }, createConnection: async (input) => { const conn = await cmd.createConnection(input); // Persist (or purge) the DB password according to the keychain toggle. await persistDbPassword(conn.id, input.use_keychain, input.password); // Persist SSH secrets to OS keychain (not SQLite): password for password // auth, passphrase for private-key auth. if (input.ssh_host && (input.ssh_auth_method ?? "password") === "password" && input.ssh_password) { await cmd.saveConnectionSshPassword(conn.id, input.ssh_password); } if (input.ssh_host && input.ssh_passphrase) { await cmd.saveConnectionSshPassphrase(conn.id, input.ssh_passphrase); } set((s) => ({ connections: [...s.connections, conn] })); return conn; }, duplicateConnection: async (id) => { const source = get().connections.find((c) => c.id === id); if (!source) throw new Error("Connection not found"); // Passwords live in the OS keychain and are NEVER copied; the duplicate // starts unkeyed and with no favorite flag. The keychain toggle is inherited // from the source (absent defaults to true / opt-out). const input: ConnectionInput = { name: `${source.name} (copy)`, db_type: source.db_type, host: source.host, port: source.port, username: source.username ?? null, database: source.database ?? null, folder_id: source.folder_id, tag_ids: source.tag_ids ?? [], environment: source.environment ?? null, ssh_host: source.ssh_host ?? null, ssh_port: source.ssh_port ?? null, ssh_user: source.ssh_user ?? null, ssh_auth_method: (source.ssh_auth_method as ConnectionInput["ssh_auth_method"]) ?? null, ssh_private_key_path: source.ssh_private_key_path ?? null, ssl_mode: (source.ssl_mode as ConnectionInput["ssl_mode"]) ?? null, ssl_ca_path: source.ssl_ca_path ?? null, ssl_cert_path: source.ssl_cert_path ?? null, ssl_key_path: source.ssl_key_path ?? null, password: null, use_keychain: source.use_keychain ?? true, }; return get().createConnection(input); }, deleteConnection: async (id) => { await cmd.deleteConnection(id); // Remove password from keychain try { await cmd.deleteConnectionPassword(id); } catch { /* ignore */ } // Drop any session-only password for the connection sessionPasswords.delete(id); set((s) => ({ connections: s.connections.filter((c) => c.id !== id) })); }, createFolder: async (input) => { const folder = await cmd.createFolder(input); set((s) => ({ folders: [...s.folders, folder] })); }, updateFolder: async (id, input) => { const folder = await cmd.updateFolder(id, input); set((s) => ({ folders: s.folders.map((f) => f.id === id ? folder : f) })); }, deleteFolder: async (id) => { await cmd.deleteFolder(id); set((s) => ({ folders: s.folders.filter((f) => f.id !== id), connections: s.connections.map((c) => c.folder_id === id ? { ...c, folder_id: null } : c) })); }, createTag: async (input) => { const tag = await cmd.createTag(input); set((s) => ({ tags: [...s.tags, tag] })); }, updateTag: async (id, input) => { const tag = await cmd.updateTag(id, input); set((s) => ({ tags: s.tags.map((t) => t.id === id ? tag : t) })); }, deleteTag: async (id) => { await cmd.deleteTag(id); set((s) => ({ tags: s.tags.filter((t) => t.id !== id), connections: s.connections.map((c) => c.tag_ids.includes(id) ? { ...c, tag_ids: c.tag_ids.filter((t) => t !== id) } : c), folders: s.folders.map((f) => f.tag_ids.includes(id) ? { ...f, tag_ids: f.tag_ids.filter((t) => t !== id) } : f), })); }, cachePassword: async (connectionId, password) => { const conn = get().connections.find((c) => c.id === connectionId); if (conn && conn.use_keychain === false) { sessionPasswords.set(connectionId, password); return; } await cmd.saveConnectionPassword(connectionId, password); }, getConnectionPassword: async (connectionId) => { const conn = get().connections.find((c) => c.id === connectionId); if (conn && conn.use_keychain === false) { return sessionPasswords.get(connectionId) ?? null; } return cmd.getConnectionPassword(connectionId); }, setSessionPassword: (connectionId, password) => { sessionPasswords.set(connectionId, password); }, clearSessionPassword: (connectionId) => { sessionPasswords.delete(connectionId); }, addTagToItems: async (tagId, folderIds, connectionIds) => { await Promise.all([ ...folderIds.map((fid) => cmd.addFolderTags(fid, [tagId])), ...connectionIds.map((cid) => cmd.addConnectionTags(cid, [tagId])), ]); set((s) => ({ folders: s.folders.map((f) => folderIds.includes(f.id) && !f.tag_ids.includes(tagId) ? { ...f, tag_ids: [...f.tag_ids, tagId] } : f, ), connections: s.connections.map((c) => connectionIds.includes(c.id) && !c.tag_ids.includes(tagId) ? { ...c, tag_ids: [...c.tag_ids, tagId] } : c, ), })); }, moveConnection: async (connectionId, newFolderId) => { const state = get(); const conn = state.connections.find((c) => c.id === connectionId); if (!conn) return; if (conn.folder_id === newFolderId) return; // Capture the snapshot atomically INSIDE the optimistic set() // to avoid stale closure issues on rapid successive drags. let previousState: { connections: Connection[] }; // Optimistic update set((s) => { previousState = { connections: [...s.connections] }; return { connections: s.connections.map((c) => c.id === connectionId ? { ...c, folder_id: newFolderId } : c, ), }; }); try { // Build a minimal ConnectionInput with only folder_id changed const input: any = { name: conn.name, db_type: conn.db_type, host: conn.host, port: conn.port, username: conn.username, database: conn.database, folder_id: newFolderId, environment: conn.environment, ssh_host: conn.ssh_host, ssh_port: conn.ssh_port, ssh_user: conn.ssh_user, ssh_auth_method: conn.ssh_auth_method, ssh_private_key_path: conn.ssh_private_key_path, ssl_mode: conn.ssl_mode, ssl_ca_path: conn.ssl_ca_path, ssl_cert_path: conn.ssl_cert_path, ssl_key_path: conn.ssl_key_path, tag_ids: conn.tag_ids ?? [], }; await cmd.updateConnection(connectionId, input); } catch (e) { set({ connections: previousState!.connections }); throw e; } }, moveSelectionToFolder: async (selectedIds, targetFolderId) => { const prev = { connections: [...get().connections], folders: [...get().folders] }; const connIds = new Set(); const folderIds = new Set(); for (const id of selectedIds) { if (prev.connections.some((c) => c.id === id)) connIds.add(id); else if (prev.folders.some((f) => f.id === id)) folderIds.add(id); } try { // Persist first: moveConnection has its own optimistic logic + rollback and // would no-op (early-return on same folder_id) if we pre-set the target. for (const id of connIds) { await get().moveConnection(id, targetFolderId); } for (const id of folderIds) { const f = prev.folders.find((x) => x.id === id); if (f) await cmd.updateFolder(id, { name: f.name, parent_id: targetFolderId, tag_ids: f.tag_ids }); } } catch (e) { set({ connections: prev.connections, folders: prev.folders }); throw e; } // Apply the move optimistically at the end so state always ends moved set((s) => ({ connections: s.connections.map((c) => connIds.has(c.id) ? { ...c, folder_id: targetFolderId } : c), folders: s.folders.map((f) => folderIds.has(f.id) ? { ...f, parent_id: targetFolderId } : f), })); }, }));