Compare commits
4 Commits
v0.0.2
...
f64b57c481
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f64b57c481 | ||
|
|
bdc1cfa49a | ||
|
|
afff9ca730 | ||
|
|
347ecd0f1b |
137
.github/workflows/release.yml
vendored
Normal file
137
.github/workflows/release.yml
vendored
Normal 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@v6
|
||||
|
||||
- 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@v6
|
||||
|
||||
- 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-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- 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.0-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/*
|
||||
@@ -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,
|
||||
|
||||
@@ -4,40 +4,61 @@
|
||||
* 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<DatabaseConnection[]>(mockConnections);
|
||||
const [activeConnectionId, setActiveConnectionId] = useState<string>('conn-1');
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string>('conn-1');
|
||||
const [connections, setConnections] = useState<DatabaseConnection[]>([]);
|
||||
const [activeConnectionId, setActiveConnectionId] = useState<string>('');
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string>('');
|
||||
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
|
||||
const [queryTabs, setQueryTabs] = useState<QueryTab[]>(mockQueryTabs);
|
||||
@@ -45,47 +66,339 @@ function App() {
|
||||
const [queryResults, setQueryResults] = useState<QueryResult | null>(null);
|
||||
const [isQueryLoading, setIsQueryLoading] = useState(false);
|
||||
|
||||
// Main view state
|
||||
const [mainView, setMainView] = useState<MainView>('query');
|
||||
// DataGrid state
|
||||
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
|
||||
const handleConnectionClick = useCallback((connection: DatabaseConnection) => {
|
||||
// TableStructure state
|
||||
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);
|
||||
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|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') {
|
||||
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}`);
|
||||
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');
|
||||
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
|
||||
const handleExecuteQuery = useCallback((query: string) => {
|
||||
// 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);
|
||||
console.log('Executing query:', query);
|
||||
|
||||
// Simulate async query execution
|
||||
setTimeout(() => {
|
||||
setQueryResults(mockQueryResults);
|
||||
setQueryResults(null);
|
||||
try {
|
||||
// Wails returns Promise<QueryResult|string>: 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);
|
||||
}, 500);
|
||||
}, []);
|
||||
}
|
||||
}, [activeConnectionId]);
|
||||
|
||||
// Handler: Tab content change
|
||||
const handleContentChange = useCallback((tabId: string, content: string) => {
|
||||
@@ -96,7 +409,6 @@ function App() {
|
||||
|
||||
// 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 (
|
||||
<AppLayout
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onSidebarToggle={setSidebarCollapsed}
|
||||
menuBar={
|
||||
<MenuBar
|
||||
title="uzdb"
|
||||
onMenuItemClick={handleMenuItemClick}
|
||||
/>
|
||||
}
|
||||
toolbar={
|
||||
<ToolBar
|
||||
buttons={[
|
||||
{
|
||||
id: 'run',
|
||||
icon: '▶',
|
||||
label: 'Run',
|
||||
tooltip: 'Execute query (Ctrl+Enter)',
|
||||
onClick: () => {
|
||||
if (activeTabId) {
|
||||
const tab = queryTabs.find(t => t.id === activeTabId);
|
||||
if (tab) handleExecuteQuery(tab.content);
|
||||
}
|
||||
<>
|
||||
<AppLayout
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onSidebarToggle={setSidebarCollapsed}
|
||||
toolbar={
|
||||
<ToolBar
|
||||
buttons={[
|
||||
{
|
||||
id: 'run',
|
||||
icon: '▶',
|
||||
label: 'Run',
|
||||
tooltip: 'Execute query (Ctrl+Enter)',
|
||||
onClick: () => {
|
||||
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={
|
||||
<ConnectionPanel
|
||||
connections={connections}
|
||||
selectedConnectionId={selectedConnectionId}
|
||||
activeConnectionId={activeConnectionId}
|
||||
onConnectionClick={handleConnectionClick}
|
||||
onNewConnection={handleNewConnection}
|
||||
onTableDoubleClick={handleTableDoubleClick}
|
||||
/>
|
||||
}
|
||||
mainContent={
|
||||
<div className="main-content">
|
||||
{/* View tabs */}
|
||||
<div className="view-tabs">
|
||||
<button
|
||||
className={`view-tab ${mainView === 'query' ? 'active' : ''}`}
|
||||
onClick={() => setMainView('query')}
|
||||
>
|
||||
📑 SQL Editor
|
||||
</button>
|
||||
<button
|
||||
className={`view-tab ${mainView === 'data' ? 'active' : ''}`}
|
||||
onClick={() => setMainView('data')}
|
||||
>
|
||||
📊 Data Grid
|
||||
</button>
|
||||
<button
|
||||
className={`view-tab ${mainView === 'structure' ? 'active' : ''}`}
|
||||
onClick={() => setMainView('structure')}
|
||||
>
|
||||
📋 Table Structure
|
||||
</button>
|
||||
{
|
||||
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={
|
||||
<ConnectionPanel
|
||||
connections={connections}
|
||||
selectedConnectionId={selectedConnectionId}
|
||||
activeConnectionId={activeConnectionId}
|
||||
onConnectionClick={handleConnectionClick}
|
||||
onNewConnection={handleNewConnection}
|
||||
onTableDoubleClick={handleTableDoubleClick}
|
||||
onEdit={handleEditConnection}
|
||||
onDelete={handleDeleteConnection}
|
||||
onDisconnect={handleDisconnectConnection}
|
||||
/>
|
||||
}
|
||||
mainContent={
|
||||
<div className="main-content">
|
||||
{/* View tabs */}
|
||||
<div className="view-tabs">
|
||||
<button
|
||||
className={`view-tab ${mainView === 'tables' ? 'active' : ''}`}
|
||||
onClick={() => setMainView('tables')}
|
||||
>
|
||||
🗂 Tables
|
||||
</button>
|
||||
<button
|
||||
className={`view-tab ${mainView === 'query' ? 'active' : ''}`}
|
||||
onClick={() => setMainView('query')}
|
||||
>
|
||||
📑 SQL Editor
|
||||
</button>
|
||||
<button
|
||||
className={`view-tab ${mainView === 'data' ? 'active' : ''}`}
|
||||
onClick={() => setMainView('data')}
|
||||
>
|
||||
📊 Data Grid
|
||||
</button>
|
||||
<button
|
||||
className={`view-tab ${mainView === 'structure' ? 'active' : ''}`}
|
||||
onClick={() => setMainView('structure')}
|
||||
>
|
||||
📋 Table Structure
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main view content */}
|
||||
<div className="view-content">
|
||||
{mainView === 'query' && (
|
||||
<QueryEditor
|
||||
tabs={queryTabs}
|
||||
activeTabId={activeTabId}
|
||||
results={queryResults}
|
||||
isLoading={isQueryLoading}
|
||||
activeConnection={activeConnection ? { name: activeConnection.name, type: activeConnection.type } : undefined}
|
||||
onTabClick={setActiveTabId}
|
||||
onCloseTab={handleCloseTab}
|
||||
onNewTab={handleNewTab}
|
||||
onContentChange={handleContentChange}
|
||||
onExecute={handleExecuteQuery}
|
||||
onSave={handleSaveQuery}
|
||||
onFormat={handleFormatSQL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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
|
||||
columns={dataGridColumns}
|
||||
rows={dataGridRows}
|
||||
totalRows={dgTotal}
|
||||
isLoading={isDataGridLoading}
|
||||
pagination={{ currentPage: dataGridPage, pageSize: dataGridPageSize, totalRows: dgTotal }}
|
||||
selectable
|
||||
editable={false}
|
||||
tableName={selectedTable.name}
|
||||
schemaName={selectedTable.schema}
|
||||
onRefresh={() => 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 (
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Main view content */}
|
||||
<div className="view-content">
|
||||
{mainView === 'query' && (
|
||||
<QueryEditor
|
||||
tabs={queryTabs}
|
||||
activeTabId={activeTabId}
|
||||
results={queryResults}
|
||||
isLoading={isQueryLoading}
|
||||
onTabClick={setActiveTabId}
|
||||
onCloseTab={handleCloseTab}
|
||||
onNewTab={handleNewTab}
|
||||
onContentChange={handleContentChange}
|
||||
onExecute={handleExecuteQuery}
|
||||
onSave={handleSaveQuery}
|
||||
onFormat={handleFormatSQL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mainView === 'data' && (
|
||||
<DataGrid
|
||||
columns={mockDataGridColumns}
|
||||
rows={mockDataGridRows}
|
||||
totalRows={1247}
|
||||
pagination={{ currentPage: 1, pageSize: 25, totalRows: 1247 }}
|
||||
selectable
|
||||
editable
|
||||
tableName="users"
|
||||
schemaName="public"
|
||||
onRefresh={() => console.log('Refreshing data...')}
|
||||
onExport={() => console.log('Exporting data...')}
|
||||
onAddRow={() => console.log('Adding row...')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mainView === 'structure' && (
|
||||
<TableStructure
|
||||
tableName="users"
|
||||
schemaName="public"
|
||||
connectionName={activeConnection?.name}
|
||||
columns={mockTableColumns}
|
||||
indexes={mockIndexes}
|
||||
foreignKeys={mockForeignKeys}
|
||||
tableInfo={mockTableInfo}
|
||||
onViewData={() => setMainView('data')}
|
||||
onEditTable={() => console.log('Edit table...')}
|
||||
onRefresh={() => console.log('Refreshing structure...')}
|
||||
/>
|
||||
)}
|
||||
</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"
|
||||
{showNewConnDialog && (
|
||||
<NewConnectionDialog
|
||||
onConfirm={handleCreateConnection}
|
||||
onCancel={() => setShowNewConnDialog(false)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingConnection && (
|
||||
<NewConnectionDialog
|
||||
mode="edit"
|
||||
initialData={{
|
||||
id: editingConnection.id,
|
||||
name: editingConnection.name,
|
||||
type: editingConnection.type === 'postgresql' ? 'postgres' : editingConnection.type as any,
|
||||
host: editingConnection.host ?? '',
|
||||
port: editingConnection.port ?? 0,
|
||||
username: '',
|
||||
database: '',
|
||||
ssl_mode: 'disable',
|
||||
timeout: 30,
|
||||
}}
|
||||
onConfirm={handleSaveEditedConnection}
|
||||
onCancel={() => setEditingConnection(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ToolBarProps> = ({
|
||||
buttons = defaultButtons,
|
||||
children,
|
||||
onButtonClick,
|
||||
activeConnection,
|
||||
}) => {
|
||||
const handleButtonClick = (button: ToolButton) => {
|
||||
button.onClick?.();
|
||||
@@ -92,10 +95,13 @@ export const ToolBar: React.FC<ToolBarProps> = ({
|
||||
<div className="toolbar-spacer" />
|
||||
|
||||
{/* Connection indicator */}
|
||||
<div className="toolbar-connection">
|
||||
<span className="connection-status-dot connected"></span>
|
||||
<span className="connection-name">🗄️ MySQL @ localhost</span>
|
||||
<span className="dropdown-arrow">▼</span>
|
||||
<div className={`toolbar-connection ${activeConnection ? 'connected' : 'disconnected'}`}>
|
||||
<span className={`connection-status-dot ${activeConnection ? 'connected' : ''}`}></span>
|
||||
<span className="connection-name">
|
||||
{activeConnection
|
||||
? `${activeConnection.type === 'postgresql' ? '🐘' : activeConnection.type === 'mysql' ? '🗄️' : '📁'} ${activeConnection.name}`
|
||||
: 'No connection'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<QueryEditorProps> = ({
|
||||
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<QueryEditorProps> = ({
|
||||
// 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<HTMLTextAreaElement>) => {
|
||||
// Ctrl+Enter to execute
|
||||
@@ -125,9 +133,8 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`query-tab ${tab.id === activeTabId ? 'active' : ''} ${
|
||||
tab.isDirty ? 'dirty' : ''
|
||||
}`}
|
||||
className={`query-tab ${tab.id === activeTabId ? 'active' : ''} ${tab.isDirty ? 'dirty' : ''
|
||||
}`}
|
||||
onClick={() => onTabClick?.(tab.id)}
|
||||
>
|
||||
<span className="tab-icon">📑</span>
|
||||
@@ -162,7 +169,7 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
|
||||
<button
|
||||
className="btn btn-primary btn-run"
|
||||
onClick={() => onExecute?.(editorContent)}
|
||||
disabled={isLoading || !editorContent.trim()}
|
||||
disabled={isLoading || !editorContent.trim() || !activeConnection}
|
||||
>
|
||||
▶ Run
|
||||
</button>
|
||||
@@ -191,10 +198,11 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
|
||||
</div>
|
||||
<div className="toolbar-spacer" />
|
||||
<div className="toolbar-group">
|
||||
<select className="connection-select" disabled={isLoading}>
|
||||
<option>🗄️ MySQL @ localhost</option>
|
||||
<option>🐘 PostgreSQL @ prod-db</option>
|
||||
</select>
|
||||
<span className={`connection-select ${activeConnection ? 'connected' : 'disconnected'}`}>
|
||||
{activeConnection
|
||||
? `${activeConnection.type === 'postgresql' ? '🐘' : activeConnection.type === 'mysql' ? '🗄️' : '📁'} ${activeConnection.name}`
|
||||
: '⚠ No connection active'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -227,12 +235,19 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
|
||||
|
||||
{/* Results Panel */}
|
||||
{results && (
|
||||
<div className="results-panel">
|
||||
<div className="results-header">
|
||||
<h4 className="results-title">Results</h4>
|
||||
{results.message && (
|
||||
<span className={`results-message ${results.error ? 'error' : 'success'}`}>
|
||||
{results.error ? '✕' : '✓'} {results.message}
|
||||
<div className={`results-panel ${results.error ? 'has-error' : ''}`}>
|
||||
<div className={`results-header ${results.error ? 'error' : ''}`}>
|
||||
<h4 className="results-title">
|
||||
{results.error ? '✕ Error' : 'Results'}
|
||||
</h4>
|
||||
{!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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
307
frontend/src/components/MainArea/TableList.css
Normal file
307
frontend/src/components/MainArea/TableList.css
Normal 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);
|
||||
}
|
||||
176
frontend/src/components/MainArea/TableList.tsx
Normal file
176
frontend/src/components/MainArea/TableList.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<TreeNodeProps> = ({
|
||||
@@ -109,6 +114,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
children,
|
||||
level,
|
||||
isActive = false,
|
||||
count,
|
||||
}) => {
|
||||
const hasChildren = children !== undefined && children !== null;
|
||||
|
||||
@@ -139,6 +145,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
{!hasChildren && <span className="tree-toggle-placeholder" />}
|
||||
<span className="tree-icon">{icon}</span>
|
||||
<span className="tree-label">{label}</span>
|
||||
{count !== undefined && (
|
||||
<span className="tree-count-badge">{count}</span>
|
||||
)}
|
||||
</div>
|
||||
{expanded && children && (
|
||||
<div className="tree-node-children" role="group">
|
||||
@@ -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<ConnectionItemProps> = ({
|
||||
@@ -176,6 +188,9 @@ const ConnectionItem: React.FC<ConnectionItemProps> = ({
|
||||
onClick,
|
||||
onContextMenu,
|
||||
onTableDoubleClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDisconnect,
|
||||
}) => {
|
||||
// Get database type icon
|
||||
const getDbTypeIcon = (): string => {
|
||||
@@ -206,13 +221,53 @@ const ConnectionItem: React.FC<ConnectionItemProps> = ({
|
||||
<StatusIndicator status={connection.status} />
|
||||
<span className="db-type-icon">{getDbTypeIcon()}</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>
|
||||
|
||||
{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 */}
|
||||
{(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 (
|
||||
<TreeNode
|
||||
@@ -222,6 +277,7 @@ const ConnectionItem: React.FC<ConnectionItemProps> = ({
|
||||
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<ConnectionPanelProps> = ({
|
||||
onContextMenu,
|
||||
onTableDoubleClick,
|
||||
collapsed = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDisconnect,
|
||||
}) => {
|
||||
// Track expanded schemas and tables
|
||||
const [expandedSchemas, setExpandedSchemas] = 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
|
||||
const handleToggleSchema = (schemaId: string) => {
|
||||
setExpandedSchemas((prev) => {
|
||||
@@ -416,6 +490,9 @@ export const ConnectionPanel: React.FC<ConnectionPanelProps> = ({
|
||||
onTableDoubleClick={(table, schema) =>
|
||||
onTableDoubleClick?.(table, schema, connection)
|
||||
}
|
||||
onEdit={() => onEdit?.(connection)}
|
||||
onDelete={() => onDelete?.(connection)}
|
||||
onDisconnect={() => onDisconnect?.(connection)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
154
frontend/src/components/common/NewConnectionDialog.css
Normal file
154
frontend/src/components/common/NewConnectionDialog.css
Normal 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);
|
||||
}
|
||||
210
frontend/src/components/common/NewConnectionDialog.tsx
Normal file
210
frontend/src/components/common/NewConnectionDialog.tsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
"src",
|
||||
"wailsjs"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
|
||||
45
frontend/wailsjs/go/app/App.d.ts
vendored
Executable file
45
frontend/wailsjs/go/app/App.d.ts
vendored
Executable 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
79
frontend/wailsjs/go/app/App.js
Executable 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);
|
||||
}
|
||||
4
frontend/wailsjs/go/main/App.d.ts
vendored
4
frontend/wailsjs/go/main/App.d.ts
vendored
@@ -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>;
|
||||
@@ -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
529
frontend/wailsjs/go/models.ts
Executable 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);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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, ""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user