From a180928714eb16fb7cdf6d8c84f4603fe5ff080f Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 04:40:33 +0300 Subject: [PATCH 01/39] feat: add export entry points for npm package consumption Co-Authored-By: Claude Opus 4.6 (1M context) --- src/exports/components.ts | 15 +++++++++++++++ src/exports/index.ts | 4 ++++ src/exports/providers.ts | 4 ++++ src/exports/types.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 49 insertions(+) create mode 100644 src/exports/components.ts create mode 100644 src/exports/index.ts create mode 100644 src/exports/providers.ts create mode 100644 src/exports/types.ts diff --git a/src/exports/components.ts b/src/exports/components.ts new file mode 100644 index 0000000..73d304c --- /dev/null +++ b/src/exports/components.ts @@ -0,0 +1,15 @@ +// src/exports/components.ts +// Re-export UI components for npm consumers +export { QueryEditor } from '../components/QueryEditor' +export type { QueryEditorRef } from '../components/QueryEditor' +export { ResultsGrid } from '../components/ResultsGrid' +export { SchemaDiagram } from '../components/SchemaDiagram' +export { DataCharts } from '../components/DataCharts' +export { CodeGenerator } from '../components/CodeGenerator' +export { TestDataGenerator } from '../components/TestDataGenerator' +export { VisualExplain } from '../components/VisualExplain' +export { NL2SQLPanel } from '../components/NL2SQLPanel' +export { DataProfiler } from '../components/DataProfiler' +export { QuerySafetyDialog } from '../components/QuerySafetyDialog' +export { ConnectionModal } from '../components/ConnectionModal' +export { SchemaExplorer } from '../components/schema-explorer' diff --git a/src/exports/index.ts b/src/exports/index.ts new file mode 100644 index 0000000..32bd019 --- /dev/null +++ b/src/exports/index.ts @@ -0,0 +1,4 @@ +// src/exports/index.ts +export * from './types' +export * from './providers' +export * from './components' diff --git a/src/exports/providers.ts b/src/exports/providers.ts new file mode 100644 index 0000000..634f00e --- /dev/null +++ b/src/exports/providers.ts @@ -0,0 +1,4 @@ +// src/exports/providers.ts +// Re-export database and LLM provider factories +export { createDatabaseProvider, getOrCreateProvider, removeProvider, clearProviderCache, getProviderCacheStats } from '../lib/db/factory' +export { createLLMProvider, getDefaultProvider, resetDefaultProvider } from '../lib/llm/factory' diff --git a/src/exports/types.ts b/src/exports/types.ts new file mode 100644 index 0000000..770f062 --- /dev/null +++ b/src/exports/types.ts @@ -0,0 +1,26 @@ +// src/exports/types.ts +// Re-export all public types for npm consumers +export type { + DatabaseType, + ConnectionEnvironment, + SSLMode, + SSLConfig, + SSHTunnelConfig, + DatabaseConnection, + TableSchema, + ColumnSchema, + IndexSchema, + ForeignKeySchema, + QueryPagination, + QueryResult, + QueryTab, + QueryHistoryItem, + SavedQuery, + SchemaSnapshot, + SavedChartConfig, + AggregationType, + DateGrouping, +} from '../lib/types' + +// Also export provider types +export type { ProviderCapabilities } from '../lib/db/types' From 1ef919fb8808f547d702257f8a281274e2981947 Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 04:44:42 +0300 Subject: [PATCH 02/39] chore: update .gitignore to exclude files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index eb337a2..8e96b0d 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,5 @@ seed-connections.yaml .playwright-mcp/*.png +npmjs-token + From dba370c77263e2d05a520b3d02ddf916d97be807 Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 04:46:12 +0300 Subject: [PATCH 03/39] feat: add API adapter pattern for platform integration Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/ConnectionModal.tsx | 6 +- src/components/DataProfiler.tsx | 80 ++++++++++++++-------- src/components/NL2SQLPanel.tsx | 54 +++++++++------ src/components/QueryEditor.tsx | 6 +- src/components/QuerySafetyDialog.tsx | 52 +++++++++------ src/hooks/use-ai-chat.ts | 99 ++++++++++++++++------------ src/hooks/use-connection-form.ts | 71 +++++++++++++------- 7 files changed, 229 insertions(+), 139 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index a89ac61..d6ffbb4 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -19,9 +19,11 @@ interface ConnectionModalProps { onClose: () => void; onConnect: (conn: DatabaseConnection) => void; editConnection?: DatabaseConnection | null; + /** Optional API adapter: when provided, bypasses the built-in /api/db/test-connection fetch. */ + onTestConnection?: (connection: DatabaseConnection) => Promise<{ success: boolean; latency?: number; error?: string }>; } -export function ConnectionModal({ isOpen, onClose, onConnect, editConnection }: ConnectionModalProps) { +export function ConnectionModal({ isOpen, onClose, onConnect, editConnection, onTestConnection }: ConnectionModalProps) { const isMobile = useIsMobile(); const { // Connection fields @@ -73,7 +75,7 @@ export function ConnectionModal({ isOpen, onClose, onConnect, editConnection }: // Derived data dbTypes, - } = useConnectionForm({ isOpen, onClose, onConnect, editConnection }); + } = useConnectionForm({ isOpen, onClose, onConnect, editConnection, onTestConnection }); const formContent = ( <> diff --git a/src/components/DataProfiler.tsx b/src/components/DataProfiler.tsx index 4fb2b78..df727d3 100644 --- a/src/components/DataProfiler.tsx +++ b/src/components/DataProfiler.tsx @@ -33,6 +33,10 @@ interface DataProfilerProps { connection: DatabaseConnection | null; schemaContext?: string; databaseType?: string; + /** Optional API adapter: when provided, bypasses the built-in /api/db/profile fetch. */ + onProfile?: (params: { connectionId: string; tableName: string }) => Promise; + /** Optional API adapter: when provided, bypasses the built-in /api/ai/describe-schema fetch. */ + onDescribeSchema?: (params: { tableName: string; schemaContext: string }) => Promise; } export function DataProfiler({ @@ -43,6 +47,8 @@ export function DataProfiler({ connection, schemaContext, databaseType, + onProfile, + onDescribeSchema, }: DataProfilerProps) { const [isLoading, setIsLoading] = useState(false); const [profile, setProfile] = useState(null); @@ -74,19 +80,28 @@ export function DataProfiler({ setError(null); try { - const columns = tableSchema.columns?.map(c => c.name) || []; - const response = await fetch('/api/db/profile', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ connection, tableName, columns }), - }); + let data: ProfileData; - if (!response.ok) { - const err = await response.json(); - throw new Error(err.error || 'Profile failed'); + if (onProfile) { + // Platform adapter: use callback instead of fetch + data = await onProfile({ connectionId: connection.id, tableName }); + } else { + // Default: existing fetch behavior + const columns = tableSchema.columns?.map(c => c.name) || []; + const response = await fetch('/api/db/profile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ connection, tableName, columns }), + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || 'Profile failed'); + } + + data = await response.json(); } - const data: ProfileData = await response.json(); setProfile(data); // Trigger AI summary @@ -105,27 +120,36 @@ export function DataProfiler({ `${c.name}: ${c.nullPercent}% null, ${c.distinctCount} distinct, min=${c.minValue || 'N/A'}, max=${c.maxValue || 'N/A'}` ).join('\n'); - const response = await fetch('/api/ai/describe-schema', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - schemaContext: `Table: ${tableName} (${data.totalRows} rows)\n\nColumn Profiles:\n${profileSummary}\n\nSchema:\n${schemaContext || ''}`, - databaseType, - mode: 'table', - }), - }); + const fullSchemaContext = `Table: ${tableName} (${data.totalRows} rows)\n\nColumn Profiles:\n${profileSummary}\n\nSchema:\n${schemaContext || ''}`; + + if (onDescribeSchema) { + // Platform adapter: use callback instead of fetch + const result = await onDescribeSchema({ tableName, schemaContext: fullSchemaContext }); + setAiSummary(result); + } else { + // Default: existing fetch behavior + const response = await fetch('/api/ai/describe-schema', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + schemaContext: fullSchemaContext, + databaseType, + mode: 'table', + }), + }); - if (!response.ok) return; + if (!response.ok) return; - const reader = response.body?.getReader(); - if (!reader) return; + const reader = response.body?.getReader(); + if (!reader) return; - let full = ''; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - full += new TextDecoder().decode(value); - setAiSummary(full); + let full = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + full += new TextDecoder().decode(value); + setAiSummary(full); + } } } catch { // AI summary is optional, don't show error diff --git a/src/components/NL2SQLPanel.tsx b/src/components/NL2SQLPanel.tsx index 0aa3844..9ea9b98 100644 --- a/src/components/NL2SQLPanel.tsx +++ b/src/components/NL2SQLPanel.tsx @@ -18,6 +18,8 @@ interface NL2SQLPanelProps { schemaContext: string; databaseType?: string; queryLanguage?: string; + /** Optional API adapter: when provided, bypasses the built-in /api/ai/nl2sql fetch. */ + onNL2SQL?: (params: { prompt: string; schemaContext: string; conversationHistory?: { role: string; content: string }[] }) => Promise; } function extractCodeBlock(text: string): string | null { @@ -34,6 +36,7 @@ export function NL2SQLPanel({ schemaContext, databaseType, queryLanguage, + onNL2SQL, }: NL2SQLPanelProps) { const [question, setQuestion] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -81,31 +84,42 @@ export function NL2SQLPanel({ // Build conversation history (exclude current question) const history = messages.map(m => ({ role: m.role, content: m.content })); - const response = await fetch('/api/ai/nl2sql', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - question: question.trim(), + let fullResponse = ''; + + if (onNL2SQL) { + // Platform adapter: use callback instead of fetch + fullResponse = await onNL2SQL({ + prompt: question.trim(), schemaContext: filteredSchema, - databaseType, - queryLanguage, conversationHistory: history, - }), - }); + }); + } else { + // Default: existing fetch behavior + const response = await fetch('/api/ai/nl2sql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + question: question.trim(), + schemaContext: filteredSchema, + databaseType, + queryLanguage, + conversationHistory: history, + }), + }); - if (!response.ok) { - const errData = await response.json(); - throw new Error(errData.error || 'Request failed'); - } + if (!response.ok) { + const errData = await response.json(); + throw new Error(errData.error || 'Request failed'); + } - const reader = response.body?.getReader(); - if (!reader) throw new Error('No reader'); + const reader = response.body?.getReader(); + if (!reader) throw new Error('No reader'); - let fullResponse = ''; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - fullResponse += new TextDecoder().decode(value); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + fullResponse += new TextDecoder().decode(value); + } } const extractedQuery = extractCodeBlock(fullResponse); diff --git a/src/components/QueryEditor.tsx b/src/components/QueryEditor.tsx index 0cfdcc7..b3f4473 100644 --- a/src/components/QueryEditor.tsx +++ b/src/components/QueryEditor.tsx @@ -35,6 +35,8 @@ interface QueryEditorProps { databaseType?: string; schemaContext?: string; capabilities?: import('@/lib/db/types').ProviderCapabilities; + /** Optional API adapter: when provided, bypasses the built-in /api/ai/chat fetch. */ + onAiChat?: (params: { prompt: string; schemaContext: string; history: { role: string; content: string }[] }) => Promise; } interface ParsedTable { @@ -92,7 +94,8 @@ export const QueryEditor = forwardRef(({ tables = [], databaseType, schemaContext, - capabilities + capabilities, + onAiChat, }, ref) => { const monaco = useMonaco(); const editorRef = useRef(null); @@ -349,6 +352,7 @@ export const QueryEditor = forwardRef(({ getEditorValue, setEditorValue: setEditorValueForAi, onChange, + onAiChat, }); useImperativeHandle(ref, () => ({ diff --git a/src/components/QuerySafetyDialog.tsx b/src/components/QuerySafetyDialog.tsx index cdf4921..86b239c 100644 --- a/src/components/QuerySafetyDialog.tsx +++ b/src/components/QuerySafetyDialog.tsx @@ -25,6 +25,8 @@ interface QuerySafetyDialogProps { databaseType?: string; onClose: () => void; onProceed: () => void; + /** Optional API adapter: when provided, bypasses the built-in /api/ai/query-safety fetch. */ + onAnalyzeSafety?: (params: { query: string; schemaContext: string }) => Promise; } function parseSafetyResponse(text: string): SafetyAnalysis | null { @@ -55,6 +57,7 @@ export function QuerySafetyDialog({ databaseType, onClose, onProceed, + onAnalyzeSafety, }: QuerySafetyDialogProps) { const [isAnalyzing, setIsAnalyzing] = useState(false); const [analysis, setAnalysis] = useState(null); @@ -91,31 +94,38 @@ export function QuerySafetyDialog({ } } - const response = await fetch('/api/ai/query-safety', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query, schemaContext: filteredSchema, databaseType }), - }); + if (onAnalyzeSafety) { + // Platform adapter: use callback instead of fetch + const result = await onAnalyzeSafety({ query, schemaContext: filteredSchema }); + setAnalysis(result); + } else { + // Default: existing fetch behavior + const response = await fetch('/api/ai/query-safety', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, schemaContext: filteredSchema, databaseType }), + }); - if (!response.ok) { - const errData = await response.json(); - throw new Error(errData.error || 'Analysis failed'); - } + if (!response.ok) { + const errData = await response.json(); + throw new Error(errData.error || 'Analysis failed'); + } - const reader = response.body?.getReader(); - if (!reader) throw new Error('No reader'); + const reader = response.body?.getReader(); + if (!reader) throw new Error('No reader'); - let fullResponse = ''; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - fullResponse += new TextDecoder().decode(value); - setRawResponse(fullResponse); - } + let fullResponse = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + fullResponse += new TextDecoder().decode(value); + setRawResponse(fullResponse); + } - const parsed = parseSafetyResponse(fullResponse); - if (parsed) { - setAnalysis(parsed); + const parsed = parseSafetyResponse(fullResponse); + if (parsed) { + setAnalysis(parsed); + } } } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); diff --git a/src/hooks/use-ai-chat.ts b/src/hooks/use-ai-chat.ts index fe4b017..0fd4160 100644 --- a/src/hooks/use-ai-chat.ts +++ b/src/hooks/use-ai-chat.ts @@ -25,6 +25,8 @@ interface AiChatDeps { setEditorValue: (value: string) => void; /** Notifies the parent of a value change */ onChange?: (val: string) => void; + /** Optional API adapter: when provided, bypasses the built-in /api/ai/chat fetch. */ + onAiChat?: (params: { prompt: string; schemaContext: string; history: { role: string; content: string }[] }) => Promise; } export interface AiChatState { @@ -47,7 +49,7 @@ export interface AiChatState { * this hook independent of the Monaco editor instance. */ export function useAiChat(deps: AiChatDeps): AiChatState { - const { parsedSchema, schemaContext, databaseType, getEditorValue, setEditorValue, onChange } = deps; + const { parsedSchema, schemaContext, databaseType, getEditorValue, setEditorValue, onChange, onAiChat } = deps; const [showAi, setShowAi] = useState(false); const [aiPrompt, setAiPrompt] = useState(''); @@ -81,25 +83,6 @@ export function useAiChat(deps: AiChatDeps): AiChatState { } } - const response = await fetch('/api/ai/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - prompt: aiPrompt, - databaseType, - schemaContext: filteredSchemaContext, - conversationHistory: aiConversationHistory.length > 0 ? aiConversationHistory : undefined, - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'AI request failed'); - } - - const reader = response.body?.getReader(); - if (!reader) throw new Error('No reader available'); - const currentVal = getEditorValue(); const shouldReplace = !currentVal || currentVal.startsWith('--'); @@ -108,31 +91,63 @@ export function useAiChat(deps: AiChatDeps): AiChatState { fullAiResponse = currentVal + '\n\n'; } - // Buffered update using requestAnimationFrame to avoid excessive re-renders - let rafId: number | null = null; - const updateEditor = () => { + if (onAiChat) { + // Platform adapter: use callback instead of fetch + const result = await onAiChat({ + prompt: aiPrompt, + schemaContext: filteredSchemaContext, + history: aiConversationHistory, + }); + fullAiResponse += result; setEditorValue(fullAiResponse); - rafId = null; - }; - - while (true) { - const { done, value: chunkValue } = await reader.read(); - if (done) break; - const chunk = new TextDecoder().decode(chunkValue); - fullAiResponse += chunk; - - // Schedule update on next animation frame if not already scheduled - if (!rafId) { - rafId = requestAnimationFrame(updateEditor); + onChange?.(fullAiResponse); + } else { + // Default: existing fetch behavior + const response = await fetch('/api/ai/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: aiPrompt, + databaseType, + schemaContext: filteredSchemaContext, + conversationHistory: aiConversationHistory.length > 0 ? aiConversationHistory : undefined, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'AI request failed'); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error('No reader available'); + + // Buffered update using requestAnimationFrame to avoid excessive re-renders + let rafId: number | null = null; + const updateEditor = () => { + setEditorValue(fullAiResponse); + rafId = null; + }; + + while (true) { + const { done, value: chunkValue } = await reader.read(); + if (done) break; + const chunk = new TextDecoder().decode(chunkValue); + fullAiResponse += chunk; + + // Schedule update on next animation frame if not already scheduled + if (!rafId) { + rafId = requestAnimationFrame(updateEditor); + } } - } - // Ensure final content is set and cancel any pending RAF - if (rafId) { - cancelAnimationFrame(rafId); + // Ensure final content is set and cancel any pending RAF + if (rafId) { + cancelAnimationFrame(rafId); + } + setEditorValue(fullAiResponse); + onChange?.(fullAiResponse); } - setEditorValue(fullAiResponse); - onChange?.(fullAiResponse); // Save conversation history for multi-turn setAiConversationHistory(prev => [ @@ -150,7 +165,7 @@ export function useAiChat(deps: AiChatDeps): AiChatState { } finally { setIsAiLoading(false); } - }, [aiPrompt, isAiLoading, schemaContext, parsedSchema, databaseType, aiConversationHistory, getEditorValue, setEditorValue, onChange]); + }, [aiPrompt, isAiLoading, schemaContext, parsedSchema, databaseType, aiConversationHistory, getEditorValue, setEditorValue, onChange, onAiChat]); return { showAi, diff --git a/src/hooks/use-connection-form.ts b/src/hooks/use-connection-form.ts index 03064ec..26d61ba 100644 --- a/src/hooks/use-connection-form.ts +++ b/src/hooks/use-connection-form.ts @@ -10,9 +10,11 @@ interface UseConnectionFormProps { onClose: () => void; onConnect: (conn: DatabaseConnection) => void; editConnection?: DatabaseConnection | null; + /** Optional API adapter: when provided, bypasses the built-in /api/db/test-connection fetch. */ + onTestConnection?: (connection: DatabaseConnection) => Promise<{ success: boolean; latency?: number; error?: string }>; } -export function useConnectionForm({ isOpen, onConnect, editConnection }: UseConnectionFormProps) { +export function useConnectionForm({ isOpen, onConnect, editConnection, onTestConnection }: UseConnectionFormProps) { const [type, setType] = useState('postgres'); const [name, setName] = useState(''); const [host, setHost] = useState('localhost'); @@ -176,26 +178,40 @@ export function useConnectionForm({ isOpen, onConnect, editConnection }: UseConn try { const conn = buildConnection(); - const response = await fetch('/api/db/test-connection', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(conn), - }); - - const result = await response.json(); - setTestResult({ - success: result.success, - message: result.success - ? `Connected successfully${result.latency ? ` (${result.latency}ms)` : ''}` - : result.error || 'Connection failed', - latency: result.latency, - }); + + if (onTestConnection) { + // Platform adapter: use callback instead of fetch + const result = await onTestConnection(conn); + setTestResult({ + success: result.success, + message: result.success + ? `Connected successfully${result.latency ? ` (${result.latency}ms)` : ''}` + : result.error || 'Connection failed', + latency: result.latency, + }); + } else { + // Default: existing fetch behavior + const response = await fetch('/api/db/test-connection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(conn), + }); + + const result = await response.json(); + setTestResult({ + success: result.success, + message: result.success + ? `Connected successfully${result.latency ? ` (${result.latency}ms)` : ''}` + : result.error || 'Connection failed', + latency: result.latency, + }); + } } catch { setTestResult({ success: false, message: 'Network error - could not reach server' }); } finally { setIsTesting(false); } - }, [buildConnection]); + }, [buildConnection, onTestConnection]); const handleConnect = useCallback(async () => { setIsTesting(true); @@ -204,14 +220,19 @@ export function useConnectionForm({ isOpen, onConnect, editConnection }: UseConn try { const conn = buildConnection(); - // Real connection test before saving - const response = await fetch('/api/db/test-connection', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(conn), - }); - - const result = await response.json(); + let result: { success: boolean; error?: string }; + if (onTestConnection) { + // Platform adapter: use callback instead of fetch + result = await onTestConnection(conn); + } else { + // Default: existing fetch behavior + const response = await fetch('/api/db/test-connection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(conn), + }); + result = await response.json(); + } if (result.success) { onConnect(conn); @@ -231,7 +252,7 @@ export function useConnectionForm({ isOpen, onConnect, editConnection }: UseConn } finally { setIsTesting(false); } - }, [buildConnection, type, onConnect]); + }, [buildConnection, type, onConnect, onTestConnection]); const handlePasteConnectionString = useCallback(() => { const trimmed = pasteInput.trim(); From 032bcdeb390d2a69a91d8f2499c8259d30d93258 Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 04:46:47 +0300 Subject: [PATCH 04/39] feat: configure package.json for npm package exports Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index ebd8417..2775135 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,17 @@ { "name": "libredb-studio", "version": "0.9.7", - "private": true, + "private": false, + "exports": { + ".": "./src/exports/index.ts", + "./components": "./src/exports/components.ts", + "./providers": "./src/exports/providers.ts", + "./types": "./src/exports/types.ts" + }, + "peerDependencies": { + "react": "^19", + "react-dom": "^19" + }, "scripts": { "dev": "next dev", "build": "next build", From f77ddd5ef3705925daa9be2caed87f43e1f8da8c Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 04:47:46 +0300 Subject: [PATCH 05/39] fix: remove unused imports and deprecated types --- src/components/ConnectionModal.tsx | 1 - src/components/DataProfiler.tsx | 2 +- src/components/NL2SQLPanel.tsx | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index d6ffbb4..2903ca3 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -1,6 +1,5 @@ "use client"; -import React from 'react'; import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer'; import { Button } from '@/components/ui/button'; diff --git a/src/components/DataProfiler.tsx b/src/components/DataProfiler.tsx index df727d3..2bcc0f6 100644 --- a/src/components/DataProfiler.tsx +++ b/src/components/DataProfiler.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Loader2, BarChart3, X, Hash, AlertCircle, Sparkles, Lock } from 'lucide-react'; import { cn } from '@/lib/utils'; import { TableSchema, DatabaseConnection } from '@/lib/types'; diff --git a/src/components/NL2SQLPanel.tsx b/src/components/NL2SQLPanel.tsx index 9ea9b98..7eec8d9 100644 --- a/src/components/NL2SQLPanel.tsx +++ b/src/components/NL2SQLPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, type FormEvent } from 'react'; import { Send, Loader2, Sparkles, X, Play, MessageSquare, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -53,7 +53,7 @@ export function NL2SQLPanel({ if (isOpen) inputRef.current?.focus(); }, [isOpen]); - const handleSubmit = async (e?: React.FormEvent) => { + const handleSubmit = async (e?: FormEvent) => { if (e) e.preventDefault(); if (!question.trim() || isLoading) return; From 22c10b350e2e08ddb9a500d416ac04f9128cb58b Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 04:51:29 +0300 Subject: [PATCH 06/39] chore: rename package to @libredb/studio for npm publishing --- .gitignore | 1 + package.json | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8e96b0d..15c6cb2 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,4 @@ seed-connections.yaml npmjs-token +.npmrc diff --git a/package.json b/package.json index 2775135..54c1f7f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,14 @@ { - "name": "libredb-studio", + "name": "@libredb/studio", "version": "0.9.7", "private": false, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/libredb/libredb-studio" + }, "exports": { ".": "./src/exports/index.ts", "./components": "./src/exports/components.ts", From 47fb61aa0a9a6403cc1cc378479d7a1e515ebca6 Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 11:55:52 +0300 Subject: [PATCH 07/39] feat: add npm publish workflow for @libredb/studio --- .github/workflows/npm-publish.yml | 100 ++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/npm-publish.yml diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..a79262e --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,100 @@ +name: NPM Publish + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version to publish (e.g., 0.9.8). Leave empty to use package.json version.' + required: false + type: string + dry_run: + description: 'Dry run (do not actually publish)' + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + validate: + name: Validate + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.9" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Lint + run: bun run lint + + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun run test + + - name: Build + run: bun run build + env: + NEXT_TELEMETRY_DISABLED: 1 + JWT_SECRET: test-secret-for-ci-build-only-32ch + ADMIN_EMAIL: admin@libredb.org + ADMIN_PASSWORD: test-admin + USER_EMAIL: user@libredb.org + USER_PASSWORD: test-user + + publish: + name: Publish to NPM + runs-on: ubuntu-latest + needs: validate + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + + - name: Set version (if provided) + if: inputs.version != '' + env: + INPUT_VERSION: ${{ inputs.version }} + run: npm version "$INPUT_VERSION" --no-git-tag-version + + - name: Verify package + run: | + echo "Package name: $(node -p "require('./package.json').name")" + echo "Package version: $(node -p "require('./package.json').version")" + + - name: Publish (dry run) + if: inputs.dry_run == true + run: npm publish --access public --dry-run + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish + if: inputs.dry_run != true + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Summary + run: | + VERSION=$(node -p "require('./package.json').version") + echo "## Published @libredb/studio@${VERSION}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- **Registry:** https://www.npmjs.com/package/@libredb/studio" >> "$GITHUB_STEP_SUMMARY" + echo "- **Install:** \`npm install @libredb/studio@${VERSION}\`" >> "$GITHUB_STEP_SUMMARY" From b947cc75a870f130d80ac102a91e36268d5ed8e3 Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 11:56:16 +0300 Subject: [PATCH 08/39] =?UTF-8?q?chore:=20add=20.npmignore=20to=20publish?= =?UTF-8?q?=20only=20source=20code=20(572=20=E2=86=92=20205=20files)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .npmignore | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .npmignore diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..b870b3c --- /dev/null +++ b/.npmignore @@ -0,0 +1,27 @@ +# Publish only source code needed for npm consumers +# Everything is ignored by default, then we allowlist + +# Ignore everything +* + +# Allow source exports +!src/exports/ +!src/exports/** + +# Allow library code (providers, types, utils) +!src/lib/ +!src/lib/** + +# Allow components +!src/components/ +!src/components/** + +# Allow hooks (used by components) +!src/hooks/ +!src/hooks/** + +# Allow package files +!package.json +!README.md +!LICENSE +!tsconfig.json From b9180a7c8c43b9af5bdc2f0bc93b875df15d3491 Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 11:57:39 +0300 Subject: [PATCH 09/39] fix: use NPMJS_TOKEN secret name in npm publish workflow --- .github/workflows/npm-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index a79262e..d1c2eb6 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -83,13 +83,13 @@ jobs: if: inputs.dry_run == true run: npm publish --access public --dry-run env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPMJS_TOKEN }} - name: Publish if: inputs.dry_run != true run: npm publish --access public env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPMJS_TOKEN }} - name: Summary run: | From c57a75c34d9ae010bbaa652e3c6f44a7c490985e Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 13:56:33 +0300 Subject: [PATCH 10/39] chore: add index.js entry point and update main field for bundler compatibility --- package.json | 8 ++------ src/exports/index.js | 2 ++ 2 files changed, 4 insertions(+), 6 deletions(-) create mode 100644 src/exports/index.js diff --git a/package.json b/package.json index 54c1f7f..f6527da 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,8 @@ "type": "git", "url": "https://github.com/libredb/libredb-studio" }, - "exports": { - ".": "./src/exports/index.ts", - "./components": "./src/exports/components.ts", - "./providers": "./src/exports/providers.ts", - "./types": "./src/exports/types.ts" - }, + "main": "./src/exports/index.js", + "types": "./src/exports/index.ts", "peerDependencies": { "react": "^19", "react-dom": "^19" diff --git a/src/exports/index.js b/src/exports/index.js new file mode 100644 index 0000000..67437ae --- /dev/null +++ b/src/exports/index.js @@ -0,0 +1,2 @@ +// JavaScript entry point for bundlers that can't resolve .ts exports +module.exports = require('./index.ts') From 044894ad5e9d29779d5bf09666597f5c9b78afa6 Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 16:01:06 +0300 Subject: [PATCH 11/39] feat(workspace): add StudioWorkspace types and props interface Co-Authored-By: Claude Sonnet 4.6 --- src/workspace/types.ts | 102 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/workspace/types.ts diff --git a/src/workspace/types.ts b/src/workspace/types.ts new file mode 100644 index 0000000..bd888ae --- /dev/null +++ b/src/workspace/types.ts @@ -0,0 +1,102 @@ +// src/workspace/types.ts +import type { DatabaseType, TableSchema, QueryResult, SavedQuery } from '@/lib/types'; + +// === Connection (platform → studio) === + +export interface WorkspaceConnection { + id: string; + name: string; + type: DatabaseType; +} + +// === User (platform → studio) === + +export interface WorkspaceUser { + id: string; + name?: string; + role?: string; +} + +// === Query result (studio ← platform) === + +export interface WorkspaceQueryResult { + rows: Record[]; + fields: string[]; + columns?: { name: string; type?: string }[]; + rowCount: number; + executionTime: number; + pagination?: { + limit: number; + offset: number; + hasMore: boolean; + totalReturned: number; + wasLimited: boolean; + }; +} + +// === Feature flags === + +export interface WorkspaceFeatures { + ai?: boolean; + charts?: boolean; + codeGenerator?: boolean; + testDataGenerator?: boolean; + schemaDiagram?: boolean; + dataImport?: boolean; + inlineEditing?: boolean; + transactions?: boolean; + connectionManagement?: boolean; + dataMasking?: boolean; +} + +export const DEFAULT_WORKSPACE_FEATURES: Required = { + ai: false, + charts: true, + codeGenerator: true, + testDataGenerator: true, + schemaDiagram: true, + dataImport: true, + inlineEditing: false, + transactions: false, + connectionManagement: false, + dataMasking: false, +}; + +// === Saved query input === + +export interface SavedQueryInput { + name: string; + query: string; + description?: string; + connectionType?: string; + tags?: string[]; +} + +// === Main props === + +export interface StudioWorkspaceProps { + connections: WorkspaceConnection[]; + currentUser?: WorkspaceUser; + + onQueryExecute: (connectionId: string, sql: string, options?: { + limit?: number; + offset?: number; + unlimited?: boolean; + }) => Promise; + onSchemaFetch: (connectionId: string) => Promise; + + onTestConnection?: (config: { + type: DatabaseType; + host: string; + port: number; + database: string; + username: string; + password: string; + sslEnabled?: boolean; + }) => Promise<{ success: boolean; message: string }>; + onSaveQuery?: (query: SavedQueryInput) => Promise; + onLoadSavedQueries?: () => Promise; + + features?: WorkspaceFeatures; + className?: string; +} From c7a757477079a4274efc057165c53eb194d49d7b Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 16:06:08 +0300 Subject: [PATCH 12/39] feat(workspace): add useConnectionAdapter hook with tests Co-Authored-By: Claude Opus 4.6 (1M context) --- src/workspace/hooks/use-connection-adapter.ts | 76 ++++ tests/hooks/use-connection-adapter.test.ts | 331 ++++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 src/workspace/hooks/use-connection-adapter.ts create mode 100644 tests/hooks/use-connection-adapter.test.ts diff --git a/src/workspace/hooks/use-connection-adapter.ts b/src/workspace/hooks/use-connection-adapter.ts new file mode 100644 index 0000000..b6c91f0 --- /dev/null +++ b/src/workspace/hooks/use-connection-adapter.ts @@ -0,0 +1,76 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import type { DatabaseConnection, TableSchema } from '@/lib/types'; +import type { WorkspaceConnection } from '@/workspace/types'; + +interface UseConnectionAdapterParams { + connections: WorkspaceConnection[]; + onSchemaFetch: (connectionId: string) => Promise; +} + +export function useConnectionAdapter({ + connections: externalConnections, + onSchemaFetch, +}: UseConnectionAdapterParams) { + const connections: DatabaseConnection[] = useMemo( + () => + externalConnections.map((c) => ({ + id: c.id, + name: c.name, + type: c.type, + createdAt: new Date(), + managed: true, + })), + [externalConnections] + ); + + const [activeConnection, setActiveConnection] = useState( + connections[0] ?? null + ); + const [schema, setSchema] = useState([]); + const [isLoadingSchema, setIsLoadingSchema] = useState(false); + + useEffect(() => { + if (connections.length === 0) { + setActiveConnection(null); + return; + } + if (activeConnection && connections.some((c) => c.id === activeConnection.id)) { + return; + } + setActiveConnection(connections[0]); + }, [connections]); // eslint-disable-line react-hooks/exhaustive-deps + + const fetchSchema = useCallback( + async (conn: DatabaseConnection) => { + setIsLoadingSchema(true); + try { + const result = await onSchemaFetch(conn.id); + setSchema(result); + } catch { + setSchema([]); + } finally { + setIsLoadingSchema(false); + } + }, + [onSchemaFetch] + ); + + const tableNames = useMemo(() => schema.map((s) => s.name), [schema]); + const schemaContext = useMemo(() => JSON.stringify(schema), [schema]); + + return { + connections, + setConnections: (() => {}) as React.Dispatch>, + activeConnection, + setActiveConnection: setActiveConnection as (conn: DatabaseConnection | null) => void, + schema, + setSchema, + isLoadingSchema, + connectionPulse: null as 'healthy' | 'degraded' | 'error' | null, + fetchSchema, + tableNames, + schemaContext, + }; +} diff --git a/tests/hooks/use-connection-adapter.test.ts b/tests/hooks/use-connection-adapter.test.ts new file mode 100644 index 0000000..7f36f7b --- /dev/null +++ b/tests/hooks/use-connection-adapter.test.ts @@ -0,0 +1,331 @@ +import '../setup-dom'; + +import { describe, test, expect, mock } from 'bun:test'; +import { renderHook, act, waitFor } from '@testing-library/react'; + +import { useConnectionAdapter } from '@/workspace/hooks/use-connection-adapter'; +import type { WorkspaceConnection } from '@/workspace/types'; +import type { TableSchema } from '@/lib/types'; + +// ── Test Data ─────────────────────────────────────────────────────────────── + +const makeWorkspaceConnection = ( + overrides: Partial = {} +): WorkspaceConnection => ({ + id: 'ws-conn-1', + name: 'Platform DB', + type: 'postgres', + ...overrides, +}); + +const makeSchema = (): TableSchema[] => [ + { + name: 'users', + columns: [ + { name: 'id', type: 'integer', nullable: false, isPrimary: true }, + { name: 'email', type: 'varchar', nullable: false, isPrimary: false }, + ], + indexes: [{ name: 'users_pkey', columns: ['id'], unique: true }], + rowCount: 100, + }, + { + name: 'orders', + columns: [ + { name: 'id', type: 'integer', nullable: false, isPrimary: true }, + { name: 'user_id', type: 'integer', nullable: false, isPrimary: false }, + ], + indexes: [{ name: 'orders_pkey', columns: ['id'], unique: true }], + rowCount: 500, + }, +]; + +// ============================================================================= +// useConnectionAdapter Tests +// ============================================================================= +describe('useConnectionAdapter', () => { + // ── Initializes with first connection as active ───────────────────────── + + test('initializes with first connection as active', () => { + const connections = [ + makeWorkspaceConnection({ id: 'c1', name: 'DB One' }), + makeWorkspaceConnection({ id: 'c2', name: 'DB Two' }), + ]; + const onSchemaFetch = mock(() => Promise.resolve([])); + + const { result } = renderHook(() => + useConnectionAdapter({ connections, onSchemaFetch }) + ); + + expect(result.current.activeConnection).not.toBeNull(); + expect(result.current.activeConnection!.id).toBe('c1'); + expect(result.current.activeConnection!.name).toBe('DB One'); + expect(result.current.activeConnection!.managed).toBe(true); + }); + + // ── Returns null activeConnection when connections array is empty ─────── + + test('returns null activeConnection when connections array is empty', () => { + const onSchemaFetch = mock(() => Promise.resolve([])); + + const { result } = renderHook(() => + useConnectionAdapter({ connections: [], onSchemaFetch }) + ); + + expect(result.current.connections).toEqual([]); + expect(result.current.activeConnection).toBeNull(); + expect(result.current.schema).toEqual([]); + expect(result.current.isLoadingSchema).toBe(false); + expect(result.current.connectionPulse).toBeNull(); + }); + + // ── setActiveConnection updates active connection ─────────────────────── + + test('setActiveConnection updates active connection', () => { + const connections = [ + makeWorkspaceConnection({ id: 'c1', name: 'DB One' }), + makeWorkspaceConnection({ id: 'c2', name: 'DB Two' }), + ]; + const onSchemaFetch = mock(() => Promise.resolve([])); + + const { result } = renderHook(() => + useConnectionAdapter({ connections, onSchemaFetch }) + ); + + expect(result.current.activeConnection!.id).toBe('c1'); + + act(() => { + result.current.setActiveConnection(result.current.connections[1]); + }); + + expect(result.current.activeConnection!.id).toBe('c2'); + expect(result.current.activeConnection!.name).toBe('DB Two'); + }); + + // ── fetchSchema calls onSchemaFetch and updates schema state ──────────── + + test('fetchSchema calls onSchemaFetch and updates schema state', async () => { + const schemaData = makeSchema(); + const onSchemaFetch = mock(() => Promise.resolve(schemaData)); + + const connections = [makeWorkspaceConnection({ id: 'c1' })]; + + const { result } = renderHook(() => + useConnectionAdapter({ connections, onSchemaFetch }) + ); + + await act(async () => { + await result.current.fetchSchema(result.current.connections[0]); + }); + + // Verify onSchemaFetch was called with the connection ID + expect(onSchemaFetch).toHaveBeenCalledTimes(1); + expect(onSchemaFetch).toHaveBeenCalledWith('c1'); + + // Verify schema was set + expect(result.current.schema).toEqual(schemaData); + + // Verify tableNames derived value + expect(result.current.tableNames).toEqual(['users', 'orders']); + + // Verify schemaContext derived value + expect(result.current.schemaContext).toBe(JSON.stringify(schemaData)); + + // Verify loading is done + expect(result.current.isLoadingSchema).toBe(false); + }); + + // ── fetchSchema sets isLoadingSchema during fetch ─────────────────────── + + test('fetchSchema sets isLoadingSchema during fetch', async () => { + let resolveSchema: ((value: TableSchema[]) => void) | undefined; + const schemaPromise = new Promise((resolve) => { + resolveSchema = resolve; + }); + const onSchemaFetch = mock(() => schemaPromise); + + const connections = [makeWorkspaceConnection({ id: 'c1' })]; + + const { result } = renderHook(() => + useConnectionAdapter({ connections, onSchemaFetch }) + ); + + // Start fetching schema (don't await) + let fetchPromise: Promise; + act(() => { + fetchPromise = result.current.fetchSchema(result.current.connections[0]); + }); + + // isLoadingSchema should be true while waiting + expect(result.current.isLoadingSchema).toBe(true); + + // Resolve the schema request + resolveSchema!(makeSchema()); + + await act(async () => { + await fetchPromise!; + }); + + expect(result.current.isLoadingSchema).toBe(false); + expect(result.current.schema).toHaveLength(2); + }); + + // ── fetchSchema error sets empty schema ───────────────────────────────── + + test('fetchSchema error sets empty schema', async () => { + const onSchemaFetch = mock(() => Promise.reject(new Error('Connection refused'))); + + const connections = [makeWorkspaceConnection({ id: 'c1' })]; + + const { result } = renderHook(() => + useConnectionAdapter({ connections, onSchemaFetch }) + ); + + await act(async () => { + await result.current.fetchSchema(result.current.connections[0]); + }); + + expect(result.current.schema).toEqual([]); + expect(result.current.isLoadingSchema).toBe(false); + }); + + // ── Updates connections when props change ─────────────────────────────── + + test('updates connections when props change', () => { + const initialConnections = [ + makeWorkspaceConnection({ id: 'c1', name: 'DB One' }), + ]; + const onSchemaFetch = mock(() => Promise.resolve([])); + + const { result, rerender } = renderHook( + ({ connections }) => + useConnectionAdapter({ connections, onSchemaFetch }), + { initialProps: { connections: initialConnections } } + ); + + expect(result.current.connections).toHaveLength(1); + expect(result.current.connections[0].id).toBe('c1'); + + // Rerender with updated connections + const updatedConnections = [ + makeWorkspaceConnection({ id: 'c1', name: 'DB One' }), + makeWorkspaceConnection({ id: 'c2', name: 'DB Two' }), + makeWorkspaceConnection({ id: 'c3', name: 'DB Three', type: 'mysql' }), + ]; + + rerender({ connections: updatedConnections }); + + expect(result.current.connections).toHaveLength(3); + expect(result.current.connections[2].id).toBe('c3'); + expect(result.current.connections[2].type).toBe('mysql'); + expect(result.current.connections[2].managed).toBe(true); + }); + + // ── Resets activeConnection when it is removed from connections ───────── + + test('resets activeConnection when it is removed from connections', async () => { + const initialConnections = [ + makeWorkspaceConnection({ id: 'c1', name: 'DB One' }), + makeWorkspaceConnection({ id: 'c2', name: 'DB Two' }), + ]; + const onSchemaFetch = mock(() => Promise.resolve([])); + + const { result, rerender } = renderHook( + ({ connections }) => + useConnectionAdapter({ connections, onSchemaFetch }), + { initialProps: { connections: initialConnections } } + ); + + // Set active to c2 + act(() => { + result.current.setActiveConnection(result.current.connections[1]); + }); + expect(result.current.activeConnection!.id).toBe('c2'); + + // Remove c2 from connections + const updatedConnections = [ + makeWorkspaceConnection({ id: 'c1', name: 'DB One' }), + ]; + + rerender({ connections: updatedConnections }); + + // activeConnection should reset to the first available connection + await waitFor(() => { + expect(result.current.activeConnection!.id).toBe('c1'); + }); + }); + + // ── Resets activeConnection to null when all connections removed ───────── + + test('resets activeConnection to null when all connections removed', async () => { + const initialConnections = [ + makeWorkspaceConnection({ id: 'c1', name: 'DB One' }), + ]; + const onSchemaFetch = mock(() => Promise.resolve([])); + + const { result, rerender } = renderHook( + ({ connections }) => + useConnectionAdapter({ connections, onSchemaFetch }), + { initialProps: { connections: initialConnections } } + ); + + expect(result.current.activeConnection!.id).toBe('c1'); + + // Remove all connections + rerender({ connections: [] }); + + await waitFor(() => { + expect(result.current.activeConnection).toBeNull(); + }); + }); + + // ── Maps WorkspaceConnection to DatabaseConnection correctly ──────────── + + test('maps WorkspaceConnection to DatabaseConnection with managed flag', () => { + const connections = [ + makeWorkspaceConnection({ id: 'c1', name: 'Platform DB', type: 'mysql' }), + ]; + const onSchemaFetch = mock(() => Promise.resolve([])); + + const { result } = renderHook(() => + useConnectionAdapter({ connections, onSchemaFetch }) + ); + + const mapped = result.current.connections[0]; + expect(mapped.id).toBe('c1'); + expect(mapped.name).toBe('Platform DB'); + expect(mapped.type).toBe('mysql'); + expect(mapped.managed).toBe(true); + expect(mapped.createdAt).toBeInstanceOf(Date); + }); + + // ── setConnections is a no-op ────────────────────────────────────────── + + test('setConnections is a no-op (connections are externally managed)', () => { + const connections = [makeWorkspaceConnection({ id: 'c1' })]; + const onSchemaFetch = mock(() => Promise.resolve([])); + + const { result } = renderHook(() => + useConnectionAdapter({ connections, onSchemaFetch }) + ); + + // Calling setConnections should not throw and should not change connections + act(() => { + result.current.setConnections([]); + }); + + expect(result.current.connections).toHaveLength(1); + }); + + // ── connectionPulse is always null ───────────────────────────────────── + + test('connectionPulse is always null (no health check in adapter)', () => { + const connections = [makeWorkspaceConnection({ id: 'c1' })]; + const onSchemaFetch = mock(() => Promise.resolve([])); + + const { result } = renderHook(() => + useConnectionAdapter({ connections, onSchemaFetch }) + ); + + expect(result.current.connectionPulse).toBeNull(); + }); +}); From 25061f7b0eef8f6c462376b0b257b28f8a8dc05c Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 16:15:44 +0300 Subject: [PATCH 13/39] feat(workspace): add useQueryAdapter hook with tests Co-Authored-By: Claude Opus 4.6 (1M context) --- src/workspace/hooks/use-query-adapter.ts | 337 +++++++++++++++++++++++ tests/hooks/use-query-adapter.test.ts | 315 +++++++++++++++++++++ 2 files changed, 652 insertions(+) create mode 100644 src/workspace/hooks/use-query-adapter.ts create mode 100644 tests/hooks/use-query-adapter.test.ts diff --git a/src/workspace/hooks/use-query-adapter.ts b/src/workspace/hooks/use-query-adapter.ts new file mode 100644 index 0000000..8d13ad3 --- /dev/null +++ b/src/workspace/hooks/use-query-adapter.ts @@ -0,0 +1,337 @@ +'use client'; + +import { useState, useCallback, useRef, type Dispatch, type SetStateAction } from 'react'; +import type { DatabaseConnection, QueryTab } from '@/lib/types'; +import type { WorkspaceQueryResult, WorkspaceFeatures } from '@/workspace/types'; +import type { BottomPanelMode } from '@/components/studio/BottomPanel'; +import { useToast } from '@/hooks/use-toast'; +import { isDangerousQuery } from '@/components/QuerySafetyDialog'; + +interface UseQueryAdapterParams { + activeConnection: DatabaseConnection | null; + onQueryExecute: (connectionId: string, sql: string, options?: { + limit?: number; + offset?: number; + unlimited?: boolean; + }) => Promise; + tabs: QueryTab[]; + activeTabId: string; + currentTab: QueryTab; + setTabs: Dispatch>; + fetchSchema: (conn: DatabaseConnection) => Promise; + features: Partial; +} + +export function useQueryAdapter({ + activeConnection, + onQueryExecute, + tabs, + activeTabId, + currentTab, + setTabs, + fetchSchema: _fetchSchema, + features: _features, +}: UseQueryAdapterParams) { + // Reserved for future use (schema refresh after DDL, feature gating) + void _fetchSchema; + void _features; + const cancelledRef = useRef(false); + + const [safetyCheckQuery, setSafetyCheckQuery] = useState(null); + const [unlimitedWarningOpen, setUnlimitedWarningOpen] = useState(false); + const [pendingUnlimitedQuery, setPendingUnlimitedQuery] = useState<{ + query: string; + tabId: string; + } | null>(null); + const [historyKey, setHistoryKey] = useState(0); + const [bottomPanelMode, setBottomPanelMode] = useState('results'); + + const { toast } = useToast(); + + const executeQuery = useCallback(async ( + overrideQuery?: string, + tabId?: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _isExplain: boolean = false, + ) => { + const targetTabId = tabId || activeTabId; + const tabToExec = tabs.find(t => t.id === targetTabId) || currentTab; + + const queryToExecute = overrideQuery || tabToExec.query; + + if (!activeConnection) { + toast({ title: 'No Connection', description: 'Select a connection first.', variant: 'destructive' }); + return; + } + + if (!queryToExecute || queryToExecute.trim() === '') { + toast({ title: 'Empty Query', description: 'Enter a query to execute.', variant: 'destructive' }); + return; + } + + // Safety check for dangerous queries (skip for force-execute via forceExecuteQuery) + if (isDangerousQuery(queryToExecute)) { + setSafetyCheckQuery(queryToExecute); + return; + } + + cancelledRef.current = false; + + // Set tab executing state + setTabs(prev => prev.map(t => t.id === targetTabId ? { + ...t, + isExecuting: true, + } : t)); + setBottomPanelMode('results'); + + const startTime = Date.now(); + + try { + const result = await onQueryExecute(activeConnection.id, queryToExecute); + + // Check if cancelled while awaiting + if (cancelledRef.current) return; + + const executionTime = result.executionTime || (Date.now() - startTime); + + setTabs(prev => prev.map(t => { + if (t.id !== targetTabId) return t; + + return { + ...t, + result: { + rows: result.rows, + fields: result.fields, + rowCount: result.rowCount, + executionTime, + pagination: result.pagination, + }, + allRows: result.rows, + currentOffset: result.rows.length, + isExecuting: false, + isLoadingMore: false, + }; + })); + + setHistoryKey(prev => prev + 1); + } catch (error) { + // Skip updates if cancelled + if (cancelledRef.current) return; + + setTabs(prev => prev.map(t => t.id === targetTabId ? { + ...t, + isExecuting: false, + isLoadingMore: false, + } : t)); + + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + toast({ title: 'Query Error', description: errorMessage, variant: 'destructive' }); + } + }, [activeConnection, tabs, currentTab, activeTabId, toast, onQueryExecute, setTabs]); + + // Force execute (bypass safety check) + const forceExecuteQuery = useCallback((query: string) => { + setSafetyCheckQuery(null); + + if (!activeConnection) { + toast({ title: 'No Connection', description: 'Select a connection first.', variant: 'destructive' }); + return; + } + + if (!query || query.trim() === '') { + toast({ title: 'Empty Query', description: 'Enter a query to execute.', variant: 'destructive' }); + return; + } + + cancelledRef.current = false; + + setTabs(prev => prev.map(t => t.id === activeTabId ? { + ...t, + isExecuting: true, + } : t)); + setBottomPanelMode('results'); + + const startTime = Date.now(); + + onQueryExecute(activeConnection.id, query) + .then((result) => { + if (cancelledRef.current) return; + + const executionTime = result.executionTime || (Date.now() - startTime); + + setTabs(prev => prev.map(t => { + if (t.id !== activeTabId) return t; + + return { + ...t, + result: { + rows: result.rows, + fields: result.fields, + rowCount: result.rowCount, + executionTime, + pagination: result.pagination, + }, + allRows: result.rows, + currentOffset: result.rows.length, + isExecuting: false, + isLoadingMore: false, + }; + })); + + setHistoryKey(prev => prev + 1); + }) + .catch((error) => { + if (cancelledRef.current) return; + + setTabs(prev => prev.map(t => t.id === activeTabId ? { + ...t, + isExecuting: false, + isLoadingMore: false, + } : t)); + + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + toast({ title: 'Query Error', description: errorMessage, variant: 'destructive' }); + }); + }, [activeConnection, activeTabId, toast, onQueryExecute, setTabs]); + + // Cancel running query (best-effort via ref flag) + const cancelQuery = useCallback(() => { + cancelledRef.current = true; + + setTabs(prev => prev.map(t => t.isExecuting ? { + ...t, + isExecuting: false, + isLoadingMore: false, + } : t)); + + toast({ title: 'Query Cancelled', description: 'Query execution was cancelled.' }); + }, [setTabs, toast]); + + // Load More handler + const handleLoadMore = useCallback(() => { + if (!currentTab.result?.pagination?.hasMore) return; + if (!activeConnection) return; + + const currentOffset = currentTab.currentOffset || currentTab.result.rows.length; + + setTabs(prev => prev.map(t => t.id === currentTab.id ? { + ...t, + isLoadingMore: true, + } : t)); + + onQueryExecute(activeConnection.id, currentTab.query, { + limit: 500, + offset: currentOffset, + }) + .then((result) => { + if (cancelledRef.current) return; + + setTabs(prev => prev.map(t => { + if (t.id !== currentTab.id) return t; + + const existingRows = t.allRows || t.result?.rows || []; + const newAllRows = [...existingRows, ...result.rows]; + + return { + ...t, + result: { + rows: newAllRows, + fields: result.fields, + rowCount: newAllRows.length, + executionTime: t.result?.executionTime || 0, + pagination: result.pagination, + }, + allRows: newAllRows, + currentOffset: currentOffset + result.rows.length, + isExecuting: false, + isLoadingMore: false, + }; + })); + }) + .catch((error) => { + if (cancelledRef.current) return; + + setTabs(prev => prev.map(t => t.id === currentTab.id ? { + ...t, + isExecuting: false, + isLoadingMore: false, + } : t)); + + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + toast({ title: 'Load More Error', description: errorMessage, variant: 'destructive' }); + }); + }, [currentTab, activeConnection, onQueryExecute, setTabs, toast]); + + // Unlimited query handler + const handleUnlimitedQuery = useCallback(() => { + if (!pendingUnlimitedQuery) return; + if (!activeConnection) return; + + const { query, tabId } = pendingUnlimitedQuery; + + cancelledRef.current = false; + + setTabs(prev => prev.map(t => t.id === tabId ? { + ...t, + isExecuting: true, + } : t)); + + onQueryExecute(activeConnection.id, query, { unlimited: true }) + .then((result) => { + if (cancelledRef.current) return; + + setTabs(prev => prev.map(t => { + if (t.id !== tabId) return t; + + return { + ...t, + result: { + rows: result.rows, + fields: result.fields, + rowCount: result.rowCount, + executionTime: result.executionTime, + pagination: result.pagination, + }, + allRows: result.rows, + currentOffset: result.rows.length, + isExecuting: false, + isLoadingMore: false, + }; + })); + + setHistoryKey(prev => prev + 1); + }) + .catch((error) => { + if (cancelledRef.current) return; + + setTabs(prev => prev.map(t => t.id === tabId ? { + ...t, + isExecuting: false, + isLoadingMore: false, + } : t)); + + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + toast({ title: 'Query Error', description: errorMessage, variant: 'destructive' }); + }); + + setUnlimitedWarningOpen(false); + setPendingUnlimitedQuery(null); + }, [pendingUnlimitedQuery, activeConnection, onQueryExecute, setTabs, toast]); + + return { + executeQuery, + forceExecuteQuery, + cancelQuery, + handleLoadMore, + handleUnlimitedQuery, + safetyCheckQuery, + setSafetyCheckQuery, + unlimitedWarningOpen, + setUnlimitedWarningOpen, + pendingUnlimitedQuery, + setPendingUnlimitedQuery, + historyKey, + bottomPanelMode, + setBottomPanelMode, + }; +} diff --git a/tests/hooks/use-query-adapter.test.ts b/tests/hooks/use-query-adapter.test.ts new file mode 100644 index 0000000..55a8bf5 --- /dev/null +++ b/tests/hooks/use-query-adapter.test.ts @@ -0,0 +1,315 @@ +import '../setup-dom'; +import '../helpers/mock-sonner'; + +import { describe, test, expect, mock, beforeEach } from 'bun:test'; +import { renderHook, act } from '@testing-library/react'; + +import { useQueryAdapter } from '@/workspace/hooks/use-query-adapter'; +import type { DatabaseConnection, QueryTab } from '@/lib/types'; +import type { WorkspaceQueryResult, WorkspaceFeatures } from '@/workspace/types'; +import { mockToastSuccess, mockToastError } from '../helpers/mock-sonner'; + +// ── Test Data ─────────────────────────────────────────────────────────────── + +const makeConnection = (overrides: Partial = {}): DatabaseConnection => ({ + id: 'conn-1', + name: 'Test DB', + type: 'postgres', + createdAt: new Date(), + managed: true, + ...overrides, +}); + +const makeTab = (overrides: Partial = {}): QueryTab => ({ + id: 'tab-1', + name: 'Query 1', + query: 'SELECT * FROM users', + result: null, + isExecuting: false, + type: 'sql', + ...overrides, +}); + +const makeQueryResult = (overrides: Partial = {}): WorkspaceQueryResult => ({ + rows: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }], + fields: ['id', 'name'], + rowCount: 2, + executionTime: 42, + pagination: { + limit: 500, + offset: 0, + hasMore: false, + totalReturned: 2, + wasLimited: false, + }, + ...overrides, +}); + +// ── Helper for mutable tabs array ──────────────────────────────────────────── + +function createMutableTabs(initial: QueryTab[]) { + const tabs = [...initial]; + const setTabs = (fn: (prev: QueryTab[]) => QueryTab[]) => { + const updated = fn(tabs); + tabs.splice(0, tabs.length, ...updated); + }; + return { tabs, setTabs: setTabs as unknown as React.Dispatch> }; +} + +// ── Default hook params factory ────────────────────────────────────────────── + +function makeHookParams(overrides: Record = {}) { + const defaultTab = makeTab(); + const { tabs, setTabs } = createMutableTabs([defaultTab]); + const onQueryExecute = mock(() => Promise.resolve(makeQueryResult())); + const fetchSchema = mock(() => Promise.resolve()); + + return { + activeConnection: makeConnection(), + onQueryExecute, + tabs, + activeTabId: 'tab-1', + currentTab: defaultTab, + setTabs, + fetchSchema, + features: {} as Partial, + ...overrides, + }; +} + +// ============================================================================= +// useQueryAdapter Tests +// ============================================================================= +describe('useQueryAdapter', () => { + beforeEach(() => { + mockToastSuccess.mockClear(); + mockToastError.mockClear(); + }); + + // ── executeQuery calls onQueryExecute with correct connectionId and sql ──── + + test('executeQuery calls onQueryExecute with correct connectionId and sql', async () => { + const params = makeHookParams(); + + const { result } = renderHook(() => useQueryAdapter(params)); + + await act(async () => { + await result.current.executeQuery('SELECT 1'); + }); + + expect(params.onQueryExecute).toHaveBeenCalledTimes(1); + expect(params.onQueryExecute).toHaveBeenCalledWith('conn-1', 'SELECT 1'); + }); + + // ── executeQuery uses tab query when no override provided ────────────────── + + test('executeQuery uses tab query when no override provided', async () => { + const params = makeHookParams(); + + const { result } = renderHook(() => useQueryAdapter(params)); + + await act(async () => { + await result.current.executeQuery(); + }); + + expect(params.onQueryExecute).toHaveBeenCalledTimes(1); + expect(params.onQueryExecute).toHaveBeenCalledWith('conn-1', 'SELECT * FROM users'); + }); + + // ── Returns error state when onQueryExecute throws ───────────────────────── + + test('returns error state when onQueryExecute throws (tab not stuck in executing)', async () => { + const defaultTab = makeTab(); + const { tabs, setTabs } = createMutableTabs([defaultTab]); + const onQueryExecute = mock(() => Promise.reject(new Error('Connection refused'))); + + const params = makeHookParams({ + onQueryExecute, + tabs, + setTabs, + currentTab: defaultTab, + }); + + const { result } = renderHook(() => useQueryAdapter(params)); + + await act(async () => { + await result.current.executeQuery('SELECT 1'); + }); + + // Tab should NOT be stuck in isExecuting + expect(tabs[0].isExecuting).toBe(false); + + // Error toast should have been called + expect(mockToastError).toHaveBeenCalled(); + }); + + // ── cancelQuery sets executing to false ──────────────────────────────────── + + test('cancelQuery sets executing to false', () => { + const defaultTab = makeTab({ isExecuting: true }); + const { tabs, setTabs } = createMutableTabs([defaultTab]); + + const params = makeHookParams({ + tabs, + setTabs, + currentTab: defaultTab, + }); + + const { result } = renderHook(() => useQueryAdapter(params)); + + act(() => { + result.current.cancelQuery(); + }); + + expect(tabs[0].isExecuting).toBe(false); + + // Should show cancellation toast + expect(mockToastSuccess).toHaveBeenCalled(); + }); + + // ── bottomPanelMode defaults to 'results' ────────────────────────────────── + + test('bottomPanelMode defaults to results', () => { + const params = makeHookParams(); + + const { result } = renderHook(() => useQueryAdapter(params)); + + expect(result.current.bottomPanelMode).toBe('results'); + }); + + // ── historyKey increments after successful query ─────────────────────────── + + test('historyKey increments after successful query', async () => { + const params = makeHookParams(); + + const { result } = renderHook(() => useQueryAdapter(params)); + + expect(result.current.historyKey).toBe(0); + + await act(async () => { + await result.current.executeQuery('SELECT 1'); + }); + + expect(result.current.historyKey).toBe(1); + + await act(async () => { + await result.current.executeQuery('SELECT 2'); + }); + + expect(result.current.historyKey).toBe(2); + }); + + // ── executeQuery toasts error when no connection ─────────────────────────── + + test('executeQuery toasts error when no connection', async () => { + const params = makeHookParams({ + activeConnection: null, + }); + + const { result } = renderHook(() => useQueryAdapter(params)); + + await act(async () => { + await result.current.executeQuery('SELECT 1'); + }); + + // onQueryExecute should NOT be called + expect(params.onQueryExecute).not.toHaveBeenCalled(); + + // Should toast error + expect(mockToastError).toHaveBeenCalled(); + }); + + // ── executeQuery toasts error when query is empty ────────────────────────── + + test('executeQuery toasts error when query is empty', async () => { + const defaultTab = makeTab({ query: '' }); + const params = makeHookParams({ + currentTab: defaultTab, + tabs: [defaultTab], + }); + + const { result } = renderHook(() => useQueryAdapter(params)); + + await act(async () => { + await result.current.executeQuery(); + }); + + expect(params.onQueryExecute).not.toHaveBeenCalled(); + expect(mockToastError).toHaveBeenCalled(); + }); + + // ── executeQuery updates tab with result data ────────────────────────────── + + test('executeQuery updates tab with result data', async () => { + const defaultTab = makeTab(); + const { tabs, setTabs } = createMutableTabs([defaultTab]); + const queryResult = makeQueryResult(); + const onQueryExecute = mock(() => Promise.resolve(queryResult)); + + const params = makeHookParams({ + onQueryExecute, + tabs, + setTabs, + currentTab: defaultTab, + }); + + const { result } = renderHook(() => useQueryAdapter(params)); + + await act(async () => { + await result.current.executeQuery('SELECT * FROM users'); + }); + + expect(tabs[0].result).not.toBeNull(); + expect(tabs[0].result!.rows).toEqual(queryResult.rows); + expect(tabs[0].result!.fields).toEqual(queryResult.fields); + expect(tabs[0].result!.rowCount).toBe(queryResult.rowCount); + expect(tabs[0].isExecuting).toBe(false); + }); + + // ── setBottomPanelMode updates correctly ─────────────────────────────────── + + test('setBottomPanelMode updates correctly', () => { + const params = makeHookParams(); + + const { result } = renderHook(() => useQueryAdapter(params)); + + act(() => { + result.current.setBottomPanelMode('history'); + }); + + expect(result.current.bottomPanelMode).toBe('history'); + }); + + // ── safetyCheckQuery and setter work ─────────────────────────────────────── + + test('safetyCheckQuery defaults to null and can be set', () => { + const params = makeHookParams(); + + const { result } = renderHook(() => useQueryAdapter(params)); + + expect(result.current.safetyCheckQuery).toBeNull(); + + act(() => { + result.current.setSafetyCheckQuery('DROP TABLE users'); + }); + + expect(result.current.safetyCheckQuery).toBe('DROP TABLE users'); + }); + + // ── forceExecuteQuery calls onQueryExecute bypassing safety ──────────────── + + test('forceExecuteQuery calls onQueryExecute for dangerous queries', async () => { + const params = makeHookParams(); + + const { result } = renderHook(() => useQueryAdapter(params)); + + // forceExecuteQuery should bypass safety check + await act(async () => { + result.current.forceExecuteQuery('DROP TABLE users'); + // Allow promise chain to resolve + await new Promise(r => setTimeout(r, 10)); + }); + + expect(params.onQueryExecute).toHaveBeenCalledWith('conn-1', 'DROP TABLE users'); + }); +}); From bf131932fbd91a0c596c94ebd79ba89818d936ea Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 16:23:20 +0300 Subject: [PATCH 14/39] feat(workspace): add StudioWorkspace composite component Composes useConnectionAdapter, useQueryAdapter, and useTabManager hooks with existing Studio UI components to provide an embeddable IDE experience for the platform integration. Removes standalone headers, connection management modals, and command palette; gates optional features (AI, charts, code generator, test data, schema diagram, data import) behind WorkspaceFeatures flags. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/workspace/StudioWorkspace.tsx | 510 ++++++++++++++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 src/workspace/StudioWorkspace.tsx diff --git a/src/workspace/StudioWorkspace.tsx b/src/workspace/StudioWorkspace.tsx new file mode 100644 index 0000000..47a8cc7 --- /dev/null +++ b/src/workspace/StudioWorkspace.tsx @@ -0,0 +1,510 @@ +'use client'; + +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { Sidebar, ConnectionsList } from '@/components/sidebar'; +import { MobileNav } from '@/components/MobileNav'; +import { SchemaExplorer } from '@/components/schema-explorer'; +import { QueryEditor, QueryEditorRef } from '@/components/QueryEditor'; +import { DataImportModal } from '@/components/DataImportModal'; +import { QuerySafetyDialog } from '@/components/QuerySafetyDialog'; +import { DataProfiler } from '@/components/DataProfiler'; +import { CodeGenerator } from '@/components/CodeGenerator'; +import { TestDataGenerator } from '@/components/TestDataGenerator'; +import { SchemaDiagram } from '@/components/SchemaDiagram'; +import { SaveQueryModal } from '@/components/SaveQueryModal'; +import { + StudioTabBar, + QueryToolbar, + BottomPanel, +} from '@/components/studio/index'; +import type { DatabaseConnection } from '@/lib/types'; +import type { MaskingConfig } from '@/lib/data-masking'; +import { useToast } from '@/hooks/use-toast'; +import { useTabManager } from '@/hooks/use-tab-manager'; +import { useConnectionAdapter } from '@/workspace/hooks/use-connection-adapter'; +import { useQueryAdapter } from '@/workspace/hooks/use-query-adapter'; +import { + type StudioWorkspaceProps, + DEFAULT_WORKSPACE_FEATURES, +} from '@/workspace/types'; +import { cn } from '@/lib/utils'; +import { AlertTriangle, Database } from 'lucide-react'; +import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; +import { AnimatePresence } from 'framer-motion'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +// No-op masking config for embedded mode (masking disabled) +const NOOP_MASKING_CONFIG: MaskingConfig = { + enabled: false, + patterns: [], + roleSettings: { + admin: { canToggle: false, canReveal: false }, + user: { canToggle: false, canReveal: false }, + }, +}; + +export function StudioWorkspace({ + connections: externalConnections, + currentUser, + onQueryExecute, + onSchemaFetch, + onSaveQuery: onSaveQueryProp, + // onLoadSavedQueries — reserved for future saved-queries panel integration + features: featuresProp, + className, +}: StudioWorkspaceProps) { + const queryEditorRef = useRef(null); + const { toast } = useToast(); + + // Merge feature flags with defaults + const features = useMemo( + () => ({ ...DEFAULT_WORKSPACE_FEATURES, ...featuresProp }), + [featuresProp], + ); + + // 1. Connection Adapter (platform-managed connections) + const conn = useConnectionAdapter({ + connections: externalConnections, + onSchemaFetch, + }); + + // 2. Tab Manager (pure UI state, reused as-is) + const tabMgr = useTabManager({ + activeConnection: conn.activeConnection, + metadata: null, + schema: conn.schema, + }); + + // 3. Query Adapter (platform-delegated execution) + const queryExec = useQueryAdapter({ + activeConnection: conn.activeConnection, + onQueryExecute, + tabs: tabMgr.tabs, + activeTabId: tabMgr.activeTabId, + currentTab: tabMgr.currentTab, + setTabs: tabMgr.setTabs, + fetchSchema: conn.fetchSchema, + features, + }); + + // === Connection change effect === + useEffect(() => { + if (conn.activeConnection) { + conn.fetchSchema(conn.activeConnection); + } else { + conn.setSchema([]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [conn.activeConnection]); + + // === Modal / overlay state === + const [showDiagram, setShowDiagram] = useState(false); + const [isSaveQueryModalOpen, setIsSaveQueryModalOpen] = useState(false); + const [savedKey, setSavedKey] = useState(0); + const [activeMobileTab, setActiveMobileTab] = useState<'database' | 'schema' | 'editor'>('editor'); + const [isImportModalOpen, setIsImportModalOpen] = useState(false); + const [isNL2SQLOpen, setIsNL2SQLOpen] = useState(false); + const [profilerTable, setProfilerTable] = useState(null); + const [codeGenTable, setCodeGenTable] = useState(null); + const [testDataTable, setTestDataTable] = useState(null); + + // === Save query handler === + const handleSaveQuery = useCallback(async (name: string, description: string, tags: string[]) => { + if (!conn.activeConnection) return; + + if (onSaveQueryProp) { + try { + await onSaveQueryProp({ + name, + query: tabMgr.currentTab.query, + description, + connectionType: conn.activeConnection.type, + tags, + }); + setSavedKey(prev => prev + 1); + toast({ title: 'Query Saved', description: `"${name}" has been added to your saved queries.` }); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Failed to save query'; + toast({ title: 'Save Failed', description: msg, variant: 'destructive' }); + } + } + }, [conn.activeConnection, tabMgr.currentTab.query, onSaveQueryProp, toast]); + + // === Export results (simplified, no masking) === + const exportResults = useCallback((format: 'csv' | 'json' | 'sql-insert' | 'sql-ddl') => { + if (!tabMgr.currentTab.result) return; + const data = tabMgr.currentTab.result.rows; + let content = ''; + let mimeType = 'text/plain'; + let ext: string = format; + + if (format === 'csv') { + const headers = Object.keys(data[0] || {}).join(','); + const rows = data.map(row => Object.values(row).map(val => `"${val}"`).join(',')).join('\n'); + content = `${headers}\n${rows}`; + mimeType = 'text/csv'; + ext = 'csv'; + } else if (format === 'json') { + content = JSON.stringify(data, null, 2); + mimeType = 'application/json'; + ext = 'json'; + } else if (format === 'sql-insert') { + const tableName = tabMgr.currentTab.name.replace(/^Query[: ]*/, '') || 'table_name'; + const columns = Object.keys(data[0] || {}); + const lines = data.map(row => { + const values = columns.map(col => { + const val = row[col]; + if (val === null || val === undefined) return 'NULL'; + if (typeof val === 'number' || typeof val === 'boolean') return String(val); + return `'${String(val).replace(/'/g, "''")}'`; + }); + return `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${values.join(', ')});`; + }); + content = lines.join('\n'); + mimeType = 'text/sql'; + ext = 'sql'; + } else if (format === 'sql-ddl') { + const tableName = tabMgr.currentTab.name.replace(/^Query[: ]*/, '') || 'table_name'; + const columns = Object.keys(data[0] || {}); + const colDefs = columns.map(col => { + const sampleVal = data[0]?.[col]; + let sqlType = 'TEXT'; + if (typeof sampleVal === 'number') { + sqlType = Number.isInteger(sampleVal) ? 'INTEGER' : 'NUMERIC'; + } else if (typeof sampleVal === 'boolean') { + sqlType = 'BOOLEAN'; + } else if (sampleVal instanceof Date) { + sqlType = 'TIMESTAMP'; + } + return ` ${col} ${sqlType}`; + }); + content = `CREATE TABLE ${tableName} (\n${colDefs.join(',\n')}\n);`; + mimeType = 'text/sql'; + ext = 'sql'; + } + + const fileName = `query_result_export.${ext}`; + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); + }, [tabMgr.currentTab]); + + // === Table click handler === + const onTableClick = useCallback((tableName: string) => { + tabMgr.handleTableClick(tableName, queryExec.executeQuery); + }, [tabMgr, queryExec.executeQuery]); + + // === No-op callbacks for disabled features === + const noop = useCallback(() => {}, []); + + return ( +
+ + + setShowDiagram(true) : undefined} + isAdmin={false} + onOpenMaintenance={noop} + databaseType={conn.activeConnection?.type} + metadata={null} + onProfileTable={features.codeGenerator ? (name: string) => setProfilerTable(name) : undefined} + onGenerateCode={features.codeGenerator ? (name: string) => setCodeGenTable(name) : undefined} + onGenerateTestData={features.testDataGenerator ? (name: string) => setTestDataTable(name) : undefined} + /> + + + +
+ {/* No desktop/mobile headers — platform provides its own */} + + + +
+ {/* Schema Diagram overlay */} + {features.schemaDiagram && ( + + {showDiagram && ( + setShowDiagram(false)} /> + )} + + )} + + {/* Mobile: Database Tab */} + {activeMobileTab === 'database' && ( +
+
+

Connections

+
+ { + conn.setActiveConnection(c); + setActiveMobileTab('editor'); + }} + onDeleteConnection={noop} + onAddConnection={noop} + /> +
+ )} + + {/* Mobile: Schema Tab */} + {activeMobileTab === 'schema' && ( +
+ {conn.activeConnection ? ( + { + onTableClick(tableName); + setActiveMobileTab('editor'); + }} + onGenerateSelect={(tableName: string) => { + tabMgr.handleGenerateSelect(tableName); + setActiveMobileTab('editor'); + }} + onCreateTableClick={undefined} + isAdmin={false} + onOpenMaintenance={noop} + databaseType={conn.activeConnection?.type} + metadata={null} + onProfileTable={features.codeGenerator ? (name: string) => setProfilerTable(name) : undefined} + onGenerateCode={features.codeGenerator ? (name: string) => setCodeGenTable(name) : undefined} + onGenerateTestData={features.testDataGenerator ? (name: string) => setTestDataTable(name) : undefined} + /> + ) : ( +
+ +

Select a connection first

+
+ )} +
+ )} + + {/* Desktop & Mobile Editor Tab */} +
+
+ + +
+ setIsSaveQueryModalOpen(true) : noop} + onExecuteQuery={() => queryExec.executeQuery()} + onCancelQuery={queryExec.cancelQuery} + onBeginTransaction={noop} + onCommitTransaction={noop} + onRollbackTransaction={noop} + onTogglePlayground={noop} + onToggleEditing={noop} + onImport={features.dataImport ? () => setIsImportModalOpen(true) : noop} + /> + +
+ tabMgr.updateTabById(tabMgr.currentTab.id, { query: val })} + language={tabMgr.currentTab.type === 'mongodb' ? 'json' : 'sql'} + tables={conn.tableNames} + databaseType={conn.activeConnection?.type} + schemaContext={conn.schemaContext} + capabilities={undefined} + /> +
+
+
+ + + queryExec.executeQuery(q)} + onLoadQuery={(q) => tabMgr.updateCurrentTab({ query: q })} + onLoadMore={ + tabMgr.currentTab.result?.pagination?.hasMore + ? queryExec.handleLoadMore + : undefined + } + isLoadingMore={tabMgr.currentTab.isLoadingMore} + onExportResults={exportResults} + /> + +
+
+
+
+
+
+
+ + {/* Modals — only render those that are feature-enabled */} + + {onSaveQueryProp && ( + setIsSaveQueryModalOpen(false)} + onSave={handleSaveQuery} + defaultQuery={tabMgr.currentTab.query} + /> + )} + + {features.dataImport && ( + setIsImportModalOpen(false)} + onImport={(sql) => queryExec.executeQuery(sql)} + tables={conn.schema} + databaseType={conn.activeConnection?.type} + /> + )} + + {/* Safety dialog — simplified, no AI analysis */} + queryExec.setSafetyCheckQuery(null)} + onProceed={() => { + if (queryExec.safetyCheckQuery) queryExec.forceExecuteQuery(queryExec.safetyCheckQuery); + }} + /> + + {/* Data Profiler */} + {features.codeGenerator && ( + setProfilerTable(null)} + tableName={profilerTable || ''} + tableSchema={conn.schema.find(t => t.name === profilerTable) || null} + connection={conn.activeConnection} + schemaContext={conn.schemaContext} + databaseType={conn.activeConnection?.type} + /> + )} + + {/* Code Generator */} + {features.codeGenerator && ( + setCodeGenTable(null)} + tableName={codeGenTable || ''} + tableSchema={conn.schema.find(t => t.name === codeGenTable) || null} + databaseType={conn.activeConnection?.type} + /> + )} + + {/* Test Data Generator */} + {features.testDataGenerator && ( + setTestDataTable(null)} + tableName={testDataTable || ''} + tableSchema={conn.schema.find(t => t.name === testDataTable) || null} + databaseType={conn.activeConnection?.type} + queryLanguage={undefined} + onExecuteQuery={(q) => queryExec.executeQuery(q)} + /> + )} + + {/* Unlimited Query Warning */} + + +
+
+
+ +
+
+ + Load all results? + + + This may slow down your browser. Max 100K rows will be loaded. + +
+
+
+
+ + Cancel + + + Load All + +
+
+
+ + {/* Mobile Navigation */} + +
+ ); +} From 5665a534027854f57577c2fa02e8d3e37ed77b7a Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 16:27:53 +0300 Subject: [PATCH 15/39] fix(workspace): stub QuerySafetyDialog AI analysis to prevent internal fetch --- src/workspace/StudioWorkspace.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/workspace/StudioWorkspace.tsx b/src/workspace/StudioWorkspace.tsx index 47a8cc7..014e959 100644 --- a/src/workspace/StudioWorkspace.tsx +++ b/src/workspace/StudioWorkspace.tsx @@ -418,7 +418,7 @@ export function StudioWorkspace({ /> )} - {/* Safety dialog — simplified, no AI analysis */} + {/* Safety dialog — stub AI analysis to prevent internal fetch */} { if (queryExec.safetyCheckQuery) queryExec.forceExecuteQuery(queryExec.safetyCheckQuery); }} + onAnalyzeSafety={async () => ({ + riskLevel: 'high' as const, + summary: 'Potentially dangerous query detected', + warnings: [{ + type: 'destructive', + severity: 'high', + message: 'This query may modify or delete data', + detail: 'Review carefully before proceeding.', + }], + affectedRows: 'unknown', + cascadeEffects: 'unknown', + recommendation: 'Review this query carefully before proceeding.', + })} /> {/* Data Profiler */} From 04cf6bc67531355829dae6be93465c4fa8c2b4e2 Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 16:29:24 +0300 Subject: [PATCH 16/39] feat(workspace): add workspace export entry point and build config Co-Authored-By: Claude Sonnet 4.6 --- package.json | 63 +++++++++++++++++++++++++++++++-- src/exports/workspace.ts | 11 ++++++ tsup.config.ts | 76 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 src/exports/workspace.ts create mode 100644 tsup.config.ts diff --git a/package.json b/package.json index f6527da..aa8ac71 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,64 @@ "type": "git", "url": "https://github.com/libredb/libredb-studio" }, - "main": "./src/exports/index.js", - "types": "./src/exports/index.ts", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./providers": { + "import": { + "types": "./dist/providers.d.mts", + "default": "./dist/providers.mjs" + }, + "require": { + "types": "./dist/providers.d.ts", + "default": "./dist/providers.js" + } + }, + "./types": { + "import": { + "types": "./dist/types.d.mts", + "default": "./dist/types.mjs" + }, + "require": { + "types": "./dist/types.d.ts", + "default": "./dist/types.js" + } + }, + "./components": { + "import": { + "types": "./dist/components.d.mts", + "default": "./dist/components.mjs" + }, + "require": { + "types": "./dist/components.d.ts", + "default": "./dist/components.js" + } + }, + "./workspace": { + "import": { + "types": "./dist/workspace.d.mts", + "default": "./dist/workspace.mjs" + }, + "require": { + "types": "./dist/workspace.d.ts", + "default": "./dist/workspace.js" + } + } + }, + "files": [ + "dist" + ], "peerDependencies": { "react": "^19", "react-dom": "^19" @@ -18,6 +74,8 @@ "scripts": { "dev": "next dev", "build": "next build", + "build:lib": "tsup", + "prepublishOnly": "tsup", "start": "next start", "lint": "eslint .", "typecheck": "tsc --noEmit", @@ -120,6 +178,7 @@ "eslint-config-next": "^16.1.6", "happy-dom": "^20.6.1", "tailwindcss": "^4", + "tsup": "^8.5.1", "tw-animate-css": "^1.4.0", "typescript": "^5" } diff --git a/src/exports/workspace.ts b/src/exports/workspace.ts new file mode 100644 index 0000000..0a8f96c --- /dev/null +++ b/src/exports/workspace.ts @@ -0,0 +1,11 @@ +// src/exports/workspace.ts +export { StudioWorkspace } from '../workspace/StudioWorkspace' +export type { + StudioWorkspaceProps, + WorkspaceConnection, + WorkspaceUser, + WorkspaceQueryResult, + WorkspaceFeatures, + SavedQueryInput, +} from '../workspace/types' +export { DEFAULT_WORKSPACE_FEATURES } from '../workspace/types' diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..8789b3c --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,76 @@ +import { defineConfig } from 'tsup' +import path from 'path' + +export default defineConfig({ + entry: { + index: 'src/exports/index.ts', + providers: 'src/exports/providers.ts', + types: 'src/exports/types.ts', + components: 'src/exports/components.ts', + workspace: 'src/exports/workspace.ts', + }, + format: ['esm', 'cjs'], + dts: true, + splitting: true, + sourcemap: true, + clean: true, + tsconfig: 'tsconfig.lib.json', + treeshake: true, + external: [ + 'react', 'react-dom', 'next', + // Database drivers — consumers install what they need + 'pg', 'mysql2', 'better-sqlite3', 'oracledb', 'mssql', 'mongodb', 'ioredis', + // SSH and crypto + 'ssh2', + // Monaco editor + 'monaco-editor', '@monaco-editor/react', + // LLM SDKs + '@google/generative-ai', + // UI libs that consumers provide + 'elkjs', 'recharts', + 'framer-motion', 'html2canvas', + '@tanstack/react-table', '@tanstack/react-virtual', + 'react-resizable-panels', 'react-hook-form', '@hookform/resolvers', + 'react-day-picker', 'embla-carousel-react', 'input-otp', + 'sonner', 'vaul', 'cmdk', 'next-themes', + // Radix primitives + /^@radix-ui\//, + // Utilities + 'class-variance-authority', 'clsx', 'tailwind-merge', + 'sql-formatter', 'date-fns', 'zod', 'yaml', 'jose', 'openid-client', + 'lucide-react', + ], + esbuildPlugins: [ + { + name: 'resolve-at-alias', + setup(build) { + // Rewrite @/ → ./ and let esbuild resolve from src/ + build.onResolve({ filter: /^@\// }, async (args) => { + return build.resolve('./' + args.path.slice(2), { + resolveDir: path.resolve(__dirname, 'src'), + kind: args.kind, + }) + }) + }, + }, + { + name: 'handle-css-and-xyflow', + setup(build) { + // Replace CSS imports with empty modules. + // CSS is handled by the consumer's bundler (Next.js/Vite), not at runtime. + build.onResolve({ filter: /\.css$/ }, (args) => ({ + path: args.path, + namespace: 'ignore-css', + })) + build.onLoad({ filter: /.*/, namespace: 'ignore-css' }, () => ({ + contents: '', + })) + // Mark @xyflow/react (non-CSS) as external + build.onResolve({ filter: /^@xyflow\/react$/ }, () => ({ + path: '@xyflow/react', + external: true, + })) + }, + }, + ], +}) From fb371cd51b5a245fa3dda9b9fb58ae2e1dbc6c39 Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 16:34:38 +0300 Subject: [PATCH 17/39] fix(workspace): remove unused QueryResult import --- src/workspace/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workspace/types.ts b/src/workspace/types.ts index bd888ae..31e8377 100644 --- a/src/workspace/types.ts +++ b/src/workspace/types.ts @@ -1,5 +1,5 @@ // src/workspace/types.ts -import type { DatabaseType, TableSchema, QueryResult, SavedQuery } from '@/lib/types'; +import type { DatabaseType, TableSchema, SavedQuery } from '@/lib/types'; // === Connection (platform → studio) === From c879d1dff5c48da3d969117290569daf76d45f74 Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 16:59:45 +0300 Subject: [PATCH 18/39] fix(workspace): add scoped dark theme CSS variables and remove MobileNav from embedded mode --- src/workspace/StudioWorkspace.tsx | 50 +++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/src/workspace/StudioWorkspace.tsx b/src/workspace/StudioWorkspace.tsx index 014e959..83c609b 100644 --- a/src/workspace/StudioWorkspace.tsx +++ b/src/workspace/StudioWorkspace.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { Sidebar, ConnectionsList } from '@/components/sidebar'; -import { MobileNav } from '@/components/MobileNav'; +// MobileNav excluded in embedded mode — platform provides its own navigation import { SchemaExplorer } from '@/components/schema-explorer'; import { QueryEditor, QueryEditorRef } from '@/components/QueryEditor'; import { DataImportModal } from '@/components/DataImportModal'; @@ -28,6 +28,40 @@ import { DEFAULT_WORKSPACE_FEATURES, } from '@/workspace/types'; import { cn } from '@/lib/utils'; + +/** + * Scoped CSS variables for studio's dark theme. + * When embedded in a host app (e.g. platform uses OKLCH colors), + * studio needs its own hex-based CSS variables to render correctly. + */ +const STUDIO_THEME_VARS: React.CSSProperties = { + // @ts-expect-error -- CSS custom properties + '--background': '#09090b', + '--foreground': '#fafafa', + '--card': '#0a0a0a', + '--card-foreground': '#fafafa', + '--popover': '#0a0a0a', + '--popover-foreground': '#fafafa', + '--primary': '#fafafa', + '--primary-foreground': '#171717', + '--secondary': '#27272a', + '--secondary-foreground': '#fafafa', + '--muted': '#27272a', + '--muted-foreground': '#a1a1aa', + '--accent': '#27272a', + '--accent-foreground': '#fafafa', + '--destructive': '#7f1d1d', + '--destructive-foreground': '#fafafa', + '--border': '#27272a', + '--input': '#27272a', + '--ring': '#d4d4d8', + '--radius': '0.5rem', + '--chart-1': '#3b82f6', + '--chart-2': '#22c55e', + '--chart-3': '#f59e0b', + '--chart-4': '#a855f7', + '--chart-5': '#ec4899', +}; import { AlertTriangle, Database } from 'lucide-react'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; import { AnimatePresence } from 'framer-motion'; @@ -209,7 +243,10 @@ export function StudioWorkspace({ const noop = useCallback(() => {}, []); return ( -
+
-
+
{/* No desktop/mobile headers — platform provides its own */} - {/* Mobile Navigation */} - + {/* Mobile Navigation — hidden in embedded mode, platform provides its own */}
); } From b24a1799a5ff3dfe8e07ce5c5c690adc5671b2e1 Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 17:03:45 +0300 Subject: [PATCH 19/39] fix(workspace): inject scoped dark theme CSS via style tag for host app compatibility --- src/workspace/StudioWorkspace.tsx | 84 +++++++++++++++++++------------ 1 file changed, 52 insertions(+), 32 deletions(-) diff --git a/src/workspace/StudioWorkspace.tsx b/src/workspace/StudioWorkspace.tsx index 83c609b..f025db3 100644 --- a/src/workspace/StudioWorkspace.tsx +++ b/src/workspace/StudioWorkspace.tsx @@ -30,38 +30,55 @@ import { import { cn } from '@/lib/utils'; /** - * Scoped CSS variables for studio's dark theme. - * When embedded in a host app (e.g. platform uses OKLCH colors), - * studio needs its own hex-based CSS variables to render correctly. + * Scoped CSS for studio's dark theme. + * When embedded in a host app that uses different CSS variable formats + * (e.g. OKLCH instead of hex), studio injects its own scoped styles + * to ensure correct rendering. Uses data-studio-workspace attribute + * for high-specificity scoping without affecting the host app. */ -const STUDIO_THEME_VARS: React.CSSProperties = { - // @ts-expect-error -- CSS custom properties - '--background': '#09090b', - '--foreground': '#fafafa', - '--card': '#0a0a0a', - '--card-foreground': '#fafafa', - '--popover': '#0a0a0a', - '--popover-foreground': '#fafafa', - '--primary': '#fafafa', - '--primary-foreground': '#171717', - '--secondary': '#27272a', - '--secondary-foreground': '#fafafa', - '--muted': '#27272a', - '--muted-foreground': '#a1a1aa', - '--accent': '#27272a', - '--accent-foreground': '#fafafa', - '--destructive': '#7f1d1d', - '--destructive-foreground': '#fafafa', - '--border': '#27272a', - '--input': '#27272a', - '--ring': '#d4d4d8', - '--radius': '0.5rem', - '--chart-1': '#3b82f6', - '--chart-2': '#22c55e', - '--chart-3': '#f59e0b', - '--chart-4': '#a855f7', - '--chart-5': '#ec4899', -}; +const STUDIO_SCOPED_CSS = ` +[data-studio-workspace] { + --background: #09090b; + --foreground: #fafafa; + --card: #0a0a0a; + --card-foreground: #fafafa; + --popover: #0a0a0a; + --popover-foreground: #fafafa; + --primary: #fafafa; + --primary-foreground: #171717; + --secondary: #27272a; + --secondary-foreground: #fafafa; + --muted: #27272a; + --muted-foreground: #a1a1aa; + --accent: #27272a; + --accent-foreground: #fafafa; + --destructive: #7f1d1d; + --destructive-foreground: #fafafa; + --border: #27272a; + --input: #27272a; + --ring: #d4d4d8; + --radius: 0.5rem; + --chart-1: #3b82f6; + --chart-2: #22c55e; + --chart-3: #f59e0b; + --chart-4: #a855f7; + --chart-5: #ec4899; +} +`; + +function useStudioTheme() { + useEffect(() => { + const id = 'studio-workspace-theme'; + if (document.getElementById(id)) return; + const style = document.createElement('style'); + style.id = id; + style.textContent = STUDIO_SCOPED_CSS; + document.head.appendChild(style); + return () => { + document.getElementById(id)?.remove(); + }; + }, []); +} import { AlertTriangle, Database } from 'lucide-react'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; import { AnimatePresence } from 'framer-motion'; @@ -128,6 +145,9 @@ export function StudioWorkspace({ features, }); + // === Inject scoped dark theme CSS === + useStudioTheme(); + // === Connection change effect === useEffect(() => { if (conn.activeConnection) { @@ -244,8 +264,8 @@ export function StudioWorkspace({ return (
From fc119df93405c5c6d86d12fe348dda70f14600a7 Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 18:27:14 +0300 Subject: [PATCH 20/39] feat: switch to Geist font and apply scoped font system in embedded workspace mode --- src/app/layout.tsx | 7 +++--- src/workspace/StudioWorkspace.tsx | 37 +++++++++++++++++++++++-------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b499b2c..6091aae 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,10 @@ import type { Metadata } from "next"; -import { Inter } from "next/font/google"; +import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { Toaster } from "@/components/ui/sonner"; -const inter = Inter({ subsets: ["latin"] }); +const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] }); +const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] }); export const metadata: Metadata = { title: "LibreDB Studio | Universal Database Editor", @@ -25,7 +26,7 @@ export default function RootLayout({ }>) { return ( - + {children} diff --git a/src/workspace/StudioWorkspace.tsx b/src/workspace/StudioWorkspace.tsx index f025db3..bad3320 100644 --- a/src/workspace/StudioWorkspace.tsx +++ b/src/workspace/StudioWorkspace.tsx @@ -38,31 +38,50 @@ import { cn } from '@/lib/utils'; */ const STUDIO_SCOPED_CSS = ` [data-studio-workspace] { + /* Dark theme — monochrome (black/white/gray) */ --background: #09090b; --foreground: #fafafa; - --card: #0a0a0a; + --card: #09090b; --card-foreground: #fafafa; - --popover: #0a0a0a; + --popover: #09090b; --popover-foreground: #fafafa; --primary: #fafafa; - --primary-foreground: #171717; + --primary-foreground: #09090b; --secondary: #27272a; --secondary-foreground: #fafafa; --muted: #27272a; --muted-foreground: #a1a1aa; --accent: #27272a; --accent-foreground: #fafafa; - --destructive: #7f1d1d; + --destructive: #dc2626; --destructive-foreground: #fafafa; --border: #27272a; --input: #27272a; --ring: #d4d4d8; --radius: 0.5rem; - --chart-1: #3b82f6; - --chart-2: #22c55e; - --chart-3: #f59e0b; - --chart-4: #a855f7; - --chart-5: #ec4899; + --chart-1: #e4e4e7; + --chart-2: #a1a1aa; + --chart-3: #71717a; + --chart-4: #52525b; + --chart-5: #3f3f46; + + /* Font — Geist (inherited from host or fallback to system) */ + font-family: var(--font-geist-sans, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: "rlig" 1, "calt" 1; + letter-spacing: -0.011em; +} +[data-studio-workspace] *, +[data-studio-workspace] *::before, +[data-studio-workspace] *::after { + font-family: inherit; +} +[data-studio-workspace] code, +[data-studio-workspace] pre, +[data-studio-workspace] kbd, +[data-studio-workspace] .font-mono { + font-family: var(--font-geist-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace); } `; From cdd9d24ecaafb6179036ba0e5a2d8b9092a7242c Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 19:30:49 +0300 Subject: [PATCH 21/39] fix: replace text-[12px] arbitrary values with text-xs for Tailwind v4 compatibility in embedded mode --- src/components/ResultsGrid.tsx | 12 ++++++------ src/components/results-grid/StatsBar.tsx | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/components/ResultsGrid.tsx b/src/components/ResultsGrid.tsx index 2d4a0bd..141d2bc 100644 --- a/src/components/ResultsGrid.tsx +++ b/src/components/ResultsGrid.tsx @@ -237,11 +237,11 @@ export function ResultsGrid({ setColumnFilters(next); }} onKeyDown={e => { if (e.key === 'Escape' || e.key === 'Enter') setActiveFilterCol(null); }} - className="w-full bg-[#050505] border border-white/10 rounded px-2 py-1 text-[11px] text-zinc-200 outline-none focus:border-blue-500/30" + className="w-full bg-[#050505] border border-white/10 rounded px-2 py-1 text-[13px] text-zinc-200 outline-none focus:border-blue-500/30" /> {hasFilter && ( )} {result.pagination?.wasLimited && ( - + AUTO-LIMITED )} @@ -93,7 +93,7 @@ export function StatsBar({ variant="ghost" size="sm" className={cn( - "h-6 px-2 text-[10px] font-bold gap-1", + "h-6 px-2 text-xs font-bold gap-1", effectiveMaskingEnabled ? "text-purple-400 bg-purple-500/10" : "text-zinc-500" )} onClick={onToggleMasking} @@ -103,7 +103,7 @@ export function StatsBar({ {effectiveMaskingEnabled ? 'MASKED' : 'MASK'} ) : effectiveMaskingEnabled ? ( - + MASKED @@ -113,13 +113,13 @@ export function StatsBar({ {/* Pending Changes Indicator */} {editingEnabled && pendingChanges && pendingChanges.length > 0 && (
- + {pendingChanges.length} change{pendingChanges.length > 1 ? 's' : ''}
diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index b1cece9..2d9b376 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -161,7 +161,7 @@ export function CommandPalette({ {conn.name} {activeConnection?.id === conn.id && ( - Active + Active )} ); @@ -179,7 +179,7 @@ export function CommandPalette({ > {table.name} - + {table.columns.length} cols {table.rowCount !== undefined && ` / ${table.rowCount} rows`} @@ -198,7 +198,7 @@ export function CommandPalette({ > {sq.name} - + {sq.query.substring(0, 40)}... @@ -216,7 +216,7 @@ export function CommandPalette({ > {item.query.substring(0, 60)} - {item.executionTime}ms + {item.executionTime}ms ))} diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 2903ca3..b0dec74 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -105,7 +105,7 @@ export function ConnectionModal({ isOpen, onClose, onConnect, editConnection, on {!isEditMode && (
-

+

Supports: postgres://, mysql://, mongodb://, redis://, oracle://, mssql://

@@ -156,7 +156,7 @@ export function ConnectionModal({ isOpen, onClose, onConnect, editConnection, on
- +
- +
{(Object.keys(ENVIRONMENT_COLORS) as ConnectionEnvironment[]).map((env) => (