From 347ecd0f1b803ca975ff47eb0f06b80a5f4b2237 Mon Sep 17 00:00:00 2001 From: loveuer Date: Mon, 6 Apr 2026 21:45:28 +0800 Subject: [PATCH] feat: add TableList component with styles and functionality for displaying database tables feat: implement NewConnectionDialog component for creating and editing database connections with form validation chore: generate TypeScript definitions and JavaScript bindings for app functions chore: add models for configuration, connection requests, and database entities --- frontend/src/App.css | 36 +- frontend/src/App.tsx | 824 +++++++++++++----- frontend/src/components/Layout/ToolBar.css | 7 +- frontend/src/components/Layout/ToolBar.tsx | 14 +- .../src/components/MainArea/QueryEditor.css | 40 +- .../src/components/MainArea/QueryEditor.tsx | 49 +- .../src/components/MainArea/TableList.css | 307 +++++++ .../src/components/MainArea/TableList.tsx | 176 ++++ .../components/Sidebar/ConnectionPanel.css | 73 ++ .../components/Sidebar/ConnectionPanel.tsx | 79 +- .../components/common/NewConnectionDialog.css | 154 ++++ .../components/common/NewConnectionDialog.tsx | 210 +++++ frontend/src/components/index.ts | 3 + frontend/tsconfig.json | 5 +- frontend/vite.config.ts | 2 +- frontend/wailsjs/go/app/App.d.ts | 45 + frontend/wailsjs/go/app/App.js | 79 ++ frontend/wailsjs/go/main/App.d.ts | 4 - frontend/wailsjs/go/main/App.js | 7 - frontend/wailsjs/go/models.ts | 529 +++++++++++ internal/app/app.go | 65 +- internal/services/connection.go | 82 +- 22 files changed, 2475 insertions(+), 315 deletions(-) create mode 100644 frontend/src/components/MainArea/TableList.css create mode 100644 frontend/src/components/MainArea/TableList.tsx create mode 100644 frontend/src/components/common/NewConnectionDialog.css create mode 100644 frontend/src/components/common/NewConnectionDialog.tsx create mode 100755 frontend/wailsjs/go/app/App.d.ts create mode 100755 frontend/wailsjs/go/app/App.js delete mode 100755 frontend/wailsjs/go/main/App.d.ts delete mode 100755 frontend/wailsjs/go/main/App.js create mode 100755 frontend/wailsjs/go/models.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index 37ac04c..0629d81 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -13,7 +13,7 @@ .view-tabs { display: flex; gap: var(--space-1); - padding: var(--space-2) var(--space-4) 0; + padding: var(--space-3) var(--space-4) 0; background-color: var(--bg-primary); border-bottom: 1px solid var(--border); flex-shrink: 0; @@ -23,8 +23,8 @@ display: flex; align-items: center; gap: var(--space-2); - padding: var(--space-2) var(--space-4); - font-size: var(--text-sm); + padding: var(--space-3) var(--space-5); + font-size: var(--text-base); font-family: var(--font-sans); color: var(--text-secondary); background: transparent; @@ -83,6 +83,33 @@ outline-offset: 2px; } +/* DataGrid empty state (no table selected) */ +.datagrid-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: var(--space-3); + color: var(--text-muted); +} + +.datagrid-empty-icon { + font-size: 40px; + line-height: 1; +} + +.datagrid-empty-state p { + margin: 0; + font-size: var(--text-base); + font-weight: 600; + color: var(--text-secondary); +} + +.datagrid-empty-state span { + font-size: var(--text-sm); +} + /* Selection color */ ::selection { background-color: rgba(59, 130, 246, 0.2); @@ -91,6 +118,7 @@ /* Print styles */ @media print { + .view-tabs, .layout-menubar, .layout-toolbar, @@ -102,4 +130,4 @@ .view-content { overflow: visible !important; } -} +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d593da4..4e8c4b1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,99 +4,411 @@ * Main application component integrating all UI components. */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import './index.css'; +import './App.css'; + +// Wails bindings +import { + GetConnections, + CreateConnection, + UpdateConnection, + DeleteConnection, + TestConnection, + GetTables, + GetTableData, + GetTableStructure, + ExecuteQuery as WailsExecuteQuery, + DisconnectConnection, +} from '../wailsjs/go/app/App'; +import { models } from '../wailsjs/go/models'; // Import components import AppLayout from './components/Layout/AppLayout'; -import MenuBar from './components/MenuBar/MenuBar'; import ToolBar from './components/Layout/ToolBar'; -import StatusBar from './components/Layout/StatusBar'; -import ConnectionPanel, { DatabaseConnection } from './components/Sidebar/ConnectionPanel'; +import ConnectionPanel, { DatabaseConnection, Schema, Table } from './components/Sidebar/ConnectionPanel'; import QueryEditor, { QueryTab, QueryResult } from './components/MainArea/QueryEditor'; -import DataGrid from './components/MainArea/DataGrid'; +import DataGrid, { Column as GridColumn, DataRow as GridRow } from './components/MainArea/DataGrid'; import TableStructure from './components/MainArea/TableStructure'; +import TableList from './components/MainArea/TableList'; +import NewConnectionDialog, { NewConnectionFormData } from './components/common/NewConnectionDialog'; -// Import mock data -import { mockConnections } from './mock/connections'; -import { - mockQueryResults, - mockQueryTabs, - mockDataGridColumns, - mockDataGridRows, - mockTableColumns, - mockIndexes, - mockForeignKeys, - mockTableInfo, -} from './mock/queryResults'; +// Keep mock data for QueryEditor initial tabs +import { mockQueryTabs } from './mock/queryResults'; +import { TableColumn, Index, ForeignKey } from './components/MainArea/TableStructure'; -type MainView = 'query' | 'data' | 'structure'; +type MainView = 'query' | 'data' | 'structure' | 'tables'; + +/** Map backend UserConnection to frontend DatabaseConnection */ +function toFrontendConn(c: models.UserConnection): DatabaseConnection { + return { + id: c.id, + name: c.name, + type: c.type === 'postgres' ? 'postgresql' : (c.type as any), + host: c.host, + port: c.port, + status: 'disconnected', + }; +} function App() { - // Application state - const [connections] = useState(mockConnections); - const [activeConnectionId, setActiveConnectionId] = useState('conn-1'); - const [selectedConnectionId, setSelectedConnectionId] = useState('conn-1'); + const [connections, setConnections] = useState([]); + const [activeConnectionId, setActiveConnectionId] = useState(''); + const [selectedConnectionId, setSelectedConnectionId] = useState(''); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - + const [showNewConnDialog, setShowNewConnDialog] = useState(false); + const [editingConnection, setEditingConnection] = useState(null); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + // Query editor state const [queryTabs, setQueryTabs] = useState(mockQueryTabs); const [activeTabId, setActiveTabId] = useState('tab-1'); const [queryResults, setQueryResults] = useState(null); const [isQueryLoading, setIsQueryLoading] = useState(false); - - // Main view state - const [mainView, setMainView] = useState('query'); - // Handler: Connection click - const handleConnectionClick = useCallback((connection: DatabaseConnection) => { + // DataGrid state + const [selectedTable, setSelectedTable] = useState<{ name: string; schema: string } | null>(null); + const [dataGridColumns, setDataGridColumns] = useState([]); + const [dataGridRows, setDataGridRows] = useState([]); + const [dataGridPage, setDataGridPage] = useState(1); + const [dataGridPageSize, setDataGridPageSize] = useState(50); + const [dataGridHasMore, setDataGridHasMore] = useState(false); + const [isDataGridLoading, setIsDataGridLoading] = useState(false); + const [dataGridError, setDataGridError] = useState(null); + + // TableStructure state + const [structureTable, setStructureTable] = useState<{ name: string; schema: string } | null>(null); + const [structureColumns, setStructureColumns] = useState([]); + const [structureIndexes, setStructureIndexes] = useState([]); + const [structureForeignKeys, setStructureForeignKeys] = useState([]); + const [isStructureLoading, setIsStructureLoading] = useState(false); + const [structureError, setStructureError] = useState(null); + + // Main view state + const [mainView, setMainView] = useState('tables'); + + // Load connections from backend on startup + const loadConnections = useCallback(async () => { + try { + const list = await GetConnections(); + setConnections((list || []).map(toFrontendConn)); + } catch (err) { + console.error('Failed to load connections:', err); + } + }, []); + + useEffect(() => { + loadConnections(); + }, [loadConnections]); + + // Handler: click a connection โ€” test + connect + const handleConnectionClick = useCallback(async (connection: DatabaseConnection) => { setSelectedConnectionId(connection.id); - if (connection.status === 'disconnected') { - console.log(`Connecting to ${connection.name}...`); - // In real app: call backend to connect + + if (connection.status === 'disconnected' || connection.status === 'error') { + // Show connecting spinner + setConnections(prev => + prev.map(c => c.id === connection.id + ? { ...c, status: 'connecting' as const, errorMessage: undefined } + : c) + ); + + try { + // Wails returns Promise: boolean on success, string on failure + const testResult = await TestConnection(connection.id); + const success = testResult === true; + const message = typeof testResult === 'string' ? testResult : ''; + + if (success) { + setActiveConnectionId(connection.id); + // Load tables and group by schema + // Wails returns Promise: array on success, string on failure + const tablesResult = await GetTables(connection.id); + const tables: models.Table[] = Array.isArray(tablesResult) ? tablesResult : []; + const schemaMap: Record = {}; + tables.forEach((t: models.Table) => { + const schemaName = t.schema || 'public'; + if (!schemaMap[schemaName]) schemaMap[schemaName] = []; + schemaMap[schemaName].push({ id: t.name, name: t.name }); + }); + const databases: Schema[] = Object.entries(schemaMap).map(([name, tbls]) => ({ + id: name, + name, + tables: tbls, + })); + setConnections(prev => + prev.map(c => c.id === connection.id + ? { ...c, status: 'active' as const, databases } + : c) + ); + } else { + setConnections(prev => + prev.map(c => c.id === connection.id + ? { ...c, status: 'error' as const, errorMessage: message || 'Connection failed' } + : c) + ); + } + } catch (err) { + setConnections(prev => + prev.map(c => c.id === connection.id + ? { ...c, status: 'error' as const, errorMessage: String(err) } + : c) + ); + } } else if (connection.status === 'connected' || connection.status === 'active') { setActiveConnectionId(connection.id); } }, []); - // Handler: New connection + // Handler: open new connection dialog const handleNewConnection = useCallback(() => { - console.log('Opening new connection dialog...'); - // In real app: open connection dialog + setShowNewConnDialog(true); }, []); - // Handler: Table double-click - const handleTableDoubleClick = useCallback(( - table: any, - schema: any, - connection: DatabaseConnection + // Handler: create connection from dialog form + const handleCreateConnection = useCallback(async (data: NewConnectionFormData) => { + const req = new models.CreateConnectionRequest(); + req.name = data.name; + req.type = data.type; + req.host = data.host; + req.port = data.port; + req.username = data.username; + req.password = data.password; + req.database = data.database; + req.ssl_mode = data.ssl_mode; + req.timeout = data.timeout || 30; + + const errMsg = await CreateConnection(req); + if (errMsg) { + throw new Error(errMsg); + } + setShowNewConnDialog(false); + await loadConnections(); + }, [loadConnections]); + + // Handler: edit connection โ€” open dialog pre-filled + const handleEditConnection = useCallback((connection: DatabaseConnection) => { + setEditingConnection(connection); + }, []); + + // Handler: save edited connection + const handleSaveEditedConnection = useCallback(async (data: NewConnectionFormData) => { + if (!editingConnection) return; + const conn = new models.UserConnection(); + conn.id = editingConnection.id; + conn.name = data.name; + conn.type = data.type; + conn.host = data.host; + conn.port = data.port; + conn.username = data.username; + conn.password = data.password; // empty string = keep existing + conn.database = data.database; + conn.ssl_mode = data.ssl_mode; + conn.timeout = data.timeout || 30; + const errMsg = await UpdateConnection(conn); + if (errMsg) throw new Error(errMsg); + setEditingConnection(null); + await loadConnections(); + }, [editingConnection, loadConnections]); + + // Handler: delete connection (with inline confirm) + const handleDeleteConnection = useCallback(async (connection: DatabaseConnection) => { + if (deleteConfirmId !== connection.id) { + // First click: show confirm state + setDeleteConfirmId(connection.id); + // Auto-reset after 3s + setTimeout(() => setDeleteConfirmId(null), 3000); + return; + } + // Second click: actually delete + setDeleteConfirmId(null); + const errMsg = await DeleteConnection(connection.id); + if (errMsg) { console.error('Delete failed:', errMsg); return; } + if (activeConnectionId === connection.id) setActiveConnectionId(''); + if (selectedConnectionId === connection.id) setSelectedConnectionId(''); + setConnections(prev => prev.filter(c => c.id !== connection.id)); + }, [deleteConfirmId, activeConnectionId, selectedConnectionId]); + + // Handler: disconnect (close DB connection, keep config) + const handleDisconnectConnection = useCallback(async (connection: DatabaseConnection) => { + await DisconnectConnection(connection.id); + setConnections(prev => + prev.map(c => c.id === connection.id ? { ...c, status: 'disconnected' as const, databases: undefined } : c) + ); + if (activeConnectionId === connection.id) { + setActiveConnectionId(''); + setSelectedTable(null); + setDataGridColumns([]); + setDataGridRows([]); + } + }, [activeConnectionId]); + + // Load data for a table (page-based) + const loadTableData = useCallback(async ( + connectionId: string, + tableName: string, + schema: string, + page: number, + pageSize: number, ) => { - console.log(`Opening table ${table.name} from schema ${schema.name}`); - setMainView('data'); + setIsDataGridLoading(true); + setDataGridError(null); + const fullName = schema && schema !== 'public' ? `${schema}.${tableName}` : tableName; + const offset = (page - 1) * pageSize; + try { + const result = await GetTableData(connectionId, fullName, pageSize, offset); + if (typeof result === 'string') { + setDataGridError(result); + setDataGridColumns([]); + setDataGridRows([]); + setDataGridHasMore(false); + } else if (result) { + const r = result as models.QueryResult; + const cols: GridColumn[] = (r.columns || []).map(name => ({ + id: name, + name, + sortable: false, + editable: false, + })); + const rows: GridRow[] = (r.rows || []).map((row, i) => { + const obj: GridRow = { id: offset + i }; + (r.columns || []).forEach((col, j) => { obj[col] = row[j]; }); + return obj; + }); + setDataGridColumns(cols); + setDataGridRows(rows); + setDataGridHasMore(rows.length === pageSize); + } + } catch (err: any) { + setDataGridError(String(err)); + } finally { + setIsDataGridLoading(false); + } }, []); - // Handler: Query execution - const handleExecuteQuery = useCallback((query: string) => { - setIsQueryLoading(true); - console.log('Executing query:', query); - - // Simulate async query execution - setTimeout(() => { - setQueryResults(mockQueryResults); - setIsQueryLoading(false); - }, 500); + // Open a table in the DataGrid view + const handleOpenTableData = useCallback(async (table: Table, schema: Schema) => { + setSelectedTable({ name: table.name, schema: schema.name }); + setDataGridPage(1); + setMainView('data'); + await loadTableData(activeConnectionId, table.name, schema.name, 1, dataGridPageSize); + }, [activeConnectionId, dataGridPageSize, loadTableData]); + + // Handler: Table double-click in sidebar + const handleTableDoubleClick = useCallback(async ( + table: Table, + schema: Schema, + _connection: DatabaseConnection + ) => { + await handleOpenTableData(table, schema); + }, [handleOpenTableData]); + + // Load structure for a table + const loadTableStructure = useCallback(async (connectionId: string, tableName: string, schema: string) => { + setIsStructureLoading(true); + setStructureError(null); + const fullName = schema && schema !== 'public' ? `${schema}.${tableName}` : tableName; + try { + const result = await GetTableStructure(connectionId, fullName); + if (typeof result === 'string') { + setStructureError(result); + setStructureColumns([]); + setStructureIndexes([]); + setStructureForeignKeys([]); + } else if (result) { + const s = result as models.TableStructure; + setStructureColumns((s.columns || []).map(c => ({ + name: c.name, + type: c.data_type, + nullable: c.nullable, + isPrimaryKey: c.is_primary, + isUnique: c.is_unique, + defaultValue: c.default || undefined, + extra: c.auto_increment ? 'auto_increment' : undefined, + }))); + setStructureIndexes((s.indexes || []).map(i => ({ + name: i.name, + type: i.is_primary ? 'PRIMARY' : i.is_unique ? 'UNIQUE' : 'INDEX', + columns: i.columns || [], + isUnique: i.is_unique, + method: i.type || 'btree', + }))); + setStructureForeignKeys((s.foreign_keys || []).map(fk => ({ + name: fk.name, + column: (fk.columns || [])[0] || '', + referencesTable: fk.referenced_table, + referencesColumn: (fk.referenced_columns || [])[0] || '', + onUpdate: fk.on_update || undefined, + onDelete: fk.on_delete || undefined, + }))); + } + } catch (err: any) { + setStructureError(String(err)); + } finally { + setIsStructureLoading(false); + } }, []); + // Open a table in the Structure view + const handleOpenTableStructure = useCallback(async (table: Table, schema: Schema) => { + setStructureTable({ name: table.name, schema: schema.name }); + setMainView('structure'); + await loadTableStructure(activeConnectionId, table.name, schema.name); + }, [activeConnectionId, loadTableStructure]); + + // Handler: Query execution via Wails + const handleExecuteQuery = useCallback(async (query: string) => { + if (!query.trim()) return; + if (!activeConnectionId) { + setQueryResults({ columns: [], rows: [], rowCount: 0, executionTime: 0, error: 'No active connection. Connect to a database first.' }); + return; + } + setIsQueryLoading(true); + setQueryResults(null); + try { + // Wails returns Promise: object on success, string on failure + const queryResult = await WailsExecuteQuery(activeConnectionId, query); + if (!queryResult) { + setQueryResults({ columns: [], rows: [], rowCount: 0, executionTime: 0, error: 'Query returned no result' }); + } else if (typeof queryResult === 'string') { + setQueryResults({ columns: [], rows: [], rowCount: 0, executionTime: 0, error: queryResult }); + } else { + const result = queryResult as models.QueryResult; + if (result.error) { + setQueryResults({ columns: [], rows: [], rowCount: 0, executionTime: 0, error: result.error }); + } else { + const affectedRows = result.affected_rows || 0; + const hasData = Array.isArray(result.columns) && result.columns.length > 0; + const message = !hasData && affectedRows > 0 + ? `${affectedRows} row(s) affected` + : !hasData + ? 'Query executed successfully' + : undefined; + setQueryResults({ + columns: result.columns || [], + rows: result.rows || [], + rowCount: result.row_count || 0, + executionTime: result.duration_ms ? result.duration_ms / 1000 : 0, + message, + }); + } + } + } catch (err: any) { + setQueryResults({ columns: [], rows: [], rowCount: 0, executionTime: 0, error: String(err) }); + } finally { + setIsQueryLoading(false); + } + }, [activeConnectionId]); + // Handler: Tab content change const handleContentChange = useCallback((tabId: string, content: string) => { - setQueryTabs(prev => prev.map(tab => + setQueryTabs(prev => prev.map(tab => tab.id === tabId ? { ...tab, content, isDirty: true } : tab )); }, []); // Handler: Save query const handleSaveQuery = useCallback((tabId: string) => { - console.log('Saving query:', tabId); setQueryTabs(prev => prev.map(tab => tab.id === tabId ? { ...tab, isDirty: false } : tab )); @@ -122,185 +434,249 @@ function App() { setActiveTabId(newTab.id); }, []); - // Handler: Format SQL - const handleFormatSQL = useCallback(() => { - console.log('Formatting SQL...'); - // In real app: format SQL using sql-formatter - }, []); + // Handler: Format SQL (placeholder) + const handleFormatSQL = useCallback(() => { }, []); - // Handler: Menu item click - const handleMenuItemClick = useCallback((menuId: string, itemId: string) => { - console.log(`Menu "${menuId}" -> Item "${itemId}"`); - - switch (itemId) { - case 'new-connection': - handleNewConnection(); - break; - case 'run-query': - if (activeTabId) { - const tab = queryTabs.find(t => t.id === activeTabId); - if (tab) handleExecuteQuery(tab.content); - } - break; - case 'toggle-sidebar': - setSidebarCollapsed(prev => !prev); - break; - default: - console.log('Unhandled menu item:', itemId); - } - }, [activeTabId, queryTabs, handleNewConnection, handleExecuteQuery]); - - // Get active connection const activeConnection = connections.find(c => c.id === activeConnectionId); return ( - - } - toolbar={ - { - if (activeTabId) { - const tab = queryTabs.find(t => t.id === activeTabId); - if (tab) handleExecuteQuery(tab.content); - } + <> + { + if (activeTabId) { + const tab = queryTabs.find(t => t.id === activeTabId); + if (tab) handleExecuteQuery(tab.content); + } + }, }, - }, - { - id: 'save', - icon: '๐Ÿ’พ', - label: 'Save', - tooltip: 'Save query (Ctrl+S)', - onClick: () => activeTabId && handleSaveQuery(activeTabId), - disabled: !queryTabs.find(t => t.id === activeTabId)?.isDirty, - }, - { - id: 'export', - icon: '๐Ÿ“ค', - label: 'Export', - tooltip: 'Export results', - disabled: !queryResults, - }, - { - id: 'find', - icon: '๐Ÿ”', - label: 'Find', - tooltip: 'Find in query (Ctrl+F)', - }, - ]} - /> - } - sidebar={ - - } - mainContent={ -
- {/* View tabs */} -
- - - + { + id: 'save', + icon: '๐Ÿ’พ', + label: 'Save', + tooltip: 'Save query (Ctrl+S)', + onClick: () => activeTabId && handleSaveQuery(activeTabId), + disabled: !queryTabs.find(t => t.id === activeTabId)?.isDirty, + }, + { + id: 'export', + icon: '๐Ÿ“ค', + label: 'Export', + tooltip: 'Export results', + disabled: !queryResults, + }, + { + id: 'find', + icon: '๐Ÿ”', + label: 'Find', + tooltip: 'Find in query (Ctrl+F)', + }, + ]} + activeConnection={activeConnection ? { name: activeConnection.name, type: activeConnection.type } : null} + /> + } + sidebar={ + + } + mainContent={ +
+ {/* View tabs */} +
+ + + + +
+ + {/* Main view content */} +
+ {mainView === 'query' && ( + + )} + + {mainView === 'data' && (() => { + // Compute total for pagination: if hasMore, signal there are more pages + const dgTotal = dataGridHasMore + ? dataGridPage * dataGridPageSize + 1 + : (dataGridPage - 1) * dataGridPageSize + dataGridRows.length; + + if (!selectedTable) { + return ( +
+
๐Ÿ“Š
+

No table selected

+ Double-click a table in the sidebar or open one from the Tables view. +
+ ); + } + + return ( + loadTableData(activeConnectionId, selectedTable.name, selectedTable.schema, dataGridPage, dataGridPageSize)} + onExport={() => { }} + onAddRow={() => { }} + onPageChange={(page) => { + setDataGridPage(page); + loadTableData(activeConnectionId, selectedTable.name, selectedTable.schema, page, dataGridPageSize); + }} + onPageSizeChange={(size) => { + setDataGridPageSize(size); + setDataGridPage(1); + loadTableData(activeConnectionId, selectedTable.name, selectedTable.schema, 1, size); + }} + /> + ); + })()} + + {mainView === 'structure' && (() => { + if (!structureTable) { + return ( +
+
๐Ÿ“‹
+

No table selected

+ Open a table from the Tables view or sidebar. +
+ ); + } + if (isStructureLoading) { + return ( +
+
โณ
+

Loading structureโ€ฆ

+
+ ); + } + if (structureError) { + return ( +
+
โš ๏ธ
+

Failed to load structure

+ {structureError} +
+ ); + } + return ( + { + if (structureTable && activeConnection?.databases) { + const schema = activeConnection.databases.find(s => s.name === structureTable.schema); + const table = schema?.tables?.find(t => t.name === structureTable.name); + if (schema && table) handleOpenTableData(table, schema); + else setMainView('data'); + } + }} + onEditTable={() => { }} + onRefresh={() => loadTableStructure(activeConnectionId, structureTable.name, structureTable.schema)} + /> + ); + })()} + + {mainView === 'tables' && ( + handleOpenTableData(table, schema)} + onViewStructure={(table, schema) => handleOpenTableStructure(table, schema)} + /> + )} +
+ } + /> - {/* Main view content */} -
- {mainView === 'query' && ( - - )} - - {mainView === 'data' && ( - console.log('Refreshing data...')} - onExport={() => console.log('Exporting data...')} - onAddRow={() => console.log('Adding row...')} - /> - )} - - {mainView === 'structure' && ( - setMainView('data')} - onEditTable={() => console.log('Edit table...')} - onRefresh={() => console.log('Refreshing structure...')} - /> - )} -
-
- } - statusBar={ - setShowNewConnDialog(false)} /> - } - /> + )} + + {editingConnection && ( + setEditingConnection(null)} + /> + )} + ); } diff --git a/frontend/src/components/Layout/ToolBar.css b/frontend/src/components/Layout/ToolBar.css index d5906ef..a7133c1 100644 --- a/frontend/src/components/Layout/ToolBar.css +++ b/frontend/src/components/Layout/ToolBar.css @@ -92,6 +92,11 @@ box-shadow: 0 0 4px var(--success); } +.toolbar-connection.disconnected { + opacity: 0.5; + cursor: default; +} + .connection-name { font-size: var(--text-sm); font-weight: 500; @@ -121,4 +126,4 @@ .connection-name { display: none; } -} +} \ No newline at end of file diff --git a/frontend/src/components/Layout/ToolBar.tsx b/frontend/src/components/Layout/ToolBar.tsx index 0bc3dbc..b9bbf69 100644 --- a/frontend/src/components/Layout/ToolBar.tsx +++ b/frontend/src/components/Layout/ToolBar.tsx @@ -24,6 +24,8 @@ export interface ToolBarProps { children?: React.ReactNode; /** Handler when button is clicked */ onButtonClick?: (buttonId: string) => void; + /** Active connection info for display */ + activeConnection?: { name: string; type: string } | null; } /** @@ -63,6 +65,7 @@ export const ToolBar: React.FC = ({ buttons = defaultButtons, children, onButtonClick, + activeConnection, }) => { const handleButtonClick = (button: ToolButton) => { button.onClick?.(); @@ -92,10 +95,13 @@ export const ToolBar: React.FC = ({
{/* Connection indicator */} -
- - ๐Ÿ—„๏ธ MySQL @ localhost - โ–ผ +
+ + + {activeConnection + ? `${activeConnection.type === 'postgresql' ? '๐Ÿ˜' : activeConnection.type === 'mysql' ? '๐Ÿ—„๏ธ' : '๐Ÿ“'} ${activeConnection.name}` + : 'No connection'} +
); diff --git a/frontend/src/components/MainArea/QueryEditor.css b/frontend/src/components/MainArea/QueryEditor.css index 86ddeb5..2be6355 100644 --- a/frontend/src/components/MainArea/QueryEditor.css +++ b/frontend/src/components/MainArea/QueryEditor.css @@ -6,6 +6,7 @@ display: flex; flex-direction: column; height: 100%; + position: relative; background-color: var(--bg-primary); overflow: hidden; } @@ -133,17 +134,20 @@ background-color: var(--bg-primary); border: 1px solid var(--border); border-radius: var(--radius-md); - cursor: pointer; + cursor: default; outline: none; + user-select: none; + white-space: nowrap; } -.connection-select:hover { - border-color: var(--text-secondary); +.connection-select.connected { + color: var(--success); + border-color: var(--success); } -.connection-select:focus { - border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +.connection-select.disconnected { + color: var(--text-muted); + font-style: italic; } /* Editor Container */ @@ -219,6 +223,15 @@ border-bottom: 1px solid var(--border); } +.results-header.error { + background-color: #fef2f2; + border-bottom-color: var(--error); +} + +.results-header.error .results-title { + color: var(--error); +} + .results-title { font-size: var(--text-sm); font-weight: 600; @@ -230,10 +243,23 @@ color: var(--success); } +.results-message.success { + color: var(--success); +} + .results-message.error { color: var(--error); } +.results-error-summary { + flex: 1; + margin-left: var(--space-3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: default; +} + .results-table-wrapper { flex: 1; overflow: auto; @@ -344,4 +370,4 @@ .loading-overlay span { font-size: var(--text-sm); color: var(--text-secondary); -} +} \ No newline at end of file diff --git a/frontend/src/components/MainArea/QueryEditor.tsx b/frontend/src/components/MainArea/QueryEditor.tsx index 3aa9793..c38a1f7 100644 --- a/frontend/src/components/MainArea/QueryEditor.tsx +++ b/frontend/src/components/MainArea/QueryEditor.tsx @@ -5,7 +5,7 @@ * Based on layout-design.md section "SQL ็ผ–่พ‘ๅ™จๆจกๅ—" */ -import React, { useState, KeyboardEvent } from 'react'; +import React, { useState, useEffect, KeyboardEvent } from 'react'; import './QueryEditor.css'; export interface QueryTab { @@ -47,6 +47,8 @@ export interface QueryEditorProps { onSave?: (tabId: string) => void; /** Handler when format is requested */ onFormat?: () => void; + /** Active connection info for display */ + activeConnection?: { name: string; type: string }; } /** @@ -64,6 +66,7 @@ export const QueryEditor: React.FC = ({ onExecute, onSave, onFormat, + activeConnection, }) => { const [editorContent, setEditorContent] = useState(''); const [cursorPosition, setCursorPosition] = useState({ line: 1, column: 1 }); @@ -71,6 +74,11 @@ export const QueryEditor: React.FC = ({ // Get active tab const activeTab = tabs.find((t) => t.id === activeTabId); + // Sync local editorContent when active tab changes + useEffect(() => { + setEditorContent(activeTab?.content || ''); + }, [activeTabId]); + // Handle keyboard shortcuts const handleKeyDown = (e: KeyboardEvent) => { // Ctrl+Enter to execute @@ -107,11 +115,11 @@ export const QueryEditor: React.FC = ({ const handleCursorChange = (e: React.ChangeEvent) => { const content = e.target.value; const cursorPos = e.target.selectionStart; - + const lines = content.substring(0, cursorPos).split('\n'); const line = lines.length; const column = lines[lines.length - 1].length + 1; - + setCursorPosition({ line, column }); setEditorContent(content); onContentChange?.(activeTabId || 'default', content); @@ -125,9 +133,8 @@ export const QueryEditor: React.FC = ({ {tabs.map((tab) => (
onTabClick?.(tab.id)} > ๐Ÿ“‘ @@ -162,7 +169,7 @@ export const QueryEditor: React.FC = ({ @@ -191,10 +198,11 @@ export const QueryEditor: React.FC = ({
- + + {activeConnection + ? `${activeConnection.type === 'postgresql' ? '๐Ÿ˜' : activeConnection.type === 'mysql' ? '๐Ÿ—„๏ธ' : '๐Ÿ“'} ${activeConnection.name}` + : 'โš  No connection active'} +
@@ -227,12 +235,19 @@ export const QueryEditor: React.FC = ({ {/* Results Panel */} {results && ( -
-
-

Results

- {results.message && ( - - {results.error ? 'โœ•' : 'โœ“'} {results.message} +
+
+

+ {results.error ? 'โœ• Error' : 'Results'} +

+ {!results.error && results.message && ( + + โœ“ {results.message} + + )} + {results.error && ( + + {results.error.length > 80 ? results.error.slice(0, 80) + 'โ€ฆ' : results.error} )}
diff --git a/frontend/src/components/MainArea/TableList.css b/frontend/src/components/MainArea/TableList.css new file mode 100644 index 0000000..195a0dd --- /dev/null +++ b/frontend/src/components/MainArea/TableList.css @@ -0,0 +1,307 @@ +/** + * TableList Component Styles + */ + +.table-list { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--bg-primary); + overflow: hidden; +} + +/* โ”€โ”€ Empty state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.table-list-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: var(--space-3); + color: var(--text-muted); +} + +.table-list-empty-icon { + font-size: 40px; + line-height: 1; +} + +.table-list-empty p { + margin: 0; + font-size: var(--text-base); + font-weight: 600; + color: var(--text-secondary); +} + +.table-list-empty span { + font-size: var(--text-sm); +} + +/* โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.table-list-header { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + background-color: var(--bg-tertiary); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.table-list-header-top { + display: flex; + align-items: center; + justify-content: space-between; +} + +.table-list-title { + display: flex; + align-items: baseline; + gap: var(--space-3); +} + +.table-list-conn-name { + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-primary); +} + +.table-list-summary { + font-size: var(--text-xs); + color: var(--text-muted); +} + +.table-list-search-row { + display: flex; + align-items: center; + gap: var(--space-2); + background-color: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0 var(--space-2); + transition: border-color var(--transition-fast) var(--ease-in-out); +} + +.table-list-search-row:focus-within { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.table-list-search-icon { + font-size: 13px; + color: var(--text-muted); + flex-shrink: 0; +} + +.table-list-search { + flex: 1; + height: 30px; + background: transparent; + border: none; + outline: none; + font-size: var(--text-sm); + color: var(--text-primary); + font-family: var(--font-sans); +} + +.table-list-search::placeholder { + color: var(--text-muted); +} + +.table-list-search-clear { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: var(--radius-sm); + font-size: 14px; + color: var(--text-muted); + cursor: pointer; + flex-shrink: 0; + line-height: 1; +} + +.table-list-search-clear:hover { + background-color: var(--bg-primary); + color: var(--text-primary); +} + +/* โ”€โ”€ Body โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.table-list-body { + flex: 1; + overflow-y: auto; + padding: var(--space-2) 0; +} + +.table-list-no-results { + padding: var(--space-6) var(--space-4); + text-align: center; + font-size: var(--text-sm); + color: var(--text-muted); +} + +.table-list-search-info { + padding: var(--space-2) var(--space-4); + font-size: var(--text-xs); + color: var(--text-muted); + text-align: right; + border-top: 1px solid var(--border); + margin-top: var(--space-2); +} + +/* โ”€โ”€ Schema group โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.table-list-schema { + margin-bottom: var(--space-1); +} + +.table-list-schema-header { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + cursor: pointer; + user-select: none; + background-color: var(--bg-secondary); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.table-list-schema-header:hover { + background-color: var(--bg-tertiary); +} + +.table-list-schema-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + font-size: 10px; + color: var(--text-secondary); + transition: transform var(--transition-fast) var(--ease-in-out); + flex-shrink: 0; +} + +.table-list-schema-toggle.expanded { + transform: rotate(90deg); +} + +.table-list-schema-icon { + font-size: var(--text-sm); + flex-shrink: 0; +} + +.table-list-schema-name { + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-primary); + flex: 1; +} + +.table-list-schema-count { + padding: 1px 7px; + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + background-color: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 9px; + line-height: 1.6; +} + +/* โ”€โ”€ Tables inside a schema โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.table-list-tables { + display: flex; + flex-direction: column; +} + +.table-list-col-headers { + display: flex; + align-items: center; + padding: var(--space-1) var(--space-4) var(--space-1) calc(var(--space-4) + 28px); + background-color: var(--bg-primary); + border-bottom: 1px solid var(--border); +} + +.col-name { + flex: 1; + font-size: var(--text-xs); + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.col-actions { + font-size: var(--text-xs); + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + width: 140px; + text-align: right; +} + +.table-list-row { + display: flex; + align-items: center; + padding: var(--space-2) var(--space-4); + gap: var(--space-2); + border-bottom: 1px solid var(--border); + transition: background-color var(--transition-fast) var(--ease-in-out); +} + +.table-list-row:hover { + background-color: var(--bg-secondary); +} + +.table-list-row:hover .table-list-row-actions { + opacity: 1; +} + +.table-list-row-icon { + font-size: 13px; + flex-shrink: 0; +} + +.table-list-row-name { + flex: 1; + font-size: var(--text-sm); + color: var(--text-primary); + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.table-list-row-actions { + display: flex; + gap: var(--space-1); + opacity: 0; + transition: opacity var(--transition-fast) var(--ease-in-out); + flex-shrink: 0; +} + +.tl-action-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + font-size: var(--text-xs); + font-family: var(--font-sans); + color: var(--text-secondary); + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + white-space: nowrap; + transition: all var(--transition-fast) var(--ease-in-out); +} + +.tl-action-btn:hover { + color: var(--primary); + border-color: var(--primary); + background-color: rgba(59, 130, 246, 0.06); +} \ No newline at end of file diff --git a/frontend/src/components/MainArea/TableList.tsx b/frontend/src/components/MainArea/TableList.tsx new file mode 100644 index 0000000..36da848 --- /dev/null +++ b/frontend/src/components/MainArea/TableList.tsx @@ -0,0 +1,176 @@ +/** + * TableList Component + * + * Shows all tables grouped by schema for the active connection, + * with search filtering and quick-action buttons. + */ + +import React, { useState, useMemo } from 'react'; +import './TableList.css'; +import { Schema, Table } from '../Sidebar/ConnectionPanel'; + +export interface TableListProps { + /** Schemas (with tables) from the active connection */ + schemas?: Schema[]; + /** Active connection name for display */ + connectionName?: string; + /** Called when user wants to view table data */ + onViewData?: (table: Table, schema: Schema) => void; + /** Called when user wants to view table structure */ + onViewStructure?: (table: Table, schema: Schema) => void; +} + +const TableList: React.FC = ({ + schemas = [], + connectionName, + onViewData, + onViewStructure, +}) => { + const [search, setSearch] = useState(''); + const [collapsedSchemas, setCollapsedSchemas] = useState>(new Set()); + + const toggleSchema = (id: string) => { + setCollapsedSchemas(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + + // Filter tables by search term + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return schemas; + return schemas + .map(schema => ({ + ...schema, + tables: (schema.tables ?? []).filter(t => + t.name.toLowerCase().includes(q) || schema.name.toLowerCase().includes(q) + ), + })) + .filter(s => s.tables.length > 0); + }, [schemas, search]); + + const totalTables = schemas.reduce((s, sc) => s + (sc.tables?.length ?? 0), 0); + const filteredTotal = filtered.reduce((s, sc) => s + (sc.tables?.length ?? 0), 0); + + if (schemas.length === 0) { + return ( +
+
+
๐Ÿ”Œ
+

No connection active

+ Connect to a database to browse its tables. +
+
+ ); + } + + return ( +
+ {/* Header bar */} +
+
+
+ {connectionName ?? 'Tables'} + + {schemas.length} {schemas.length === 1 ? 'schema' : 'schemas'} ยท {totalTables} {totalTables === 1 ? 'table' : 'tables'} + +
+
+
+ ๐Ÿ” + setSearch(e.target.value)} + spellCheck={false} + /> + {search && ( + + )} +
+
+ + {/* Table list body */} +
+ {filtered.length === 0 ? ( +
+ No tables match "{search}" +
+ ) : ( + <> + {filtered.map(schema => { + const isCollapsed = collapsedSchemas.has(schema.id); + const tables = schema.tables ?? []; + + return ( +
+ {/* Schema header */} +
toggleSchema(schema.id)} + role="button" + tabIndex={0} + onKeyDown={e => e.key === 'Enter' && toggleSchema(schema.id)} + > + โ–ถ + ๐Ÿ“Š + {schema.name} + {tables.length} +
+ + {/* Tables */} + {!isCollapsed && ( +
+ {/* Column headings */} +
+ Table name + Actions +
+ {tables.map(table => ( +
+ ๐Ÿ“‹ + {table.name} +
+ + +
+
+ ))} +
+ )} +
+ ); + })} + {search && ( +
+ Showing {filteredTotal} of {totalTables} tables +
+ )} + + )} +
+
+ ); +}; + +export default TableList; diff --git a/frontend/src/components/Sidebar/ConnectionPanel.css b/frontend/src/components/Sidebar/ConnectionPanel.css index 300e35e..65fdf47 100644 --- a/frontend/src/components/Sidebar/ConnectionPanel.css +++ b/frontend/src/components/Sidebar/ConnectionPanel.css @@ -233,3 +233,76 @@ background-color: var(--border); margin: var(--space-2) 0; } + +/* Connection action buttons (edit/disconnect/delete) */ +.connection-actions { + display: flex; + align-items: center; + gap: 2px; + margin-left: auto; + opacity: 0; + transition: opacity 0.15s; +} + +.connection-item:hover .connection-actions { + opacity: 1; +} + +.connection-action-btn { + padding: 3px 4px; + border-radius: var(--radius-sm); + background: transparent; + border: none; + cursor: pointer; + color: var(--text-secondary); + font-size: 13px; + line-height: 1; +} + +.connection-action-btn:hover { + background: var(--bg-primary); + color: var(--text-primary); +} + +.connection-action-btn.danger:hover { + color: #ef4444; +} + +/* Connecting label */ +.connecting-label { + font-size: 11px; + color: var(--text-muted); + font-style: italic; + white-space: nowrap; +} + +/* Connection error message */ +.connection-error-msg { + padding: 4px var(--space-4) 6px calc(var(--space-4) + 24px); + font-size: 11px; + color: #f87171; + line-height: 1.4; + word-break: break-word; +} + +/* Connection stats (schema/table count summary) */ +.connection-stats { + padding: 2px var(--space-4) 6px calc(var(--space-4) + 24px); + font-size: 11px; + color: var(--text-muted); + line-height: 1.4; +} + +/* Tree count badge */ +.tree-count-badge { + margin-left: auto; + padding: 1px 6px; + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + background-color: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 9px; + line-height: 1.6; + flex-shrink: 0; +} \ No newline at end of file diff --git a/frontend/src/components/Sidebar/ConnectionPanel.tsx b/frontend/src/components/Sidebar/ConnectionPanel.tsx index 5df8264..a167590 100644 --- a/frontend/src/components/Sidebar/ConnectionPanel.tsx +++ b/frontend/src/components/Sidebar/ConnectionPanel.tsx @@ -5,7 +5,7 @@ * Based on layout-design.md section "ๅทฆไพง่ฟžๆŽฅ้ขๆฟ่ฎพ่ฎก" */ -import React, { useState, KeyboardEvent } from 'react'; +import React, { useState, useEffect, KeyboardEvent } from 'react'; import { StatusIndicator, StatusType } from '../common/StatusIndicator'; import './ConnectionPanel.css'; @@ -20,6 +20,7 @@ export interface DatabaseConnection { port?: number; status: StatusType; databases?: Schema[]; + errorMessage?: string; } /** @@ -84,6 +85,9 @@ export interface ConnectionPanelProps { onTableDoubleClick?: (table: Table, schema: Schema, connection: DatabaseConnection) => void; /** Collapsed state */ collapsed?: boolean; + onEdit?: (connection: DatabaseConnection) => void; + onDelete?: (connection: DatabaseConnection) => void; + onDisconnect?: (connection: DatabaseConnection) => void; } /** @@ -98,6 +102,7 @@ interface TreeNodeProps { children?: React.ReactNode; level: number; isActive?: boolean; + count?: number; } const TreeNode: React.FC = ({ @@ -109,6 +114,7 @@ const TreeNode: React.FC = ({ children, level, isActive = false, + count, }) => { const hasChildren = children !== undefined && children !== null; @@ -139,6 +145,9 @@ const TreeNode: React.FC = ({ {!hasChildren && } {icon} {label} + {count !== undefined && ( + {count} + )}
{expanded && children && (
@@ -163,6 +172,9 @@ interface ConnectionItemProps { onClick: () => void; onContextMenu: (event: React.MouseEvent) => void; onTableDoubleClick: (table: Table, schema: Schema) => void; + onEdit: () => void; + onDelete: () => void; + onDisconnect: () => void; } const ConnectionItem: React.FC = ({ @@ -176,6 +188,9 @@ const ConnectionItem: React.FC = ({ onClick, onContextMenu, onTableDoubleClick, + onEdit, + onDelete, + onDisconnect, }) => { // Get database type icon const getDbTypeIcon = (): string => { @@ -206,13 +221,53 @@ const ConnectionItem: React.FC = ({ {getDbTypeIcon()} {connection.name} + {connection.status === 'connecting' && ( + connectingโ€ฆ + )} +
+ + {(connection.status === 'connected' || connection.status === 'active') && ( + + )} + +
+ {connection.status === 'error' && connection.errorMessage && ( +
{connection.errorMessage}
+ )} + + {/* Summary stats when connected */} + {(connection.status === 'connected' || connection.status === 'active') && + connection.databases && (() => { + const totalTables = connection.databases.reduce((sum, s) => sum + (s.tables?.length ?? 0), 0); + const schemaCount = connection.databases.length; + return ( +
+ {schemaCount} {schemaCount === 1 ? 'schema' : 'schemas'} ยท {totalTables} {totalTables === 1 ? 'table' : 'tables'} +
+ ); + })() + } + {/* Schema tree - only show if connected and has databases */} {(connection.status === 'connected' || connection.status === 'active') && connection.databases && connection.databases.map((schema) => { const isSchemaExpanded = expandedSchemas.has(schema.id); + const tableCount = schema.tables?.length ?? 0; return ( = ({ level={1} expanded={isSchemaExpanded} onToggle={() => onToggleSchema(schema.id)} + count={tableCount} > {/* Tables */} {schema.tables && schema.tables.length > 0 && ( @@ -289,11 +345,29 @@ export const ConnectionPanel: React.FC = ({ onContextMenu, onTableDoubleClick, collapsed = false, + onEdit, + onDelete, + onDisconnect, }) => { // Track expanded schemas and tables const [expandedSchemas, setExpandedSchemas] = useState>(new Set()); const [expandedTables, setExpandedTables] = useState>(new Set()); + // Auto-expand first schema when connection databases are newly populated + useEffect(() => { + connections.forEach(conn => { + if (conn.databases && conn.databases.length > 0) { + const firstSchemaId = conn.databases[0].id; + setExpandedSchemas(prev => { + if (prev.has(firstSchemaId)) return prev; + const next = new Set(prev); + next.add(firstSchemaId); + return next; + }); + } + }); + }, [connections]); + // Toggle schema expansion const handleToggleSchema = (schemaId: string) => { setExpandedSchemas((prev) => { @@ -416,6 +490,9 @@ export const ConnectionPanel: React.FC = ({ onTableDoubleClick={(table, schema) => onTableDoubleClick?.(table, schema, connection) } + onEdit={() => onEdit?.(connection)} + onDelete={() => onDelete?.(connection)} + onDisconnect={() => onDisconnect?.(connection)} /> )) )} diff --git a/frontend/src/components/common/NewConnectionDialog.css b/frontend/src/components/common/NewConnectionDialog.css new file mode 100644 index 0000000..de3893a --- /dev/null +++ b/frontend/src/components/common/NewConnectionDialog.css @@ -0,0 +1,154 @@ +/* NewConnectionDialog Styles */ + +.dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(2px); +} + +.dialog-box { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + width: 480px; + max-width: 95vw; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4); +} + +.dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-5) var(--space-6); + border-bottom: 1px solid var(--border); +} + +.dialog-title { + font-size: var(--text-lg); + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.dialog-close { + font-size: var(--text-base); + color: var(--text-secondary); +} + +.dialog-form { + padding: var(--space-5) var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +/* Type selector */ +.type-selector { + display: flex; + gap: var(--space-2); +} + +.type-btn { + flex: 1; + padding: var(--space-3) var(--space-2); + font-size: var(--text-sm); + font-family: var(--font-sans); + font-weight: 500; + color: var(--text-secondary); + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast) var(--ease-in-out); + white-space: nowrap; +} + +.type-btn:hover { + color: var(--text-primary); + border-color: var(--text-secondary); +} + +.type-btn.active { + color: var(--primary); + border-color: var(--primary); + background: rgba(59, 130, 246, 0.1); +} + +/* Form groups */ +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.form-group.flex-1 { + flex: 1; +} + +.form-group.flex-3 { + flex: 3; +} + +.form-row { + display: flex; + gap: var(--space-3); +} + +.form-label { + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-secondary); +} + +.required { + color: #ef4444; +} + +.form-input { + padding: var(--space-2) var(--space-3); + font-size: var(--text-sm); + font-family: var(--font-sans); + color: var(--text-primary); + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + outline: none; + transition: border-color var(--transition-fast) var(--ease-in-out); + width: 100%; + box-sizing: border-box; +} + +.form-input:focus { + border-color: var(--primary); +} + +.form-input::placeholder { + color: var(--text-muted); +} + +/* Error */ +.form-error { + padding: var(--space-3); + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.4); + border-radius: var(--radius-md); + font-size: var(--text-sm); + color: #f87171; +} + +/* Actions */ +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-3); + padding-top: var(--space-2); + border-top: 1px solid var(--border); + margin-top: var(--space-2); +} \ No newline at end of file diff --git a/frontend/src/components/common/NewConnectionDialog.tsx b/frontend/src/components/common/NewConnectionDialog.tsx new file mode 100644 index 0000000..12cf906 --- /dev/null +++ b/frontend/src/components/common/NewConnectionDialog.tsx @@ -0,0 +1,210 @@ +/** + * NewConnectionDialog Component + * Modal form for creating a new database connection. + */ + +import React, { useState } from 'react'; +import './NewConnectionDialog.css'; + +export interface NewConnectionFormData { + name: string; + type: 'mysql' | 'postgres' | 'sqlite'; + host: string; + port: number; + username: string; + password: string; + database: string; + ssl_mode: string; + timeout: number; +} + +interface NewConnectionDialogProps { + onConfirm: (data: NewConnectionFormData) => Promise; + onCancel: () => void; + initialData?: Partial & { id?: string }; + mode?: 'create' | 'edit'; +} + +const DEFAULT_PORTS: Record = { + mysql: 3306, + postgres: 5432, + sqlite: 0, +}; + +const NewConnectionDialog: React.FC = ({ onConfirm, onCancel, initialData, mode = 'create' }) => { + const isEditMode = mode === 'edit'; + const [form, setForm] = useState({ + name: initialData?.name ?? '', + type: initialData?.type ?? 'postgres', + host: initialData?.host ?? '', + port: initialData?.port ?? 5432, + username: initialData?.username ?? '', + password: '', + database: initialData?.database ?? '', + ssl_mode: initialData?.ssl_mode ?? 'disable', + timeout: initialData?.timeout ?? 30, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleTypeChange = (type: NewConnectionFormData['type']) => { + setForm((prev) => ({ + ...prev, + type, + port: DEFAULT_PORTS[type] || 0, + })); + }; + + const handleChange = (field: keyof NewConnectionFormData, value: string | number) => { + setForm((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await onConfirm(form); + } catch (err: any) { + setError(err?.message || String(err)); + } finally { + setLoading(false); + } + }; + + const isSQLite = form.type === 'sqlite'; + + return ( +
+
e.stopPropagation()}> +
+

{isEditMode ? 'Edit Connection' : 'New Connection'}

+ +
+ +
+ {/* Connection Type */} +
+ +
+ {(['postgres', 'mysql', 'sqlite'] as const).map((t) => ( + + ))} +
+
+ + {/* Name */} +
+ + handleChange('name', e.target.value)} + required + autoFocus + /> +
+ + {!isSQLite && ( + <> + {/* Host & Port */} +
+
+ + handleChange('host', e.target.value)} + required + /> +
+
+ + handleChange('port', parseInt(e.target.value, 10) || 0)} + required + /> +
+
+ + {/* Username & Password */} +
+
+ + handleChange('username', e.target.value)} + /> +
+
+ + handleChange('password', e.target.value)} + /> +
+
+ + )} + + {/* Database / File */} +
+ + handleChange('database', e.target.value)} + required + /> +
+ + {error &&
{error}
} + +
+ + +
+
+
+
+ ); +}; + +export default NewConnectionDialog; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 5488fdc..80d78ba 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -8,6 +8,9 @@ export { StatusIndicator } from './common/StatusIndicator'; export type { StatusIndicatorProps, StatusType } from './common/StatusIndicator'; +export { default as NewConnectionDialog } from './common/NewConnectionDialog'; +export type { NewConnectionFormData } from './common/NewConnectionDialog'; + // Layout components export { AppLayout } from './Layout/AppLayout'; export type { AppLayoutProps } from './Layout/AppLayout'; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 823e83d..ca58ba4 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -21,11 +21,12 @@ "jsx": "react-jsx" }, "include": [ - "src" + "src", + "wailsjs" ], "references": [ { "path": "./tsconfig.node.json" } ] -} +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4955065..b1b5f91 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,4 +1,4 @@ -import {defineConfig} from 'vite' +import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts new file mode 100755 index 0000000..6b471e6 --- /dev/null +++ b/frontend/wailsjs/go/app/App.d.ts @@ -0,0 +1,45 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH ร‚ MODIWL +// This file is automatically generated. DO NOT EDIT +import {models} from '../models'; +import {config} from '../models'; +import {services} from '../models'; +import {handler} from '../models'; +import {context} from '../models'; + +export function CreateConnection(arg1:models.CreateConnectionRequest):Promise; + +export function CreateSavedQuery(arg1:models.CreateSavedQueryRequest):Promise; + +export function DeleteConnection(arg1:string):Promise; + +export function DeleteSavedQuery(arg1:number):Promise; + +export function DisconnectConnection(arg1:string):Promise; + +export function ExecuteQuery(arg1:string,arg2:string):Promise; + +export function GetConnections():Promise>; + +export function GetQueryHistory(arg1:string,arg2:number,arg3:number):Promise>; + +export function GetSavedQueries(arg1:string):Promise|string>; + +export function GetTableData(arg1:string,arg2:string,arg3:number,arg4:number):Promise; + +export function GetTableStructure(arg1:string,arg2:string):Promise; + +export function GetTables(arg1:string):Promise|string>; + +export function Initialize(arg1:config.Config,arg2:services.ConnectionService,arg3:services.QueryService,arg4:handler.HTTPServer):Promise; + +export function OnStartup(arg1:context.Context):Promise; + +export function Shutdown():Promise; + +export function StartHTTPServer():Promise; + +export function TestConnection(arg1:string):Promise; + +export function UpdateConnection(arg1:models.UserConnection):Promise; + +export function UpdateSavedQuery(arg1:number,arg2:models.UpdateSavedQueryRequest):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js new file mode 100755 index 0000000..7f6bfb8 --- /dev/null +++ b/frontend/wailsjs/go/app/App.js @@ -0,0 +1,79 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH ร‚ MODIWL +// This file is automatically generated. DO NOT EDIT + +export function CreateConnection(arg1) { + return window['go']['app']['App']['CreateConnection'](arg1); +} + +export function CreateSavedQuery(arg1) { + return window['go']['app']['App']['CreateSavedQuery'](arg1); +} + +export function DeleteConnection(arg1) { + return window['go']['app']['App']['DeleteConnection'](arg1); +} + +export function DeleteSavedQuery(arg1) { + return window['go']['app']['App']['DeleteSavedQuery'](arg1); +} + +export function DisconnectConnection(arg1) { + return window['go']['app']['App']['DisconnectConnection'](arg1); +} + +export function ExecuteQuery(arg1, arg2) { + return window['go']['app']['App']['ExecuteQuery'](arg1, arg2); +} + +export function GetConnections() { + return window['go']['app']['App']['GetConnections'](); +} + +export function GetQueryHistory(arg1, arg2, arg3) { + return window['go']['app']['App']['GetQueryHistory'](arg1, arg2, arg3); +} + +export function GetSavedQueries(arg1) { + return window['go']['app']['App']['GetSavedQueries'](arg1); +} + +export function GetTableData(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['GetTableData'](arg1, arg2, arg3, arg4); +} + +export function GetTableStructure(arg1, arg2) { + return window['go']['app']['App']['GetTableStructure'](arg1, arg2); +} + +export function GetTables(arg1) { + return window['go']['app']['App']['GetTables'](arg1); +} + +export function Initialize(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['Initialize'](arg1, arg2, arg3, arg4); +} + +export function OnStartup(arg1) { + return window['go']['app']['App']['OnStartup'](arg1); +} + +export function Shutdown() { + return window['go']['app']['App']['Shutdown'](); +} + +export function StartHTTPServer() { + return window['go']['app']['App']['StartHTTPServer'](); +} + +export function TestConnection(arg1) { + return window['go']['app']['App']['TestConnection'](arg1); +} + +export function UpdateConnection(arg1) { + return window['go']['app']['App']['UpdateConnection'](arg1); +} + +export function UpdateSavedQuery(arg1, arg2) { + return window['go']['app']['App']['UpdateSavedQuery'](arg1, arg2); +} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts deleted file mode 100755 index 02a3bb9..0000000 --- a/frontend/wailsjs/go/main/App.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH ร‚ MODIWL -// This file is automatically generated. DO NOT EDIT - -export function Greet(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js deleted file mode 100755 index c71ae77..0000000 --- a/frontend/wailsjs/go/main/App.js +++ /dev/null @@ -1,7 +0,0 @@ -// @ts-check -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH ร‚ MODIWL -// This file is automatically generated. DO NOT EDIT - -export function Greet(arg1) { - return window['go']['main']['App']['Greet'](arg1); -} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts new file mode 100755 index 0000000..77dcfb9 --- /dev/null +++ b/frontend/wailsjs/go/models.ts @@ -0,0 +1,529 @@ +export namespace config { + + export class APIConfig { + enabled: boolean; + port: string; + + static createFrom(source: any = {}) { + return new APIConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.port = source["port"]; + } + } + export class LoggerConfig { + level: string; + format: string; + output_path: string; + + static createFrom(source: any = {}) { + return new LoggerConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.level = source["level"]; + this.format = source["format"]; + this.output_path = source["output_path"]; + } + } + export class EncryptionConfig { + key_file: string; + + static createFrom(source: any = {}) { + return new EncryptionConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.key_file = source["key_file"]; + } + } + export class DatabaseConfig { + sqlite_path: string; + max_open_conns: number; + max_idle_conns: number; + max_lifetime: number; + + static createFrom(source: any = {}) { + return new DatabaseConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.sqlite_path = source["sqlite_path"]; + this.max_open_conns = source["max_open_conns"]; + this.max_idle_conns = source["max_idle_conns"]; + this.max_lifetime = source["max_lifetime"]; + } + } + export class Config { + app_name: string; + version: string; + environment: string; + database: DatabaseConfig; + encryption: EncryptionConfig; + logger: LoggerConfig; + api: APIConfig; + + static createFrom(source: any = {}) { + return new Config(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.app_name = source["app_name"]; + this.version = source["version"]; + this.environment = source["environment"]; + this.database = this.convertValues(source["database"], DatabaseConfig); + this.encryption = this.convertValues(source["encryption"], EncryptionConfig); + this.logger = this.convertValues(source["logger"], LoggerConfig); + this.api = this.convertValues(source["api"], APIConfig); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + + + +} + +export namespace handler { + + export class HTTPServer { + + + static createFrom(source: any = {}) { + return new HTTPServer(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + + } + } + +} + +export namespace models { + + export class CreateConnectionRequest { + name: string; + type: string; + host: string; + port: number; + username: string; + password: string; + database: string; + ssl_mode: string; + timeout: number; + + static createFrom(source: any = {}) { + return new CreateConnectionRequest(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.type = source["type"]; + this.host = source["host"]; + this.port = source["port"]; + this.username = source["username"]; + this.password = source["password"]; + this.database = source["database"]; + this.ssl_mode = source["ssl_mode"]; + this.timeout = source["timeout"]; + } + } + export class CreateSavedQueryRequest { + name: string; + description: string; + sql: string; + connection_id: string; + tags: string; + + static createFrom(source: any = {}) { + return new CreateSavedQueryRequest(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.description = source["description"]; + this.sql = source["sql"]; + this.connection_id = source["connection_id"]; + this.tags = source["tags"]; + } + } + export class ForeignKey { + name: string; + columns: string[]; + referenced_table: string; + referenced_columns: string[]; + on_delete?: string; + on_update?: string; + + static createFrom(source: any = {}) { + return new ForeignKey(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.columns = source["columns"]; + this.referenced_table = source["referenced_table"]; + this.referenced_columns = source["referenced_columns"]; + this.on_delete = source["on_delete"]; + this.on_update = source["on_update"]; + } + } + export class QueryHistory { + id: number; + connection_id: string; + sql: string; + duration_ms: number; + // Go type: time + executed_at: any; + rows_affected: number; + error?: string; + success: boolean; + result_preview?: string; + + static createFrom(source: any = {}) { + return new QueryHistory(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.connection_id = source["connection_id"]; + this.sql = source["sql"]; + this.duration_ms = source["duration_ms"]; + this.executed_at = this.convertValues(source["executed_at"], null); + this.rows_affected = source["rows_affected"]; + this.error = source["error"]; + this.success = source["success"]; + this.result_preview = source["result_preview"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class QueryResult { + columns: string[]; + rows: any[][]; + row_count: number; + affected_rows: number; + duration_ms: number; + success: boolean; + error?: string; + + static createFrom(source: any = {}) { + return new QueryResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.columns = source["columns"]; + this.rows = source["rows"]; + this.row_count = source["row_count"]; + this.affected_rows = source["affected_rows"]; + this.duration_ms = source["duration_ms"]; + this.success = source["success"]; + this.error = source["error"]; + } + } + export class SavedQuery { + id: number; + name: string; + description: string; + sql: string; + connection_id: string; + tags: string; + // Go type: time + created_at: any; + // Go type: time + updated_at: any; + + static createFrom(source: any = {}) { + return new SavedQuery(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.description = source["description"]; + this.sql = source["sql"]; + this.connection_id = source["connection_id"]; + this.tags = source["tags"]; + this.created_at = this.convertValues(source["created_at"], null); + this.updated_at = this.convertValues(source["updated_at"], null); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class Table { + name: string; + schema?: string; + type: string; + row_count?: number; + description?: string; + + static createFrom(source: any = {}) { + return new Table(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.schema = source["schema"]; + this.type = source["type"]; + this.row_count = source["row_count"]; + this.description = source["description"]; + } + } + export class TableColumn { + name: string; + data_type: string; + nullable: boolean; + default?: string; + is_primary: boolean; + is_unique: boolean; + auto_increment: boolean; + length?: number; + scale?: number; + comment?: string; + + static createFrom(source: any = {}) { + return new TableColumn(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.data_type = source["data_type"]; + this.nullable = source["nullable"]; + this.default = source["default"]; + this.is_primary = source["is_primary"]; + this.is_unique = source["is_unique"]; + this.auto_increment = source["auto_increment"]; + this.length = source["length"]; + this.scale = source["scale"]; + this.comment = source["comment"]; + } + } + export class TableIndex { + name: string; + columns: string[]; + is_unique: boolean; + is_primary: boolean; + type?: string; + + static createFrom(source: any = {}) { + return new TableIndex(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.columns = source["columns"]; + this.is_unique = source["is_unique"]; + this.is_primary = source["is_primary"]; + this.type = source["type"]; + } + } + export class TableStructure { + table_name: string; + schema?: string; + columns: TableColumn[]; + indexes?: TableIndex[]; + foreign_keys?: ForeignKey[]; + + static createFrom(source: any = {}) { + return new TableStructure(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.table_name = source["table_name"]; + this.schema = source["schema"]; + this.columns = this.convertValues(source["columns"], TableColumn); + this.indexes = this.convertValues(source["indexes"], TableIndex); + this.foreign_keys = this.convertValues(source["foreign_keys"], ForeignKey); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class UpdateSavedQueryRequest { + name: string; + description: string; + sql: string; + connection_id: string; + tags: string; + + static createFrom(source: any = {}) { + return new UpdateSavedQueryRequest(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.description = source["description"]; + this.sql = source["sql"]; + this.connection_id = source["connection_id"]; + this.tags = source["tags"]; + } + } + export class UserConnection { + id: string; + name: string; + type: string; + host?: string; + port?: number; + username?: string; + password: string; + database: string; + ssl_mode?: string; + timeout: number; + // Go type: time + created_at: any; + // Go type: time + updated_at: any; + + static createFrom(source: any = {}) { + return new UserConnection(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.type = source["type"]; + this.host = source["host"]; + this.port = source["port"]; + this.username = source["username"]; + this.password = source["password"]; + this.database = source["database"]; + this.ssl_mode = source["ssl_mode"]; + this.timeout = source["timeout"]; + this.created_at = this.convertValues(source["created_at"], null); + this.updated_at = this.convertValues(source["updated_at"], null); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + +export namespace services { + + export class ConnectionService { + + + static createFrom(source: any = {}) { + return new ConnectionService(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + + } + } + export class QueryService { + + + static createFrom(source: any = {}) { + return new QueryService(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + + } + } + +} + diff --git a/internal/app/app.go b/internal/app/app.go index 75d6809..96fd9e6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,6 +2,7 @@ package app import ( "context" + "errors" "time" "go.uber.org/zap" @@ -13,14 +14,25 @@ import ( "uzdb/internal/services" ) +// rootErrMsg unwraps the full error chain and returns the deepest error message. +func rootErrMsg(err error) string { + for { + unwrapped := errors.Unwrap(err) + if unwrapped == nil { + return err.Error() + } + err = unwrapped + } +} + // App is the main application structure for Wails bindings type App struct { - ctx context.Context - config *config.Config - connectionSvc *services.ConnectionService - querySvc *services.QueryService - httpServer *handler.HTTPServer - shutdownFunc context.CancelFunc + ctx context.Context + config *config.Config + connectionSvc *services.ConnectionService + querySvc *services.QueryService + httpServer *handler.HTTPServer + shutdownFunc context.CancelFunc } // NewApp creates a new App instance @@ -44,7 +56,7 @@ func (a *App) Initialize( // OnStartup is called when the app starts (public method for Wails) func (a *App) OnStartup(ctx context.Context) { a.ctx = ctx - + config.GetLogger().Info("Wails application started") } @@ -124,6 +136,24 @@ func (a *App) DeleteConnection(id string) string { return "" } +// DisconnectConnection removes an active connection from the connection manager +// Returns error message or empty string on success +func (a *App) DisconnectConnection(id string) string { + if a.connectionSvc == nil { + return "Service not initialized" + } + + err := a.connectionSvc.DisconnectConnection(a.ctx, id) + if err != nil { + config.GetLogger().Error("failed to disconnect connection", + zap.String("id", id), + zap.Error(err)) + return err.Error() + } + + return "" +} + // TestConnection tests a database connection // Returns (success, error_message) func (a *App) TestConnection(id string) (bool, string) { @@ -137,14 +167,19 @@ func (a *App) TestConnection(id string) (bool, string) { return false, err.Error() } - return result.Success, result.Message + return result.Success, func() string { + if result.Success { + return "" + } + return result.Message + }() } // ExecuteQuery executes a SQL query on a connection // Returns query result or error message func (a *App) ExecuteQuery(connectionID, sql string) (*models.QueryResult, string) { if a.connectionSvc == nil { - return nil, "Service not initialized" + return &models.QueryResult{Success: false, Error: "Service not initialized"}, "" } result, err := a.connectionSvc.ExecuteQuery(a.ctx, connectionID, sql) @@ -153,7 +188,7 @@ func (a *App) ExecuteQuery(connectionID, sql string) (*models.QueryResult, strin zap.String("connection_id", connectionID), zap.String("sql", sql), zap.Error(err)) - return nil, err.Error() + return &models.QueryResult{Success: false, Error: rootErrMsg(err)}, "" } return result, "" @@ -188,7 +223,7 @@ func (a *App) GetTableData(connectionID, tableName string, limit, offset int) (* zap.String("connection_id", connectionID), zap.String("table", tableName), zap.Error(err)) - return nil, err.Error() + return nil, rootErrMsg(err) } return result, "" @@ -306,19 +341,19 @@ func (a *App) StartHTTPServer() string { // Shutdown gracefully shuts down the application func (a *App) Shutdown() { config.GetLogger().Info("shutting down application") - + if a.shutdownFunc != nil { a.shutdownFunc() } - + if a.httpServer != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() a.httpServer.Shutdown(ctx) } - + // Close all database connections database.CloseSQLite() - + config.Sync() } diff --git a/internal/services/connection.go b/internal/services/connection.go index 6a8b2c2..842a976 100644 --- a/internal/services/connection.go +++ b/internal/services/connection.go @@ -3,6 +3,7 @@ package services import ( "context" "fmt" + "strings" "time" "go.uber.org/zap" @@ -16,9 +17,9 @@ import ( // ConnectionService manages database connections type ConnectionService struct { - db *gorm.DB - connManager *database.ConnectionManager - encryptSvc *EncryptionService + db *gorm.DB + connManager *database.ConnectionManager + encryptSvc *EncryptionService } // NewConnectionService creates a new connection service @@ -37,7 +38,7 @@ func NewConnectionService( // GetAllConnections returns all user connections func (s *ConnectionService) GetAllConnections(ctx context.Context) ([]models.UserConnection, error) { var connections []models.UserConnection - + result := s.db.WithContext(ctx).Find(&connections) if result.Error != nil { return nil, fmt.Errorf("failed to get connections: %w", result.Error) @@ -57,7 +58,7 @@ func (s *ConnectionService) GetAllConnections(ctx context.Context) ([]models.Use // GetConnectionByID returns a connection by ID func (s *ConnectionService) GetConnectionByID(ctx context.Context, id string) (*models.UserConnection, error) { var conn models.UserConnection - + result := s.db.WithContext(ctx).First(&conn, "id = ?", id) if result.Error != nil { if result.Error == gorm.ErrRecordNotFound { @@ -87,16 +88,16 @@ func (s *ConnectionService) CreateConnection(ctx context.Context, req *models.Cr } conn := &models.UserConnection{ - ID: utils.GenerateID(), - Name: req.Name, - Type: req.Type, - Host: req.Host, - Port: req.Port, - Username: req.Username, - Password: encryptedPassword, - Database: req.Database, - SSLMode: req.SSLMode, - Timeout: req.Timeout, + ID: utils.GenerateID(), + Name: req.Name, + Type: req.Type, + Host: req.Host, + Port: req.Port, + Username: req.Username, + Password: encryptedPassword, + Database: req.Database, + SSLMode: req.SSLMode, + Timeout: req.Timeout, } if conn.Timeout <= 0 { @@ -204,6 +205,17 @@ func (s *ConnectionService) DeleteConnection(ctx context.Context, id string) err return nil } +// DisconnectConnection removes an active connection from the connection manager +func (s *ConnectionService) DisconnectConnection(ctx context.Context, id string) error { + if err := s.connManager.RemoveConnection(id); err != nil { + return fmt.Errorf("failed to disconnect connection: %w", err) + } + + config.GetLogger().Info("connection disconnected", zap.String("id", id)) + + return nil +} + // TestConnection tests a database connection func (s *ConnectionService) TestConnection(ctx context.Context, id string) (*models.ConnectionTestResult, error) { // Get connection config @@ -266,7 +278,7 @@ func (s *ConnectionService) ExecuteQuery(ctx context.Context, connectionID, sql // Execute query startTime := time.Now() - + var result *models.QueryResult if utils.IsReadOnlyQuery(sql) { result, err = dbConn.ExecuteQuery(sql) @@ -283,7 +295,7 @@ func (s *ConnectionService) ExecuteQuery(ctx context.Context, connectionID, sql Duration: duration.Milliseconds(), Success: err == nil, } - + if result != nil { history.RowsAffected = result.AffectedRows } @@ -343,21 +355,35 @@ func (s *ConnectionService) GetTableData( return nil, err } - var query string - switch conn.Type { - case models.ConnectionTypeMySQL: - query = fmt.Sprintf("SELECT * FROM `%s` LIMIT %d OFFSET %d", tableName, limit, offset) - case models.ConnectionTypePostgreSQL: - query = fmt.Sprintf(`SELECT * FROM "%s" LIMIT %d OFFSET %d`, tableName, limit, offset) - case models.ConnectionTypeSQLite: - query = fmt.Sprintf(`SELECT * FROM "%s" LIMIT %d OFFSET %d`, tableName, limit, offset) - default: - return nil, models.ErrValidationFailed - } + // Build a properly-quoted table reference that handles 'schema.table' notation + tableRef := buildTableRef(conn.Type, tableName) + + query := fmt.Sprintf("SELECT * FROM %s LIMIT %d OFFSET %d", tableRef, limit, offset) return s.ExecuteQuery(ctx, connectionID, query) } +// buildTableRef returns a properly-quoted table reference. +// tableName may be plain 'table' or schema-qualified 'schema.table'. +func buildTableRef(dbType models.ConnectionType, tableName string) string { + if strings.Contains(tableName, ".") { + parts := strings.SplitN(tableName, ".", 2) + schema, table := parts[0], parts[1] + switch dbType { + case models.ConnectionTypeMySQL: + return fmt.Sprintf("`%s`.`%s`", schema, table) + default: + return fmt.Sprintf(`"%s"."%s"`, schema, table) + } + } + switch dbType { + case models.ConnectionTypeMySQL: + return fmt.Sprintf("`%s`", tableName) + default: + return fmt.Sprintf(`"%s"`, tableName) + } +} + // GetTableStructure returns the structure of a table func (s *ConnectionService) GetTableStructure(ctx context.Context, connectionID, tableName string) (*models.TableStructure, error) { // Get connection config