Files
gridline/desktop/src/stores/connectionStore.ts
T
adrianbonpin 7bd4e8d05f
Release / windows (push) Failing after 18m10s
Release / linux (push) Successful in 19m10s
fix: PG cell-edit serialization + recent-connections startup race (#6)
2026-09-01 04:23:07 +00:00

289 lines
12 KiB
TypeScript

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<string, string>();
interface ConnectionState {
connections: Connection[]; folders: Folder[]; tags: Tag[];
tagOrder: string[];
recent: Connection[];
loading: boolean; error: string | null;
loadAll: () => Promise<void>;
loadRecent: () => Promise<void>;
recordRecent: (id: string) => Promise<void>;
toggleFavorite: (id: string) => Promise<void>;
loadTagOrder: () => Promise<void>;
setTagOrder: (order: string[]) => Promise<void>;
createConnection: (input: ConnectionInput) => Promise<Connection>;
deleteConnection: (id: string) => Promise<void>;
duplicateConnection: (id: string) => Promise<Connection>;
createFolder: (input: FolderInput) => Promise<void>;
updateFolder: (id: string, input: FolderInput) => Promise<void>;
deleteFolder: (id: string) => Promise<void>;
createTag: (input: TagInput) => Promise<void>;
updateTag: (id: string, input: TagInput) => Promise<void>;
deleteTag: (id: string) => Promise<void>;
addTagToItems: (tagId: string, folderIds: string[], connectionIds: string[]) => Promise<void>;
moveConnection: (connectionId: string, newFolderId: string | null) => Promise<void>;
moveSelectionToFolder: (selectedIds: string[], targetFolderId: string | null) => Promise<void>;
cachePassword: (connectionId: string, password: string) => Promise<void>;
getConnectionPassword: (connectionId: string) => Promise<string | null>;
setSessionPassword: (connectionId: string, password: string) => void;
clearSessionPassword: (connectionId: string) => void;
}
export const useConnectionStore = create<ConnectionState>((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<string>();
const folderIds = new Set<string>();
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),
}));
},
}));