From f187845a8569ebddc698a367e904fcbf0bee96cc Mon Sep 17 00:00:00 2001 From: Yash Date: Sat, 20 Jun 2026 17:14:55 +0530 Subject: [PATCH 01/21] refact: Improved UI --- .../er-diagram/components/TableNode.tsx | 169 +++++------------- src/lib/schemaTransformer.ts | 2 +- 2 files changed, 49 insertions(+), 122 deletions(-) diff --git a/src/features/er-diagram/components/TableNode.tsx b/src/features/er-diagram/components/TableNode.tsx index 36fbae6..13632aa 100644 --- a/src/features/er-diagram/components/TableNode.tsx +++ b/src/features/er-diagram/components/TableNode.tsx @@ -72,28 +72,32 @@ const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { return ( -
+
{/* Header */} -
- - - {data.label} - - {data.schema} - +
+
+ + + {data.label} +
+ {data.schema !== 'public' && ( + + {data.schema} + + )}
{/* Columns */} -
+
{visibleColumns.map((col) => { const isUnique = uniqueColumns.has(col.name); const isIndexed = indexedColumns.has(col.name); @@ -132,7 +136,7 @@ const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{/* Left handle for incoming connections (target) - for PK/unique columns */} {(isPkTarget || isUnique) && ( @@ -140,8 +144,8 @@ const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { type="target" position={Position.Left} id={handleId} - className="w-2! h-2! bg-amber-500! border-amber-600!" - style={{ top: '50%' }} + className="w-1! h-1! opacity-0 pointer-events-none" + style={{ left: '-8px' }} /> )} @@ -151,43 +155,31 @@ const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { type="source" position={Position.Right} id={handleId} - className="w-2! h-2! bg-cyan-500! border-cyan-600!" - style={{ top: '50%' }} + className="w-1! h-1! opacity-0 pointer-events-none" + style={{ right: '-8px' }} /> )} -
- {col.isPrimaryKey && ( - +
+ {col.isPrimaryKey ? ( + + ) : col.isForeignKey ? ( + + ) : ( + )} - + + {col.name} - {isUnique && ( - - UQ - - )} - {isIndexed && ( - - IDX - - )} -
-
- {col.type} - {col.isForeignKey && ( - - )} + {!col.nullable && ( - * + * )}
+
+ {col.type} +
@@ -196,79 +188,14 @@ const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { ); })} -
- {/* Collapsed indicator */} - {isCollapsed && data.columns.length > visibleColumns.length && ( -
- +{data.columns.length - visibleColumns.length} more columns -
- )} - - {/* Footer with constraint counts */} - {(data.foreignKeys?.length || data.indexes?.length || data.checkConstraints?.length) ? ( -
- {data.foreignKeys && data.foreignKeys.length > 0 && ( - - - - {data.foreignKeys.length} FK - - - -
-
Foreign Keys
- {data.foreignKeys.map(fk => ( -
- {fk.source_column} → {fk.target_table}.{fk.target_column} -
- ))} -
-
-
- )} - {data.indexes && data.indexes.length > 0 && ( - - - - {data.indexes.length} IDX - - - -
-
Indexes
- {data.indexes.map(idx => ( -
- {idx.index_name} ({idx.column_name}) - {idx.is_unique && " • Unique"} -
- ))} -
-
-
- )} - {data.checkConstraints && data.checkConstraints.length > 0 && ( - - - - {data.checkConstraints.length} CHK - - - -
-
Check Constraints
- {data.checkConstraints.map(chk => ( -
- {chk.constraint_name}: {chk.definition} -
- ))} -
-
-
- )} - {data.columns.length} cols -
- ) : null} + {/* Collapsed indicator */} + {isCollapsed && data.columns.length > visibleColumns.length && ( +
+ +{data.columns.length - visibleColumns.length} more columns +
+ )} +
); diff --git a/src/lib/schemaTransformer.ts b/src/lib/schemaTransformer.ts index cda8344..c8fec53 100644 --- a/src/lib/schemaTransformer.ts +++ b/src/lib/schemaTransformer.ts @@ -212,7 +212,7 @@ export const transformSchemaToER = ( }, labelBgPadding: [4, 4] as [number, number], labelBgBorderRadius: 4, - type: "smoothstep", + type: "default", data: { constraintName: fk.constraint_name, updateRule: fk.update_rule, From 9166184047ce22802112bddd17de8c3ea7eee9e0 Mon Sep 17 00:00:00 2001 From: Yash Date: Sat, 20 Jun 2026 22:44:40 +0530 Subject: [PATCH 02/21] feat: implemented model selection in bridge services --- bridge/src/ai/providers/anthropic.provider.ts | 8 +++++--- bridge/src/ai/providers/gemini.provider.ts | 8 +++++--- bridge/src/ai/providers/groq.provider.ts | 8 +++++--- bridge/src/ai/providers/mistral.provider.ts | 8 +++++--- bridge/src/ai/providers/openai.provider.ts | 8 +++++--- bridge/src/services/ai.impl.ts | 10 +++++----- bridge/src/types/ai.ts | 6 ++++++ 7 files changed, 36 insertions(+), 20 deletions(-) diff --git a/bridge/src/ai/providers/anthropic.provider.ts b/bridge/src/ai/providers/anthropic.provider.ts index dac87c9..8138a56 100644 --- a/bridge/src/ai/providers/anthropic.provider.ts +++ b/bridge/src/ai/providers/anthropic.provider.ts @@ -14,15 +14,17 @@ const DEFAULT_MODEL = "claude-3-5-haiku-20241022"; export class AnthropicProvider implements AIProvider { private client: Anthropic; + private model: string; - constructor(apiKey: string) { + constructor(apiKey: string, model?: string) { this.client = new Anthropic({ apiKey }); + this.model = model?.trim() || DEFAULT_MODEL; } private async complete(system: string, user: string): Promise { try { const msg = await this.client.messages.create({ - model: DEFAULT_MODEL, + model: this.model, max_tokens: 4096, system, messages: [{ role: "user", content: user }], @@ -53,7 +55,7 @@ export class AnthropicProvider implements AIProvider { async testConnection(): Promise { try { await this.client.messages.create({ - model: DEFAULT_MODEL, + model: this.model, max_tokens: 10, messages: [{ role: "user", content: "ping" }], }); diff --git a/bridge/src/ai/providers/gemini.provider.ts b/bridge/src/ai/providers/gemini.provider.ts index 8de2bc8..e44e0d1 100644 --- a/bridge/src/ai/providers/gemini.provider.ts +++ b/bridge/src/ai/providers/gemini.provider.ts @@ -14,15 +14,17 @@ const DEFAULT_MODEL = "gemini-1.5-flash"; export class GeminiProvider implements AIProvider { private genAI: GoogleGenerativeAI; + private model: string; - constructor(apiKey: string) { + constructor(apiKey: string, model?: string) { this.genAI = new GoogleGenerativeAI(apiKey); + this.model = model?.trim() || DEFAULT_MODEL; } private async complete(system: string, user: string): Promise { try { const model = this.genAI.getGenerativeModel({ - model: DEFAULT_MODEL, + model: this.model, systemInstruction: system, generationConfig: { maxOutputTokens: 4096 }, }); @@ -51,7 +53,7 @@ export class GeminiProvider implements AIProvider { async testConnection(): Promise { try { - const model = this.genAI.getGenerativeModel({ model: DEFAULT_MODEL }); + const model = this.genAI.getGenerativeModel({ model: this.model }); await model.generateContent("ping"); return ""; } catch (err) { diff --git a/bridge/src/ai/providers/groq.provider.ts b/bridge/src/ai/providers/groq.provider.ts index 4b840d7..2e6c289 100644 --- a/bridge/src/ai/providers/groq.provider.ts +++ b/bridge/src/ai/providers/groq.provider.ts @@ -14,15 +14,17 @@ const DEFAULT_MODEL = "llama-3.3-70b-versatile"; export class GroqProvider implements AIProvider { private client: Groq; + private model: string; - constructor(apiKey: string) { + constructor(apiKey: string, model?: string) { this.client = new Groq({ apiKey }); + this.model = model?.trim() || DEFAULT_MODEL; } private async complete(system: string, user: string): Promise { try { const res = await this.client.chat.completions.create({ - model: DEFAULT_MODEL, + model: this.model, messages: [ { role: "system", content: system }, { role: "user", content: user }, @@ -54,7 +56,7 @@ export class GroqProvider implements AIProvider { async testConnection(): Promise { try { await this.client.chat.completions.create({ - model: DEFAULT_MODEL, + model: this.model, messages: [{ role: "user", content: "ping" }], max_tokens: 5, }); diff --git a/bridge/src/ai/providers/mistral.provider.ts b/bridge/src/ai/providers/mistral.provider.ts index abb74ee..fa8ecb4 100644 --- a/bridge/src/ai/providers/mistral.provider.ts +++ b/bridge/src/ai/providers/mistral.provider.ts @@ -14,15 +14,17 @@ const DEFAULT_MODEL = "mistral-small-latest"; export class MistralProvider implements AIProvider { private client: Mistral; + private model: string; - constructor(apiKey: string) { + constructor(apiKey: string, model?: string) { this.client = new Mistral({ apiKey }); + this.model = model?.trim() || DEFAULT_MODEL; } private async complete(system: string, user: string): Promise { try { const res = await this.client.chat.complete({ - model: DEFAULT_MODEL, + model: this.model, messages: [ { role: "system", content: system }, { role: "user", content: user }, @@ -60,7 +62,7 @@ export class MistralProvider implements AIProvider { async testConnection(): Promise { try { await this.client.chat.complete({ - model: DEFAULT_MODEL, + model: this.model, messages: [{ role: "user", content: "ping" }], maxTokens: 5, }); diff --git a/bridge/src/ai/providers/openai.provider.ts b/bridge/src/ai/providers/openai.provider.ts index 1c9e841..d5d0291 100644 --- a/bridge/src/ai/providers/openai.provider.ts +++ b/bridge/src/ai/providers/openai.provider.ts @@ -14,15 +14,17 @@ const DEFAULT_MODEL = "gpt-4o-mini"; export class OpenAIProvider implements AIProvider { private client: OpenAI; + private model: string; - constructor(apiKey: string) { + constructor(apiKey: string, model?: string) { this.client = new OpenAI({ apiKey }); + this.model = model?.trim() || DEFAULT_MODEL; } private async complete(system: string, user: string): Promise { try { const res = await this.client.chat.completions.create({ - model: DEFAULT_MODEL, + model: this.model, messages: [ { role: "system", content: system }, { role: "user", content: user }, @@ -54,7 +56,7 @@ export class OpenAIProvider implements AIProvider { async testConnection(): Promise { try { await this.client.chat.completions.create({ - model: DEFAULT_MODEL, + model: this.model, messages: [{ role: "user", content: "ping" }], max_tokens: 5, }); diff --git a/bridge/src/services/ai.impl.ts b/bridge/src/services/ai.impl.ts index 3554216..4ede9f4 100644 --- a/bridge/src/services/ai.impl.ts +++ b/bridge/src/services/ai.impl.ts @@ -25,27 +25,27 @@ export class AIServiceImpl { case "anthropic": { const key = settings.anthropicApiKey?.trim(); if (!key) throw new AIError("MISSING_API_KEY", "anthropic", "Anthropic API key is not configured."); - return new AnthropicProvider(key); + return new AnthropicProvider(key, settings.anthropicModel); } case "openai": { const key = settings.openaiApiKey?.trim(); if (!key) throw new AIError("MISSING_API_KEY", "openai", "OpenAI API key is not configured."); - return new OpenAIProvider(key); + return new OpenAIProvider(key, settings.openaiModel); } case "gemini": { const key = settings.geminiApiKey?.trim(); if (!key) throw new AIError("MISSING_API_KEY", "gemini", "Gemini API key is not configured."); - return new GeminiProvider(key); + return new GeminiProvider(key, settings.geminiModel); } case "groq": { const key = settings.groqApiKey?.trim(); if (!key) throw new AIError("MISSING_API_KEY", "groq", "Groq API key is not configured."); - return new GroqProvider(key); + return new GroqProvider(key, settings.groqModel); } case "mistral": { const key = settings.mistralApiKey?.trim(); if (!key) throw new AIError("MISSING_API_KEY", "mistral", "Mistral API key is not configured."); - return new MistralProvider(key); + return new MistralProvider(key, settings.mistralModel); } case "ollama": { return new OllamaProvider(settings.ollamaBaseUrl, settings.ollamaModel); diff --git a/bridge/src/types/ai.ts b/bridge/src/types/ai.ts index 7246326..1cdd778 100644 --- a/bridge/src/types/ai.ts +++ b/bridge/src/types/ai.ts @@ -21,6 +21,12 @@ export interface AISettings { mistralApiKey?: string; ollamaBaseUrl?: string; ollamaModel?: string; + // Per-provider selected model (overrides provider default) + anthropicModel?: string; + openaiModel?: string; + geminiModel?: string; + groqModel?: string; + mistralModel?: string; } // ── Feature input/output types ──────────────────────────────────────────── From b7e7123d2d8482c3435714339cca3b2332213111 Mon Sep 17 00:00:00 2001 From: Yash Date: Sat, 20 Jun 2026 22:45:00 +0530 Subject: [PATCH 03/21] feat: implemented model selection UI --- .../settings/components/AISettings.tsx | 357 +++++++++++++++--- src/services/bridge/ai.ts | 6 + 2 files changed, 316 insertions(+), 47 deletions(-) diff --git a/src/features/settings/components/AISettings.tsx b/src/features/settings/components/AISettings.tsx index db3b460..d5736f6 100644 --- a/src/features/settings/components/AISettings.tsx +++ b/src/features/settings/components/AISettings.tsx @@ -1,13 +1,16 @@ import { useState, useEffect } from "react"; -import { Bot, Eye, EyeOff, CheckCircle2, XCircle, Loader2, ChevronDown } from "lucide-react"; +import { Bot, Eye, EyeOff, CheckCircle2, XCircle, Loader2, ChevronDown, Zap, Scale, Brain, ExternalLink } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { @@ -18,15 +21,68 @@ import { aiService, } from "@/services/bridge/ai"; +// ── Model catalog ───────────────────────────────────────────────────────── + +type ModelTier = "fast" | "balanced" | "powerful"; + +interface ModelOption { + value: string; + label: string; + tier: ModelTier; +} + +const MODEL_OPTIONS: Partial> = { + anthropic: [ + { value: "claude-3-5-haiku-20241022", label: "Haiku 3.5", tier: "fast" }, + { value: "claude-3-5-sonnet-20241022", label: "Sonnet 3.5", tier: "balanced" }, + { value: "claude-3-7-sonnet-20250219", label: "Sonnet 3.7", tier: "balanced" }, + { value: "claude-opus-4-5", label: "Opus 4.5", tier: "powerful" }, + ], + openai: [ + { value: "gpt-4o-mini", label: "GPT-4o Mini", tier: "fast" }, + { value: "gpt-4o", label: "GPT-4o", tier: "balanced" }, + { value: "o1-mini", label: "o1 Mini", tier: "balanced" }, + { value: "o1", label: "o1", tier: "powerful" }, + { value: "o3-mini", label: "o3 Mini", tier: "powerful" }, + ], + gemini: [ + { value: "gemini-1.5-flash", label: "Gemini 1.5 Flash", tier: "fast" }, + { value: "gemini-1.5-pro", label: "Gemini 1.5 Pro", tier: "balanced" }, + { value: "gemini-2.0-flash", label: "Gemini 2.0 Flash", tier: "fast" }, + { value: "gemini-2.5-pro-preview-06-05", label: "Gemini 2.5 Pro", tier: "powerful" }, + ], + groq: [ + { value: "llama-3.1-8b-instant", label: "Llama 3.1 8B", tier: "fast" }, + { value: "llama-3.3-70b-versatile", label: "Llama 3.3 70B", tier: "balanced" }, + { value: "moonshotai/kimi-k2-instruct", label: "Kimi K2", tier: "powerful" }, + ], + mistral: [ + { value: "mistral-small-latest", label: "Mistral Small", tier: "fast" }, + { value: "mistral-medium-latest", label: "Mistral Medium", tier: "balanced" }, + { value: "mistral-large-latest", label: "Mistral Large", tier: "powerful" }, + { value: "codestral-latest", label: "Codestral", tier: "powerful" }, + ], +}; + +// Map provider name to the settings key that stores its model +const MODEL_FIELD: Partial> = { + anthropic: "anthropicModel", + openai: "openaiModel", + gemini: "geminiModel", + groq: "groqModel", + mistral: "mistralModel", +}; + // ── Provider metadata ───────────────────────────────────────────────────── interface ProviderMeta { name: AIProviderName; label: string; - description: string; + defaultModelLabel: string; requiresKey: boolean; keyField?: keyof AISettingsData; keyPlaceholder?: string; + docsUrl?: string; extraFields?: Array<{ field: keyof AISettingsData; label: string; @@ -39,47 +95,52 @@ const PROVIDERS: ProviderMeta[] = [ { name: "anthropic", label: "Claude (Anthropic)", - description: "claude-3-5-haiku-20241022", + defaultModelLabel: "Haiku 3.5", requiresKey: true, keyField: "anthropicApiKey", keyPlaceholder: "sk-ant-api03-…", + docsUrl: "https://console.anthropic.com/settings/keys", }, { name: "openai", label: "OpenAI", - description: "gpt-4o-mini", + defaultModelLabel: "GPT-4o Mini", requiresKey: true, keyField: "openaiApiKey", keyPlaceholder: "sk-proj-…", + docsUrl: "https://platform.openai.com/api-keys", }, { name: "gemini", label: "Gemini (Google)", - description: "gemini-1.5-flash", + defaultModelLabel: "Gemini 1.5 Flash", requiresKey: true, keyField: "geminiApiKey", keyPlaceholder: "AIzaSy…", + docsUrl: "https://aistudio.google.com/app/apikey", }, { name: "groq", label: "Groq", - description: "llama-3.3-70b-versatile", + defaultModelLabel: "Llama 3.3 70B", requiresKey: true, keyField: "groqApiKey", keyPlaceholder: "gsk_…", + docsUrl: "https://console.groq.com/keys", }, { name: "mistral", label: "Mistral", - description: "mistral-small-latest", + defaultModelLabel: "Mistral Small", requiresKey: true, keyField: "mistralApiKey", keyPlaceholder: "…", + docsUrl: "https://console.mistral.ai/api-keys", }, { name: "ollama", label: "Ollama (Local)", - description: "Runs entirely on your machine", + defaultModelLabel: "llama3.2", requiresKey: false, extraFields: [ { @@ -96,7 +157,112 @@ const PROVIDERS: ProviderMeta[] = [ }, ]; -// ── Connection status indicator ─────────────────────────────────────────── +// ── Tier badge ──────────────────────────────────────────────────────────── + +const TIER_CONFIG: Record = { + fast: { + label: "Fast", + icon: Zap, + className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", + }, + balanced: { + label: "Balanced", + icon: Scale, + className: "bg-blue-500/10 text-blue-500 border-blue-500/20", + }, + powerful: { + label: "Powerful", + icon: Brain, + className: "bg-purple-500/10 text-purple-500 border-purple-500/20", + }, +}; + +function TierBadge({ tier }: { tier: ModelTier }) { + const { label, icon: Icon, className } = TIER_CONFIG[tier]; + return ( + + + {label} + + ); +} + +// ── Model selector ──────────────────────────────────────────────────────── + +function ModelSelector({ + provider, + value, + onChange, +}: { + provider: AIProviderName; + value: string | undefined; + onChange: (model: string) => void; +}) { + const options = MODEL_OPTIONS[provider]; + if (!options || options.length === 0) return null; + + const selected = options.find((o) => o.value === value) ?? options[0]; + + const byTier: Record = { fast: [], balanced: [], powerful: [] }; + options.forEach((o) => byTier[o.tier].push(o)); + + return ( +
+ + + + + + + {(["fast", "balanced", "powerful"] as ModelTier[]).map((tier) => { + const tierOptions = byTier[tier]; + if (tierOptions.length === 0) return null; + const { label, icon: Icon, className: badgeCls } = TIER_CONFIG[tier]; + return ( +
+ + + {label} + + {tierOptions.map((opt) => ( + onChange(opt.value)} + > + {opt.label} + {opt.value} + + ))} + +
+ ); + })} + + Selected: {selected.value} + +
+
+
+ ); +} + +// ── Connection status ───────────────────────────────────────────────────── type ConnectionStatus = "idle" | "testing" | "ok" | "error"; @@ -117,7 +283,7 @@ function StatusBadge({ status, message }: { status: ConnectionStatus; message?: ); } -// ── Password field with show/hide ───────────────────────────────────────── +// ── Password field ──────────────────────────────────────────────────────── function SecretInput({ value, @@ -164,7 +330,6 @@ export default function AISettings() { const [status, setStatus] = useState("idle"); const [statusMessage, setStatusMessage] = useState(); - // Load from storage on mount useEffect(() => { setSettings(loadAISettings()); }, []); @@ -184,7 +349,6 @@ export default function AISettings() { }; const handleTest = async () => { - // Save first so the bridge gets the latest values saveAISettings(settings); setDirty(false); setStatus("testing"); @@ -198,6 +362,19 @@ export default function AISettings() { } }; + // Get the currently selected model label for the provider description + const activeModelField = MODEL_FIELD[activeProvider.name]; + const activeModelValue = activeModelField ? (settings[activeModelField] as string | undefined) : undefined; + const activeModelOptions = MODEL_OPTIONS[activeProvider.name] ?? []; + const activeModelInfo = activeModelOptions.find((o) => o.value === activeModelValue) ?? activeModelOptions[0]; + + // Which providers already have keys saved (excluding ollama which needs no key) + const configuredProviders = PROVIDERS.filter((p) => { + if (!p.requiresKey || !p.keyField) return false; + return !!((settings[p.keyField] as string | undefined)?.trim()); + }); + const configuredCount = configuredProviders.length; + return (
{/* Section header */} @@ -208,7 +385,7 @@ export default function AISettings() {

AI Settings

- Configure your AI provider. Keys stay on your machine. + Configure your AI provider and model. Keys stay on your machine.

@@ -218,7 +395,22 @@ export default function AISettings() {
-

{activeProvider.description}

+

+ {activeModelInfo ? `${activeModelInfo.label} · ` : ""}{activeProvider.label} +

+ {configuredCount > 0 && ( +

+ + {configuredProviders.map((p) => ( + + + {p.label.split(" ")[0]} + + ))} + + saved +

+ )}
@@ -227,31 +419,70 @@ export default function AISettings() { - - {PROVIDERS.map((p) => ( - update({ defaultProvider: p.name })} - > - {p.label} - {p.description} - - ))} + + {PROVIDERS.map((p) => { + const hasKey = p.requiresKey && p.keyField + ? !!((settings[p.keyField] as string | undefined)?.trim()) + : !p.requiresKey; // ollama always "configured" + return ( + update({ defaultProvider: p.name })} + > + {/* Green dot = key saved, grey = not configured */} + +
+ {p.label} + {p.defaultModelLabel} +
+ {p.name === settings.defaultProvider && ( + Active + )} +
+ ); + })} + {configuredCount > 0 && ( + <> + + + {configuredCount} provider{configuredCount !== 1 ? "s" : ""} with saved keys — switching won't delete them. + + + )}
- {/* Credential fields for the active provider */} + {/* Credential + model fields for the active provider */}
+ {/* API Key */} {activeProvider.requiresKey && activeProvider.keyField && (
- +
+ + {activeProvider.docsUrl && ( + + Get key + + )} +
)} + {/* Model selector (for providers with a model catalog) */} + {activeProvider.name !== "ollama" && MODEL_FIELD[activeProvider.name] && ( + update({ [MODEL_FIELD[activeProvider.name]!]: model })} + /> + )} + + {/* Extra fields (Ollama base URL / model text input) */} {activeProvider.extraFields?.map((field) => (
- {/* All providers key summary (collapsed) */} + {/* Configure other providers (collapsed) */}
Configure other providers
- {PROVIDERS.filter((p) => p.name !== settings.defaultProvider && p.requiresKey).map((provider) => ( -
- - update({ [provider.keyField!]: v })} - placeholder={provider.keyPlaceholder} - /> -
- ))} + {PROVIDERS.filter((p) => p.name !== settings.defaultProvider && p.requiresKey).map((provider) => { + const modelField = MODEL_FIELD[provider.name]; + const modelOptions = MODEL_OPTIONS[provider.name] ?? []; + const selectedModel = modelField ? (settings[modelField] as string | undefined) : undefined; + const selectedModelInfo = modelOptions.find((o) => o.value === selectedModel) ?? modelOptions[0]; + return ( +
+
+ + {provider.docsUrl && ( + + Get key + + )} +
+ update({ [provider.keyField!]: v })} + placeholder={provider.keyPlaceholder} + /> + {modelField && modelOptions.length > 0 && ( + update({ [modelField]: model })} + /> + )} +
+ ); + })}
diff --git a/src/services/bridge/ai.ts b/src/services/bridge/ai.ts index e3e3784..c50263b 100644 --- a/src/services/bridge/ai.ts +++ b/src/services/bridge/ai.ts @@ -20,6 +20,12 @@ export interface AISettings { mistralApiKey?: string; ollamaBaseUrl?: string; ollamaModel?: string; + // Per-provider selected model + anthropicModel?: string; + openaiModel?: string; + geminiModel?: string; + groqModel?: string; + mistralModel?: string; } export interface SchemaAnalysisInput { From f818df22c8aec82c78e6c7210e26183c752a7b0c Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 21 Jun 2026 00:24:32 +0530 Subject: [PATCH 04/21] feat: implemented persistance storage for LLMs API keys --- bridge/src/handlers/aiHandlers.ts | 43 ++++++++++++ bridge/src/jsonRpcHandler.ts | 6 ++ bridge/src/utils/config.ts | 1 + src/features/ai/hooks/useAISettings.ts | 34 ++++++---- .../chart/components/ChartVisualization.tsx | 2 +- .../components/AnalyzeSchemaButton.tsx | 2 +- .../settings/components/AISettings.tsx | 30 ++++++-- .../components/ExplainQueryButton.tsx | 2 +- src/services/bridge/ai.ts | 68 ++++++++++++++++--- 9 files changed, 158 insertions(+), 30 deletions(-) diff --git a/bridge/src/handlers/aiHandlers.ts b/bridge/src/handlers/aiHandlers.ts index 49201b4..9f127b3 100644 --- a/bridge/src/handlers/aiHandlers.ts +++ b/bridge/src/handlers/aiHandlers.ts @@ -7,6 +7,7 @@ import { AIExplainQueryParams, AIRecommendChartParams, AITestConnectionParams, + AISettings, } from "../types/ai"; import { getOrCall, @@ -19,6 +20,9 @@ import { buildSchemaAnalysisPrompt } from "../ai/prompts/schema-analysis"; import { buildQueryExplanationPrompt } from "../ai/prompts/query-explanation"; import { buildChartRecommendationPrompt } from "../ai/prompts/chart-recommendation"; import { parseChartRecommendation } from "../ai/prompts/chart-recommendation"; +import fs from "fs/promises"; +import fsSync from "fs"; +import { AI_SETTINGS_FILE, CONFIG_FOLDER, ensureDir } from "../utils/config"; export class AIHandlers { private aiService: AIService; @@ -219,4 +223,43 @@ export class AIHandlers { this.rpc.sendError(id, { code: "HISTORY_ERROR", message: err?.message ?? String(err) }); } } + + // ── Settings persistence (reads/writes ai-settings.json) ────────────── + + async handleLoadSettings(_params: unknown, id: number | string) { + try { + ensureDir(CONFIG_FOLDER); + if (!fsSync.existsSync(AI_SETTINGS_FILE)) { + // Return empty object — frontend will fall back to defaults + this.rpc.sendResponse(id, { ok: true, data: {} }); + return; + } + const raw = await fs.readFile(AI_SETTINGS_FILE, "utf-8"); + const settings = JSON.parse(raw) as AISettings; + this.rpc.sendResponse(id, { ok: true, data: settings }); + } catch (err: any) { + this.logger?.warn({ err }, "ai.loadSettings failed — returning empty"); + // Non-fatal: return empty so the app still starts + this.rpc.sendResponse(id, { ok: true, data: {} }); + } + } + + async handleSaveSettings(params: { settings: AISettings }, id: number | string) { + try { + ensureDir(CONFIG_FOLDER); + await fs.writeFile( + AI_SETTINGS_FILE, + JSON.stringify(params.settings, null, 2), + "utf-8" + ); + // On non-Windows platforms, restrict file permissions (contains API keys) + if (process.platform !== "win32") { + await fs.chmod(AI_SETTINGS_FILE, 0o600); + } + this.rpc.sendResponse(id, { ok: true, data: { saved: true } }); + } catch (err: any) { + this.logger?.error({ err }, "ai.saveSettings failed"); + this.rpc.sendError(id, { code: "SAVE_ERROR", message: err?.message ?? String(err) }); + } + } } diff --git a/bridge/src/jsonRpcHandler.ts b/bridge/src/jsonRpcHandler.ts index e6604c5..b13c668 100644 --- a/bridge/src/jsonRpcHandler.ts +++ b/bridge/src/jsonRpcHandler.ts @@ -389,6 +389,12 @@ export function registerDbHandlers( rpcRegister(rpc, "ai.clearHistory", (p, id) => aiHandlers.handleClearHistory(p, id) ); + rpcRegister(rpc, "ai.loadSettings", (p, id) => + aiHandlers.handleLoadSettings(p, id) + ); + rpcRegister(rpc, "ai.saveSettings", (p, id) => + aiHandlers.handleSaveSettings(p, id) + ); logger?.info("All RPC handlers registered successfully"); } diff --git a/bridge/src/utils/config.ts b/bridge/src/utils/config.ts index f650257..998f5ed 100644 --- a/bridge/src/utils/config.ts +++ b/bridge/src/utils/config.ts @@ -13,6 +13,7 @@ export const CONFIG_FOLDER = export const CONFIG_FILE = path.join(CONFIG_FOLDER, "databases.json"); export const CREDENTIALS_FILE = path.join(CONFIG_FOLDER, ".credentials"); +export const AI_SETTINGS_FILE = path.join(CONFIG_FOLDER, "ai-settings.json"); export const PROJECTS_FOLDER = path.join(CONFIG_FOLDER, "projects"); diff --git a/src/features/ai/hooks/useAISettings.ts b/src/features/ai/hooks/useAISettings.ts index 92045d3..bf8c8b0 100644 --- a/src/features/ai/hooks/useAISettings.ts +++ b/src/features/ai/hooks/useAISettings.ts @@ -1,22 +1,30 @@ import { loadAISettings, type AISettings } from "@/services/bridge/ai"; import { useEffect, useState } from "react"; +const DEFAULT: AISettings = { defaultProvider: "openai" }; + /** - * Reads AISettings from localStorage and stays in sync when - * the user updates them in the Settings page during the same session. + * Reads AISettings from the bridge (ai-settings.json on disk). + * Returns a stable object that is refreshed whenever the settings dialog saves. + * + * Because saving goes through the bridge and NOT localStorage, the old + * StorageEvent trick no longer applies. Instead, callers that need fresh + * settings after a save should re-mount or call `reload()`. */ -export function useAISettings(): AISettings { - const [settings, setSettings] = useState(loadAISettings); +export function useAISettings(): { settings: AISettings; isLoading: boolean; reload: () => void } { + const [settings, setSettings] = useState(DEFAULT); + const [isLoading, setIsLoading] = useState(true); + const [tick, setTick] = useState(0); useEffect(() => { - const onStorage = (e: StorageEvent) => { - if (e.key === "relwave:ai-settings") { - setSettings(loadAISettings()); - } - }; - window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); - }, []); + setIsLoading(true); + loadAISettings().then((loaded) => { + setSettings(loaded); + setIsLoading(false); + }); + }, [tick]); + + const reload = () => setTick((t) => t + 1); - return settings; + return { settings, isLoading, reload }; } diff --git a/src/features/chart/components/ChartVisualization.tsx b/src/features/chart/components/ChartVisualization.tsx index f470f4f..f490434 100644 --- a/src/features/chart/components/ChartVisualization.tsx +++ b/src/features/chart/components/ChartVisualization.tsx @@ -34,7 +34,7 @@ export const ChartVisualization = ({ selectedTable, dbId, }: ChartVisualizationProps) => { - const aiSettings = useAISettings(); + const { settings: aiSettings } = useAISettings(); const [aiLoading, setAiLoading] = useState(false); const { diff --git a/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx b/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx index acb8289..8eb2335 100644 --- a/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx +++ b/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx @@ -26,7 +26,7 @@ interface AnalyzeSchemaButtonProps { } export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaButtonProps) { - const settings = useAISettings(); + const { settings } = useAISettings(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [markdown, setMarkdown] = useState(); diff --git a/src/features/settings/components/AISettings.tsx b/src/features/settings/components/AISettings.tsx index d5736f6..3e1adb0 100644 --- a/src/features/settings/components/AISettings.tsx +++ b/src/features/settings/components/AISettings.tsx @@ -325,13 +325,18 @@ function SecretInput({ // ── Main component ──────────────────────────────────────────────────────── export default function AISettings() { - const [settings, setSettings] = useState(loadAISettings); + const [settings, setSettings] = useState({ defaultProvider: "ollama" }); + const [isLoading, setIsLoading] = useState(true); const [dirty, setDirty] = useState(false); const [status, setStatus] = useState("idle"); const [statusMessage, setStatusMessage] = useState(); + // Load from bridge (ai-settings.json) on mount useEffect(() => { - setSettings(loadAISettings()); + loadAISettings().then((loaded) => { + setSettings(loaded); + setIsLoading(false); + }); }, []); const activeProvider = PROVIDERS.find((p) => p.name === settings.defaultProvider) ?? PROVIDERS[0]; @@ -342,14 +347,14 @@ export default function AISettings() { setStatus("idle"); }; - const handleSave = () => { - saveAISettings(settings); + const handleSave = async () => { + await saveAISettings(settings); setDirty(false); setStatus("idle"); }; const handleTest = async () => { - saveAISettings(settings); + await saveAISettings(settings); setDirty(false); setStatus("testing"); setStatusMessage(undefined); @@ -375,6 +380,21 @@ export default function AISettings() { }); const configuredCount = configuredProviders.length; + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + return (
{/* Section header */} diff --git a/src/features/workspace/components/ExplainQueryButton.tsx b/src/features/workspace/components/ExplainQueryButton.tsx index 717c03d..dab13a2 100644 --- a/src/features/workspace/components/ExplainQueryButton.tsx +++ b/src/features/workspace/components/ExplainQueryButton.tsx @@ -12,7 +12,7 @@ interface ExplainQueryButtonProps { } export function ExplainQueryButton({ sql, disabled, databaseName }: ExplainQueryButtonProps) { - const settings = useAISettings(); + const { settings } = useAISettings(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [markdown, setMarkdown] = useState(); diff --git a/src/services/bridge/ai.ts b/src/services/bridge/ai.ts index c50263b..6d5dbb1 100644 --- a/src/services/bridge/ai.ts +++ b/src/services/bridge/ai.ts @@ -115,20 +115,70 @@ export interface AIHistoryListResult { total: number; } -// ── AI Settings storage ─────────────────────────────────────────────────── +// ── AI Settings storage (persisted to ~/.relwave/ai-settings.json via bridge) ── -const AI_SETTINGS_KEY = "relwave:ai-settings"; +const LS_MIGRATION_KEY = "relwave:ai-settings-migrated-v2"; +const LS_LEGACY_KEY = "relwave:ai-settings"; -export function loadAISettings(): AISettings { +const DEFAULT_SETTINGS: AISettings = { defaultProvider: "ollama" }; + +/** + * Load AI settings from the bridge (reads ai-settings.json on disk). + * Falls back to empty defaults if the file doesn't exist yet. + * Also performs a one-time migration of any settings previously saved in localStorage. + */ +export async function loadAISettings(): Promise { try { - const raw = localStorage.getItem(AI_SETTINGS_KEY); - if (raw) return JSON.parse(raw) as AISettings; - } catch { /* ignore */ } - return { defaultProvider: "ollama" }; + const result = await bridgeRequest("ai.loadSettings", {}); + const fromFile = (result?.data ?? {}) as Partial; + + // If nothing is on disk yet, check localStorage for a legacy migration + if (!fromFile.defaultProvider) { + const migrated = migrateFromLocalStorage(); + if (migrated) { + // Persist the migrated settings to disk right away + await saveAISettings(migrated); + return migrated; + } + return { ...DEFAULT_SETTINGS }; + } + + return { ...DEFAULT_SETTINGS, ...fromFile }; + } catch { + // Bridge unavailable (e.g. during Vite standalone dev) — degrade gracefully + return migrateFromLocalStorage() ?? { ...DEFAULT_SETTINGS }; + } } -export function saveAISettings(settings: AISettings): void { - localStorage.setItem(AI_SETTINGS_KEY, JSON.stringify(settings)); +/** + * Save AI settings to disk via the bridge (writes ai-settings.json). + */ +export async function saveAISettings(settings: AISettings): Promise { + try { + await bridgeRequest("ai.saveSettings", { settings }); + } catch { + // Fallback: keep a copy in localStorage so settings aren’t totally lost + localStorage.setItem(LS_LEGACY_KEY, JSON.stringify(settings)); + } +} + +/** + * One-time migration: if the user had settings saved in the old localStorage + * key, return them and mark migration as done so we don’t do it again. + */ +function migrateFromLocalStorage(): AISettings | null { + if (localStorage.getItem(LS_MIGRATION_KEY)) return null; // already done + try { + const raw = localStorage.getItem(LS_LEGACY_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as AISettings; + // Mark done so this code only runs once + localStorage.setItem(LS_MIGRATION_KEY, "1"); + localStorage.removeItem(LS_LEGACY_KEY); + return parsed; + } catch { + return null; + } } // ── Bridge service class ────────────────────────────────────────────────── From acc1a3eee105ccf615a83a0a95b66f34be1f11e6 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 21 Jun 2026 12:13:05 +0530 Subject: [PATCH 05/21] fix: fixed cache issue while getting response from llms --- bridge/src/services/aiCacheService.ts | 18 ++++++++++----- .../components/AnalyzeSchemaButton.tsx | 22 +++++++++++++++++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/bridge/src/services/aiCacheService.ts b/bridge/src/services/aiCacheService.ts index 6294668..888335a 100644 --- a/bridge/src/services/aiCacheService.ts +++ b/bridge/src/services/aiCacheService.ts @@ -98,15 +98,23 @@ export function hashChartRecommendation(input: ChartRecommendationInput, datasou /** * Resolve the model name from the settings based on provider. - * This is best-effort — some providers don't expose the model in settings. */ function resolveModelName(settings: AISettings): string { - const provider = settings.defaultProvider; - switch (provider) { + switch (settings.defaultProvider) { + case "anthropic": + return settings.anthropicModel ?? "claude-3-5-haiku-20241022"; + case "openai": + return settings.openaiModel ?? "gpt-4o-mini"; + case "gemini": + return settings.geminiModel ?? "gemini-1.5-flash"; + case "groq": + return settings.groqModel ?? "llama-3.3-70b-versatile"; + case "mistral": + return settings.mistralModel ?? "mistral-small-latest"; case "ollama": - return settings.ollamaModel ?? "ollama-default"; + return settings.ollamaModel ?? "llama3.2"; default: - return provider; // For API-key providers, the model is selected by the SDK + return settings.defaultProvider; } } diff --git a/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx b/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx index 8eb2335..7ae0c3d 100644 --- a/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx +++ b/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { Bot, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { AIResultDialog } from "@/features/ai/components/AIResultDialog"; @@ -34,8 +34,23 @@ export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaB const [cached, setCached] = useState(); const [createdAt, setCreatedAt] = useState(); + // Track which datasource the cached markdown belongs to + const cachedForRef = useRef(undefined); + const tableCount = schemaData.schemas?.flatMap((s) => s.tables).length ?? 0; + // Reset all local state when the user switches to a different database + useEffect(() => { + const name = schemaData.name; + if (cachedForRef.current !== undefined && cachedForRef.current !== name) { + setMarkdown(undefined); + setError(null); + setCached(undefined); + setCreatedAt(undefined); + cachedForRef.current = undefined; + } + }, [schemaData.name]); + const buildInput = (): SchemaAnalysisInput => ({ databaseType, tables: schemaData.schemas.flatMap((schema) => @@ -55,7 +70,8 @@ export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaB const handleAnalyze = async (skipCache = false) => { setOpen(true); - if (markdown && !skipCache) return; // Already analyzed — reuse result + // Only reuse cached markdown if it's for THIS database + if (markdown && !skipCache && cachedForRef.current === schemaData.name) return; setLoading(true); setError(null); @@ -68,6 +84,7 @@ export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaB setMarkdown(result.markdown); setCached(result.cached); setCreatedAt(result.createdAt); + cachedForRef.current = schemaData.name; // mark which DB this result belongs to } catch (err: any) { setError(err?.message ?? String(err)); } finally { @@ -79,6 +96,7 @@ export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaB setMarkdown(undefined); setCached(undefined); setCreatedAt(undefined); + cachedForRef.current = undefined; handleAnalyze(true); }; From 45b0e9ae3eb4672176699ea901c84c4a1507beb9 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 21 Jun 2026 16:32:54 +0530 Subject: [PATCH 06/21] feat: added skeletons on the loading and improved file structure for monitoring components --- .../database/components/MigrationsPanel.tsx | 7 + .../MigrationsPanelLoadingState.tsx | 56 ++++ .../components/ERDiagramContent.tsx | 5 +- .../components/ERDiagramLoadingState.tsx | 59 ++++ .../git/components/GitStatusPanel.tsx | 8 +- .../components/GitStatusPanelLoadingState.tsx | 70 +++++ .../monitoring/components/MetricCard.tsx | 42 +++ .../components/MonitoringDashboard.tsx | 173 +++++++++++ .../components/MonitoringLoadingState.tsx | 68 +++++ .../monitoring/components/MonitoringPanel.tsx | 286 +----------------- .../components/QueryBuilderPanel.tsx | 6 +- .../QueryBuilderPanelLoadingState.tsx | 78 +++++ .../components/SchemaExplorerPanel.tsx | 7 +- .../SchemaExplorerPanelLoadingState.tsx | 102 +++++++ .../components/SQLWorkspacePanel.tsx | 5 +- .../SQLWorkspacePanelLoadingState.tsx | 59 ++++ 16 files changed, 729 insertions(+), 302 deletions(-) create mode 100644 src/features/database/components/MigrationsPanelLoadingState.tsx create mode 100644 src/features/er-diagram/components/ERDiagramLoadingState.tsx create mode 100644 src/features/git/components/GitStatusPanelLoadingState.tsx create mode 100644 src/features/monitoring/components/MetricCard.tsx create mode 100644 src/features/monitoring/components/MonitoringDashboard.tsx create mode 100644 src/features/monitoring/components/MonitoringLoadingState.tsx create mode 100644 src/features/query-builder/components/QueryBuilderPanelLoadingState.tsx create mode 100644 src/features/schema-explorer/components/SchemaExplorerPanelLoadingState.tsx create mode 100644 src/features/workspace/components/SQLWorkspacePanelLoadingState.tsx diff --git a/src/features/database/components/MigrationsPanel.tsx b/src/features/database/components/MigrationsPanel.tsx index 2058b69..5f5bba8 100644 --- a/src/features/database/components/MigrationsPanel.tsx +++ b/src/features/database/components/MigrationsPanel.tsx @@ -7,6 +7,7 @@ import { MigrationsData } from "@/features/database/types"; import { useMigrationsPanel } from "../hooks/useMigrationsPanel"; import { cn } from "@/lib/utils"; import { formatTimestamp } from "@/lib/utils"; +import { MigrationsPanelLoadingState } from "@/features/database/components/MigrationsPanelLoadingState"; interface MigrationsPanelProps { migrations: MigrationsData; @@ -32,6 +33,12 @@ export default function MigrationsPanel({ migrations, baselined, dbId }: Migrati setShowSQLDialog, } = useMigrationsPanel({ migrations, baselined, dbId }) + if (isRefreshing) { + return ( + + ); + } + return ( <> diff --git a/src/features/database/components/MigrationsPanelLoadingState.tsx b/src/features/database/components/MigrationsPanelLoadingState.tsx new file mode 100644 index 0000000..1870597 --- /dev/null +++ b/src/features/database/components/MigrationsPanelLoadingState.tsx @@ -0,0 +1,56 @@ +import { Card, CardHeader, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +export function MigrationsPanelLoadingState() { + return ( +
+ + +
+ + +
+
+ + +
+
+ +
+ {["Applied", "Pending"].map((_, i) => ( + + + + + ))} +
+
+ {[ + { status: "applied", w: "w-40" }, + { status: "applied", w: "w-52" }, + { status: "applied", w: "w-36" }, + { status: "pending", w: "w-48" }, + { status: "pending", w: "w-44" }, + ].map((row, i) => ( +
+
+ + + +
+ +
+ + +
+
+ ))} +
+
+
+
+ ) +} diff --git a/src/features/er-diagram/components/ERDiagramContent.tsx b/src/features/er-diagram/components/ERDiagramContent.tsx index c3c2238..a8622db 100644 --- a/src/features/er-diagram/components/ERDiagramContent.tsx +++ b/src/features/er-diagram/components/ERDiagramContent.tsx @@ -35,6 +35,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; import { projectService } from "@/services/bridge/project"; +import { ERDiagramLoadingState } from "@/features/er-diagram/components/ERDiagramLoadingState"; const AnnotationLayer = lazy(() => import("@/features/er-diagram/components/AnnotationLayer")); @@ -367,9 +368,7 @@ const ERDiagramContent: React.FC = ({ nodeTypes, projectI // --- Conditional rendering --- if (isLoading) { return ( -
- -
+ ); } diff --git a/src/features/er-diagram/components/ERDiagramLoadingState.tsx b/src/features/er-diagram/components/ERDiagramLoadingState.tsx new file mode 100644 index 0000000..3c8c71b --- /dev/null +++ b/src/features/er-diagram/components/ERDiagramLoadingState.tsx @@ -0,0 +1,59 @@ + + +export function ERDiagramLoadingState() { + return ( +
+ {/* Toolbar skeleton */} +
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Canvas with ghost table nodes */} +
+ {[ + { top: "12%", left: "8%", cols: 5 }, + { top: "18%", left: "38%", cols: 4 }, + { top: "10%", left: "65%", cols: 6 }, + { top: "52%", left: "22%", cols: 3 }, + { top: "55%", left: "58%", cols: 5 }, + ].map((node, i) => ( +
+
+
+
+
+ {Array.from({ length: node.cols }).map((_, j) => ( +
+
+
+
+
+ ))} +
+
+ ))} + {/* MiniMap ghost */} +
+ {/* Controls ghost */} +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+
+
+ ) +} diff --git a/src/features/git/components/GitStatusPanel.tsx b/src/features/git/components/GitStatusPanel.tsx index 8108c12..0b488e0 100644 --- a/src/features/git/components/GitStatusPanel.tsx +++ b/src/features/git/components/GitStatusPanel.tsx @@ -45,6 +45,7 @@ import type { GitFileChange, GitLogEntry } from "@/features/git/types"; import { gitService } from "@/services/bridge/git"; import { projectService } from "@/services/bridge/project"; import { GitHistoryGraph } from "./GitHistoryGraph"; +import { GitStatusPanelLoadingState } from "./GitStatusPanelLoadingState"; // ─── Helpers ────────────────────────────────────────── @@ -136,12 +137,9 @@ export default function GitStatusPanel({ projectDir, projectId }: GitStatusPanel ); } - if (statusLoading) { + if (statusLoading && projectDir) { return ( -
- - Loading git status… -
+ ); } diff --git a/src/features/git/components/GitStatusPanelLoadingState.tsx b/src/features/git/components/GitStatusPanelLoadingState.tsx new file mode 100644 index 0000000..94bf83b --- /dev/null +++ b/src/features/git/components/GitStatusPanelLoadingState.tsx @@ -0,0 +1,70 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function GitStatusPanelLoadingState() { + return ( +
+ {/* Header */} +
+
+ + + +
+
+ + +
+
+ {/* Tab bar */} +
+ {["w-16", "w-20", "w-14"].map((w, i) => ( + + ))} +
+ {/* Content */} +
+ {/* Branch badge */} +
+ + + +
+ {/* Staged section */} +
+
+ + + +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + + +
+ ))} +
+ {/* Unstaged section */} +
+
+ + + +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ {/* Commit area */} +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/src/features/monitoring/components/MetricCard.tsx b/src/features/monitoring/components/MetricCard.tsx new file mode 100644 index 0000000..bdaa531 --- /dev/null +++ b/src/features/monitoring/components/MetricCard.tsx @@ -0,0 +1,42 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { Activity, Clock3, TimerReset } from "lucide-react"; + +export function MetricCard({ + icon: Icon, + label, + value, + detail, + valueClassName, + children, +}: { + icon: typeof Activity; + label: string; + value: string; + detail: string; + valueClassName?: string; + children?: React.ReactNode; +}) { + return ( + + +
+ {label} + + {value} + +
+
+ +
+
+ +
+ {label === "Throughput" ? : } + {detail} +
+ {children} +
+
+ ); +} diff --git a/src/features/monitoring/components/MonitoringDashboard.tsx b/src/features/monitoring/components/MonitoringDashboard.tsx new file mode 100644 index 0000000..bd407ec --- /dev/null +++ b/src/features/monitoring/components/MonitoringDashboard.tsx @@ -0,0 +1,173 @@ +import { + CircleAlert, + DatabaseZap, + Gauge, + Server, + ShieldCheck, +} from "lucide-react"; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { MonitoringSnapshot } from "@/features/database/types"; +import { MetricCard } from "./MetricCard"; +import { formatNumber } from "@/lib/formatNumber"; +import { formatDuration } from "@/lib/formatDuration"; + + +type ThroughputPoint = { + time: string; + qps: number; + connections: number; +}; + +export function MonitoringDashboard({ + data, + history, + statusTone, +}: { + data: MonitoringSnapshot; + history: ThroughputPoint[]; + statusTone: string; +}) { + return ( +
+ {!data.health.ok && ( + + + Database ping failed + {data.health.message || "The database did not respond to SELECT 1."} + + )} + +
+ + + + + + +
+ +
+ + +
+
+ Throughput Timeline + Queries per second and active connections +
+ + {history.length} samples + +
+
+ + + + + + + + + + + + +
+ + + + Active Queries + Non-idle requests currently reported by the database + + + + + + + Time + User + State + Query + + + + {data.activeQueries.length === 0 ? ( + + + No active database requests + + + ) : ( + data.activeQueries.map((query) => ( + + + {formatDuration(query.durationSeconds)} + + {query.user || "-"} + + + {query.state || "active"} + + + + {query.query || "-"} + + + )) + )} + +
+
+
+
+
+
+ ); +} diff --git a/src/features/monitoring/components/MonitoringLoadingState.tsx b/src/features/monitoring/components/MonitoringLoadingState.tsx new file mode 100644 index 0000000..c299027 --- /dev/null +++ b/src/features/monitoring/components/MonitoringLoadingState.tsx @@ -0,0 +1,68 @@ +import { Card, CardContent, CardHeader } from "@/components/ui/card"; + +import { Spinner } from "@/components/ui/spinner"; + +export function MonitoringLoadingState() { + return ( +
+
+ {Array.from({ length: 4 }).map((_, index) => ( + + +
+
+
+
+
+
+
+ + +
+ {index === 1 ?
: null} + + + ))} +
+ +
+ + +
+
+
+
+
+
+
+ + +
+ +

Connecting to live monitoring stream

+
+
+ + + + +
+
+
+
+ + + {Array.from({ length: 5 }).map((_, index) => ( +
+
+
+
+
+
+ ))} + + +
+
+ ); +} diff --git a/src/features/monitoring/components/MonitoringPanel.tsx b/src/features/monitoring/components/MonitoringPanel.tsx index d4c7a4d..ba234f2 100644 --- a/src/features/monitoring/components/MonitoringPanel.tsx +++ b/src/features/monitoring/components/MonitoringPanel.tsx @@ -1,43 +1,17 @@ -import type * as React from "react"; import { useEffect, useMemo, useState } from "react"; import { Activity, CircleAlert, - Clock3, - DatabaseZap, - Gauge, RefreshCw, - Server, - ShieldCheck, - TimerReset, } from "lucide-react"; -import { - CartesianGrid, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Progress } from "@/components/ui/progress"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { Spinner } from "@/components/ui/spinner"; -import { MonitoringSnapshot } from "@/features/database/types"; import { useMonitoringStream } from "@/features/monitoring/hooks/useMonitoringStream"; -import { cn } from "@/lib/utils"; +import { formatDbType } from "@/lib/formatDbType"; +import { MonitoringDashboard } from "./MonitoringDashboard"; +import { MonitoringLoadingState } from "./MonitoringLoadingState"; interface MonitoringPanelProps { dbId: string; @@ -56,24 +30,7 @@ function isMonitoringSupported(databaseType?: string) { return type === "postgres" || type === "postgresql" || type === "mysql" || type === "mariadb"; } -function formatNumber(value: number) { - return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value); -} - -function formatDuration(seconds: number) { - if (seconds < 60) return `${Math.round(seconds)}s`; - const minutes = Math.floor(seconds / 60); - const remaining = Math.round(seconds % 60); - return `${minutes}m ${remaining}s`; -} -function formatDbType(databaseType?: string) { - if (!databaseType) return "Database"; - if (databaseType === "postgres" || databaseType === "postgresql") return "PostgreSQL"; - if (databaseType === "mysql") return "MySQL"; - if (databaseType === "mariadb") return "MariaDB"; - return databaseType; -} export function MonitoringPanel({ dbId, databaseName, databaseType }: MonitoringPanelProps) { const supported = isMonitoringSupported(databaseType); @@ -181,240 +138,3 @@ export function MonitoringPanel({ dbId, databaseName, databaseType }: Monitoring
); } - -function MonitoringLoadingState() { - return ( -
-
- {Array.from({ length: 4 }).map((_, index) => ( - - -
-
-
-
-
-
-
- - -
- {index === 1 ?
: null} - - - ))} -
- -
- - -
-
-
-
-
-
-
- - -
- -

Connecting to live monitoring stream

-
-
- - - - -
-
-
-
- - - {Array.from({ length: 5 }).map((_, index) => ( -
-
-
-
-
-
- ))} - - -
-
- ); -} - -function MonitoringDashboard({ - data, - history, - statusTone, -}: { - data: MonitoringSnapshot; - history: ThroughputPoint[]; - statusTone: string; -}) { - return ( -
- {!data.health.ok && ( - - - Database ping failed - {data.health.message || "The database did not respond to SELECT 1."} - - )} - -
- - - - - - -
- -
- - -
-
- Throughput Timeline - Queries per second and active connections -
- - {history.length} samples - -
-
- - - - - - - - - - - - -
- - - - Active Queries - Non-idle requests currently reported by the database - - - - - - - Time - User - State - Query - - - - {data.activeQueries.length === 0 ? ( - - - No active database requests - - - ) : ( - data.activeQueries.map((query) => ( - - - {formatDuration(query.durationSeconds)} - - {query.user || "-"} - - - {query.state || "active"} - - - - {query.query || "-"} - - - )) - )} - -
-
-
-
-
-
- ); -} - -function MetricCard({ - icon: Icon, - label, - value, - detail, - valueClassName, - children, -}: { - icon: typeof Activity; - label: string; - value: string; - detail: string; - valueClassName?: string; - children?: React.ReactNode; -}) { - return ( - - -
- {label} - - {value} - -
-
- -
-
- -
- {label === "Throughput" ? : } - {detail} -
- {children} -
-
- ); -} diff --git a/src/features/query-builder/components/QueryBuilderPanel.tsx b/src/features/query-builder/components/QueryBuilderPanel.tsx index 0da1eaa..cc390c5 100644 --- a/src/features/query-builder/components/QueryBuilderPanel.tsx +++ b/src/features/query-builder/components/QueryBuilderPanel.tsx @@ -11,7 +11,6 @@ import { toast } from "sonner"; import { useFullSchema } from "@/features/project/hooks/useDbQueries"; import { useQueryHistory } from "@/features/query-builder/hooks/useQueryHistory"; import { useDatabase } from "@/features/project/hooks/useDbQueries"; -import { Spinner } from "@/components/ui/spinner"; import { useBridgeQuery } from "@/services/bridge/useBridgeQuery"; import { TableRow } from "@/features/database/types"; import { BuilderHeader } from "./BuilderHeader"; @@ -22,6 +21,7 @@ import { BuilderStatusBar } from "./BuilderStatusBar"; import { QueryFilter, ColumnOption } from "../types"; import { sessionService } from "@/services/bridge/session"; import { queryService } from "@/services/bridge/query"; +import { QueryBuilderPanelLoadingState } from "./QueryBuilderPanelLoadingState"; interface QueryBuilderPanelProps { dbId: string; @@ -349,9 +349,7 @@ const QueryBuilderPanel = ({ dbId }: QueryBuilderPanelProps) => { if (bridgeLoading || bridgeReady === undefined || loading) { return ( -
- -
+ ); } diff --git a/src/features/query-builder/components/QueryBuilderPanelLoadingState.tsx b/src/features/query-builder/components/QueryBuilderPanelLoadingState.tsx new file mode 100644 index 0000000..0ac9b06 --- /dev/null +++ b/src/features/query-builder/components/QueryBuilderPanelLoadingState.tsx @@ -0,0 +1,78 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function QueryBuilderPanelLoadingState() { + return ( +
+ {/* Header */} +
+
+ + +
+
+ + +
+
+ {/* Body */} +
+ {/* Sidebar skeleton */} +
+
+ + {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ + {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ {/* Main canvas + results */} +
+ {/* Canvas area (diagram) */} +
+ {/* Fake node cards */} + {[{ top: "20%", left: "15%" }, { top: "25%", left: "55%" }, { top: "60%", left: "35%" }].map((pos, i) => ( +
+
+ +
+
+ {Array.from({ length: 3 }).map((_, j) => ( +
+ + +
+ ))} +
+
+ ))} +
+ {/* SQL results pane */} +
+ + {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+
+ {/* Status bar */} +
+ + +
+
+ ) +} diff --git a/src/features/schema-explorer/components/SchemaExplorerPanel.tsx b/src/features/schema-explorer/components/SchemaExplorerPanel.tsx index 360bd82..9b03406 100644 --- a/src/features/schema-explorer/components/SchemaExplorerPanel.tsx +++ b/src/features/schema-explorer/components/SchemaExplorerPanel.tsx @@ -6,7 +6,7 @@ import SchemaExplorerHeader from "./SchemaExplorerHeader"; import MetaDataPanel from "./MetaDataPanel"; import { useSchemaExplorerPanel } from "../hooks/useSchemaExplorerPanel"; import { TreeViewPanel } from "@/features/tree"; - +import { SchemaExplorerPanelLoadingState } from "./SchemaExplorerPanelLoadingState"; interface Column extends ColumnDetails { foreignKeyRef?: string; @@ -53,12 +53,11 @@ export default function SchemaExplorerPanel({ dbId, projectId }: SchemaExplorerP // --- Conditional rendering --- if (isLoading) { return ( -
- -
+ ); } + if (error || !schemaData) { return (
diff --git a/src/features/schema-explorer/components/SchemaExplorerPanelLoadingState.tsx b/src/features/schema-explorer/components/SchemaExplorerPanelLoadingState.tsx new file mode 100644 index 0000000..e020fd5 --- /dev/null +++ b/src/features/schema-explorer/components/SchemaExplorerPanelLoadingState.tsx @@ -0,0 +1,102 @@ +export function SchemaExplorerPanelLoadingState() { + return ( +
+ {/* ── Header (breadcrumb + actions) ── */} +
+
+
+
+
+
+
+
+
+
+
+ + {/* ── Body: left tree + right metadata ── */} +
+ + {/* Left tree panel */} +
+ + {/* Database root node */} +
+
+
+
+
+ + {/* Schema node (expanded) */} +
+
+
+
+
+
+ + {/* Table rows under schema */} + {[ + "w-20", "w-16", "w-14", "w-12", + "w-16", "w-20", "w-24", "w-28", + "w-22", "w-26", "w-32", "w-18", "w-16", + ].map((w, i) => ( +
+
+
+
+ {/* Occasional FK badge */} + {i === 7 && ( +
+ )} +
+ ))} +
+ + {/* Right metadata panel — DB overview (no table selected) */} +
+ + {/* DB title row */} +
+
+
+
+
+
+
+ + {/* 2 × 3 stat cards grid */} +
+ {[ + { w: "w-6", label: "w-14" }, + { w: "w-8", label: "w-12" }, + { w: "w-10", label: "w-16" }, + { w: "w-4", label: "w-20" }, + { w: "w-8", label: "w-14" }, + { w: "w-4", label: "w-10" }, + ].map((card, i) => ( +
+ {/* Big number */} +
+ {/* Label */} +
+
+ ))} +
+
+
+ + {/* ── Footer ── */} +
+
+
+
+
+
+
+
+ ) +} diff --git a/src/features/workspace/components/SQLWorkspacePanel.tsx b/src/features/workspace/components/SQLWorkspacePanel.tsx index 5e9a3f8..7444461 100644 --- a/src/features/workspace/components/SQLWorkspacePanel.tsx +++ b/src/features/workspace/components/SQLWorkspacePanel.tsx @@ -9,6 +9,7 @@ import { ResultsPanel } from "./ResultsPanel"; import { StatusBar } from "./StatusBar"; import { QueryTab, QueryHistoryItem } from "../types"; import { SqlEditor } from "./SqlEditor"; +import { SQLWorkspacePanelLoadingState } from "@/features/workspace/components/SQLWorkspacePanelLoadingState"; interface SQLWorkspacePanelProps { dbId: string; @@ -154,9 +155,7 @@ const SQLWorkspacePanel = ({ dbId }: SQLWorkspacePanelProps) => { if (!bridgeReady) { return ( -
- -
+ ); } diff --git a/src/features/workspace/components/SQLWorkspacePanelLoadingState.tsx b/src/features/workspace/components/SQLWorkspacePanelLoadingState.tsx new file mode 100644 index 0000000..1cf2503 --- /dev/null +++ b/src/features/workspace/components/SQLWorkspacePanelLoadingState.tsx @@ -0,0 +1,59 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function SQLWorkspacePanelLoadingState() { + return ( +
+ {/* Header skeleton */} +
+
+ + +
+
+ + +
+
+ {/* Body */} +
+ {/* Sidebar skeleton */} +
+ + {Array.from({ length: 7 }).map((_, i) => ( +
+ + +
+ ))} +
+ {/* Editor area skeleton */} +
+ {/* Tab bar */} +
+ + + +
+ {/* Code area */} +
+ {["w-1/3", "w-2/5", "w-1/2", "w-1/4", "w-2/3", "w-1/3"].map((w, i) => ( + + ))} +
+ {/* Results area */} +
+ + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+
+ {/* Status bar */} +
+ + +
+
+ ) +} From 85bfce5a2da85555e65f008531605456ec4bc270 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 21 Jun 2026 16:44:41 +0530 Subject: [PATCH 07/21] feat: add utility functions for formatting database types, durations, and numbers --- .../components/MonitoringDashboard.tsx | 4 ++-- .../monitoring/components/MonitoringPanel.tsx | 2 +- src/lib/utils.ts | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/features/monitoring/components/MonitoringDashboard.tsx b/src/features/monitoring/components/MonitoringDashboard.tsx index bd407ec..2dae588 100644 --- a/src/features/monitoring/components/MonitoringDashboard.tsx +++ b/src/features/monitoring/components/MonitoringDashboard.tsx @@ -29,8 +29,8 @@ import { } from "@/components/ui/table"; import { MonitoringSnapshot } from "@/features/database/types"; import { MetricCard } from "./MetricCard"; -import { formatNumber } from "@/lib/formatNumber"; -import { formatDuration } from "@/lib/formatDuration"; +import { formatNumber } from "@/lib/utils"; +import { formatDuration } from "@/lib/utils"; type ThroughputPoint = { diff --git a/src/features/monitoring/components/MonitoringPanel.tsx b/src/features/monitoring/components/MonitoringPanel.tsx index ba234f2..2f88b51 100644 --- a/src/features/monitoring/components/MonitoringPanel.tsx +++ b/src/features/monitoring/components/MonitoringPanel.tsx @@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { useMonitoringStream } from "@/features/monitoring/hooks/useMonitoringStream"; -import { formatDbType } from "@/lib/formatDbType"; +import { formatDbType } from "@/lib/utils"; import { MonitoringDashboard } from "./MonitoringDashboard"; import { MonitoringLoadingState } from "./MonitoringLoadingState"; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 19215ca..14b7fdd 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -41,3 +41,22 @@ export function formatRelativeTime(dateString?: string | null): string { if (diffDays < 7) return `${diffDays}d ago`; return formatTimestamp(dateString); } + +export function formatDbType(databaseType?: string) { + if (!databaseType) return "Database"; + if (databaseType === "postgres" || databaseType === "postgresql") return "PostgreSQL"; + if (databaseType === "mysql") return "MySQL"; + if (databaseType === "mariadb") return "MariaDB"; + return databaseType; +} +export function formatDuration(seconds: number) { + if (seconds < 60) return `${Math.round(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remaining = Math.round(seconds % 60); + return `${minutes}m ${remaining}s`; +} + +export function formatNumber(value: number) { + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value); +} + From bc90a3d4050b2690c32355e6ff1284bb329f7928 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 21 Jun 2026 17:14:43 +0530 Subject: [PATCH 08/21] feat: added formatTimestamp for TimeStamp columns --- src/components/shared/DataTable.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/shared/DataTable.tsx b/src/components/shared/DataTable.tsx index e64786b..f504282 100644 --- a/src/components/shared/DataTable.tsx +++ b/src/components/shared/DataTable.tsx @@ -9,6 +9,7 @@ import { import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { Database, Pencil, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { formatTimestamp } from "@/lib/utils"; interface DataTableProps { data: Array>; @@ -173,7 +174,7 @@ function formatCellValue(value: any): React.ReactNode { if (value instanceof Date) { return ( - {value.toLocaleString()} + {formatTimestamp(value.toISOString())} ); } @@ -196,7 +197,7 @@ function formatCellValue(value: any): React.ReactNode { // Check if it looks like a date string if (/^\d{4}-\d{2}-\d{2}/.test(strValue)) { - return {strValue}; + return {formatTimestamp(strValue)}; } // Check if it looks like an ID or UUID From 6069f95fcdd072e0506238e84e924ee2ac80bd02 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 21 Jun 2026 23:49:19 +0530 Subject: [PATCH 09/21] feat: enhance the loaders and timestamp func --- src/components/shared/DataTable.tsx | 26 ++++++++-- .../components/MonitoringLoadingState.tsx | 30 ++++++------ .../QueryBuilderPanelLoadingState.tsx | 15 +++--- .../components/AnalyzeSchemaButton.tsx | 16 +++--- .../components/SchemaExplorerHeader.tsx | 1 + .../SchemaExplorerPanelLoadingState.tsx | 49 ++++++++++--------- .../SQLWorkspacePanelLoadingState.tsx | 9 ++-- 7 files changed, 86 insertions(+), 60 deletions(-) diff --git a/src/components/shared/DataTable.tsx b/src/components/shared/DataTable.tsx index f504282..9daab30 100644 --- a/src/components/shared/DataTable.tsx +++ b/src/components/shared/DataTable.tsx @@ -85,7 +85,7 @@ export const DataTable = ({ title={row[column]?.toString() || 'NULL'} > {row[column] !== null && row[column] !== undefined ? ( - formatCellValue(row[column]) + formatCellValue(row[column], column) ) : ( null )} @@ -147,7 +147,7 @@ export const DataTable = ({ }; // Helper function to format cell values with improved styling -function formatCellValue(value: any): React.ReactNode { +function formatCellValue(value: any, columnName?: string): React.ReactNode { if (value === null || value === undefined) { return null; } @@ -164,6 +164,14 @@ function formatCellValue(value: any): React.ReactNode { } if (typeof value === 'number') { + const isLikelyTimestampCol = columnName?.toLowerCase().match(/time|date|created|updated|deleted/); + if (isLikelyTimestampCol) { + if (value > 1e9 && value < 1e10) { // Seconds + return {formatTimestamp(new Date(value * 1000).toISOString())}; + } else if (value > 1e12 && value < 1e13) { // Milliseconds + return {formatTimestamp(new Date(value).toISOString())}; + } + } return ( {value.toLocaleString()} @@ -196,10 +204,22 @@ function formatCellValue(value: any): React.ReactNode { const strValue = String(value); // Check if it looks like a date string - if (/^\d{4}-\d{2}-\d{2}/.test(strValue)) { + if (typeof strValue === 'string' && /^\d{4}-\d{2}-\d{2}/.test(strValue)) { return {formatTimestamp(strValue)}; } + // Check if it's a UNIX timestamp (seconds or ms) based on column name heuristic + const isLikelyTimestampCol = columnName?.toLowerCase().match(/time|date|created|updated|deleted/); + if (isLikelyTimestampCol) { + // 10 digits (seconds) or 13 digits (ms) + if (/^\d{10}$/.test(strValue)) { + return {formatTimestamp(new Date(Number(strValue) * 1000).toISOString())}; + } + if (/^\d{13}$/.test(strValue)) { + return {formatTimestamp(new Date(Number(strValue)).toISOString())}; + } + } + // Check if it looks like an ID or UUID if (/^[a-f0-9-]{36}$/i.test(strValue)) { return {strValue}; diff --git a/src/features/monitoring/components/MonitoringLoadingState.tsx b/src/features/monitoring/components/MonitoringLoadingState.tsx index c299027..4b220eb 100644 --- a/src/features/monitoring/components/MonitoringLoadingState.tsx +++ b/src/features/monitoring/components/MonitoringLoadingState.tsx @@ -1,5 +1,5 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; - +import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; export function MonitoringLoadingState() { @@ -10,16 +10,16 @@ export function MonitoringLoadingState() {
-
-
+ +
-
+
-
- {index === 1 ?
: null} + + {index === 1 ? : null} ))} @@ -30,10 +30,10 @@ export function MonitoringLoadingState() {
-
-
+ +
-
+
@@ -47,17 +47,17 @@ export function MonitoringLoadingState() {
-
-
+ +
{Array.from({ length: 5 }).map((_, index) => (
-
-
-
-
+ + + +
))} diff --git a/src/features/query-builder/components/QueryBuilderPanelLoadingState.tsx b/src/features/query-builder/components/QueryBuilderPanelLoadingState.tsx index 0ac9b06..3d80fd0 100644 --- a/src/features/query-builder/components/QueryBuilderPanelLoadingState.tsx +++ b/src/features/query-builder/components/QueryBuilderPanelLoadingState.tsx @@ -1,4 +1,5 @@ import { Skeleton } from "@/components/ui/skeleton"; +import { Card } from "@/components/ui/card"; export function QueryBuilderPanelLoadingState() { return ( @@ -17,7 +18,7 @@ export function QueryBuilderPanelLoadingState() { {/* Body */}
{/* Sidebar skeleton */} -
+
{Array.from({ length: 5 }).map((_, i) => ( @@ -33,16 +34,16 @@ export function QueryBuilderPanelLoadingState() { ))}
-
+ {/* Main canvas + results */} -
+ {/* Canvas area (diagram) */}
{/* Fake node cards */} {[{ top: "20%", left: "15%" }, { top: "25%", left: "55%" }, { top: "60%", left: "35%" }].map((pos, i) => ( -
@@ -56,7 +57,7 @@ export function QueryBuilderPanelLoadingState() {
))}
-
+
))}
{/* SQL results pane */} @@ -66,7 +67,7 @@ export function QueryBuilderPanelLoadingState() { ))}
-
+
{/* Status bar */}
diff --git a/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx b/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx index 7ae0c3d..68161fb 100644 --- a/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx +++ b/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx @@ -23,9 +23,10 @@ interface AnalyzeSchemaButtonProps { }>; }; databaseType?: string; + dbId?: string; } -export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaButtonProps) { +export function AnalyzeSchemaButton({ schemaData, databaseType, dbId }: AnalyzeSchemaButtonProps) { const { settings } = useAISettings(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); @@ -41,15 +42,15 @@ export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaB // Reset all local state when the user switches to a different database useEffect(() => { - const name = schemaData.name; - if (cachedForRef.current !== undefined && cachedForRef.current !== name) { + const identifier = dbId ?? schemaData.name; + if (cachedForRef.current !== undefined && cachedForRef.current !== identifier) { setMarkdown(undefined); setError(null); setCached(undefined); setCreatedAt(undefined); cachedForRef.current = undefined; } - }, [schemaData.name]); + }, [schemaData.name, dbId]); const buildInput = (): SchemaAnalysisInput => ({ databaseType, @@ -70,8 +71,9 @@ export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaB const handleAnalyze = async (skipCache = false) => { setOpen(true); + const identifier = dbId ?? schemaData.name; // Only reuse cached markdown if it's for THIS database - if (markdown && !skipCache && cachedForRef.current === schemaData.name) return; + if (markdown && !skipCache && cachedForRef.current === identifier) return; setLoading(true); setError(null); @@ -79,12 +81,12 @@ export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaB const input = buildInput(); const result = await aiService.analyzeSchema(settings, input, { skipCache, - datasourceName: schemaData.name, + datasourceName: identifier, }); setMarkdown(result.markdown); setCached(result.cached); setCreatedAt(result.createdAt); - cachedForRef.current = schemaData.name; // mark which DB this result belongs to + cachedForRef.current = identifier; // mark which DB this result belongs to } catch (err: any) { setError(err?.message ?? String(err)); } finally { diff --git a/src/features/schema-explorer/components/SchemaExplorerHeader.tsx b/src/features/schema-explorer/components/SchemaExplorerHeader.tsx index 84f884d..2d83e2b 100644 --- a/src/features/schema-explorer/components/SchemaExplorerHeader.tsx +++ b/src/features/schema-explorer/components/SchemaExplorerHeader.tsx @@ -51,6 +51,7 @@ const SchemaExplorerHeader = ({ dbId, database, onTableCreated, selectedTable }: {database.schemas && database.schemas.length > 0 && ( )} diff --git a/src/features/schema-explorer/components/SchemaExplorerPanelLoadingState.tsx b/src/features/schema-explorer/components/SchemaExplorerPanelLoadingState.tsx index e020fd5..ddc5f51 100644 --- a/src/features/schema-explorer/components/SchemaExplorerPanelLoadingState.tsx +++ b/src/features/schema-explorer/components/SchemaExplorerPanelLoadingState.tsx @@ -1,16 +1,18 @@ +import { Skeleton } from "@/components/ui/skeleton"; + export function SchemaExplorerPanelLoadingState() { return (
{/* ── Header (breadcrumb + actions) ── */}
-
-
-
+ + +
-
-
+ +
@@ -22,17 +24,17 @@ export function SchemaExplorerPanelLoadingState() { {/* Database root node */}
-
-
-
+ + +
{/* Schema node (expanded) */}
-
-
-
-
+ + + +
{/* Table rows under schema */} @@ -42,12 +44,12 @@ export function SchemaExplorerPanelLoadingState() { "w-22", "w-26", "w-32", "w-18", "w-16", ].map((w, i) => (
-
-
-
+ + + {/* Occasional FK badge */} {i === 7 && ( -
+ )}
))} @@ -58,13 +60,12 @@ export function SchemaExplorerPanelLoadingState() { {/* DB title row */}
-
+
-
-
+ +
- {/* 2 × 3 stat cards grid */}
{[ @@ -80,9 +81,9 @@ export function SchemaExplorerPanelLoadingState() { className="rounded-lg border border-border/30 bg-card/50 p-4 space-y-2" > {/* Big number */} -
+ {/* Label */} -
+
))}
@@ -92,10 +93,10 @@ export function SchemaExplorerPanelLoadingState() { {/* ── Footer ── */}
-
+
-
+
) diff --git a/src/features/workspace/components/SQLWorkspacePanelLoadingState.tsx b/src/features/workspace/components/SQLWorkspacePanelLoadingState.tsx index 1cf2503..adfbe5d 100644 --- a/src/features/workspace/components/SQLWorkspacePanelLoadingState.tsx +++ b/src/features/workspace/components/SQLWorkspacePanelLoadingState.tsx @@ -1,4 +1,5 @@ import { Skeleton } from "@/components/ui/skeleton"; +import { Card } from "@/components/ui/card"; export function SQLWorkspacePanelLoadingState() { return ( @@ -17,7 +18,7 @@ export function SQLWorkspacePanelLoadingState() { {/* Body */}
{/* Sidebar skeleton */} -
+ {Array.from({ length: 7 }).map((_, i) => (
@@ -25,9 +26,9 @@ export function SQLWorkspacePanelLoadingState() {
))} -
+ {/* Editor area skeleton */} -
+ {/* Tab bar */}
@@ -47,7 +48,7 @@ export function SQLWorkspacePanelLoadingState() { ))}
-
+
{/* Status bar */}
From b4a92c3760a48169f2931cfe9a44a5ca3e1ec687 Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 23 Jun 2026 21:09:03 +0530 Subject: [PATCH 10/21] feat(bridge): implement LRU cache and improve connector stability --- bridge/__tests__/connectors/sqlite.test.ts | 4 +- bridge/src/connectors/postgres.ts | 278 +++++++++++---------- bridge/src/connectors/sqlite.ts | 72 +++--- bridge/src/utils/lruCache.ts | 61 +++++ 4 files changed, 240 insertions(+), 175 deletions(-) create mode 100644 bridge/src/utils/lruCache.ts diff --git a/bridge/__tests__/connectors/sqlite.test.ts b/bridge/__tests__/connectors/sqlite.test.ts index 5e08256..f6f6136 100644 --- a/bridge/__tests__/connectors/sqlite.test.ts +++ b/bridge/__tests__/connectors/sqlite.test.ts @@ -103,7 +103,7 @@ describe("SQLite Connector", () => { const connection = await sqliteConnector.testConnection({ path: tmpDir }); expect(connection.ok).toBe(false); expect(connection.status).toBe("disconnected"); - expect(connection.message).toContain("directory"); + expect(connection.message).toContain("must have a valid SQLite extension"); }); }); @@ -261,7 +261,7 @@ describe("SQLite Connector", () => { expect(stats).toHaveProperty("total_db_size_mb"); expect(stats).toHaveProperty("total_rows"); expect(stats.total_tables).toBeGreaterThanOrEqual(2); - expect(stats.total_rows).toBeGreaterThan(0); + expect(stats.total_rows).toBe(-1); }); }); diff --git a/bridge/src/connectors/postgres.ts b/bridge/src/connectors/postgres.ts index 4f56d94..df7c3c9 100644 --- a/bridge/src/connectors/postgres.ts +++ b/bridge/src/connectors/postgres.ts @@ -1,5 +1,5 @@ // bridge/src/connectors/postgres.ts -import { Client } from "pg"; +import { Pool, PoolClient } from "pg"; import QueryStream from "pg-query-stream"; import { Readable } from "stream"; import { loadLocalMigrations, writeBaselineMigration } from "../utils/baselineMigration"; @@ -13,6 +13,7 @@ import { STATS_CACHE_TTL, SCHEMA_CACHE_TTL } from "../types/cache"; +import { LRUCache } from "../utils/lruCache"; import { TableInfo, DBStats, @@ -82,17 +83,17 @@ import { pgQuoteIdentifier } from "../queries/postgres/crud"; export class PostgresCacheManager { // Cache stores for different data types - private tableListCache = new Map>(); - private primaryKeysCache = new Map>(); - private dbStatsCache = new Map>(); - private schemasCache = new Map>(); - private tableDetailsCache = new Map>(); - private foreignKeysCache = new Map>(); - private indexesCache = new Map>(); - private uniqueCache = new Map>(); - private checksCache = new Map>(); - private enumsCache = new Map>(); - private sequencesCache = new Map>(); + private tableListCache = new LRUCache(100); + private primaryKeysCache = new LRUCache(1000); + private dbStatsCache = new LRUCache(50); + private schemasCache = new LRUCache(50); + private tableDetailsCache = new LRUCache(1000); + private foreignKeysCache = new LRUCache(1000); + private indexesCache = new LRUCache(1000); + private uniqueCache = new LRUCache(1000); + private checksCache = new LRUCache(1000); + private enumsCache = new LRUCache(1000); + private sequencesCache = new LRUCache(1000); /** * Generate cache key from config @@ -398,34 +399,39 @@ export const postgresCache = new PostgresCacheManager(); * Creates a new Client instance from the config. * Encapsulates the configuration mapping logic. */ -function createClient(cfg: PGConfig): Client { - // Build SSL configuration - let sslConfig: boolean | { rejectUnauthorized: boolean } | undefined; - - if (cfg.ssl) { - // For cloud databases (Supabase, Railway, etc.), we need to allow self-signed certs - // sslmode=require or sslmode=prefer should use rejectUnauthorized: false - sslConfig = { - rejectUnauthorized: cfg.sslmode === 'verify-full' || cfg.sslmode === 'verify-ca' - }; - } - - return new Client({ - host: cfg.host, - port: cfg.port, - user: cfg.user, - ssl: sslConfig, - password: cfg.password || undefined, - database: cfg.database || undefined, - }); +const connectionPools = new Map(); + +export function getPool(cfg: PGConfig): Pool { + const key = `${cfg.host}:${cfg.port || 5432}:${cfg.database || ""}:${cfg.user}`; + let pool = connectionPools.get(key); + if (!pool) { + let sslConfig; + if (cfg.ssl) { + sslConfig = { rejectUnauthorized: cfg.sslmode === 'verify-full' || cfg.sslmode === 'verify-ca' }; + } + pool = new Pool({ + host: cfg.host, + port: cfg.port, + user: cfg.user, + ssl: sslConfig, + password: cfg.password || undefined, + database: cfg.database || undefined, + max: 10, + idleTimeoutMillis: 30000, + }); + connectionPools.set(key, pool); + } + return pool; } + + /** test connection quickly */ export async function testConnection(cfg: PGConfig): Promise<{ ok: boolean; message?: string; status: 'connected' | 'disconnected' }> { - const client = createClient(cfg); + const pool = getPool(cfg); try { - await client.connect(); - await client.end(); + const client = await pool.connect(); + await client.release(); return { ok: true, status: 'connected', message: "Connection successful" }; } catch (err: any) { return { ok: false, message: err.message || String(err), status: 'disconnected' }; @@ -437,15 +443,15 @@ export async function testConnection(cfg: PGConfig): Promise<{ ok: boolean; mess * Returns true if successful (pg_cancel_backend returns boolean). */ export async function pgCancel(cfg: PGConfig, targetPid: number) { - const c = createClient(cfg); + const pool = getPool(cfg); + const c = await pool.connect(); try { - await c.connect(); const res = await c.query(PG_CANCEL_QUERY, [targetPid]); - await c.end(); + await c.release(); return res.rows?.[0]?.cancelled === true; } catch (err) { try { - await c.end(); + await c.release(); } catch (e) { } throw err; } @@ -466,10 +472,10 @@ export async function fetchTableData( page: number ): Promise<{ rows: any[]; total: number }> { - const client = createClient(config); + const pool = getPool(config); + const client = await pool.connect(); try { - await client.connect(); const safeSchema = `"${schemaName.replace(/"/g, '""')}"`; const safeTable = `"${tableName.replace(/"/g, '""')}"`; @@ -522,7 +528,7 @@ export async function fetchTableData( ); } finally { try { - await client.end(); + await client.release(); } catch (_) { } } } @@ -539,7 +545,8 @@ export async function listTables(connection: PGConfig, schemaName?: string) { return cached; } - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); let query = PG_LIST_TABLES; let queryParams: string[] = []; @@ -551,12 +558,11 @@ export async function listTables(connection: PGConfig, schemaName?: string) { } try { - await client.connect(); // Execute the dynamically constructed query const res = await client.query(query, queryParams); - await client.end(); + await client.release(); const result = res.rows; @@ -566,7 +572,7 @@ export async function listTables(connection: PGConfig, schemaName?: string) { return result; // [{schema, name, type}, ...] } catch (err) { try { - await client.end(); + await client.release(); } catch (e) { } throw err; } @@ -579,10 +585,10 @@ export async function listPrimaryKeys(connection: PGConfig, schemaName: string = return cached; } - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_GET_PRIMARY_KEYS, [tableName]); const result = res.rows; @@ -593,7 +599,7 @@ export async function listPrimaryKeys(connection: PGConfig, schemaName: string = return result; } catch (err) { try { - await client.end(); + await client.release(); } catch (e) { } throw err; } @@ -608,10 +614,10 @@ export async function listForeignKeys( const cached = postgresCache.getForeignKeys(connection, schemaName, tableName); if (cached !== null) return cached; - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_GET_FOREIGN_KEYS, [tableName, schemaName]); const result = res.rows; @@ -622,7 +628,7 @@ export async function listForeignKeys( throw err; } finally { try { - await client.end(); + await client.release(); } catch { } } } @@ -633,15 +639,15 @@ export async function listIndexes(connection: PGConfig, schemaName = "public", t const cached = postgresCache.getIndexes(connection, schemaName, tableName); if (cached !== null) return cached; - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_GET_INDEXES, [tableName, schemaName]); postgresCache.setIndexes(connection, schemaName, tableName, res.rows); return res.rows; } finally { - try { await client.end(); } catch { } + try { await client.release(); } catch { } } } @@ -649,15 +655,15 @@ export async function listUniqueConstraints(connection: PGConfig, schemaName = " const cached = postgresCache.getUnique(connection, schemaName, tableName); if (cached !== null) return cached; - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_GET_UNIQUE_CONSTRAINTS, [tableName, schemaName]); postgresCache.setUnique(connection, schemaName, tableName, res.rows); return res.rows; } finally { - try { await client.end(); } catch { } + try { await client.release(); } catch { } } } @@ -665,15 +671,15 @@ export async function listCheckConstraints(connection: PGConfig, schemaName = "p const cached = postgresCache.getChecks(connection, schemaName, tableName); if (cached !== null) return cached; - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_GET_CHECK_CONSTRAINTS, [tableName, schemaName]); postgresCache.setChecks(connection, schemaName, tableName, res.rows); return res.rows; } finally { - try { await client.end(); } catch { } + try { await client.release(); } catch { } } } @@ -682,15 +688,15 @@ export async function listEnumTypes(connection: PGConfig, schemaName = "public") const cached = postgresCache.getEnums(connection, schemaName); if (cached !== null) return cached; - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_LIST_ENUMS, [schemaName]); postgresCache.setEnums(connection, schemaName, res.rows); return res.rows; } finally { - try { await client.end(); } catch { } + try { await client.release(); } catch { } } } @@ -698,15 +704,15 @@ export async function listSequences(connection: PGConfig, schemaName = "public") const cached = postgresCache.getSequences(connection, schemaName); if (cached !== null) return cached; - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_LIST_SEQUENCES, [schemaName]); postgresCache.setSequences(connection, schemaName, res.rows); return res.rows; } finally { - try { await client.end(); } catch { } + try { await client.release(); } catch { } } } @@ -734,10 +740,10 @@ export async function getSchemaMetadataBatch( enumTypes: EnumInfo[]; sequences: SequenceInfo[]; }> { - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); // Execute all queries in parallel using imported batch queries const [ @@ -855,7 +861,7 @@ export async function getSchemaMetadataBatch( sequences: sequencesResult.rows }; } finally { - try { await client.end(); } catch { } + try { await client.release(); } catch { } } } @@ -873,14 +879,15 @@ export function streamQueryCancelable( onBatch: (rows: any[], columns: { name: string }[]) => Promise | void, onDone?: () => void ): { promise: Promise; cancel: () => Promise } { - const client = createClient(cfg); + let client: PoolClient | null = null; let stream: Readable | null = null; let finished = false; let cancelled = false; let backendPid: number | null = null; const promise = (async () => { - await client.connect(); + const pool = getPool(cfg); + client = await pool.connect(); // capture backend pid (node-postgres exposes processID) // @ts-ignore @@ -941,7 +948,7 @@ export function streamQueryCancelable( } } finally { try { - await client.end(); + await client.release(); } catch (e) { } } } @@ -973,7 +980,7 @@ export function streamQueryCancelable( // 3) Close client connection try { - await client.end(); + await client.release(); } catch (e) { /* ignore */ } @@ -993,13 +1000,13 @@ export async function getDBStats(connection: PGConfig): Promise<{ return cached; } - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_GET_DB_STATS); // CRITICAL: Ensure the pg client is closed after a successful query - await client.end(); + await client.release(); // CRITICAL: Update the return type structure const result = res.rows?.[0] as { @@ -1017,7 +1024,7 @@ export async function getDBStats(connection: PGConfig): Promise<{ console.error("Error fetching database stats:", error); // Attempt to close the client even if an error occurred during connection/query try { - await client.end(); + await client.release(); } catch (endError) { console.error("Error closing client after failure:", endError); } @@ -1035,11 +1042,11 @@ export async function listSchemas(connection: PGConfig) { return cached; } - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_LIST_SCHEMAS); - await client.end(); + await client.release(); const result = res.rows; @@ -1049,7 +1056,7 @@ export async function listSchemas(connection: PGConfig) { return result; // [{ name: 'public' }, { name: 'analytics' }, ...] } catch (err) { try { - await client.end(); + await client.release(); } catch (e) { } throw err; } @@ -1067,11 +1074,11 @@ export async function getTableDetails( return cached; } - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_GET_TABLE_DETAILS, [`${schemaName}.${tableName}`]); - await client.end(); + await client.release(); const result = res.rows; @@ -1104,7 +1111,8 @@ export async function createTable( columns: ColumnDetail[], foreignKeys: ForeignKeyInfo[] = [] ) { - const client = createClient(conn); + const pool = getPool(conn); + const client = await pool.connect(); const primaryKeys = columns .filter(c => c.is_primary_key) @@ -1136,7 +1144,6 @@ export async function createTable( `; try { - await client.connect(); await client.query("BEGIN"); await client.query(createTableQuery); @@ -1161,7 +1168,7 @@ export async function createTable( await client.query("ROLLBACK"); throw err; } finally { - await client.end(); + await client.release(); } } @@ -1185,10 +1192,10 @@ export async function createIndexes( schemaName: string, indexes: IndexInfo[] ): Promise { - const client = createClient(conn); + const pool = getPool(conn); + const client = await pool.connect(); const grouped = groupIndexes(indexes); try { - await client.connect(); for (const group of grouped) { const first = group[0]; @@ -1214,7 +1221,7 @@ export async function createIndexes( } catch (error) { throw error; } finally { - await client.end(); + await client.release(); } } @@ -1225,10 +1232,10 @@ export async function alterTable( tableName: string, operations: PGAlterTableOperation[] ): Promise { - const client = createClient(conn); + const pool = getPool(conn); + const client = await pool.connect(); try { - await client.connect(); await client.query("BEGIN"); for (const op of operations) { @@ -1306,7 +1313,7 @@ export async function alterTable( await client.query("ROLLBACK"); throw err; } finally { - await client.end(); + await client.release(); } } @@ -1317,10 +1324,10 @@ export async function dropTable( tableName: string, mode: PGDropMode = "RESTRICT" ): Promise { - const client = createClient(conn); + const pool = getPool(conn); + const client = await pool.connect(); try { - await client.connect(); await client.query("BEGIN"); if (mode !== "CASCADE") { @@ -1368,33 +1375,34 @@ export async function dropTable( await client.query("ROLLBACK"); throw err; } finally { - await client.end(); + await client.release(); } } export async function ensureMigrationTable(client: PGConfig) { - const connection = createClient(client) + const pool = getPool(client); + const connection = await pool.connect(); try { await connection.connect() await connection.query(PG_CREATE_MIGRATION_TABLE); } catch (error) { throw error; } finally { - await connection.end(); + await connection.release(); } } export async function hasAnyMigrations(connection: PGConfig): Promise { - const client = createClient(connection) + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const { rows } = await client.query(PG_CHECK_MIGRATIONS_EXIST); return rows.length > 0; } catch (error) { throw error; } finally { - await client.end(); + await client.release(); } } @@ -1404,15 +1412,15 @@ export async function insertBaseline( name: string, checksum: string ): Promise { - const client = createClient(conn) + const pool = getPool(conn); + const client = await pool.connect(); try { - await client.connect(); await client.query(PG_INSERT_MIGRATION, [version, name, checksum]); return true; } catch (error) { throw error; } finally { - await client.end(); + await client.release(); } } @@ -1421,13 +1429,13 @@ export async function baselineIfNeeded( migrationsDir: string, snapshot?: SchemaFile ) { - const client = createClient(conn); + const pool = getPool(conn); + const client = await pool.connect(); try { - await client.connect(); - await ensureMigrationTable(client); + await ensureMigrationTable(conn); - const hasMigrations = await hasAnyMigrations(client); + const hasMigrations = await hasAnyMigrations(conn); if (hasMigrations) return { baselined: false }; const version = Date.now().toString(); @@ -1456,11 +1464,11 @@ export async function baselineIfNeeded( .update(fs.readFileSync(filePath)) .digest("hex"); - await insertBaseline(client, version, name, checksum); + await insertBaseline(conn, version, name, checksum); return { baselined: true, version }; } finally { - await client.end(); + await client.release(); } } @@ -1469,10 +1477,10 @@ export async function baselineIfNeeded( export async function listAppliedMigrations( cfg: PGConfig, ): Promise { - const client = createClient(cfg); + const pool = getPool(cfg); + const client = await pool.connect(); try { - await client.connect(); // Important: table may not exist yet const tableExists = await client.query(` @@ -1491,7 +1499,7 @@ export async function listAppliedMigrations( return res.rows as AppliedMigration[]; } finally { - await client.end(); + await client.release(); } } @@ -1545,10 +1553,10 @@ export async function applyMigration( cfg: PGConfig, migrationFilePath: string ): Promise { - const client = createClient(cfg); + const pool = getPool(cfg); + const client = await pool.connect(); try { - await client.connect(); // Read and parse migration file const { readMigrationFile } = await import('../utils/migrationFileReader'); @@ -1578,7 +1586,7 @@ export async function applyMigration( await client.query('ROLLBACK'); throw error; } finally { - await client.end(); + await client.release(); } } @@ -1590,10 +1598,10 @@ export async function rollbackMigration( version: string, migrationFilePath: string ): Promise { - const client = createClient(cfg); + const pool = getPool(cfg); + const client = await pool.connect(); try { - await client.connect(); // Read and parse migration file const { readMigrationFile } = await import('../utils/migrationFileReader'); @@ -1619,7 +1627,7 @@ export async function rollbackMigration( await client.query('ROLLBACK'); throw error; } finally { - await client.end(); + await client.release(); } } @@ -1637,10 +1645,10 @@ export async function insertRow( tableName: string, rowData: Record ): Promise { - const client = createClient(cfg); + const pool = getPool(cfg); + const client = await pool.connect(); try { - await client.connect(); const columns = Object.keys(rowData); const values = Object.values(rowData); @@ -1671,7 +1679,7 @@ export async function insertRow( throw new Error(`Failed to insert row into ${schemaName}.${tableName}: ${error}`); } finally { try { - await client.end(); + await client.release(); } catch (_) { } } } @@ -1694,10 +1702,10 @@ export async function updateRow( primaryKeyValue: any, rowData: Record ): Promise { - const client = createClient(cfg); + const pool = getPool(cfg); + const client = await pool.connect(); try { - await client.connect(); const columns = Object.keys(rowData); const values = Object.values(rowData); @@ -1744,7 +1752,7 @@ export async function updateRow( throw new Error(`Failed to update row in ${schemaName}.${tableName}: ${error}`); } finally { try { - await client.end(); + await client.release(); } catch (_) { } } } @@ -1765,10 +1773,10 @@ export async function deleteRow( primaryKeyColumn: string, primaryKeyValue: any ): Promise { - const client = createClient(cfg); + const pool = getPool(cfg); + const client = await pool.connect(); try { - await client.connect(); const safeSchema = `"${schemaName.replace(/"/g, '""')}"`; const safeTable = `"${tableName.replace(/"/g, '""')}"`; @@ -1807,7 +1815,7 @@ export async function deleteRow( throw new Error(`Failed to delete row from ${schemaName}.${tableName}: ${error}`); } finally { try { - await client.end(); + await client.release(); } catch (_) { } } } @@ -1831,10 +1839,10 @@ export async function searchTable( page: number = 1, pageSize: number = 50 ): Promise<{ rows: any[]; total: number }> { - const client = createClient(cfg); + const pool = getPool(cfg); + const client = await pool.connect(); try { - await client.connect(); const safeSchema = `"${schemaName.replace(/"/g, '""')}"`; const safeTable = `"${tableName.replace(/"/g, '""')}"`; @@ -1892,7 +1900,7 @@ export async function searchTable( throw new Error(`Failed to search table ${schemaName}.${tableName}: ${error}`); } finally { try { - await client.end(); + await client.release(); } catch (_) { } } } @@ -1904,15 +1912,15 @@ export async function searchTable( export async function listSchemaNames(connection: PGConfig): Promise { // Check cache first (re-use schemas cache if available, or a new cache if needed) // For now, simpler to just query as it's very fast - const client = createClient(connection); + const pool = getPool(connection); + const client = await pool.connect(); try { - await client.connect(); const res = await client.query(PG_LIST_SCHEMAS); return res.rows.map((r: any) => r.name); } finally { try { - await client.end(); + await client.release(); } catch (e) { } } } diff --git a/bridge/src/connectors/sqlite.ts b/bridge/src/connectors/sqlite.ts index 38b56e4..f5b9f75 100644 --- a/bridge/src/connectors/sqlite.ts +++ b/bridge/src/connectors/sqlite.ts @@ -62,16 +62,18 @@ import { SchemaFile } from "../services/projectStore"; // CACHING SYSTEM FOR SQLITE CONNECTOR // ============================================ +import { LRUCache } from "../utils/lruCache"; + export class SQLiteCacheManager { - private tableListCache = new Map>(); - private primaryKeysCache = new Map>(); - private dbStatsCache = new Map>(); - private schemasCache = new Map>(); - private tableDetailsCache = new Map>(); - private foreignKeysCache = new Map>(); - private indexesCache = new Map>(); - private uniqueCache = new Map>(); - private checksCache = new Map>(); + private tableListCache = new LRUCache(100); + private primaryKeysCache = new LRUCache(1000); + private dbStatsCache = new LRUCache(50); + private schemasCache = new LRUCache(50); + private tableDetailsCache = new LRUCache(1000); + private foreignKeysCache = new LRUCache(1000); + private indexesCache = new LRUCache(1000); + private uniqueCache = new LRUCache(1000); + private checksCache = new LRUCache(1000); private getConfigKey(cfg: SQLiteConfig): string { return cfg.path; @@ -276,6 +278,15 @@ function validateSQLitePath( ); } + const validExtensions = ['.db', '.sqlite', '.sqlite3', '.db3', '.s3db', '.sl3']; + const ext = dbPath.substring(dbPath.lastIndexOf('.')).toLowerCase(); + + if (dbPath !== ':memory:' && !validExtensions.includes(ext)) { + throw new Error( + `Invalid SQLite path "${dbPath}" - must have a valid SQLite extension (.db, .sqlite, etc.) to prevent arbitrary file access.` + ); + } + if (fs.existsSync(dbPath)) { const stat = fs.statSync(dbPath); if (stat.isDirectory()) { @@ -623,27 +634,9 @@ export async function getDBStats(cfg: SQLiteConfig): Promise { // Get DB file size const pageCount = db.pragma("page_count", { simple: true }) as number; const pageSize = db.pragma("page_size", { simple: true }) as number; - const totalSizeMB = (pageCount * pageSize) / (1024 * 1024); - - // Count total rows across all tables. - // On large databases this can be very expensive and will block the event loop - // because better-sqlite3 is synchronous. To keep stats fetching responsive, - // only compute total_rows for databases up to a certain size. - const MAX_DB_SIZE_MB_FOR_ROWCOUNT = 50; - let totalRows = 0; - if (totalSizeMB <= MAX_DB_SIZE_MB_FOR_ROWCOUNT) { - const tables = db.prepare(SQLITE_LIST_TABLES).all() as any[]; - for (const t of tables) { - try { - const countRow = db - .prepare(`SELECT COUNT(*) AS cnt FROM ${quoteIdent(t.name)}`) - .get() as any; - totalRows += Number(countRow?.cnt) || 0; - } catch { - // Skip tables that can't be counted - } - } - } + const totalSizeMB = (pageCount * pageSize) / (1024 * 1024); // Count total rows across all tables. + // Removed because better-sqlite3 is synchronous and SELECT COUNT(*) blocks the Node event loop. + let totalRows = -1; const result: DBStats = { total_tables: totalTables, @@ -991,16 +984,19 @@ export async function createIndexes( grouped.get(idx.index_name)!.push(idx); } - for (const [, group] of grouped) { - const sorted = group.sort((a, b) => (a.ordinal_position || 0) - (b.ordinal_position || 0)); - const first = sorted[0]; - if (first.is_primary) continue; + const transaction = db.transaction(() => { + for (const [, group] of grouped) { + const sorted = group.sort((a, b) => (a.ordinal_position || 0) - (b.ordinal_position || 0)); + const first = sorted[0]; + if (first.is_primary) continue; - const cols = sorted.map(i => quoteIdent(i.column_name)).join(", "); - const sql = `CREATE ${first.is_unique ? "UNIQUE" : ""} INDEX IF NOT EXISTS ${quoteIdent(first.index_name)} ON ${quoteIdent(first.table_name)} (${cols});`; - db.exec(sql); - } + const cols = sorted.map(i => quoteIdent(i.column_name)).join(", "); + const sql = `CREATE ${first.is_unique ? "UNIQUE" : ""} INDEX IF NOT EXISTS ${quoteIdent(first.index_name)} ON ${quoteIdent(first.table_name)} (${cols});`; + db.exec(sql); + } + }); + transaction(); return true; } finally { db.close(); diff --git a/bridge/src/utils/lruCache.ts b/bridge/src/utils/lruCache.ts new file mode 100644 index 0000000..31bad12 --- /dev/null +++ b/bridge/src/utils/lruCache.ts @@ -0,0 +1,61 @@ +import { CacheEntry } from "../types/cache"; + +export class LRUCache { + private map = new Map>(); + private maxSize: number; + + constructor(maxSize = 1000) { + this.maxSize = maxSize; + } + + get(key: K): CacheEntry | undefined { + const entry = this.map.get(key); + if (!entry) return undefined; + + // Check TTL on read to lazily evict + if (Date.now() - entry.timestamp > entry.ttl) { + this.map.delete(key); + return undefined; + } + + // Refresh position to maintain LRU order (most recently used at the end) + this.map.delete(key); + this.map.set(key, entry); + return entry; + } + + set(key: K, value: CacheEntry): void { + if (this.map.has(key)) { + this.map.delete(key); + } + this.map.set(key, value); + + if (this.map.size > this.maxSize) { + // The first item in a Map iterator is the oldest inserted item + const oldestKey = this.map.keys().next().value; + if (oldestKey !== undefined) { + this.map.delete(oldestKey); + } + } + } + + delete(key: K): void { + this.map.delete(key); + } + + clear(): void { + this.map.clear(); + } + + get size(): number { + return this.map.size; + } + + keys(): IterableIterator { + return this.map.keys(); + } + + [Symbol.iterator](): IterableIterator<[K, CacheEntry]> { + return this.map[Symbol.iterator](); + } +} From 05059ca4c45863b73fa508e9c2bc162ab7742b26 Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 23 Jun 2026 21:09:14 +0530 Subject: [PATCH 11/21] fix(bridge): resolve flaky dbStore test and improve error handling --- bridge/__tests__/dbStore.test.ts | 4 ++-- bridge/__tests__/discoveryService.test.ts | 4 ++-- bridge/src/index.ts | 10 ++++++++++ bridge/src/services/gitService.ts | 22 ++++++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/bridge/__tests__/dbStore.test.ts b/bridge/__tests__/dbStore.test.ts index f9f851d..b626b3d 100644 --- a/bridge/__tests__/dbStore.test.ts +++ b/bridge/__tests__/dbStore.test.ts @@ -11,7 +11,7 @@ const TEST_CONFIG_FILE = path.join(TEST_CONFIG_FOLDER, "databases.json"); const TEST_CREDENTIALS_FILE = path.join(TEST_CONFIG_FOLDER, ".credentials"); // Short TTL for testing cache expiration -const SHORT_CACHE_TTL = 200; // 200ms for testing +const SHORT_CACHE_TTL = 1000; // 200ms for testing const NORMAL_CACHE_TTL = 30000; // 30 seconds const mockDBPayload = { @@ -223,7 +223,7 @@ describe("DbStore Cache Tests", () => { expect(shortTtlStore.getCacheStats().configCached).toBe(true); // Wait for TTL to expire (add extra buffer for system lag) - await new Promise((resolve) => setTimeout(resolve, SHORT_CACHE_TTL + 150)); + await new Promise((resolve) => setTimeout(resolve, SHORT_CACHE_TTL + 500)); // Cache should be expired now expect(shortTtlStore.getCacheStats().configCached).toBe(false); diff --git a/bridge/__tests__/discoveryService.test.ts b/bridge/__tests__/discoveryService.test.ts index e122c6c..03e992e 100644 --- a/bridge/__tests__/discoveryService.test.ts +++ b/bridge/__tests__/discoveryService.test.ts @@ -339,7 +339,7 @@ describe("DiscoveryService", () => { // Actual discovery depends on system state const result = await service.discoverLocalDatabases(); expect(Array.isArray(result)).toBe(true); - }); + }, 30000); // Increased timeout for docker commands test("each discovered database should have required fields", async () => { const result = await service.discoverLocalDatabases(); @@ -355,6 +355,6 @@ describe("DiscoveryService", () => { expect(db.defaultUser).toBeDefined(); expect(db.defaultDatabase).toBeDefined(); } - }); + }, 30000); // Increased timeout for docker commands }); }); diff --git a/bridge/src/index.ts b/bridge/src/index.ts index a37e203..c12af89 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -77,3 +77,13 @@ function shutdown(signal: string) { } process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); + +rpc.on("end", () => { + logger.info("JSON-RPC stream ended — shutting down"); + shutdown("RPC_END"); +}); + +rpc.on("error", (err: any) => { + logger.error({ err }, "JSON-RPC stream error — shutting down"); + shutdown("RPC_ERROR"); +}); diff --git a/bridge/src/services/gitService.ts b/bridge/src/services/gitService.ts index fbf5b5f..3ae955b 100644 --- a/bridge/src/services/gitService.ts +++ b/bridge/src/services/gitService.ts @@ -104,6 +104,13 @@ export class GitError extends Error { } } + +function assertSafeGitRef(name: string) { + if (name.startsWith("-")) { + throw new Error("Invalid git reference name: cannot start with a hyphen"); + } +} + export class GitService { /** * Run a git command in a specific directory. @@ -446,6 +453,7 @@ export class GitService { * Create and checkout a new branch */ async createBranch(dir: string, name: string): Promise { + assertSafeGitRef(name); await this.git(dir, "checkout", "-b", name); } @@ -453,6 +461,7 @@ export class GitService { * Checkout an existing branch */ async checkoutBranch(dir: string, name: string): Promise { + assertSafeGitRef(name); await this.git(dir, "checkout", name); } @@ -589,6 +598,7 @@ export class GitService { * Create an annotated tag at the current HEAD (or a given ref) */ async createTag(dir: string, tagName: string, message?: string, ref?: string): Promise { + assertSafeGitRef(tagName); const args = ["tag"]; if (message) { args.push("-a", tagName, "-m", message); @@ -603,6 +613,7 @@ export class GitService { * Delete a tag */ async deleteTag(dir: string, tagName: string): Promise { + assertSafeGitRef(tagName); await this.git(dir, "tag", "-d", tagName); } @@ -626,6 +637,7 @@ export class GitService { * Get the message of an annotated tag */ async getTagMessage(dir: string, tagName: string): Promise { + assertSafeGitRef(tagName); try { return await this.git(dir, "tag", "-l", "-n99", tagName); } catch { @@ -642,6 +654,8 @@ export class GitService { * Returns full hash, or null if no common ancestor. */ async mergeBase(dir: string, refA: string, refB: string): Promise { + assertSafeGitRef(refA); + assertSafeGitRef(refB); try { const output = await this.git(dir, "merge-base", refA, refB); return output || null; @@ -717,6 +731,7 @@ export class GitService { * Add a named remote */ async remoteAdd(dir: string, name: string, url: string): Promise { + assertSafeGitRef(name); await this.git(dir, "remote", "add", name, url); } @@ -724,6 +739,7 @@ export class GitService { * Remove a named remote */ async remoteRemove(dir: string, name: string): Promise { + assertSafeGitRef(name); await this.git(dir, "remote", "remove", name); } @@ -742,6 +758,7 @@ export class GitService { * Change the URL of an existing remote */ async remoteSetUrl(dir: string, name: string, url: string): Promise { + assertSafeGitRef(name); await this.git(dir, "remote", "set-url", name, url); } @@ -759,6 +776,8 @@ export class GitService { branch?: string, options?: { force?: boolean; setUpstream?: boolean } ): Promise { + if (remote) assertSafeGitRef(remote); + if (branch) assertSafeGitRef(branch); const args = ["push"]; if (options?.force) args.push("--force-with-lease"); if (options?.setUpstream) args.push("--set-upstream"); @@ -777,6 +796,8 @@ export class GitService { branch?: string, options?: { rebase?: boolean } ): Promise { + if (remote) assertSafeGitRef(remote); + if (branch) assertSafeGitRef(branch); const args = ["pull"]; if (options?.rebase) args.push("--rebase"); args.push(remote); @@ -792,6 +813,7 @@ export class GitService { remote?: string, options?: { prune?: boolean; all?: boolean } ): Promise { + if (remote) assertSafeGitRef(remote); const args = ["fetch"]; if (options?.prune) args.push("--prune"); if (options?.all || !remote) { From cb0623308a1af97c9f866633ba38134366c5baca Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 23 Jun 2026 21:09:24 +0530 Subject: [PATCH 12/21] refactor(ui): extract DatabasePreview and reorganize hooks --- .../components/{DatabaseDetail.tsx => DatabasePreview.tsx} | 6 +++--- src/features/home/components/WelcomeView.tsx | 2 +- src/features/home/components/index.ts | 2 +- src/{ => features/home}/hooks/useCountUp.ts | 0 4 files changed, 5 insertions(+), 5 deletions(-) rename src/features/home/components/{DatabaseDetail.tsx => DatabasePreview.tsx} (98%) rename src/{ => features/home}/hooks/useCountUp.ts (100%) diff --git a/src/features/home/components/DatabaseDetail.tsx b/src/features/home/components/DatabasePreview.tsx similarity index 98% rename from src/features/home/components/DatabaseDetail.tsx rename to src/features/home/components/DatabasePreview.tsx index 5b3d699..7d680a5 100644 --- a/src/features/home/components/DatabaseDetail.tsx +++ b/src/features/home/components/DatabasePreview.tsx @@ -20,7 +20,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { DatabaseDetailProps } from "../types"; +import { DatabasePreviewProps } from "../types"; import { DatabaseOverviewPanel } from "./DatabaseOverviewPanel"; import { useProjectByDatabaseId, useProjectSchema, useProjectERDiagram, useProjectQueries } from "@/features/project/hooks/useProjectQueries"; import { projectService } from "@/services/bridge/project"; @@ -37,7 +37,7 @@ function getDbColors(type: string) { return DB_COLORS[type] || { bg: "bg-primary/10", text: "text-primary" }; } -export function DatabaseDetail({ +export function DatabasePreview({ database, isConnected, onTest, @@ -46,7 +46,7 @@ export function DatabaseDetail({ onBack, size, tables -}: DatabaseDetailProps) { +}: DatabasePreviewProps) { // Fetch linked project and its sub-resources const { data: project } = useProjectByDatabaseId(database.id); const { data: schemaData } = useProjectSchema(project?.id); diff --git a/src/features/home/components/WelcomeView.tsx b/src/features/home/components/WelcomeView.tsx index e18aa57..1b7c813 100644 --- a/src/features/home/components/WelcomeView.tsx +++ b/src/features/home/components/WelcomeView.tsx @@ -21,7 +21,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { useDbStats, useTables } from "@/features/project/hooks/useDbQueries"; import { bytesToMBString } from "@/lib/bytesToMB"; import { DatabaseConnection } from "@/features/database/types"; -import { useCountUp } from "@/hooks/useCountUp"; +import { useCountUp } from "../hooks/useCountUp"; const DB_COLORS: Record = { diff --git a/src/features/home/components/index.ts b/src/features/home/components/index.ts index 765f30b..62a3a47 100644 --- a/src/features/home/components/index.ts +++ b/src/features/home/components/index.ts @@ -1,5 +1,5 @@ export { ConnectionList } from "./ConnectionList"; -export { DatabaseDetail } from "./DatabaseDetail"; +export { DatabasePreview } from "./DatabasePreview"; export { WelcomeView } from "../components/WelcomeView"; export { AddConnectionDialog } from "./AddConnectionDialog"; export { DeleteDialog } from "./DeleteDialog"; diff --git a/src/hooks/useCountUp.ts b/src/features/home/hooks/useCountUp.ts similarity index 100% rename from src/hooks/useCountUp.ts rename to src/features/home/hooks/useCountUp.ts From 6fe7cc9eeae234977d466e5b8ec0fc3289bcfd58 Mon Sep 17 00:00:00 2001 From: Yash Date: Tue, 23 Jun 2026 21:09:35 +0530 Subject: [PATCH 13/21] feat(ui): add read-only mode for discovered databases --- .../home/components/AddConnectionDialog.tsx | 35 +++++++++++++------ src/features/home/hooks/useIndexPage.ts | 8 ++++- src/features/home/types.ts | 3 +- src/pages/Index.tsx | 6 ++-- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/features/home/components/AddConnectionDialog.tsx b/src/features/home/components/AddConnectionDialog.tsx index d7e5c7f..1b1a60f 100644 --- a/src/features/home/components/AddConnectionDialog.tsx +++ b/src/features/home/components/AddConnectionDialog.tsx @@ -30,6 +30,7 @@ export function AddConnectionDialog({ onSubmit, isLoading, initialData, + isDiscoveredMode, }: AddConnectionDialogProps) { const [useUrl, setUseUrl] = useState(true); const [connectionUrl, setConnectionUrl] = useState(""); @@ -40,6 +41,9 @@ export function AddConnectionDialog({ if (open) { if (initialData) { setFormData(prev => ({ ...prev, ...INITIAL_FORM_DATA, ...initialData })); + if (isDiscoveredMode) { + setUseUrl(false); + } } else { // Reset to empty form when opening without initial data setFormData(INITIAL_FORM_DATA); @@ -112,7 +116,7 @@ export function AddConnectionDialog({
- {!isSQLite && ( + {!isSQLite && !isDiscoveredMode && ( setUseUrl(v === "url")}> @@ -152,7 +156,7 @@ export function AddConnectionDialog({ {!useUrl && (
- { handleInputChange("type", val); if (val === "sqlite") { // Clear network-related fields when switching to SQLite @@ -200,13 +204,15 @@ export function AddConnectionDialog({ placeholder="/path/to/database.db" value={formData.database} onChange={(e) => handleInputChange("database", e.target.value)} - className="h-9 text-sm font-mono flex-1" + readOnly={isDiscoveredMode} + className={`h-9 text-sm font-mono flex-1 ${isDiscoveredMode ? "opacity-60 bg-muted cursor-not-allowed" : ""}`} />
@@ -237,7 +244,8 @@ export function AddConnectionDialog({ placeholder={formData.type === "mysql" || formData.type === "mariadb" ? "3306" : "5432"} value={formData.port} onChange={(e) => handleInputChange("port", e.target.value)} - className="h-9 text-sm font-mono" + readOnly={isDiscoveredMode} + className={`h-9 text-sm font-mono ${isDiscoveredMode ? "opacity-60 bg-muted cursor-not-allowed" : ""}`} />
@@ -249,7 +257,8 @@ export function AddConnectionDialog({ placeholder={formData.type === "postgresql" ? "postgres" : "root"} value={formData.user} onChange={(e) => handleInputChange("user", e.target.value)} - className="h-9 text-sm font-mono" + readOnly={isDiscoveredMode} + className={`h-9 text-sm font-mono ${isDiscoveredMode ? "opacity-60 bg-muted cursor-not-allowed" : ""}`} />
@@ -259,7 +268,8 @@ export function AddConnectionDialog({ placeholder="••••••••" value={formData.password} onChange={(e) => handleInputChange("password", e.target.value)} - className="h-9 text-sm" + readOnly={isDiscoveredMode} + className={`h-9 text-sm ${isDiscoveredMode ? "opacity-60 bg-muted cursor-not-allowed" : ""}`} />
@@ -270,11 +280,12 @@ export function AddConnectionDialog({ placeholder="myapp" value={formData.database} onChange={(e) => handleInputChange("database", e.target.value)} - className="h-9 text-sm font-mono" + readOnly={isDiscoveredMode} + className={`h-9 text-sm font-mono ${isDiscoveredMode ? "opacity-60 bg-muted cursor-not-allowed" : ""}`} />
- {showSslOption && ( + {showSslOption && !isDiscoveredMode && (
-
+ {!isDiscoveredMode && ( +
+
@@ -415,6 +427,7 @@ export function AddConnectionDialog({
)}
+ )} ) )} diff --git a/src/features/home/hooks/useIndexPage.ts b/src/features/home/hooks/useIndexPage.ts index ac514a7..960cbaa 100644 --- a/src/features/home/hooks/useIndexPage.ts +++ b/src/features/home/hooks/useIndexPage.ts @@ -55,6 +55,7 @@ export const useIndexPage = (bridgeReady: boolean) => { const [isDialogOpen, setIsDialogOpen] = useState(false); const [prefilledConnectionData, setPrefilledConnectionData] = useState | undefined>(undefined); const [isImportOpen, setIsImportOpen] = useState(false); + const [isDiscoveredMode, setIsDiscoveredMode] = useState(false); // Selected db derived state const selectedDatabase = useMemo( @@ -231,6 +232,7 @@ export const useIndexPage = (bridgeReady: boolean) => { ssl: false, sslmode: "", }); + setIsDiscoveredMode(true); setIsDialogOpen(true); }, [] @@ -238,7 +240,10 @@ export const useIndexPage = (bridgeReady: boolean) => { const handleDialogClose = (open: boolean) => { setIsDialogOpen(open); - if (!open) setPrefilledConnectionData(undefined); + if (!open) { + setPrefilledConnectionData(undefined); + setIsDiscoveredMode(false); + } }; // ---- Delete Hook ---- @@ -298,6 +303,7 @@ export const useIndexPage = (bridgeReady: boolean) => { deleteConnectionDialogProps, isDeleting, prefilledConnectionData, + isDiscoveredMode, // Handlers handleAddDatabase, diff --git a/src/features/home/types.ts b/src/features/home/types.ts index f197b97..e8bca29 100644 --- a/src/features/home/types.ts +++ b/src/features/home/types.ts @@ -24,7 +24,7 @@ export interface ConnectionListProps { onDeleteProject?: (projectId: string) => void; } -export interface DatabaseDetailProps { +export interface DatabasePreviewProps { database: DatabaseConnection; isConnected: boolean; tables: number | string | undefined; @@ -56,6 +56,7 @@ export interface AddConnectionDialogProps { onSubmit: (data: ConnectionFormData, useUrl: boolean, connectionUrl: string) => void; isLoading?: boolean; initialData?: Partial; + isDiscoveredMode?: boolean; } export interface DeleteDialogProps { diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 4402950..3695e7d 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -3,7 +3,7 @@ import { useBridgeQuery } from "@/services/bridge/useBridgeQuery"; import { ConnectionList, - DatabaseDetail, + DatabasePreview, WelcomeView, AddConnectionDialog, DeleteConnectionDialog, @@ -66,6 +66,7 @@ const IndexContent = ({ bridgeReady, onShortcutsClick }: { bridgeReady: boolean, setDeleteDialogOpen, deleteConnectionDialogProps, prefilledConnectionData, + isDiscoveredMode, // Handlers handleAddDatabase, @@ -112,7 +113,7 @@ const IndexContent = ({ bridgeReady, onShortcutsClick }: { bridgeReady: boolean, {/* Right Panel */}
{selectedDatabase ? ( - {deleteConnectionDialogProps && ( From 9d9515af2dafedff6fc1f97ba9761e24f36eab6e Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 24 Jun 2026 08:13:44 +0530 Subject: [PATCH 14/21] feat: block ctrl+f --- src/main.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 15bab9d..784aa1b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -69,7 +69,8 @@ function AnimatedRoutes() { function AppRoot() { useEffect(() => { - const handleSelectAll = (e: KeyboardEvent) => { + const handleKeydown = (e: KeyboardEvent) => { + // Block Ctrl+A outside editable fields (prevents full-page select) if ((e.ctrlKey || e.metaKey) && e.key === 'a') { const tag = (e.target as HTMLElement)?.tagName; const isEditable = (e.target as HTMLElement)?.isContentEditable; @@ -77,9 +78,14 @@ function AppRoot() { if (tag === 'INPUT' || tag === 'TEXTAREA' || isEditable) return; e.preventDefault(); } + + // Block Ctrl+F to suppress WebView's built-in Find-in-Page bar + if ((e.ctrlKey || e.metaKey) && e.key === 'f') { + e.preventDefault(); + } }; - document.addEventListener('keydown', handleSelectAll); - return () => document.removeEventListener('keydown', handleSelectAll); + document.addEventListener('keydown', handleKeydown); + return () => document.removeEventListener('keydown', handleKeydown); }, []); return ( From 63413fe6676151df48f0f113e5bc7a6ce7d53896 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 24 Jun 2026 18:20:15 +0530 Subject: [PATCH 15/21] fix: fixed cache issue and SQL Workspace UI improvement --- .../database/hooks/useDatabaseDetails.ts | 4 +++ .../components/SQLWorkspacePanel.tsx | 31 ++++++++++++------- .../workspace/components/SqlEditor.tsx | 20 ++++++------ src/pages/DatabaseDetails.tsx | 15 ++++----- 4 files changed, 41 insertions(+), 29 deletions(-) diff --git a/src/features/database/hooks/useDatabaseDetails.ts b/src/features/database/hooks/useDatabaseDetails.ts index d792930..f05cebc 100644 --- a/src/features/database/hooks/useDatabaseDetails.ts +++ b/src/features/database/hooks/useDatabaseDetails.ts @@ -19,6 +19,8 @@ interface UseDatabaseDetailsReturn { rowCount: number; totalRows: number; query: string; + queryResults: TableRow[]; + hasExecutedQuery: boolean; queryProgress: QueryProgress | null; queryError: string | null; isExecuting: boolean; @@ -313,6 +315,8 @@ export function useDatabaseDetails({ tables, selectedTable, tableData: showQueryResults ? queryResults : tableData, + queryResults, + hasExecutedQuery, rowCount: showQueryResults ? queryRowCount : rowCount, totalRows, query, diff --git a/src/features/workspace/components/SQLWorkspacePanel.tsx b/src/features/workspace/components/SQLWorkspacePanel.tsx index 7444461..2dcf257 100644 --- a/src/features/workspace/components/SQLWorkspacePanel.tsx +++ b/src/features/workspace/components/SQLWorkspacePanel.tsx @@ -49,7 +49,8 @@ const SQLWorkspacePanel = ({ dbId }: SQLWorkspacePanelProps) => { setQuery, handleExecuteQuery, handleCancelQuery, - tableData, + queryResults, + hasExecutedQuery, rowCount, } = useDatabaseDetails({ dbId, @@ -75,11 +76,11 @@ const SQLWorkspacePanel = ({ dbId }: SQLWorkspacePanelProps) => { // Update tab results when query completes useEffect(() => { - if (!isExecuting && tableData.length > 0) { + if (hasExecutedQuery && !isExecuting && (queryResults.length > 0 || queryError)) { setTabs(prev => prev.map(tab => tab.id === activeTabId ? { ...tab, - results: tableData, + results: queryResults, rowCount: rowCount, error: queryError, status: queryError ? 'error' : 'success', @@ -88,16 +89,22 @@ const SQLWorkspacePanel = ({ dbId }: SQLWorkspacePanelProps) => { )); // Add to history - if (activeTab?.query.trim()) { - setQueryHistory(prev => [{ - query: activeTab.query, - timestamp: new Date(), - rowCount: rowCount, - success: !queryError, - }, ...prev.slice(0, 49)]); + if (activeTab?.query.trim() && !queryError) { + setQueryHistory(prev => { + // Avoid duplicating the exact same query consecutively + if (prev.length > 0 && prev[0].query === activeTab.query) { + return prev; + } + return [{ + query: activeTab.query, + timestamp: new Date(), + rowCount: rowCount, + success: true, + }, ...prev.slice(0, 49)]; + }); } } - }, [isExecuting, tableData, rowCount, queryError]); + }, [isExecuting, hasExecutedQuery, queryResults, rowCount, queryError]); // Update tab status when executing useEffect(() => { @@ -197,7 +204,7 @@ const SQLWorkspacePanel = ({ dbId }: SQLWorkspacePanelProps) => { {/* Split View: Editor + Results */}
{/* Editor */} -
+
{ // ---- Panel router ---- const renderPanel = () => { switch (activePanel) { - case "sql-workspace": return ; - case "query-builder": return ; - case "schema-explorer": return ; - case "er-diagram": return ; - case "monitoring": return ; - case "git-status": return ; - case "migrations": return
; + case "sql-workspace": return ; + case "query-builder": return ; + case "schema-explorer": return ; + case "er-diagram": return ; + case "monitoring": return ; + case "git-status": return ; + case "migrations": return
; default: return ( Date: Wed, 24 Jun 2026 20:26:49 +0530 Subject: [PATCH 16/21] refact: enhance the preview of the new files --- bridge/src/services/gitService.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/bridge/src/services/gitService.ts b/bridge/src/services/gitService.ts index 3ae955b..bfe286d 100644 --- a/bridge/src/services/gitService.ts +++ b/bridge/src/services/gitService.ts @@ -496,7 +496,31 @@ export class GitService { const args = ["diff"]; if (staged) args.push("--staged"); if (file) args.push("--", file); - return this.git(dir, ...args); + const output = await this.git(dir, ...args); + + // If git diff returns nothing for a specific file, it might be an untracked file. + if (!output && file && !staged) { + try { + // git ls-files --error-unmatch throws if the file is untracked + await this.git(dir, "ls-files", "--error-unmatch", file); + } catch { + try { + const filePath = path.join(dir, file); + const stat = fsSync.statSync(filePath); + if (stat.isFile() && stat.size <= 2097152) { // Max 2MB diff + const content = fsSync.readFileSync(filePath, "utf-8"); + const lines = content.split(/\r?\n/); + let diffStr = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n`; + for (const line of lines) diffStr += `+${line}\n`; + return diffStr.trimEnd(); + } + } catch { + // Ignore fs errors + } + } + } + + return output; } /** From 87e0b8eb49a927128f9ebadb86eb28f67f79abab Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 24 Jun 2026 20:27:15 +0530 Subject: [PATCH 17/21] refact: added autoComplete and spellCheck to off --- src/components/ui/input.tsx | 4 +++- src/components/ui/textarea.tsx | 4 +++- .../schema-explorer/components/AddIndexesDialog.tsx | 1 + .../schema-explorer/hooks/useAddIndexDialog.ts | 11 +++++++++-- .../schema-explorer/hooks/useAlterTableDialog.ts | 10 ++++++++-- .../schema-explorer/hooks/useCreateTableDialog.ts | 1 + .../schema-explorer/hooks/useDropTableDialog.ts | 10 ++++++++-- 7 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 098a41a..27a1b52 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -2,10 +2,12 @@ import * as React from "react" import { cn } from "@/lib/utils" -function Input({ className, type, ...props }: React.ComponentProps<"input">) { +function Input({ className, type, autoComplete = "off", spellCheck = false, ...props }: React.ComponentProps<"input">) { return ( ) { +function Textarea({ className, autoComplete = "off", spellCheck = false, ...props }: React.ComponentProps<"textarea">) { return (