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 {
|
.view-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
padding: var(--space-2) var(--space-4) 0;
|
padding: var(--space-3) var(--space-4) 0;
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-3) var(--space-5);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-base);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -83,6 +83,33 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DataGrid empty state (no table selected) */
|
||||||
|
.datagrid-empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: var(--space-3);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datagrid-empty-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datagrid-empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.datagrid-empty-state span {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
/* Selection color */
|
/* Selection color */
|
||||||
::selection {
|
::selection {
|
||||||
background-color: rgba(59, 130, 246, 0.2);
|
background-color: rgba(59, 130, 246, 0.2);
|
||||||
@@ -91,6 +118,7 @@
|
|||||||
|
|
||||||
/* Print styles */
|
/* Print styles */
|
||||||
@media print {
|
@media print {
|
||||||
|
|
||||||
.view-tabs,
|
.view-tabs,
|
||||||
.layout-menubar,
|
.layout-menubar,
|
||||||
.layout-toolbar,
|
.layout-toolbar,
|
||||||
@@ -102,4 +130,4 @@
|
|||||||
.view-content {
|
.view-content {
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,99 +4,411 @@
|
|||||||
* Main application component integrating all UI components.
|
* Main application component integrating all UI components.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
// Wails bindings
|
||||||
|
import {
|
||||||
|
GetConnections,
|
||||||
|
CreateConnection,
|
||||||
|
UpdateConnection,
|
||||||
|
DeleteConnection,
|
||||||
|
TestConnection,
|
||||||
|
GetTables,
|
||||||
|
GetTableData,
|
||||||
|
GetTableStructure,
|
||||||
|
ExecuteQuery as WailsExecuteQuery,
|
||||||
|
DisconnectConnection,
|
||||||
|
} from '../wailsjs/go/app/App';
|
||||||
|
import { models } from '../wailsjs/go/models';
|
||||||
|
|
||||||
// Import components
|
// Import components
|
||||||
import AppLayout from './components/Layout/AppLayout';
|
import AppLayout from './components/Layout/AppLayout';
|
||||||
import MenuBar from './components/MenuBar/MenuBar';
|
|
||||||
import ToolBar from './components/Layout/ToolBar';
|
import ToolBar from './components/Layout/ToolBar';
|
||||||
import StatusBar from './components/Layout/StatusBar';
|
import ConnectionPanel, { DatabaseConnection, Schema, Table } from './components/Sidebar/ConnectionPanel';
|
||||||
import ConnectionPanel, { DatabaseConnection } from './components/Sidebar/ConnectionPanel';
|
|
||||||
import QueryEditor, { QueryTab, QueryResult } from './components/MainArea/QueryEditor';
|
import QueryEditor, { QueryTab, QueryResult } from './components/MainArea/QueryEditor';
|
||||||
import DataGrid from './components/MainArea/DataGrid';
|
import DataGrid, { Column as GridColumn, DataRow as GridRow } from './components/MainArea/DataGrid';
|
||||||
import TableStructure from './components/MainArea/TableStructure';
|
import TableStructure from './components/MainArea/TableStructure';
|
||||||
|
import TableList from './components/MainArea/TableList';
|
||||||
|
import NewConnectionDialog, { NewConnectionFormData } from './components/common/NewConnectionDialog';
|
||||||
|
|
||||||
// Import mock data
|
// Keep mock data for QueryEditor initial tabs
|
||||||
import { mockConnections } from './mock/connections';
|
import { mockQueryTabs } from './mock/queryResults';
|
||||||
import {
|
import { TableColumn, Index, ForeignKey } from './components/MainArea/TableStructure';
|
||||||
mockQueryResults,
|
|
||||||
mockQueryTabs,
|
|
||||||
mockDataGridColumns,
|
|
||||||
mockDataGridRows,
|
|
||||||
mockTableColumns,
|
|
||||||
mockIndexes,
|
|
||||||
mockForeignKeys,
|
|
||||||
mockTableInfo,
|
|
||||||
} from './mock/queryResults';
|
|
||||||
|
|
||||||
type MainView = 'query' | 'data' | 'structure';
|
type MainView = 'query' | 'data' | 'structure' | 'tables';
|
||||||
|
|
||||||
|
/** Map backend UserConnection to frontend DatabaseConnection */
|
||||||
|
function toFrontendConn(c: models.UserConnection): DatabaseConnection {
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
type: c.type === 'postgres' ? 'postgresql' : (c.type as any),
|
||||||
|
host: c.host,
|
||||||
|
port: c.port,
|
||||||
|
status: 'disconnected',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Application state
|
const [connections, setConnections] = useState<DatabaseConnection[]>([]);
|
||||||
const [connections] = useState<DatabaseConnection[]>(mockConnections);
|
const [activeConnectionId, setActiveConnectionId] = useState<string>('');
|
||||||
const [activeConnectionId, setActiveConnectionId] = useState<string>('conn-1');
|
const [selectedConnectionId, setSelectedConnectionId] = useState<string>('');
|
||||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string>('conn-1');
|
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [showNewConnDialog, setShowNewConnDialog] = useState(false);
|
||||||
|
const [editingConnection, setEditingConnection] = useState<DatabaseConnection | null>(null);
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Query editor state
|
// Query editor state
|
||||||
const [queryTabs, setQueryTabs] = useState<QueryTab[]>(mockQueryTabs);
|
const [queryTabs, setQueryTabs] = useState<QueryTab[]>(mockQueryTabs);
|
||||||
const [activeTabId, setActiveTabId] = useState<string>('tab-1');
|
const [activeTabId, setActiveTabId] = useState<string>('tab-1');
|
||||||
const [queryResults, setQueryResults] = useState<QueryResult | null>(null);
|
const [queryResults, setQueryResults] = useState<QueryResult | null>(null);
|
||||||
const [isQueryLoading, setIsQueryLoading] = useState(false);
|
const [isQueryLoading, setIsQueryLoading] = useState(false);
|
||||||
|
|
||||||
// Main view state
|
|
||||||
const [mainView, setMainView] = useState<MainView>('query');
|
|
||||||
|
|
||||||
// Handler: Connection click
|
// DataGrid state
|
||||||
const handleConnectionClick = useCallback((connection: DatabaseConnection) => {
|
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);
|
||||||
|
|
||||||
|
// 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);
|
setSelectedConnectionId(connection.id);
|
||||||
if (connection.status === 'disconnected') {
|
|
||||||
console.log(`Connecting to ${connection.name}...`);
|
if (connection.status === 'disconnected' || connection.status === 'error') {
|
||||||
// In real app: call backend to connect
|
// Show connecting spinner
|
||||||
|
setConnections(prev =>
|
||||||
|
prev.map(c => c.id === connection.id
|
||||||
|
? { ...c, status: 'connecting' as const, errorMessage: undefined }
|
||||||
|
: c)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wails returns Promise<boolean|string>: boolean on success, string on failure
|
||||||
|
const testResult = await TestConnection(connection.id);
|
||||||
|
const success = testResult === true;
|
||||||
|
const message = typeof testResult === 'string' ? testResult : '';
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setActiveConnectionId(connection.id);
|
||||||
|
// Load tables and group by schema
|
||||||
|
// Wails returns Promise<Table[]|string>: array on success, string on failure
|
||||||
|
const tablesResult = await GetTables(connection.id);
|
||||||
|
const tables: models.Table[] = Array.isArray(tablesResult) ? tablesResult : [];
|
||||||
|
const schemaMap: Record<string, Table[]> = {};
|
||||||
|
tables.forEach((t: models.Table) => {
|
||||||
|
const schemaName = t.schema || 'public';
|
||||||
|
if (!schemaMap[schemaName]) schemaMap[schemaName] = [];
|
||||||
|
schemaMap[schemaName].push({ id: t.name, name: t.name });
|
||||||
|
});
|
||||||
|
const databases: Schema[] = Object.entries(schemaMap).map(([name, tbls]) => ({
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
tables: tbls,
|
||||||
|
}));
|
||||||
|
setConnections(prev =>
|
||||||
|
prev.map(c => c.id === connection.id
|
||||||
|
? { ...c, status: 'active' as const, databases }
|
||||||
|
: c)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setConnections(prev =>
|
||||||
|
prev.map(c => c.id === connection.id
|
||||||
|
? { ...c, status: 'error' as const, errorMessage: message || 'Connection failed' }
|
||||||
|
: c)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setConnections(prev =>
|
||||||
|
prev.map(c => c.id === connection.id
|
||||||
|
? { ...c, status: 'error' as const, errorMessage: String(err) }
|
||||||
|
: c)
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (connection.status === 'connected' || connection.status === 'active') {
|
} else if (connection.status === 'connected' || connection.status === 'active') {
|
||||||
setActiveConnectionId(connection.id);
|
setActiveConnectionId(connection.id);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handler: New connection
|
// Handler: open new connection dialog
|
||||||
const handleNewConnection = useCallback(() => {
|
const handleNewConnection = useCallback(() => {
|
||||||
console.log('Opening new connection dialog...');
|
setShowNewConnDialog(true);
|
||||||
// In real app: open connection dialog
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handler: Table double-click
|
// Handler: create connection from dialog form
|
||||||
const handleTableDoubleClick = useCallback((
|
const handleCreateConnection = useCallback(async (data: NewConnectionFormData) => {
|
||||||
table: any,
|
const req = new models.CreateConnectionRequest();
|
||||||
schema: any,
|
req.name = data.name;
|
||||||
connection: DatabaseConnection
|
req.type = data.type;
|
||||||
|
req.host = data.host;
|
||||||
|
req.port = data.port;
|
||||||
|
req.username = data.username;
|
||||||
|
req.password = data.password;
|
||||||
|
req.database = data.database;
|
||||||
|
req.ssl_mode = data.ssl_mode;
|
||||||
|
req.timeout = data.timeout || 30;
|
||||||
|
|
||||||
|
const errMsg = await CreateConnection(req);
|
||||||
|
if (errMsg) {
|
||||||
|
throw new Error(errMsg);
|
||||||
|
}
|
||||||
|
setShowNewConnDialog(false);
|
||||||
|
await loadConnections();
|
||||||
|
}, [loadConnections]);
|
||||||
|
|
||||||
|
// Handler: edit connection — open dialog pre-filled
|
||||||
|
const handleEditConnection = useCallback((connection: DatabaseConnection) => {
|
||||||
|
setEditingConnection(connection);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handler: save edited connection
|
||||||
|
const handleSaveEditedConnection = useCallback(async (data: NewConnectionFormData) => {
|
||||||
|
if (!editingConnection) return;
|
||||||
|
const conn = new models.UserConnection();
|
||||||
|
conn.id = editingConnection.id;
|
||||||
|
conn.name = data.name;
|
||||||
|
conn.type = data.type;
|
||||||
|
conn.host = data.host;
|
||||||
|
conn.port = data.port;
|
||||||
|
conn.username = data.username;
|
||||||
|
conn.password = data.password; // empty string = keep existing
|
||||||
|
conn.database = data.database;
|
||||||
|
conn.ssl_mode = data.ssl_mode;
|
||||||
|
conn.timeout = data.timeout || 30;
|
||||||
|
const errMsg = await UpdateConnection(conn);
|
||||||
|
if (errMsg) throw new Error(errMsg);
|
||||||
|
setEditingConnection(null);
|
||||||
|
await loadConnections();
|
||||||
|
}, [editingConnection, loadConnections]);
|
||||||
|
|
||||||
|
// Handler: delete connection (with inline confirm)
|
||||||
|
const handleDeleteConnection = useCallback(async (connection: DatabaseConnection) => {
|
||||||
|
if (deleteConfirmId !== connection.id) {
|
||||||
|
// First click: show confirm state
|
||||||
|
setDeleteConfirmId(connection.id);
|
||||||
|
// Auto-reset after 3s
|
||||||
|
setTimeout(() => setDeleteConfirmId(null), 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Second click: actually delete
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
const errMsg = await DeleteConnection(connection.id);
|
||||||
|
if (errMsg) { console.error('Delete failed:', errMsg); return; }
|
||||||
|
if (activeConnectionId === connection.id) setActiveConnectionId('');
|
||||||
|
if (selectedConnectionId === connection.id) setSelectedConnectionId('');
|
||||||
|
setConnections(prev => prev.filter(c => c.id !== connection.id));
|
||||||
|
}, [deleteConfirmId, activeConnectionId, selectedConnectionId]);
|
||||||
|
|
||||||
|
// Handler: disconnect (close DB connection, keep config)
|
||||||
|
const handleDisconnectConnection = useCallback(async (connection: DatabaseConnection) => {
|
||||||
|
await DisconnectConnection(connection.id);
|
||||||
|
setConnections(prev =>
|
||||||
|
prev.map(c => c.id === connection.id ? { ...c, status: 'disconnected' as const, databases: undefined } : c)
|
||||||
|
);
|
||||||
|
if (activeConnectionId === connection.id) {
|
||||||
|
setActiveConnectionId('');
|
||||||
|
setSelectedTable(null);
|
||||||
|
setDataGridColumns([]);
|
||||||
|
setDataGridRows([]);
|
||||||
|
}
|
||||||
|
}, [activeConnectionId]);
|
||||||
|
|
||||||
|
// Load data for a table (page-based)
|
||||||
|
const loadTableData = useCallback(async (
|
||||||
|
connectionId: string,
|
||||||
|
tableName: string,
|
||||||
|
schema: string,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
) => {
|
) => {
|
||||||
console.log(`Opening table ${table.name} from schema ${schema.name}`);
|
setIsDataGridLoading(true);
|
||||||
setMainView('data');
|
setDataGridError(null);
|
||||||
|
const fullName = schema && schema !== 'public' ? `${schema}.${tableName}` : tableName;
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
try {
|
||||||
|
const result = await GetTableData(connectionId, fullName, pageSize, offset);
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
setDataGridError(result);
|
||||||
|
setDataGridColumns([]);
|
||||||
|
setDataGridRows([]);
|
||||||
|
setDataGridHasMore(false);
|
||||||
|
} else if (result) {
|
||||||
|
const r = result as models.QueryResult;
|
||||||
|
const cols: GridColumn[] = (r.columns || []).map(name => ({
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
sortable: false,
|
||||||
|
editable: false,
|
||||||
|
}));
|
||||||
|
const rows: GridRow[] = (r.rows || []).map((row, i) => {
|
||||||
|
const obj: GridRow = { id: offset + i };
|
||||||
|
(r.columns || []).forEach((col, j) => { obj[col] = row[j]; });
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
setDataGridColumns(cols);
|
||||||
|
setDataGridRows(rows);
|
||||||
|
setDataGridHasMore(rows.length === pageSize);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setDataGridError(String(err));
|
||||||
|
} finally {
|
||||||
|
setIsDataGridLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handler: Query execution
|
// Open a table in the DataGrid view
|
||||||
const handleExecuteQuery = useCallback((query: string) => {
|
const handleOpenTableData = useCallback(async (table: Table, schema: Schema) => {
|
||||||
setIsQueryLoading(true);
|
setSelectedTable({ name: table.name, schema: schema.name });
|
||||||
console.log('Executing query:', query);
|
setDataGridPage(1);
|
||||||
|
setMainView('data');
|
||||||
// Simulate async query execution
|
await loadTableData(activeConnectionId, table.name, schema.name, 1, dataGridPageSize);
|
||||||
setTimeout(() => {
|
}, [activeConnectionId, dataGridPageSize, loadTableData]);
|
||||||
setQueryResults(mockQueryResults);
|
|
||||||
setIsQueryLoading(false);
|
// Handler: Table double-click in sidebar
|
||||||
}, 500);
|
const handleTableDoubleClick = useCallback(async (
|
||||||
|
table: Table,
|
||||||
|
schema: Schema,
|
||||||
|
_connection: DatabaseConnection
|
||||||
|
) => {
|
||||||
|
await handleOpenTableData(table, schema);
|
||||||
|
}, [handleOpenTableData]);
|
||||||
|
|
||||||
|
// Load structure for a table
|
||||||
|
const loadTableStructure = useCallback(async (connectionId: string, tableName: string, schema: string) => {
|
||||||
|
setIsStructureLoading(true);
|
||||||
|
setStructureError(null);
|
||||||
|
const fullName = schema && schema !== 'public' ? `${schema}.${tableName}` : tableName;
|
||||||
|
try {
|
||||||
|
const result = await GetTableStructure(connectionId, fullName);
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
setStructureError(result);
|
||||||
|
setStructureColumns([]);
|
||||||
|
setStructureIndexes([]);
|
||||||
|
setStructureForeignKeys([]);
|
||||||
|
} else if (result) {
|
||||||
|
const s = result as models.TableStructure;
|
||||||
|
setStructureColumns((s.columns || []).map(c => ({
|
||||||
|
name: c.name,
|
||||||
|
type: c.data_type,
|
||||||
|
nullable: c.nullable,
|
||||||
|
isPrimaryKey: c.is_primary,
|
||||||
|
isUnique: c.is_unique,
|
||||||
|
defaultValue: c.default || undefined,
|
||||||
|
extra: c.auto_increment ? 'auto_increment' : undefined,
|
||||||
|
})));
|
||||||
|
setStructureIndexes((s.indexes || []).map(i => ({
|
||||||
|
name: i.name,
|
||||||
|
type: i.is_primary ? 'PRIMARY' : i.is_unique ? 'UNIQUE' : 'INDEX',
|
||||||
|
columns: i.columns || [],
|
||||||
|
isUnique: i.is_unique,
|
||||||
|
method: i.type || 'btree',
|
||||||
|
})));
|
||||||
|
setStructureForeignKeys((s.foreign_keys || []).map(fk => ({
|
||||||
|
name: fk.name,
|
||||||
|
column: (fk.columns || [])[0] || '',
|
||||||
|
referencesTable: fk.referenced_table,
|
||||||
|
referencesColumn: (fk.referenced_columns || [])[0] || '',
|
||||||
|
onUpdate: fk.on_update || undefined,
|
||||||
|
onDelete: fk.on_delete || undefined,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setStructureError(String(err));
|
||||||
|
} finally {
|
||||||
|
setIsStructureLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Open a table in the Structure view
|
||||||
|
const handleOpenTableStructure = useCallback(async (table: Table, schema: Schema) => {
|
||||||
|
setStructureTable({ name: table.name, schema: schema.name });
|
||||||
|
setMainView('structure');
|
||||||
|
await loadTableStructure(activeConnectionId, table.name, schema.name);
|
||||||
|
}, [activeConnectionId, loadTableStructure]);
|
||||||
|
|
||||||
|
// Handler: Query execution via Wails
|
||||||
|
const handleExecuteQuery = useCallback(async (query: string) => {
|
||||||
|
if (!query.trim()) return;
|
||||||
|
if (!activeConnectionId) {
|
||||||
|
setQueryResults({ columns: [], rows: [], rowCount: 0, executionTime: 0, error: 'No active connection. Connect to a database first.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsQueryLoading(true);
|
||||||
|
setQueryResults(null);
|
||||||
|
try {
|
||||||
|
// Wails returns Promise<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);
|
||||||
|
}
|
||||||
|
}, [activeConnectionId]);
|
||||||
|
|
||||||
// Handler: Tab content change
|
// Handler: Tab content change
|
||||||
const handleContentChange = useCallback((tabId: string, content: string) => {
|
const handleContentChange = useCallback((tabId: string, content: string) => {
|
||||||
setQueryTabs(prev => prev.map(tab =>
|
setQueryTabs(prev => prev.map(tab =>
|
||||||
tab.id === tabId ? { ...tab, content, isDirty: true } : tab
|
tab.id === tabId ? { ...tab, content, isDirty: true } : tab
|
||||||
));
|
));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handler: Save query
|
// Handler: Save query
|
||||||
const handleSaveQuery = useCallback((tabId: string) => {
|
const handleSaveQuery = useCallback((tabId: string) => {
|
||||||
console.log('Saving query:', tabId);
|
|
||||||
setQueryTabs(prev => prev.map(tab =>
|
setQueryTabs(prev => prev.map(tab =>
|
||||||
tab.id === tabId ? { ...tab, isDirty: false } : tab
|
tab.id === tabId ? { ...tab, isDirty: false } : tab
|
||||||
));
|
));
|
||||||
@@ -122,185 +434,249 @@ function App() {
|
|||||||
setActiveTabId(newTab.id);
|
setActiveTabId(newTab.id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handler: Format SQL
|
// Handler: Format SQL (placeholder)
|
||||||
const handleFormatSQL = useCallback(() => {
|
const handleFormatSQL = useCallback(() => { }, []);
|
||||||
console.log('Formatting SQL...');
|
|
||||||
// In real app: format SQL using sql-formatter
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handler: Menu item click
|
|
||||||
const handleMenuItemClick = useCallback((menuId: string, itemId: string) => {
|
|
||||||
console.log(`Menu "${menuId}" -> Item "${itemId}"`);
|
|
||||||
|
|
||||||
switch (itemId) {
|
|
||||||
case 'new-connection':
|
|
||||||
handleNewConnection();
|
|
||||||
break;
|
|
||||||
case 'run-query':
|
|
||||||
if (activeTabId) {
|
|
||||||
const tab = queryTabs.find(t => t.id === activeTabId);
|
|
||||||
if (tab) handleExecuteQuery(tab.content);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'toggle-sidebar':
|
|
||||||
setSidebarCollapsed(prev => !prev);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.log('Unhandled menu item:', itemId);
|
|
||||||
}
|
|
||||||
}, [activeTabId, queryTabs, handleNewConnection, handleExecuteQuery]);
|
|
||||||
|
|
||||||
// Get active connection
|
|
||||||
const activeConnection = connections.find(c => c.id === activeConnectionId);
|
const activeConnection = connections.find(c => c.id === activeConnectionId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout
|
<>
|
||||||
sidebarCollapsed={sidebarCollapsed}
|
<AppLayout
|
||||||
onSidebarToggle={setSidebarCollapsed}
|
sidebarCollapsed={sidebarCollapsed}
|
||||||
menuBar={
|
onSidebarToggle={setSidebarCollapsed}
|
||||||
<MenuBar
|
toolbar={
|
||||||
title="uzdb"
|
<ToolBar
|
||||||
onMenuItemClick={handleMenuItemClick}
|
buttons={[
|
||||||
/>
|
{
|
||||||
}
|
id: 'run',
|
||||||
toolbar={
|
icon: '▶',
|
||||||
<ToolBar
|
label: 'Run',
|
||||||
buttons={[
|
tooltip: 'Execute query (Ctrl+Enter)',
|
||||||
{
|
onClick: () => {
|
||||||
id: 'run',
|
if (activeTabId) {
|
||||||
icon: '▶',
|
const tab = queryTabs.find(t => t.id === activeTabId);
|
||||||
label: 'Run',
|
if (tab) handleExecuteQuery(tab.content);
|
||||||
tooltip: 'Execute query (Ctrl+Enter)',
|
}
|
||||||
onClick: () => {
|
},
|
||||||
if (activeTabId) {
|
|
||||||
const tab = queryTabs.find(t => t.id === activeTabId);
|
|
||||||
if (tab) handleExecuteQuery(tab.content);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
id: 'save',
|
||||||
id: 'save',
|
icon: '💾',
|
||||||
icon: '💾',
|
label: 'Save',
|
||||||
label: 'Save',
|
tooltip: 'Save query (Ctrl+S)',
|
||||||
tooltip: 'Save query (Ctrl+S)',
|
onClick: () => activeTabId && handleSaveQuery(activeTabId),
|
||||||
onClick: () => activeTabId && handleSaveQuery(activeTabId),
|
disabled: !queryTabs.find(t => t.id === activeTabId)?.isDirty,
|
||||||
disabled: !queryTabs.find(t => t.id === activeTabId)?.isDirty,
|
},
|
||||||
},
|
{
|
||||||
{
|
id: 'export',
|
||||||
id: 'export',
|
icon: '📤',
|
||||||
icon: '📤',
|
label: 'Export',
|
||||||
label: 'Export',
|
tooltip: 'Export results',
|
||||||
tooltip: 'Export results',
|
disabled: !queryResults,
|
||||||
disabled: !queryResults,
|
},
|
||||||
},
|
{
|
||||||
{
|
id: 'find',
|
||||||
id: 'find',
|
icon: '🔍',
|
||||||
icon: '🔍',
|
label: 'Find',
|
||||||
label: 'Find',
|
tooltip: 'Find in query (Ctrl+F)',
|
||||||
tooltip: 'Find in query (Ctrl+F)',
|
},
|
||||||
},
|
]}
|
||||||
]}
|
activeConnection={activeConnection ? { name: activeConnection.name, type: activeConnection.type } : null}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
sidebar={
|
sidebar={
|
||||||
<ConnectionPanel
|
<ConnectionPanel
|
||||||
connections={connections}
|
connections={connections}
|
||||||
selectedConnectionId={selectedConnectionId}
|
selectedConnectionId={selectedConnectionId}
|
||||||
activeConnectionId={activeConnectionId}
|
activeConnectionId={activeConnectionId}
|
||||||
onConnectionClick={handleConnectionClick}
|
onConnectionClick={handleConnectionClick}
|
||||||
onNewConnection={handleNewConnection}
|
onNewConnection={handleNewConnection}
|
||||||
onTableDoubleClick={handleTableDoubleClick}
|
onTableDoubleClick={handleTableDoubleClick}
|
||||||
/>
|
onEdit={handleEditConnection}
|
||||||
}
|
onDelete={handleDeleteConnection}
|
||||||
mainContent={
|
onDisconnect={handleDisconnectConnection}
|
||||||
<div className="main-content">
|
/>
|
||||||
{/* View tabs */}
|
}
|
||||||
<div className="view-tabs">
|
mainContent={
|
||||||
<button
|
<div className="main-content">
|
||||||
className={`view-tab ${mainView === 'query' ? 'active' : ''}`}
|
{/* View tabs */}
|
||||||
onClick={() => setMainView('query')}
|
<div className="view-tabs">
|
||||||
>
|
<button
|
||||||
📑 SQL Editor
|
className={`view-tab ${mainView === 'tables' ? 'active' : ''}`}
|
||||||
</button>
|
onClick={() => setMainView('tables')}
|
||||||
<button
|
>
|
||||||
className={`view-tab ${mainView === 'data' ? 'active' : ''}`}
|
🗂 Tables
|
||||||
onClick={() => setMainView('data')}
|
</button>
|
||||||
>
|
<button
|
||||||
📊 Data Grid
|
className={`view-tab ${mainView === 'query' ? 'active' : ''}`}
|
||||||
</button>
|
onClick={() => setMainView('query')}
|
||||||
<button
|
>
|
||||||
className={`view-tab ${mainView === 'structure' ? 'active' : ''}`}
|
📑 SQL Editor
|
||||||
onClick={() => setMainView('structure')}
|
</button>
|
||||||
>
|
<button
|
||||||
📋 Table Structure
|
className={`view-tab ${mainView === 'data' ? 'active' : ''}`}
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Main view content */}
|
{showNewConnDialog && (
|
||||||
<div className="view-content">
|
<NewConnectionDialog
|
||||||
{mainView === 'query' && (
|
onConfirm={handleCreateConnection}
|
||||||
<QueryEditor
|
onCancel={() => setShowNewConnDialog(false)}
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
/>
|
|
||||||
|
{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);
|
box-shadow: 0 0 4px var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-connection.disconnected {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.connection-name {
|
.connection-name {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -121,4 +126,4 @@
|
|||||||
.connection-name {
|
.connection-name {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,8 @@ export interface ToolBarProps {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
/** Handler when button is clicked */
|
/** Handler when button is clicked */
|
||||||
onButtonClick?: (buttonId: string) => void;
|
onButtonClick?: (buttonId: string) => void;
|
||||||
|
/** Active connection info for display */
|
||||||
|
activeConnection?: { name: string; type: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +65,7 @@ export const ToolBar: React.FC<ToolBarProps> = ({
|
|||||||
buttons = defaultButtons,
|
buttons = defaultButtons,
|
||||||
children,
|
children,
|
||||||
onButtonClick,
|
onButtonClick,
|
||||||
|
activeConnection,
|
||||||
}) => {
|
}) => {
|
||||||
const handleButtonClick = (button: ToolButton) => {
|
const handleButtonClick = (button: ToolButton) => {
|
||||||
button.onClick?.();
|
button.onClick?.();
|
||||||
@@ -92,10 +95,13 @@ export const ToolBar: React.FC<ToolBarProps> = ({
|
|||||||
<div className="toolbar-spacer" />
|
<div className="toolbar-spacer" />
|
||||||
|
|
||||||
{/* Connection indicator */}
|
{/* Connection indicator */}
|
||||||
<div className="toolbar-connection">
|
<div className={`toolbar-connection ${activeConnection ? 'connected' : 'disconnected'}`}>
|
||||||
<span className="connection-status-dot connected"></span>
|
<span className={`connection-status-dot ${activeConnection ? 'connected' : ''}`}></span>
|
||||||
<span className="connection-name">🗄️ MySQL @ localhost</span>
|
<span className="connection-name">
|
||||||
<span className="dropdown-arrow">▼</span>
|
{activeConnection
|
||||||
|
? `${activeConnection.type === 'postgresql' ? '🐘' : activeConnection.type === 'mysql' ? '🗄️' : '📁'} ${activeConnection.name}`
|
||||||
|
: 'No connection'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -133,17 +134,20 @@
|
|||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: default;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-select:hover {
|
.connection-select.connected {
|
||||||
border-color: var(--text-secondary);
|
color: var(--success);
|
||||||
|
border-color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-select:focus {
|
.connection-select.disconnected {
|
||||||
border-color: var(--primary);
|
color: var(--text-muted);
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Editor Container */
|
/* Editor Container */
|
||||||
@@ -219,6 +223,15 @@
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.results-header.error {
|
||||||
|
background-color: #fef2f2;
|
||||||
|
border-bottom-color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-header.error .results-title {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
.results-title {
|
.results-title {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -230,10 +243,23 @@
|
|||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.results-message.success {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
.results-message.error {
|
.results-message.error {
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.results-error-summary {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: var(--space-3);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.results-table-wrapper {
|
.results-table-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -344,4 +370,4 @@
|
|||||||
.loading-overlay span {
|
.loading-overlay span {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* Based on layout-design.md section "SQL 编辑器模块"
|
* Based on layout-design.md section "SQL 编辑器模块"
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, KeyboardEvent } from 'react';
|
import React, { useState, useEffect, KeyboardEvent } from 'react';
|
||||||
import './QueryEditor.css';
|
import './QueryEditor.css';
|
||||||
|
|
||||||
export interface QueryTab {
|
export interface QueryTab {
|
||||||
@@ -47,6 +47,8 @@ export interface QueryEditorProps {
|
|||||||
onSave?: (tabId: string) => void;
|
onSave?: (tabId: string) => void;
|
||||||
/** Handler when format is requested */
|
/** Handler when format is requested */
|
||||||
onFormat?: () => void;
|
onFormat?: () => void;
|
||||||
|
/** Active connection info for display */
|
||||||
|
activeConnection?: { name: string; type: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,6 +66,7 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
|
|||||||
onExecute,
|
onExecute,
|
||||||
onSave,
|
onSave,
|
||||||
onFormat,
|
onFormat,
|
||||||
|
activeConnection,
|
||||||
}) => {
|
}) => {
|
||||||
const [editorContent, setEditorContent] = useState('');
|
const [editorContent, setEditorContent] = useState('');
|
||||||
const [cursorPosition, setCursorPosition] = useState({ line: 1, column: 1 });
|
const [cursorPosition, setCursorPosition] = useState({ line: 1, column: 1 });
|
||||||
@@ -71,6 +74,11 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
|
|||||||
// Get active tab
|
// Get active tab
|
||||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||||
|
|
||||||
|
// Sync local editorContent when active tab changes
|
||||||
|
useEffect(() => {
|
||||||
|
setEditorContent(activeTab?.content || '');
|
||||||
|
}, [activeTabId]);
|
||||||
|
|
||||||
// Handle keyboard shortcuts
|
// Handle keyboard shortcuts
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
// Ctrl+Enter to execute
|
// Ctrl+Enter to execute
|
||||||
@@ -107,11 +115,11 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
|
|||||||
const handleCursorChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleCursorChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const content = e.target.value;
|
const content = e.target.value;
|
||||||
const cursorPos = e.target.selectionStart;
|
const cursorPos = e.target.selectionStart;
|
||||||
|
|
||||||
const lines = content.substring(0, cursorPos).split('\n');
|
const lines = content.substring(0, cursorPos).split('\n');
|
||||||
const line = lines.length;
|
const line = lines.length;
|
||||||
const column = lines[lines.length - 1].length + 1;
|
const column = lines[lines.length - 1].length + 1;
|
||||||
|
|
||||||
setCursorPosition({ line, column });
|
setCursorPosition({ line, column });
|
||||||
setEditorContent(content);
|
setEditorContent(content);
|
||||||
onContentChange?.(activeTabId || 'default', content);
|
onContentChange?.(activeTabId || 'default', content);
|
||||||
@@ -125,9 +133,8 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
|
|||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<div
|
<div
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
className={`query-tab ${tab.id === activeTabId ? 'active' : ''} ${
|
className={`query-tab ${tab.id === activeTabId ? 'active' : ''} ${tab.isDirty ? 'dirty' : ''
|
||||||
tab.isDirty ? 'dirty' : ''
|
}`}
|
||||||
}`}
|
|
||||||
onClick={() => onTabClick?.(tab.id)}
|
onClick={() => onTabClick?.(tab.id)}
|
||||||
>
|
>
|
||||||
<span className="tab-icon">📑</span>
|
<span className="tab-icon">📑</span>
|
||||||
@@ -162,7 +169,7 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
|
|||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-run"
|
className="btn btn-primary btn-run"
|
||||||
onClick={() => onExecute?.(editorContent)}
|
onClick={() => onExecute?.(editorContent)}
|
||||||
disabled={isLoading || !editorContent.trim()}
|
disabled={isLoading || !editorContent.trim() || !activeConnection}
|
||||||
>
|
>
|
||||||
▶ Run
|
▶ Run
|
||||||
</button>
|
</button>
|
||||||
@@ -191,10 +198,11 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="toolbar-spacer" />
|
<div className="toolbar-spacer" />
|
||||||
<div className="toolbar-group">
|
<div className="toolbar-group">
|
||||||
<select className="connection-select" disabled={isLoading}>
|
<span className={`connection-select ${activeConnection ? 'connected' : 'disconnected'}`}>
|
||||||
<option>🗄️ MySQL @ localhost</option>
|
{activeConnection
|
||||||
<option>🐘 PostgreSQL @ prod-db</option>
|
? `${activeConnection.type === 'postgresql' ? '🐘' : activeConnection.type === 'mysql' ? '🗄️' : '📁'} ${activeConnection.name}`
|
||||||
</select>
|
: '⚠ No connection active'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -227,12 +235,19 @@ export const QueryEditor: React.FC<QueryEditorProps> = ({
|
|||||||
|
|
||||||
{/* Results Panel */}
|
{/* Results Panel */}
|
||||||
{results && (
|
{results && (
|
||||||
<div className="results-panel">
|
<div className={`results-panel ${results.error ? 'has-error' : ''}`}>
|
||||||
<div className="results-header">
|
<div className={`results-header ${results.error ? 'error' : ''}`}>
|
||||||
<h4 className="results-title">Results</h4>
|
<h4 className="results-title">
|
||||||
{results.message && (
|
{results.error ? '✕ Error' : 'Results'}
|
||||||
<span className={`results-message ${results.error ? 'error' : 'success'}`}>
|
</h4>
|
||||||
{results.error ? '✕' : '✓'} {results.message}
|
{!results.error && results.message && (
|
||||||
|
<span className="results-message success">
|
||||||
|
✓ {results.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{results.error && (
|
||||||
|
<span className="results-message error results-error-summary" title={results.error}>
|
||||||
|
{results.error.length > 80 ? results.error.slice(0, 80) + '…' : results.error}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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);
|
background-color: var(--border);
|
||||||
margin: var(--space-2) 0;
|
margin: var(--space-2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Connection action buttons (edit/disconnect/delete) */
|
||||||
|
.connection-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-item:hover .connection-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-action-btn {
|
||||||
|
padding: 3px 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-action-btn:hover {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-action-btn.danger:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connecting label */
|
||||||
|
.connecting-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection error message */
|
||||||
|
.connection-error-msg {
|
||||||
|
padding: 4px var(--space-4) 6px calc(var(--space-4) + 24px);
|
||||||
|
font-size: 11px;
|
||||||
|
color: #f87171;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection stats (schema/table count summary) */
|
||||||
|
.connection-stats {
|
||||||
|
padding: 2px var(--space-4) 6px calc(var(--space-4) + 24px);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tree count badge */
|
||||||
|
.tree-count-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 9px;
|
||||||
|
line-height: 1.6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* Based on layout-design.md section "左侧连接面板设计"
|
* Based on layout-design.md section "左侧连接面板设计"
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, KeyboardEvent } from 'react';
|
import React, { useState, useEffect, KeyboardEvent } from 'react';
|
||||||
import { StatusIndicator, StatusType } from '../common/StatusIndicator';
|
import { StatusIndicator, StatusType } from '../common/StatusIndicator';
|
||||||
import './ConnectionPanel.css';
|
import './ConnectionPanel.css';
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ export interface DatabaseConnection {
|
|||||||
port?: number;
|
port?: number;
|
||||||
status: StatusType;
|
status: StatusType;
|
||||||
databases?: Schema[];
|
databases?: Schema[];
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,6 +85,9 @@ export interface ConnectionPanelProps {
|
|||||||
onTableDoubleClick?: (table: Table, schema: Schema, connection: DatabaseConnection) => void;
|
onTableDoubleClick?: (table: Table, schema: Schema, connection: DatabaseConnection) => void;
|
||||||
/** Collapsed state */
|
/** Collapsed state */
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
|
onEdit?: (connection: DatabaseConnection) => void;
|
||||||
|
onDelete?: (connection: DatabaseConnection) => void;
|
||||||
|
onDisconnect?: (connection: DatabaseConnection) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,6 +102,7 @@ interface TreeNodeProps {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
level: number;
|
level: number;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||||
@@ -109,6 +114,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
|||||||
children,
|
children,
|
||||||
level,
|
level,
|
||||||
isActive = false,
|
isActive = false,
|
||||||
|
count,
|
||||||
}) => {
|
}) => {
|
||||||
const hasChildren = children !== undefined && children !== null;
|
const hasChildren = children !== undefined && children !== null;
|
||||||
|
|
||||||
@@ -139,6 +145,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
|||||||
{!hasChildren && <span className="tree-toggle-placeholder" />}
|
{!hasChildren && <span className="tree-toggle-placeholder" />}
|
||||||
<span className="tree-icon">{icon}</span>
|
<span className="tree-icon">{icon}</span>
|
||||||
<span className="tree-label">{label}</span>
|
<span className="tree-label">{label}</span>
|
||||||
|
{count !== undefined && (
|
||||||
|
<span className="tree-count-badge">{count}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{expanded && children && (
|
{expanded && children && (
|
||||||
<div className="tree-node-children" role="group">
|
<div className="tree-node-children" role="group">
|
||||||
@@ -163,6 +172,9 @@ interface ConnectionItemProps {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onContextMenu: (event: React.MouseEvent) => void;
|
onContextMenu: (event: React.MouseEvent) => void;
|
||||||
onTableDoubleClick: (table: Table, schema: Schema) => void;
|
onTableDoubleClick: (table: Table, schema: Schema) => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConnectionItem: React.FC<ConnectionItemProps> = ({
|
const ConnectionItem: React.FC<ConnectionItemProps> = ({
|
||||||
@@ -176,6 +188,9 @@ const ConnectionItem: React.FC<ConnectionItemProps> = ({
|
|||||||
onClick,
|
onClick,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
onTableDoubleClick,
|
onTableDoubleClick,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onDisconnect,
|
||||||
}) => {
|
}) => {
|
||||||
// Get database type icon
|
// Get database type icon
|
||||||
const getDbTypeIcon = (): string => {
|
const getDbTypeIcon = (): string => {
|
||||||
@@ -206,13 +221,53 @@ const ConnectionItem: React.FC<ConnectionItemProps> = ({
|
|||||||
<StatusIndicator status={connection.status} />
|
<StatusIndicator status={connection.status} />
|
||||||
<span className="db-type-icon">{getDbTypeIcon()}</span>
|
<span className="db-type-icon">{getDbTypeIcon()}</span>
|
||||||
<span className="connection-name">{connection.name}</span>
|
<span className="connection-name">{connection.name}</span>
|
||||||
|
{connection.status === 'connecting' && (
|
||||||
|
<span className="connecting-label">connecting…</span>
|
||||||
|
)}
|
||||||
|
<div className="connection-actions">
|
||||||
|
<button
|
||||||
|
className="connection-action-btn"
|
||||||
|
title="Edit"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
||||||
|
>✏️</button>
|
||||||
|
{(connection.status === 'connected' || connection.status === 'active') && (
|
||||||
|
<button
|
||||||
|
className="connection-action-btn"
|
||||||
|
title="Disconnect"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDisconnect(); }}
|
||||||
|
>⏹</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="connection-action-btn danger"
|
||||||
|
title="Delete"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||||
|
>🗑</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{connection.status === 'error' && connection.errorMessage && (
|
||||||
|
<div className="connection-error-msg">{connection.errorMessage}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary stats when connected */}
|
||||||
|
{(connection.status === 'connected' || connection.status === 'active') &&
|
||||||
|
connection.databases && (() => {
|
||||||
|
const totalTables = connection.databases.reduce((sum, s) => sum + (s.tables?.length ?? 0), 0);
|
||||||
|
const schemaCount = connection.databases.length;
|
||||||
|
return (
|
||||||
|
<div className="connection-stats">
|
||||||
|
{schemaCount} {schemaCount === 1 ? 'schema' : 'schemas'} · {totalTables} {totalTables === 1 ? 'table' : 'tables'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
{/* Schema tree - only show if connected and has databases */}
|
{/* Schema tree - only show if connected and has databases */}
|
||||||
{(connection.status === 'connected' || connection.status === 'active') &&
|
{(connection.status === 'connected' || connection.status === 'active') &&
|
||||||
connection.databases &&
|
connection.databases &&
|
||||||
connection.databases.map((schema) => {
|
connection.databases.map((schema) => {
|
||||||
const isSchemaExpanded = expandedSchemas.has(schema.id);
|
const isSchemaExpanded = expandedSchemas.has(schema.id);
|
||||||
|
const tableCount = schema.tables?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
@@ -222,6 +277,7 @@ const ConnectionItem: React.FC<ConnectionItemProps> = ({
|
|||||||
level={1}
|
level={1}
|
||||||
expanded={isSchemaExpanded}
|
expanded={isSchemaExpanded}
|
||||||
onToggle={() => onToggleSchema(schema.id)}
|
onToggle={() => onToggleSchema(schema.id)}
|
||||||
|
count={tableCount}
|
||||||
>
|
>
|
||||||
{/* Tables */}
|
{/* Tables */}
|
||||||
{schema.tables && schema.tables.length > 0 && (
|
{schema.tables && schema.tables.length > 0 && (
|
||||||
@@ -289,11 +345,29 @@ export const ConnectionPanel: React.FC<ConnectionPanelProps> = ({
|
|||||||
onContextMenu,
|
onContextMenu,
|
||||||
onTableDoubleClick,
|
onTableDoubleClick,
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onDisconnect,
|
||||||
}) => {
|
}) => {
|
||||||
// Track expanded schemas and tables
|
// Track expanded schemas and tables
|
||||||
const [expandedSchemas, setExpandedSchemas] = useState<Set<string>>(new Set());
|
const [expandedSchemas, setExpandedSchemas] = useState<Set<string>>(new Set());
|
||||||
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
|
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Auto-expand first schema when connection databases are newly populated
|
||||||
|
useEffect(() => {
|
||||||
|
connections.forEach(conn => {
|
||||||
|
if (conn.databases && conn.databases.length > 0) {
|
||||||
|
const firstSchemaId = conn.databases[0].id;
|
||||||
|
setExpandedSchemas(prev => {
|
||||||
|
if (prev.has(firstSchemaId)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(firstSchemaId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [connections]);
|
||||||
|
|
||||||
// Toggle schema expansion
|
// Toggle schema expansion
|
||||||
const handleToggleSchema = (schemaId: string) => {
|
const handleToggleSchema = (schemaId: string) => {
|
||||||
setExpandedSchemas((prev) => {
|
setExpandedSchemas((prev) => {
|
||||||
@@ -416,6 +490,9 @@ export const ConnectionPanel: React.FC<ConnectionPanelProps> = ({
|
|||||||
onTableDoubleClick={(table, schema) =>
|
onTableDoubleClick={(table, schema) =>
|
||||||
onTableDoubleClick?.(table, schema, connection)
|
onTableDoubleClick?.(table, schema, connection)
|
||||||
}
|
}
|
||||||
|
onEdit={() => onEdit?.(connection)}
|
||||||
|
onDelete={() => onDelete?.(connection)}
|
||||||
|
onDisconnect={() => onDisconnect?.(connection)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
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 { StatusIndicator } from './common/StatusIndicator';
|
||||||
export type { StatusIndicatorProps, StatusType } from './common/StatusIndicator';
|
export type { StatusIndicatorProps, StatusType } from './common/StatusIndicator';
|
||||||
|
|
||||||
|
export { default as NewConnectionDialog } from './common/NewConnectionDialog';
|
||||||
|
export type { NewConnectionFormData } from './common/NewConnectionDialog';
|
||||||
|
|
||||||
// Layout components
|
// Layout components
|
||||||
export { AppLayout } from './Layout/AppLayout';
|
export { AppLayout } from './Layout/AppLayout';
|
||||||
export type { AppLayoutProps } from './Layout/AppLayout';
|
export type { AppLayoutProps } from './Layout/AppLayout';
|
||||||
|
|||||||
@@ -21,11 +21,12 @@
|
|||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src",
|
||||||
|
"wailsjs"
|
||||||
],
|
],
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.node.json"
|
"path": "./tsconfig.node.json"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {defineConfig} from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -13,14 +14,25 @@ import (
|
|||||||
"uzdb/internal/services"
|
"uzdb/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// rootErrMsg unwraps the full error chain and returns the deepest error message.
|
||||||
|
func rootErrMsg(err error) string {
|
||||||
|
for {
|
||||||
|
unwrapped := errors.Unwrap(err)
|
||||||
|
if unwrapped == nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
err = unwrapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// App is the main application structure for Wails bindings
|
// App is the main application structure for Wails bindings
|
||||||
type App struct {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
config *config.Config
|
config *config.Config
|
||||||
connectionSvc *services.ConnectionService
|
connectionSvc *services.ConnectionService
|
||||||
querySvc *services.QueryService
|
querySvc *services.QueryService
|
||||||
httpServer *handler.HTTPServer
|
httpServer *handler.HTTPServer
|
||||||
shutdownFunc context.CancelFunc
|
shutdownFunc context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp creates a new App instance
|
// NewApp creates a new App instance
|
||||||
@@ -44,7 +56,7 @@ func (a *App) Initialize(
|
|||||||
// OnStartup is called when the app starts (public method for Wails)
|
// OnStartup is called when the app starts (public method for Wails)
|
||||||
func (a *App) OnStartup(ctx context.Context) {
|
func (a *App) OnStartup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
|
|
||||||
config.GetLogger().Info("Wails application started")
|
config.GetLogger().Info("Wails application started")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +136,24 @@ func (a *App) DeleteConnection(id string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisconnectConnection removes an active connection from the connection manager
|
||||||
|
// Returns error message or empty string on success
|
||||||
|
func (a *App) DisconnectConnection(id string) string {
|
||||||
|
if a.connectionSvc == nil {
|
||||||
|
return "Service not initialized"
|
||||||
|
}
|
||||||
|
|
||||||
|
err := a.connectionSvc.DisconnectConnection(a.ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
config.GetLogger().Error("failed to disconnect connection",
|
||||||
|
zap.String("id", id),
|
||||||
|
zap.Error(err))
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// TestConnection tests a database connection
|
// TestConnection tests a database connection
|
||||||
// Returns (success, error_message)
|
// Returns (success, error_message)
|
||||||
func (a *App) TestConnection(id string) (bool, string) {
|
func (a *App) TestConnection(id string) (bool, string) {
|
||||||
@@ -137,14 +167,19 @@ func (a *App) TestConnection(id string) (bool, string) {
|
|||||||
return false, err.Error()
|
return false, err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Success, result.Message
|
return result.Success, func() string {
|
||||||
|
if result.Success {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return result.Message
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteQuery executes a SQL query on a connection
|
// ExecuteQuery executes a SQL query on a connection
|
||||||
// Returns query result or error message
|
// Returns query result or error message
|
||||||
func (a *App) ExecuteQuery(connectionID, sql string) (*models.QueryResult, string) {
|
func (a *App) ExecuteQuery(connectionID, sql string) (*models.QueryResult, string) {
|
||||||
if a.connectionSvc == nil {
|
if a.connectionSvc == nil {
|
||||||
return nil, "Service not initialized"
|
return &models.QueryResult{Success: false, Error: "Service not initialized"}, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := a.connectionSvc.ExecuteQuery(a.ctx, connectionID, sql)
|
result, err := a.connectionSvc.ExecuteQuery(a.ctx, connectionID, sql)
|
||||||
@@ -153,7 +188,7 @@ func (a *App) ExecuteQuery(connectionID, sql string) (*models.QueryResult, strin
|
|||||||
zap.String("connection_id", connectionID),
|
zap.String("connection_id", connectionID),
|
||||||
zap.String("sql", sql),
|
zap.String("sql", sql),
|
||||||
zap.Error(err))
|
zap.Error(err))
|
||||||
return nil, err.Error()
|
return &models.QueryResult{Success: false, Error: rootErrMsg(err)}, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, ""
|
return result, ""
|
||||||
@@ -188,7 +223,7 @@ func (a *App) GetTableData(connectionID, tableName string, limit, offset int) (*
|
|||||||
zap.String("connection_id", connectionID),
|
zap.String("connection_id", connectionID),
|
||||||
zap.String("table", tableName),
|
zap.String("table", tableName),
|
||||||
zap.Error(err))
|
zap.Error(err))
|
||||||
return nil, err.Error()
|
return nil, rootErrMsg(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, ""
|
return result, ""
|
||||||
@@ -306,19 +341,19 @@ func (a *App) StartHTTPServer() string {
|
|||||||
// Shutdown gracefully shuts down the application
|
// Shutdown gracefully shuts down the application
|
||||||
func (a *App) Shutdown() {
|
func (a *App) Shutdown() {
|
||||||
config.GetLogger().Info("shutting down application")
|
config.GetLogger().Info("shutting down application")
|
||||||
|
|
||||||
if a.shutdownFunc != nil {
|
if a.shutdownFunc != nil {
|
||||||
a.shutdownFunc()
|
a.shutdownFunc()
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.httpServer != nil {
|
if a.httpServer != nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
a.httpServer.Shutdown(ctx)
|
a.httpServer.Shutdown(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close all database connections
|
// Close all database connections
|
||||||
database.CloseSQLite()
|
database.CloseSQLite()
|
||||||
|
|
||||||
config.Sync()
|
config.Sync()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -16,9 +17,9 @@ import (
|
|||||||
|
|
||||||
// ConnectionService manages database connections
|
// ConnectionService manages database connections
|
||||||
type ConnectionService struct {
|
type ConnectionService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
connManager *database.ConnectionManager
|
connManager *database.ConnectionManager
|
||||||
encryptSvc *EncryptionService
|
encryptSvc *EncryptionService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConnectionService creates a new connection service
|
// NewConnectionService creates a new connection service
|
||||||
@@ -37,7 +38,7 @@ func NewConnectionService(
|
|||||||
// GetAllConnections returns all user connections
|
// GetAllConnections returns all user connections
|
||||||
func (s *ConnectionService) GetAllConnections(ctx context.Context) ([]models.UserConnection, error) {
|
func (s *ConnectionService) GetAllConnections(ctx context.Context) ([]models.UserConnection, error) {
|
||||||
var connections []models.UserConnection
|
var connections []models.UserConnection
|
||||||
|
|
||||||
result := s.db.WithContext(ctx).Find(&connections)
|
result := s.db.WithContext(ctx).Find(&connections)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, fmt.Errorf("failed to get connections: %w", result.Error)
|
return nil, fmt.Errorf("failed to get connections: %w", result.Error)
|
||||||
@@ -57,7 +58,7 @@ func (s *ConnectionService) GetAllConnections(ctx context.Context) ([]models.Use
|
|||||||
// GetConnectionByID returns a connection by ID
|
// GetConnectionByID returns a connection by ID
|
||||||
func (s *ConnectionService) GetConnectionByID(ctx context.Context, id string) (*models.UserConnection, error) {
|
func (s *ConnectionService) GetConnectionByID(ctx context.Context, id string) (*models.UserConnection, error) {
|
||||||
var conn models.UserConnection
|
var conn models.UserConnection
|
||||||
|
|
||||||
result := s.db.WithContext(ctx).First(&conn, "id = ?", id)
|
result := s.db.WithContext(ctx).First(&conn, "id = ?", id)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
if result.Error == gorm.ErrRecordNotFound {
|
if result.Error == gorm.ErrRecordNotFound {
|
||||||
@@ -87,16 +88,16 @@ func (s *ConnectionService) CreateConnection(ctx context.Context, req *models.Cr
|
|||||||
}
|
}
|
||||||
|
|
||||||
conn := &models.UserConnection{
|
conn := &models.UserConnection{
|
||||||
ID: utils.GenerateID(),
|
ID: utils.GenerateID(),
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
Host: req.Host,
|
Host: req.Host,
|
||||||
Port: req.Port,
|
Port: req.Port,
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
Password: encryptedPassword,
|
Password: encryptedPassword,
|
||||||
Database: req.Database,
|
Database: req.Database,
|
||||||
SSLMode: req.SSLMode,
|
SSLMode: req.SSLMode,
|
||||||
Timeout: req.Timeout,
|
Timeout: req.Timeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
if conn.Timeout <= 0 {
|
if conn.Timeout <= 0 {
|
||||||
@@ -204,6 +205,17 @@ func (s *ConnectionService) DeleteConnection(ctx context.Context, id string) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisconnectConnection removes an active connection from the connection manager
|
||||||
|
func (s *ConnectionService) DisconnectConnection(ctx context.Context, id string) error {
|
||||||
|
if err := s.connManager.RemoveConnection(id); err != nil {
|
||||||
|
return fmt.Errorf("failed to disconnect connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.GetLogger().Info("connection disconnected", zap.String("id", id))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// TestConnection tests a database connection
|
// TestConnection tests a database connection
|
||||||
func (s *ConnectionService) TestConnection(ctx context.Context, id string) (*models.ConnectionTestResult, error) {
|
func (s *ConnectionService) TestConnection(ctx context.Context, id string) (*models.ConnectionTestResult, error) {
|
||||||
// Get connection config
|
// Get connection config
|
||||||
@@ -266,7 +278,7 @@ func (s *ConnectionService) ExecuteQuery(ctx context.Context, connectionID, sql
|
|||||||
|
|
||||||
// Execute query
|
// Execute query
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
var result *models.QueryResult
|
var result *models.QueryResult
|
||||||
if utils.IsReadOnlyQuery(sql) {
|
if utils.IsReadOnlyQuery(sql) {
|
||||||
result, err = dbConn.ExecuteQuery(sql)
|
result, err = dbConn.ExecuteQuery(sql)
|
||||||
@@ -283,7 +295,7 @@ func (s *ConnectionService) ExecuteQuery(ctx context.Context, connectionID, sql
|
|||||||
Duration: duration.Milliseconds(),
|
Duration: duration.Milliseconds(),
|
||||||
Success: err == nil,
|
Success: err == nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
if result != nil {
|
if result != nil {
|
||||||
history.RowsAffected = result.AffectedRows
|
history.RowsAffected = result.AffectedRows
|
||||||
}
|
}
|
||||||
@@ -343,21 +355,35 @@ func (s *ConnectionService) GetTableData(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var query string
|
// Build a properly-quoted table reference that handles 'schema.table' notation
|
||||||
switch conn.Type {
|
tableRef := buildTableRef(conn.Type, tableName)
|
||||||
case models.ConnectionTypeMySQL:
|
|
||||||
query = fmt.Sprintf("SELECT * FROM `%s` LIMIT %d OFFSET %d", tableName, limit, offset)
|
query := fmt.Sprintf("SELECT * FROM %s LIMIT %d OFFSET %d", tableRef, limit, offset)
|
||||||
case models.ConnectionTypePostgreSQL:
|
|
||||||
query = fmt.Sprintf(`SELECT * FROM "%s" LIMIT %d OFFSET %d`, tableName, limit, offset)
|
|
||||||
case models.ConnectionTypeSQLite:
|
|
||||||
query = fmt.Sprintf(`SELECT * FROM "%s" LIMIT %d OFFSET %d`, tableName, limit, offset)
|
|
||||||
default:
|
|
||||||
return nil, models.ErrValidationFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.ExecuteQuery(ctx, connectionID, query)
|
return s.ExecuteQuery(ctx, connectionID, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildTableRef returns a properly-quoted table reference.
|
||||||
|
// tableName may be plain 'table' or schema-qualified 'schema.table'.
|
||||||
|
func buildTableRef(dbType models.ConnectionType, tableName string) string {
|
||||||
|
if strings.Contains(tableName, ".") {
|
||||||
|
parts := strings.SplitN(tableName, ".", 2)
|
||||||
|
schema, table := parts[0], parts[1]
|
||||||
|
switch dbType {
|
||||||
|
case models.ConnectionTypeMySQL:
|
||||||
|
return fmt.Sprintf("`%s`.`%s`", schema, table)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf(`"%s"."%s"`, schema, table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch dbType {
|
||||||
|
case models.ConnectionTypeMySQL:
|
||||||
|
return fmt.Sprintf("`%s`", tableName)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf(`"%s"`, tableName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetTableStructure returns the structure of a table
|
// GetTableStructure returns the structure of a table
|
||||||
func (s *ConnectionService) GetTableStructure(ctx context.Context, connectionID, tableName string) (*models.TableStructure, error) {
|
func (s *ConnectionService) GetTableStructure(ctx context.Context, connectionID, tableName string) (*models.TableStructure, error) {
|
||||||
// Get connection config
|
// Get connection config
|
||||||
|
|||||||
Reference in New Issue
Block a user