From c57a75c34d9ae010bbaa652e3c6f44a7c490985e Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 26 Mar 2026 13:56:33 +0300 Subject: [PATCH 01/22] 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 02/22] 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 03/22] 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 04/22] 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 05/22] 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 06/22] 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 07/22] 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 08/22] 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 09/22] 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 10/22] 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 11/22] 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 12/22] 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) => (