2 Commits

Author SHA1 Message Date
loveuer
afff9ca730 feat: add GitHub Actions workflow for building and releasing uzdb across Windows, macOS, and Linux
Some checks failed
Build and Release / Build Windows (amd64) (push) Has been cancelled
Build and Release / Build macOS (arm64) (push) Has been cancelled
Build and Release / Build Linux (amd64) (push) Has been cancelled
Build and Release / Create GitHub Release (push) Has been cancelled
2026-04-06 21:48:50 +08:00
loveuer
347ecd0f1b 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
2026-04-06 21:45:28 +08:00
23 changed files with 2612 additions and 315 deletions

137
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,137 @@
name: Build and Release
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
permissions:
contents: write
jobs:
build-windows:
name: Build Windows (amd64)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Build
run: wails build -platform windows/amd64 -ldflags "-X main.version=${{ github.ref_name }}"
- name: Package
run: |
cd build/bin
Compress-Archive -Path uzdb.exe -DestinationPath uzdb-${{ github.ref_name }}-windows-amd64.zip
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: uzdb-windows-amd64
path: build/bin/uzdb-${{ github.ref_name }}-windows-amd64.zip
build-macos:
name: Build macOS (arm64)
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Build
run: wails build -platform darwin/arm64 -ldflags "-X main.version=${{ github.ref_name }}"
- name: Package
run: |
cd build/bin
zip -r uzdb-${{ github.ref_name }}-darwin-arm64.zip uzdb.app
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: uzdb-macos-arm64
path: build/bin/uzdb-${{ github.ref_name }}-darwin-arm64.zip
build-linux:
name: Build Linux (amd64)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Build
run: wails build -platform linux/amd64 -ldflags "-X main.version=${{ github.ref_name }}"
- name: Package
run: |
cd build/bin
tar -czf uzdb-${{ github.ref_name }}-linux-amd64.tar.gz uzdb
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: uzdb-linux-amd64
path: build/bin/uzdb-${{ github.ref_name }}-linux-amd64.tar.gz
release:
name: Create GitHub Release
needs: [ build-windows, build-macos, build-linux ]
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Create release
uses: softprops/action-gh-release@v2
with:
name: uzdb ${{ github.ref_name }}
draft: false
prerelease: false
generate_release_notes: true
files: dist/*

View File

@@ -13,7 +13,7 @@
.view-tabs { .view-tabs {
display: flex; display: flex;
gap: var(--space-1); 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); background-color: var(--bg-primary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
@@ -23,8 +23,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
padding: var(--space-2) var(--space-4); padding: var(--space-3) var(--space-5);
font-size: var(--text-sm); font-size: var(--text-base);
font-family: var(--font-sans); font-family: var(--font-sans);
color: var(--text-secondary); color: var(--text-secondary);
background: transparent; background: transparent;
@@ -83,6 +83,33 @@
outline-offset: 2px; 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 color */
::selection { ::selection {
background-color: rgba(59, 130, 246, 0.2); background-color: rgba(59, 130, 246, 0.2);
@@ -91,6 +118,7 @@
/* Print styles */ /* Print styles */
@media print { @media print {
.view-tabs, .view-tabs,
.layout-menubar, .layout-menubar,
.layout-toolbar, .layout-toolbar,

View File

@@ -4,40 +4,61 @@
* Main application component integrating all UI components. * Main application component integrating all UI components.
*/ */
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import './index.css'; 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 components
import AppLayout from './components/Layout/AppLayout'; import AppLayout from './components/Layout/AppLayout';
import MenuBar from './components/MenuBar/MenuBar';
import ToolBar from './components/Layout/ToolBar'; import ToolBar from './components/Layout/ToolBar';
import StatusBar from './components/Layout/StatusBar'; import ConnectionPanel, { DatabaseConnection, Schema, Table } from './components/Sidebar/ConnectionPanel';
import ConnectionPanel, { DatabaseConnection } from './components/Sidebar/ConnectionPanel';
import QueryEditor, { QueryTab, QueryResult } from './components/MainArea/QueryEditor'; 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 TableStructure from './components/MainArea/TableStructure';
import TableList from './components/MainArea/TableList';
import NewConnectionDialog, { NewConnectionFormData } from './components/common/NewConnectionDialog';
// Import mock data // Keep mock data for QueryEditor initial tabs
import { mockConnections } from './mock/connections'; import { mockQueryTabs } from './mock/queryResults';
import { import { TableColumn, Index, ForeignKey } from './components/MainArea/TableStructure';
mockQueryResults,
mockQueryTabs,
mockDataGridColumns,
mockDataGridRows,
mockTableColumns,
mockIndexes,
mockForeignKeys,
mockTableInfo,
} from './mock/queryResults';
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() { function App() {
// Application state const [connections, setConnections] = useState<DatabaseConnection[]>([]);
const [connections] = useState<DatabaseConnection[]>(mockConnections); const [activeConnectionId, setActiveConnectionId] = useState<string>('');
const [activeConnectionId, setActiveConnectionId] = useState<string>('conn-1'); const [selectedConnectionId, setSelectedConnectionId] = useState<string>('');
const [selectedConnectionId, setSelectedConnectionId] = useState<string>('conn-1');
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showNewConnDialog, setShowNewConnDialog] = useState(false);
const [editingConnection, setEditingConnection] = useState<DatabaseConnection | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
// Query editor state // Query editor state
const [queryTabs, setQueryTabs] = useState<QueryTab[]>(mockQueryTabs); const [queryTabs, setQueryTabs] = useState<QueryTab[]>(mockQueryTabs);
@@ -45,47 +66,339 @@ function App() {
const [queryResults, setQueryResults] = useState<QueryResult | null>(null); const [queryResults, setQueryResults] = useState<QueryResult | null>(null);
const [isQueryLoading, setIsQueryLoading] = useState(false); const [isQueryLoading, setIsQueryLoading] = useState(false);
// Main view state // DataGrid state
const [mainView, setMainView] = useState<MainView>('query'); const [selectedTable, setSelectedTable] = useState<{ name: string; schema: string } | null>(null);
const [dataGridColumns, setDataGridColumns] = useState<GridColumn[]>([]);
const [dataGridRows, setDataGridRows] = useState<GridRow[]>([]);
const [dataGridPage, setDataGridPage] = useState(1);
const [dataGridPageSize, setDataGridPageSize] = useState(50);
const [dataGridHasMore, setDataGridHasMore] = useState(false);
const [isDataGridLoading, setIsDataGridLoading] = useState(false);
const [dataGridError, setDataGridError] = useState<string | null>(null);
// Handler: Connection click // TableStructure state
const handleConnectionClick = useCallback((connection: DatabaseConnection) => { const [structureTable, setStructureTable] = useState<{ name: string; schema: string } | null>(null);
const [structureColumns, setStructureColumns] = useState<TableColumn[]>([]);
const [structureIndexes, setStructureIndexes] = useState<Index[]>([]);
const [structureForeignKeys, setStructureForeignKeys] = useState<ForeignKey[]>([]);
const [isStructureLoading, setIsStructureLoading] = useState(false);
const [structureError, setStructureError] = useState<string | null>(null);
// Main view state
const [mainView, setMainView] = useState<MainView>('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); setSelectedConnectionId(connection.id);
if (connection.status === 'disconnected') {
console.log(`Connecting to ${connection.name}...`); if (connection.status === 'disconnected' || connection.status === 'error') {
// In real app: call backend to connect // 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|string>: 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<Table[]|string>: array on success, string on failure
const tablesResult = await GetTables(connection.id);
const tables: models.Table[] = Array.isArray(tablesResult) ? tablesResult : [];
const schemaMap: Record<string, Table[]> = {};
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') { } else if (connection.status === 'connected' || connection.status === 'active') {
setActiveConnectionId(connection.id); setActiveConnectionId(connection.id);
} }
}, []); }, []);
// Handler: New connection // Handler: open new connection dialog
const handleNewConnection = useCallback(() => { const handleNewConnection = useCallback(() => {
console.log('Opening new connection dialog...'); setShowNewConnDialog(true);
// In real app: open connection dialog
}, []); }, []);
// Handler: Table double-click // Handler: create connection from dialog form
const handleTableDoubleClick = useCallback(( const handleCreateConnection = useCallback(async (data: NewConnectionFormData) => {
table: any, const req = new models.CreateConnectionRequest();
schema: any, req.name = data.name;
connection: DatabaseConnection 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}`); 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);
}
}, []);
// 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'); 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);
}
}, []); }, []);
// Handler: Query execution // Open a table in the Structure view
const handleExecuteQuery = useCallback((query: string) => { 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); setIsQueryLoading(true);
console.log('Executing query:', query); setQueryResults(null);
try {
// Simulate async query execution // Wails returns Promise<QueryResult|string>: object on success, string on failure
setTimeout(() => { const queryResult = await WailsExecuteQuery(activeConnectionId, query);
setQueryResults(mockQueryResults); 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); setIsQueryLoading(false);
}, 500); }
}, []); }, [activeConnectionId]);
// Handler: Tab content change // Handler: Tab content change
const handleContentChange = useCallback((tabId: string, content: string) => { const handleContentChange = useCallback((tabId: string, content: string) => {
@@ -96,7 +409,6 @@ function App() {
// Handler: Save query // Handler: Save query
const handleSaveQuery = useCallback((tabId: string) => { const handleSaveQuery = useCallback((tabId: string) => {
console.log('Saving query:', tabId);
setQueryTabs(prev => prev.map(tab => setQueryTabs(prev => prev.map(tab =>
tab.id === tabId ? { ...tab, isDirty: false } : tab tab.id === tabId ? { ...tab, isDirty: false } : tab
)); ));
@@ -122,47 +434,16 @@ function App() {
setActiveTabId(newTab.id); setActiveTabId(newTab.id);
}, []); }, []);
// Handler: Format SQL // Handler: Format SQL (placeholder)
const handleFormatSQL = useCallback(() => { const handleFormatSQL = useCallback(() => { }, []);
console.log('Formatting SQL...');
// In real app: format SQL using sql-formatter
}, []);
// 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); const activeConnection = connections.find(c => c.id === activeConnectionId);
return ( return (
<>
<AppLayout <AppLayout
sidebarCollapsed={sidebarCollapsed} sidebarCollapsed={sidebarCollapsed}
onSidebarToggle={setSidebarCollapsed} onSidebarToggle={setSidebarCollapsed}
menuBar={
<MenuBar
title="uzdb"
onMenuItemClick={handleMenuItemClick}
/>
}
toolbar={ toolbar={
<ToolBar <ToolBar
buttons={[ buttons={[
@@ -200,6 +481,7 @@ function App() {
tooltip: 'Find in query (Ctrl+F)', tooltip: 'Find in query (Ctrl+F)',
}, },
]} ]}
activeConnection={activeConnection ? { name: activeConnection.name, type: activeConnection.type } : null}
/> />
} }
sidebar={ sidebar={
@@ -210,12 +492,21 @@ function App() {
onConnectionClick={handleConnectionClick} onConnectionClick={handleConnectionClick}
onNewConnection={handleNewConnection} onNewConnection={handleNewConnection}
onTableDoubleClick={handleTableDoubleClick} onTableDoubleClick={handleTableDoubleClick}
onEdit={handleEditConnection}
onDelete={handleDeleteConnection}
onDisconnect={handleDisconnectConnection}
/> />
} }
mainContent={ mainContent={
<div className="main-content"> <div className="main-content">
{/* View tabs */} {/* View tabs */}
<div className="view-tabs"> <div className="view-tabs">
<button
className={`view-tab ${mainView === 'tables' ? 'active' : ''}`}
onClick={() => setMainView('tables')}
>
🗂 Tables
</button>
<button <button
className={`view-tab ${mainView === 'query' ? 'active' : ''}`} className={`view-tab ${mainView === 'query' ? 'active' : ''}`}
onClick={() => setMainView('query')} onClick={() => setMainView('query')}
@@ -244,6 +535,7 @@ function App() {
activeTabId={activeTabId} activeTabId={activeTabId}
results={queryResults} results={queryResults}
isLoading={isQueryLoading} isLoading={isQueryLoading}
activeConnection={activeConnection ? { name: activeConnection.name, type: activeConnection.type } : undefined}
onTabClick={setActiveTabId} onTabClick={setActiveTabId}
onCloseTab={handleCloseTab} onCloseTab={handleCloseTab}
onNewTab={handleNewTab} onNewTab={handleNewTab}
@@ -254,53 +546,137 @@ function App() {
/> />
)} )}
{mainView === 'data' && ( {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 (
<div className="datagrid-empty-state">
<div className="datagrid-empty-icon">📊</div>
<p>No table selected</p>
<span>Double-click a table in the sidebar or open one from the Tables view.</span>
</div>
);
}
return (
<DataGrid <DataGrid
columns={mockDataGridColumns} columns={dataGridColumns}
rows={mockDataGridRows} rows={dataGridRows}
totalRows={1247} totalRows={dgTotal}
pagination={{ currentPage: 1, pageSize: 25, totalRows: 1247 }} isLoading={isDataGridLoading}
pagination={{ currentPage: dataGridPage, pageSize: dataGridPageSize, totalRows: dgTotal }}
selectable selectable
editable editable={false}
tableName="users" tableName={selectedTable.name}
schemaName="public" schemaName={selectedTable.schema}
onRefresh={() => console.log('Refreshing data...')} onRefresh={() => loadTableData(activeConnectionId, selectedTable.name, selectedTable.schema, dataGridPage, dataGridPageSize)}
onExport={() => console.log('Exporting data...')} onExport={() => { }}
onAddRow={() => console.log('Adding row...')} 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 (
<div className="datagrid-empty-state">
<div className="datagrid-empty-icon">📋</div>
<p>No table selected</p>
<span>Open a table from the Tables view or sidebar.</span>
</div>
);
}
if (isStructureLoading) {
return (
<div className="datagrid-empty-state">
<div className="datagrid-empty-icon"></div>
<p>Loading structure</p>
</div>
);
}
if (structureError) {
return (
<div className="datagrid-empty-state">
<div className="datagrid-empty-icon"></div>
<p>Failed to load structure</p>
<span>{structureError}</span>
</div>
);
}
return (
<TableStructure
tableName={structureTable.name}
schemaName={structureTable.schema}
connectionName={activeConnection?.name}
columns={structureColumns}
indexes={structureIndexes}
foreignKeys={structureForeignKeys}
onViewData={() => {
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' && (
<TableList
schemas={activeConnection?.databases}
connectionName={activeConnection?.name}
onViewData={(table, schema) => handleOpenTableData(table, schema)}
onViewStructure={(table, schema) => handleOpenTableStructure(table, schema)}
/>
)}
</div>
</div>
}
/>
{showNewConnDialog && (
<NewConnectionDialog
onConfirm={handleCreateConnection}
onCancel={() => setShowNewConnDialog(false)}
/> />
)} )}
{mainView === 'structure' && ( {editingConnection && (
<TableStructure <NewConnectionDialog
tableName="users" mode="edit"
schemaName="public" initialData={{
connectionName={activeConnection?.name} id: editingConnection.id,
columns={mockTableColumns} name: editingConnection.name,
indexes={mockIndexes} type: editingConnection.type === 'postgresql' ? 'postgres' : editingConnection.type as any,
foreignKeys={mockForeignKeys} host: editingConnection.host ?? '',
tableInfo={mockTableInfo} port: editingConnection.port ?? 0,
onViewData={() => setMainView('data')} username: '',
onEditTable={() => console.log('Edit table...')} database: '',
onRefresh={() => console.log('Refreshing structure...')} ssl_mode: 'disable',
timeout: 30,
}}
onConfirm={handleSaveEditedConnection}
onCancel={() => setEditingConnection(null)}
/> />
)} )}
</div> </>
</div>
}
statusBar={
<StatusBar
connectionInfo={activeConnection?.status === 'active'
? `✓ Connected to ${activeConnection.name}`
: 'Ready'
}
queryInfo={queryResults ? `${queryResults.rowCount} rows in ${queryResults.executionTime}s` : undefined}
statusType={queryResults?.error ? 'error' : queryResults ? 'success' : 'normal'}
encoding="UTF-8"
lineEnding="LF"
editorMode="ins"
/>
}
/>
); );
} }

View File

@@ -92,6 +92,11 @@
box-shadow: 0 0 4px var(--success); box-shadow: 0 0 4px var(--success);
} }
.toolbar-connection.disconnected {
opacity: 0.5;
cursor: default;
}
.connection-name { .connection-name {
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: 500; font-weight: 500;

View File

@@ -24,6 +24,8 @@ export interface ToolBarProps {
children?: React.ReactNode; children?: React.ReactNode;
/** Handler when button is clicked */ /** Handler when button is clicked */
onButtonClick?: (buttonId: string) => void; onButtonClick?: (buttonId: string) => void;
/** Active connection info for display */
activeConnection?: { name: string; type: string } | null;
} }
/** /**
@@ -63,6 +65,7 @@ export const ToolBar: React.FC<ToolBarProps> = ({
buttons = defaultButtons, buttons = defaultButtons,
children, children,
onButtonClick, onButtonClick,
activeConnection,
}) => { }) => {
const handleButtonClick = (button: ToolButton) => { const handleButtonClick = (button: ToolButton) => {
button.onClick?.(); button.onClick?.();
@@ -92,10 +95,13 @@ export const ToolBar: React.FC<ToolBarProps> = ({
<div className="toolbar-spacer" /> <div className="toolbar-spacer" />
{/* Connection indicator */} {/* Connection indicator */}
<div className="toolbar-connection"> <div className={`toolbar-connection ${activeConnection ? 'connected' : 'disconnected'}`}>
<span className="connection-status-dot connected"></span> <span className={`connection-status-dot ${activeConnection ? 'connected' : ''}`}></span>
<span className="connection-name">🗄 MySQL @ localhost</span> <span className="connection-name">
<span className="dropdown-arrow"></span> {activeConnection
? `${activeConnection.type === 'postgresql' ? '🐘' : activeConnection.type === 'mysql' ? '🗄️' : '📁'} ${activeConnection.name}`
: 'No connection'}
</span>
</div> </div>
</div> </div>
); );

View File

@@ -6,6 +6,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
position: relative;
background-color: var(--bg-primary); background-color: var(--bg-primary);
overflow: hidden; overflow: hidden;
} }
@@ -133,17 +134,20 @@
background-color: var(--bg-primary); background-color: var(--bg-primary);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer; cursor: default;
outline: none; outline: none;
user-select: none;
white-space: nowrap;
} }
.connection-select:hover { .connection-select.connected {
border-color: var(--text-secondary); color: var(--success);
border-color: var(--success);
} }
.connection-select:focus { .connection-select.disconnected {
border-color: var(--primary); color: var(--text-muted);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); font-style: italic;
} }
/* Editor Container */ /* Editor Container */
@@ -219,6 +223,15 @@
border-bottom: 1px solid var(--border); 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 { .results-title {
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: 600; font-weight: 600;
@@ -230,10 +243,23 @@
color: var(--success); color: var(--success);
} }
.results-message.success {
color: var(--success);
}
.results-message.error { .results-message.error {
color: var(--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 { .results-table-wrapper {
flex: 1; flex: 1;
overflow: auto; overflow: auto;

View File

@@ -5,7 +5,7 @@
* Based on layout-design.md section "SQL 编辑器模块" * Based on layout-design.md section "SQL 编辑器模块"
*/ */
import React, { useState, KeyboardEvent } from 'react'; import React, { useState, useEffect, KeyboardEvent } from 'react';
import './QueryEditor.css'; import './QueryEditor.css';
export interface QueryTab { export interface QueryTab {
@@ -47,6 +47,8 @@ export interface QueryEditorProps {
onSave?: (tabId: string) => void; onSave?: (tabId: string) => void;
/** Handler when format is requested */ /** Handler when format is requested */
onFormat?: () => void; onFormat?: () => void;
/** Active connection info for display */
activeConnection?: { name: string; type: string };
} }
/** /**
@@ -64,6 +66,7 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
onExecute, onExecute,
onSave, onSave,
onFormat, onFormat,
activeConnection,
}) => { }) => {
const [editorContent, setEditorContent] = useState(''); const [editorContent, setEditorContent] = useState('');
const [cursorPosition, setCursorPosition] = useState({ line: 1, column: 1 }); const [cursorPosition, setCursorPosition] = useState({ line: 1, column: 1 });
@@ -71,6 +74,11 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
// Get active tab // Get active tab
const activeTab = tabs.find((t) => t.id === activeTabId); const activeTab = tabs.find((t) => t.id === activeTabId);
// Sync local editorContent when active tab changes
useEffect(() => {
setEditorContent(activeTab?.content || '');
}, [activeTabId]);
// Handle keyboard shortcuts // Handle keyboard shortcuts
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
// Ctrl+Enter to execute // Ctrl+Enter to execute
@@ -125,8 +133,7 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
{tabs.map((tab) => ( {tabs.map((tab) => (
<div <div
key={tab.id} key={tab.id}
className={`query-tab ${tab.id === activeTabId ? 'active' : ''} ${ className={`query-tab ${tab.id === activeTabId ? 'active' : ''} ${tab.isDirty ? 'dirty' : ''
tab.isDirty ? 'dirty' : ''
}`} }`}
onClick={() => onTabClick?.(tab.id)} onClick={() => onTabClick?.(tab.id)}
> >
@@ -162,7 +169,7 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
<button <button
className="btn btn-primary btn-run" className="btn btn-primary btn-run"
onClick={() => onExecute?.(editorContent)} onClick={() => onExecute?.(editorContent)}
disabled={isLoading || !editorContent.trim()} disabled={isLoading || !editorContent.trim() || !activeConnection}
> >
Run Run
</button> </button>
@@ -191,10 +198,11 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
</div> </div>
<div className="toolbar-spacer" /> <div className="toolbar-spacer" />
<div className="toolbar-group"> <div className="toolbar-group">
<select className="connection-select" disabled={isLoading}> <span className={`connection-select ${activeConnection ? 'connected' : 'disconnected'}`}>
<option>🗄 MySQL @ localhost</option> {activeConnection
<option>🐘 PostgreSQL @ prod-db</option> ? `${activeConnection.type === 'postgresql' ? '🐘' : activeConnection.type === 'mysql' ? '🗄️' : '📁'} ${activeConnection.name}`
</select> : '⚠ No connection active'}
</span>
</div> </div>
</div> </div>
@@ -227,12 +235,19 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
{/* Results Panel */} {/* Results Panel */}
{results && ( {results && (
<div className="results-panel"> <div className={`results-panel ${results.error ? 'has-error' : ''}`}>
<div className="results-header"> <div className={`results-header ${results.error ? 'error' : ''}`}>
<h4 className="results-title">Results</h4> <h4 className="results-title">
{results.message && ( {results.error ? '✕ Error' : 'Results'}
<span className={`results-message ${results.error ? 'error' : 'success'}`}> </h4>
{results.error ? '✕' : '✓'} {results.message} {!results.error && results.message && (
<span className="results-message success">
{results.message}
</span>
)}
{results.error && (
<span className="results-message error results-error-summary" title={results.error}>
{results.error.length > 80 ? results.error.slice(0, 80) + '…' : results.error}
</span> </span>
)} )}
</div> </div>

View File

@@ -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);
}

View File

@@ -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<TableListProps> = ({
schemas = [],
connectionName,
onViewData,
onViewStructure,
}) => {
const [search, setSearch] = useState('');
const [collapsedSchemas, setCollapsedSchemas] = useState<Set<string>>(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 (
<div className="table-list">
<div className="table-list-empty">
<div className="table-list-empty-icon">🔌</div>
<p>No connection active</p>
<span>Connect to a database to browse its tables.</span>
</div>
</div>
);
}
return (
<div className="table-list">
{/* Header bar */}
<div className="table-list-header">
<div className="table-list-header-top">
<div className="table-list-title">
<span className="table-list-conn-name">{connectionName ?? 'Tables'}</span>
<span className="table-list-summary">
{schemas.length} {schemas.length === 1 ? 'schema' : 'schemas'} · {totalTables} {totalTables === 1 ? 'table' : 'tables'}
</span>
</div>
</div>
<div className="table-list-search-row">
<span className="table-list-search-icon">🔍</span>
<input
className="table-list-search"
type="text"
placeholder="Filter tables…"
value={search}
onChange={e => setSearch(e.target.value)}
spellCheck={false}
/>
{search && (
<button
className="table-list-search-clear"
onClick={() => setSearch('')}
title="Clear filter"
>×</button>
)}
</div>
</div>
{/* Table list body */}
<div className="table-list-body">
{filtered.length === 0 ? (
<div className="table-list-no-results">
No tables match <strong>"{search}"</strong>
</div>
) : (
<>
{filtered.map(schema => {
const isCollapsed = collapsedSchemas.has(schema.id);
const tables = schema.tables ?? [];
return (
<div key={schema.id} className="table-list-schema">
{/* Schema header */}
<div
className="table-list-schema-header"
onClick={() => toggleSchema(schema.id)}
role="button"
tabIndex={0}
onKeyDown={e => e.key === 'Enter' && toggleSchema(schema.id)}
>
<span className={`table-list-schema-toggle ${isCollapsed ? '' : 'expanded'}`}></span>
<span className="table-list-schema-icon">📊</span>
<span className="table-list-schema-name">{schema.name}</span>
<span className="table-list-schema-count">{tables.length}</span>
</div>
{/* Tables */}
{!isCollapsed && (
<div className="table-list-tables">
{/* Column headings */}
<div className="table-list-col-headers">
<span className="col-name">Table name</span>
<span className="col-actions">Actions</span>
</div>
{tables.map(table => (
<div key={table.id} className="table-list-row">
<span className="table-list-row-icon">📋</span>
<span className="table-list-row-name">{table.name}</span>
<div className="table-list-row-actions">
<button
className="tl-action-btn"
title="View data"
onClick={() => onViewData?.(table, schema)}
>
📊 Data
</button>
<button
className="tl-action-btn"
title="View structure"
onClick={() => onViewStructure?.(table, schema)}
>
📋 Structure
</button>
</div>
</div>
))}
</div>
)}
</div>
);
})}
{search && (
<div className="table-list-search-info">
Showing {filteredTotal} of {totalTables} tables
</div>
)}
</>
)}
</div>
</div>
);
};
export default TableList;

View File

@@ -233,3 +233,76 @@
background-color: var(--border); background-color: var(--border);
margin: var(--space-2) 0; 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;
}

View File

@@ -5,7 +5,7 @@
* Based on layout-design.md section "左侧连接面板设计" * 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 { StatusIndicator, StatusType } from '../common/StatusIndicator';
import './ConnectionPanel.css'; import './ConnectionPanel.css';
@@ -20,6 +20,7 @@ export interface DatabaseConnection {
port?: number; port?: number;
status: StatusType; status: StatusType;
databases?: Schema[]; databases?: Schema[];
errorMessage?: string;
} }
/** /**
@@ -84,6 +85,9 @@ export interface ConnectionPanelProps {
onTableDoubleClick?: (table: Table, schema: Schema, connection: DatabaseConnection) => void; onTableDoubleClick?: (table: Table, schema: Schema, connection: DatabaseConnection) => void;
/** Collapsed state */ /** Collapsed state */
collapsed?: boolean; collapsed?: boolean;
onEdit?: (connection: DatabaseConnection) => void;
onDelete?: (connection: DatabaseConnection) => void;
onDisconnect?: (connection: DatabaseConnection) => void;
} }
/** /**
@@ -98,6 +102,7 @@ interface TreeNodeProps {
children?: React.ReactNode; children?: React.ReactNode;
level: number; level: number;
isActive?: boolean; isActive?: boolean;
count?: number;
} }
const TreeNode: React.FC<TreeNodeProps> = ({ const TreeNode: React.FC<TreeNodeProps> = ({
@@ -109,6 +114,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
children, children,
level, level,
isActive = false, isActive = false,
count,
}) => { }) => {
const hasChildren = children !== undefined && children !== null; const hasChildren = children !== undefined && children !== null;
@@ -139,6 +145,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
{!hasChildren && <span className="tree-toggle-placeholder" />} {!hasChildren && <span className="tree-toggle-placeholder" />}
<span className="tree-icon">{icon}</span> <span className="tree-icon">{icon}</span>
<span className="tree-label">{label}</span> <span className="tree-label">{label}</span>
{count !== undefined && (
<span className="tree-count-badge">{count}</span>
)}
</div> </div>
{expanded && children && ( {expanded && children && (
<div className="tree-node-children" role="group"> <div className="tree-node-children" role="group">
@@ -163,6 +172,9 @@ interface ConnectionItemProps {
onClick: () => void; onClick: () => void;
onContextMenu: (event: React.MouseEvent) => void; onContextMenu: (event: React.MouseEvent) => void;
onTableDoubleClick: (table: Table, schema: Schema) => void; onTableDoubleClick: (table: Table, schema: Schema) => void;
onEdit: () => void;
onDelete: () => void;
onDisconnect: () => void;
} }
const ConnectionItem: React.FC<ConnectionItemProps> = ({ const ConnectionItem: React.FC<ConnectionItemProps> = ({
@@ -176,6 +188,9 @@ const ConnectionItem: React.FC<ConnectionItemProps> = ({
onClick, onClick,
onContextMenu, onContextMenu,
onTableDoubleClick, onTableDoubleClick,
onEdit,
onDelete,
onDisconnect,
}) => { }) => {
// Get database type icon // Get database type icon
const getDbTypeIcon = (): string => { const getDbTypeIcon = (): string => {
@@ -206,13 +221,53 @@ const ConnectionItem: React.FC<ConnectionItemProps> = ({
<StatusIndicator status={connection.status} /> <StatusIndicator status={connection.status} />
<span className="db-type-icon">{getDbTypeIcon()}</span> <span className="db-type-icon">{getDbTypeIcon()}</span>
<span className="connection-name">{connection.name}</span> <span className="connection-name">{connection.name}</span>
{connection.status === 'connecting' && (
<span className="connecting-label">connecting</span>
)}
<div className="connection-actions">
<button
className="connection-action-btn"
title="Edit"
onClick={(e) => { e.stopPropagation(); onEdit(); }}
></button>
{(connection.status === 'connected' || connection.status === 'active') && (
<button
className="connection-action-btn"
title="Disconnect"
onClick={(e) => { e.stopPropagation(); onDisconnect(); }}
></button>
)}
<button
className="connection-action-btn danger"
title="Delete"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
>🗑</button>
</div> </div>
</div>
{connection.status === 'error' && connection.errorMessage && (
<div className="connection-error-msg">{connection.errorMessage}</div>
)}
{/* 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 (
<div className="connection-stats">
{schemaCount} {schemaCount === 1 ? 'schema' : 'schemas'} · {totalTables} {totalTables === 1 ? 'table' : 'tables'}
</div>
);
})()
}
{/* Schema tree - only show if connected and has databases */} {/* Schema tree - only show if connected and has databases */}
{(connection.status === 'connected' || connection.status === 'active') && {(connection.status === 'connected' || connection.status === 'active') &&
connection.databases && connection.databases &&
connection.databases.map((schema) => { connection.databases.map((schema) => {
const isSchemaExpanded = expandedSchemas.has(schema.id); const isSchemaExpanded = expandedSchemas.has(schema.id);
const tableCount = schema.tables?.length ?? 0;
return ( return (
<TreeNode <TreeNode
@@ -222,6 +277,7 @@ const ConnectionItem: React.FC<ConnectionItemProps> = ({
level={1} level={1}
expanded={isSchemaExpanded} expanded={isSchemaExpanded}
onToggle={() => onToggleSchema(schema.id)} onToggle={() => onToggleSchema(schema.id)}
count={tableCount}
> >
{/* Tables */} {/* Tables */}
{schema.tables && schema.tables.length > 0 && ( {schema.tables && schema.tables.length > 0 && (
@@ -289,11 +345,29 @@ export const ConnectionPanel: React.FC<ConnectionPanelProps> = ({
onContextMenu, onContextMenu,
onTableDoubleClick, onTableDoubleClick,
collapsed = false, collapsed = false,
onEdit,
onDelete,
onDisconnect,
}) => { }) => {
// Track expanded schemas and tables // Track expanded schemas and tables
const [expandedSchemas, setExpandedSchemas] = useState<Set<string>>(new Set()); const [expandedSchemas, setExpandedSchemas] = useState<Set<string>>(new Set());
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set()); const [expandedTables, setExpandedTables] = useState<Set<string>>(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 // Toggle schema expansion
const handleToggleSchema = (schemaId: string) => { const handleToggleSchema = (schemaId: string) => {
setExpandedSchemas((prev) => { setExpandedSchemas((prev) => {
@@ -416,6 +490,9 @@ export const ConnectionPanel: React.FC<ConnectionPanelProps> = ({
onTableDoubleClick={(table, schema) => onTableDoubleClick={(table, schema) =>
onTableDoubleClick?.(table, schema, connection) onTableDoubleClick?.(table, schema, connection)
} }
onEdit={() => onEdit?.(connection)}
onDelete={() => onDelete?.(connection)}
onDisconnect={() => onDisconnect?.(connection)}
/> />
)) ))
)} )}

View File

@@ -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);
}

View File

@@ -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<void>;
onCancel: () => void;
initialData?: Partial<NewConnectionFormData> & { id?: string };
mode?: 'create' | 'edit';
}
const DEFAULT_PORTS: Record<string, number> = {
mysql: 3306,
postgres: 5432,
sqlite: 0,
};
const NewConnectionDialog: React.FC<NewConnectionDialogProps> = ({ onConfirm, onCancel, initialData, mode = 'create' }) => {
const isEditMode = mode === 'edit';
const [form, setForm] = useState<NewConnectionFormData>({
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 (
<div className="dialog-overlay" onClick={onCancel}>
<div className="dialog-box" onClick={(e) => e.stopPropagation()}>
<div className="dialog-header">
<h2 className="dialog-title">{isEditMode ? 'Edit Connection' : 'New Connection'}</h2>
<button className="btn-icon dialog-close" onClick={onCancel} aria-label="Close"></button>
</div>
<form className="dialog-form" onSubmit={handleSubmit}>
{/* Connection Type */}
<div className="form-group">
<label className="form-label">Database Type</label>
<div className="type-selector">
{(['postgres', 'mysql', 'sqlite'] as const).map((t) => (
<button
key={t}
type="button"
className={`type-btn ${form.type === t ? 'active' : ''}`}
onClick={() => handleTypeChange(t)}
disabled={isEditMode}
>
{t === 'postgres' ? '🐘 PostgreSQL' : t === 'mysql' ? '🗄️ MySQL' : '◪ SQLite'}
</button>
))}
</div>
</div>
{/* Name */}
<div className="form-group">
<label className="form-label" htmlFor="conn-name">Connection Name <span className="required">*</span></label>
<input
id="conn-name"
className="form-input"
type="text"
placeholder="e.g. My Postgres DB"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
required
autoFocus
/>
</div>
{!isSQLite && (
<>
{/* Host & Port */}
<div className="form-row">
<div className="form-group flex-3">
<label className="form-label" htmlFor="conn-host">Host <span className="required">*</span></label>
<input
id="conn-host"
className="form-input"
type="text"
placeholder="127.0.0.1"
value={form.host}
onChange={(e) => handleChange('host', e.target.value)}
required
/>
</div>
<div className="form-group flex-1">
<label className="form-label" htmlFor="conn-port">Port <span className="required">*</span></label>
<input
id="conn-port"
className="form-input"
type="number"
min={1}
max={65535}
value={form.port}
onChange={(e) => handleChange('port', parseInt(e.target.value, 10) || 0)}
required
/>
</div>
</div>
{/* Username & Password */}
<div className="form-row">
<div className="form-group flex-1">
<label className="form-label" htmlFor="conn-user">Username</label>
<input
id="conn-user"
className="form-input"
type="text"
placeholder="postgres"
value={form.username}
onChange={(e) => handleChange('username', e.target.value)}
/>
</div>
<div className="form-group flex-1">
<label className="form-label" htmlFor="conn-pass">Password</label>
<input
id="conn-pass"
className="form-input"
type="password"
placeholder={isEditMode ? 'Leave blank to keep current' : '••••••••'}
value={form.password}
onChange={(e) => handleChange('password', e.target.value)}
/>
</div>
</div>
</>
)}
{/* Database / File */}
<div className="form-group">
<label className="form-label" htmlFor="conn-db">
{isSQLite ? 'File Path' : 'Database'} <span className="required">*</span>
</label>
<input
id="conn-db"
className="form-input"
type="text"
placeholder={isSQLite ? '/path/to/db.sqlite' : 'postgres'}
value={form.database}
onChange={(e) => handleChange('database', e.target.value)}
required
/>
</div>
{error && <div className="form-error">{error}</div>}
<div className="dialog-actions">
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={loading}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? (isEditMode ? 'Saving…' : 'Connecting…') : (isEditMode ? 'Save Changes' : 'Create Connection')}
</button>
</div>
</form>
</div>
</div>
);
};
export default NewConnectionDialog;

View File

@@ -8,6 +8,9 @@
export { StatusIndicator } from './common/StatusIndicator'; export { StatusIndicator } from './common/StatusIndicator';
export type { StatusIndicatorProps, StatusType } 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 // Layout components
export { AppLayout } from './Layout/AppLayout'; export { AppLayout } from './Layout/AppLayout';
export type { AppLayoutProps } from './Layout/AppLayout'; export type { AppLayoutProps } from './Layout/AppLayout';

View File

@@ -21,7 +21,8 @@
"jsx": "react-jsx" "jsx": "react-jsx"
}, },
"include": [ "include": [
"src" "src",
"wailsjs"
], ],
"references": [ "references": [
{ {

45
frontend/wailsjs/go/app/App.d.ts vendored Executable file
View File

@@ -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<string>;
export function CreateSavedQuery(arg1:models.CreateSavedQueryRequest):Promise<models.SavedQuery|string>;
export function DeleteConnection(arg1:string):Promise<string>;
export function DeleteSavedQuery(arg1:number):Promise<string>;
export function DisconnectConnection(arg1:string):Promise<string>;
export function ExecuteQuery(arg1:string,arg2:string):Promise<models.QueryResult|string>;
export function GetConnections():Promise<Array<models.UserConnection>>;
export function GetQueryHistory(arg1:string,arg2:number,arg3:number):Promise<Array<models.QueryHistory>>;
export function GetSavedQueries(arg1:string):Promise<Array<models.SavedQuery>|string>;
export function GetTableData(arg1:string,arg2:string,arg3:number,arg4:number):Promise<models.QueryResult|string>;
export function GetTableStructure(arg1:string,arg2:string):Promise<models.TableStructure|string>;
export function GetTables(arg1:string):Promise<Array<models.Table>|string>;
export function Initialize(arg1:config.Config,arg2:services.ConnectionService,arg3:services.QueryService,arg4:handler.HTTPServer):Promise<void>;
export function OnStartup(arg1:context.Context):Promise<void>;
export function Shutdown():Promise<void>;
export function StartHTTPServer():Promise<string>;
export function TestConnection(arg1:string):Promise<boolean|string>;
export function UpdateConnection(arg1:models.UserConnection):Promise<string>;
export function UpdateSavedQuery(arg1:number,arg2:models.UpdateSavedQueryRequest):Promise<models.SavedQuery|string>;

79
frontend/wailsjs/go/app/App.js Executable file
View File

@@ -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);
}

View File

@@ -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<string>;

View File

@@ -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);
}

529
frontend/wailsjs/go/models.ts Executable file
View File

@@ -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);
}
}
}

View File

@@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"errors"
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
@@ -13,6 +14,17 @@ import (
"uzdb/internal/services" "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 // App is the main application structure for Wails bindings
type App struct { type App struct {
ctx context.Context ctx context.Context
@@ -124,6 +136,24 @@ func (a *App) DeleteConnection(id string) string {
return "" 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 // TestConnection tests a database connection
// Returns (success, error_message) // Returns (success, error_message)
func (a *App) TestConnection(id string) (bool, string) { 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 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 // ExecuteQuery executes a SQL query on a connection
// Returns query result or error message // Returns query result or error message
func (a *App) ExecuteQuery(connectionID, sql string) (*models.QueryResult, string) { func (a *App) ExecuteQuery(connectionID, sql string) (*models.QueryResult, string) {
if a.connectionSvc == nil { 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) 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("connection_id", connectionID),
zap.String("sql", sql), zap.String("sql", sql),
zap.Error(err)) zap.Error(err))
return nil, err.Error() return &models.QueryResult{Success: false, Error: rootErrMsg(err)}, ""
} }
return result, "" return result, ""
@@ -188,7 +223,7 @@ func (a *App) GetTableData(connectionID, tableName string, limit, offset int) (*
zap.String("connection_id", connectionID), zap.String("connection_id", connectionID),
zap.String("table", tableName), zap.String("table", tableName),
zap.Error(err)) zap.Error(err))
return nil, err.Error() return nil, rootErrMsg(err)
} }
return result, "" return result, ""

View File

@@ -3,6 +3,7 @@ package services
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
@@ -204,6 +205,17 @@ func (s *ConnectionService) DeleteConnection(ctx context.Context, id string) err
return nil 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 // TestConnection tests a database connection
func (s *ConnectionService) TestConnection(ctx context.Context, id string) (*models.ConnectionTestResult, error) { func (s *ConnectionService) TestConnection(ctx context.Context, id string) (*models.ConnectionTestResult, error) {
// Get connection config // Get connection config
@@ -343,21 +355,35 @@ func (s *ConnectionService) GetTableData(
return nil, err return nil, err
} }
var query string // Build a properly-quoted table reference that handles 'schema.table' notation
switch conn.Type { tableRef := buildTableRef(conn.Type, tableName)
case models.ConnectionTypeMySQL:
query = fmt.Sprintf("SELECT * FROM `%s` LIMIT %d OFFSET %d", tableName, limit, offset) query := fmt.Sprintf("SELECT * FROM %s LIMIT %d OFFSET %d", tableRef, 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
}
return s.ExecuteQuery(ctx, connectionID, query) 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 // GetTableStructure returns the structure of a table
func (s *ConnectionService) GetTableStructure(ctx context.Context, connectionID, tableName string) (*models.TableStructure, error) { func (s *ConnectionService) GetTableStructure(ctx context.Context, connectionID, tableName string) (*models.TableStructure, error) {
// Get connection config // Get connection config