mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-06 20:03:05 +08:00
✨ feat(frontend): 升级 DataGrid 组件并引入高性能拖拽交互
- 实现基于原生 DOM 事件的零渲染列宽拖拽,彻底解决卡顿与误触排序问题 - 查询编辑器集成 DataGrid,支持 SQL 结果直接编辑与事务提交 - 侧边栏新增上下文感知的 "新建查询" 快捷入口 - 优化 TabManager 渲染逻辑与全局布局,消除不必要的滚动条
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,4 +13,5 @@ node_modules/
|
|||||||
|
|
||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.gemini-clipboard
|
.gemini-clipboard
|
||||||
|
GoNavi-Wails
|
||||||
647
app.go
647
app.go
@@ -1,647 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/csv"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
// App struct
|
|
||||||
type App struct {
|
|
||||||
ctx context.Context
|
|
||||||
dbCache map[string]Database // Cache for DB connections
|
|
||||||
mu sync.Mutex // Mutex for cache access
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewApp creates a new App application struct
|
|
||||||
func NewApp() *App {
|
|
||||||
return &App{
|
|
||||||
dbCache: make(map[string]Database),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startup is called when the app starts. The context is saved
|
|
||||||
// so we can call the runtime methods
|
|
||||||
func (a *App) startup(ctx context.Context) {
|
|
||||||
a.ctx = ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// shutdown is called when the app terminates
|
|
||||||
func (a *App) shutdown(ctx context.Context) {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
for _, db := range a.dbCache {
|
|
||||||
db.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConnectionConfig struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Host string `json:"host"`
|
|
||||||
Port int `json:"port"`
|
|
||||||
User string `json:"user"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
Database string `json:"database"`
|
|
||||||
UseSSH bool `json:"useSSH"`
|
|
||||||
SSH SSHConfig `json:"ssh"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type QueryResult struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Data interface{} `json:"data"`
|
|
||||||
Fields []string `json:"fields,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Generate a unique key for the connection config
|
|
||||||
func getCacheKey(config ConnectionConfig) string {
|
|
||||||
// Include DB type, host, port, user, db name (and SSH params if relevant)
|
|
||||||
return fmt.Sprintf("%s|%s|%s:%d|%s|%s|%v", config.Type, config.User, config.Host, config.Port, config.Database, config.SSH.Host, config.UseSSH)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Get or create a database connection
|
|
||||||
func (a *App) getDatabase(config ConnectionConfig) (Database, error) {
|
|
||||||
key := getCacheKey(config)
|
|
||||||
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
|
|
||||||
if db, ok := a.dbCache[key]; ok {
|
|
||||||
// Verify connection is still alive
|
|
||||||
if err := db.Ping(); err == nil {
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
// If ping fails, close and remove to reconnect
|
|
||||||
db.Close()
|
|
||||||
delete(a.dbCache, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new connection
|
|
||||||
db, err := NewDatabase(config.Type)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Connect(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
a.dbCache[key] = db
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic DB Methods
|
|
||||||
|
|
||||||
func (a *App) DBConnect(config ConnectionConfig) QueryResult {
|
|
||||||
// Force reconnection or just check/create
|
|
||||||
// We can remove old connection if exists to force reconnect
|
|
||||||
key := getCacheKey(config)
|
|
||||||
a.mu.Lock()
|
|
||||||
if oldDB, ok := a.dbCache[key]; ok {
|
|
||||||
oldDB.Close()
|
|
||||||
delete(a.dbCache, key)
|
|
||||||
}
|
|
||||||
_, err := a.getDatabase(config)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getDatabase already connects, so just return success
|
|
||||||
return QueryResult{Success: true, Message: "Connected successfully"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateDatabase creates a new database
|
|
||||||
func (a *App) CreateDatabase(config ConnectionConfig, dbName string) QueryResult {
|
|
||||||
runConfig := config
|
|
||||||
runConfig.Database = "" // Connect to server root
|
|
||||||
|
|
||||||
db, err := a.getDatabase(runConfig)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf("CREATE DATABASE `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", dbName)
|
|
||||||
if runConfig.Type == "postgres" {
|
|
||||||
query = fmt.Sprintf("CREATE DATABASE \"%s\"", dbName)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.Exec(query)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
return QueryResult{Success: true, Message: "Database created successfully"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backwards Compatibility Wrappers
|
|
||||||
|
|
||||||
func (a *App) MySQLConnect(config ConnectionConfig) QueryResult {
|
|
||||||
config.Type = "mysql"
|
|
||||||
return a.DBConnect(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) MySQLQuery(config ConnectionConfig, dbName string, query string) QueryResult {
|
|
||||||
config.Type = "mysql"
|
|
||||||
return a.DBQuery(config, dbName, query)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) MySQLGetDatabases(config ConnectionConfig) QueryResult {
|
|
||||||
config.Type = "mysql"
|
|
||||||
return a.DBGetDatabases(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) MySQLGetTables(config ConnectionConfig, dbName string) QueryResult {
|
|
||||||
config.Type = "mysql"
|
|
||||||
return a.DBGetTables(config, dbName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) MySQLShowCreateTable(config ConnectionConfig, dbName string, tableName string) QueryResult {
|
|
||||||
config.Type = "mysql"
|
|
||||||
return a.DBShowCreateTable(config, dbName, tableName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DBQuery executes a query
|
|
||||||
func (a *App) DBQuery(config ConnectionConfig, dbName string, query string) QueryResult {
|
|
||||||
runConfig := config
|
|
||||||
if dbName != "" {
|
|
||||||
runConfig.Database = dbName
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := a.getDatabase(runConfig)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
// Do NOT defer db.Close() here, as we cache it
|
|
||||||
|
|
||||||
// Check if it's a SELECT query
|
|
||||||
lowerQuery := strings.TrimSpace(strings.ToLower(query))
|
|
||||||
if strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain") {
|
|
||||||
data, columns, err := db.Query(query)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
return QueryResult{Success: true, Data: data, Fields: columns}
|
|
||||||
} else {
|
|
||||||
// Exec
|
|
||||||
affected, err := db.Exec(query)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
return QueryResult{Success: true, Data: map[string]int64{"affectedRows": affected}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DBGetDatabases returns a list of databases
|
|
||||||
func (a *App) DBGetDatabases(config ConnectionConfig) QueryResult {
|
|
||||||
db, err := a.getDatabase(config)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
dbs, err := db.GetDatabases()
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
var resData []map[string]string
|
|
||||||
for _, name := range dbs {
|
|
||||||
resData = append(resData, map[string]string{"Database": name})
|
|
||||||
}
|
|
||||||
|
|
||||||
return QueryResult{Success: true, Data: resData}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DBGetTables returns a list of tables
|
|
||||||
func (a *App) DBGetTables(config ConnectionConfig, dbName string) QueryResult {
|
|
||||||
runConfig := config
|
|
||||||
if dbName != "" {
|
|
||||||
runConfig.Database = dbName
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := a.getDatabase(runConfig)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
tables, err := db.GetTables(dbName)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
var resData []map[string]string
|
|
||||||
for _, name := range tables {
|
|
||||||
resData = append(resData, map[string]string{"Table": name})
|
|
||||||
}
|
|
||||||
|
|
||||||
return QueryResult{Success: true, Data: resData}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DBShowCreateTable returns the create statement
|
|
||||||
func (a *App) DBShowCreateTable(config ConnectionConfig, dbName string, tableName string) QueryResult {
|
|
||||||
runConfig := config
|
|
||||||
if dbName != "" {
|
|
||||||
runConfig.Database = dbName
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := a.getDatabase(runConfig)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlStr, err := db.GetCreateStatement(dbName, tableName)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
return QueryResult{Success: true, Data: sqlStr}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DBGetColumns returns column definitions
|
|
||||||
func (a *App) DBGetColumns(config ConnectionConfig, dbName string, tableName string) QueryResult {
|
|
||||||
runConfig := config
|
|
||||||
if dbName != "" {
|
|
||||||
runConfig.Database = dbName
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := a.getDatabase(runConfig)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
columns, err := db.GetColumns(dbName, tableName)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
return QueryResult{Success: true, Data: columns}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DBGetIndexes returns index definitions
|
|
||||||
func (a *App) DBGetIndexes(config ConnectionConfig, dbName string, tableName string) QueryResult {
|
|
||||||
runConfig := config
|
|
||||||
if dbName != "" {
|
|
||||||
runConfig.Database = dbName
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := a.getDatabase(runConfig)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
indexes, err := db.GetIndexes(dbName, tableName)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
return QueryResult{Success: true, Data: indexes}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DBGetForeignKeys returns foreign key definitions
|
|
||||||
func (a *App) DBGetForeignKeys(config ConnectionConfig, dbName string, tableName string) QueryResult {
|
|
||||||
runConfig := config
|
|
||||||
if dbName != "" {
|
|
||||||
runConfig.Database = dbName
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := a.getDatabase(runConfig)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
fks, err := db.GetForeignKeys(dbName, tableName)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
return QueryResult{Success: true, Data: fks}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DBGetTriggers returns trigger definitions
|
|
||||||
func (a *App) DBGetTriggers(config ConnectionConfig, dbName string, tableName string) QueryResult {
|
|
||||||
runConfig := config
|
|
||||||
if dbName != "" {
|
|
||||||
runConfig.Database = dbName
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := a.getDatabase(runConfig)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
triggers, err := db.GetTriggers(dbName, tableName)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
return QueryResult{Success: true, Data: triggers}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DBGetAllColumns returns all columns for all tables in a database (for autocomplete)
|
|
||||||
func (a *App) DBGetAllColumns(config ConnectionConfig, dbName string) QueryResult {
|
|
||||||
runConfig := config
|
|
||||||
if dbName != "" {
|
|
||||||
runConfig.Database = dbName
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := a.getDatabase(runConfig)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
cols, err := db.GetAllColumns(dbName)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
return QueryResult{Success: true, Data: cols}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenSQLFile opens a file dialog and returns the file content
|
|
||||||
func (a *App) OpenSQLFile() QueryResult {
|
|
||||||
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
|
||||||
Title: "Select SQL File",
|
|
||||||
Filters: []runtime.FileFilter{
|
|
||||||
{
|
|
||||||
DisplayName: "SQL Files (*.sql)",
|
|
||||||
Pattern: "*.sql",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
DisplayName: "All Files (*.*)",
|
|
||||||
Pattern: "*.*",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
if selection == "" {
|
|
||||||
return QueryResult{Success: false, Message: "Cancelled"}
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := os.ReadFile(selection)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
return QueryResult{Success: true, Data: string(content)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportData imports data from CSV/JSON file into an existing table
|
|
||||||
func (a *App) ImportData(config ConnectionConfig, dbName, tableName string) QueryResult {
|
|
||||||
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
|
||||||
Title: fmt.Sprintf("Import into %s", tableName),
|
|
||||||
Filters: []runtime.FileFilter{
|
|
||||||
{
|
|
||||||
DisplayName: "Data Files",
|
|
||||||
Pattern: "*.csv;*.json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
if selection == "" {
|
|
||||||
return QueryResult{Success: false, Message: "Cancelled"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read File
|
|
||||||
f, err := os.Open(selection)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
// Parse based on extension
|
|
||||||
var rows []map[string]interface{}
|
|
||||||
|
|
||||||
if strings.HasSuffix(strings.ToLower(selection), ".json") {
|
|
||||||
decoder := json.NewDecoder(f)
|
|
||||||
if err := decoder.Decode(&rows); err != nil {
|
|
||||||
return QueryResult{Success: false, Message: "JSON Parse Error: " + err.Error()}
|
|
||||||
}
|
|
||||||
} else if strings.HasSuffix(strings.ToLower(selection), ".csv") {
|
|
||||||
reader := csv.NewReader(f)
|
|
||||||
records, err := reader.ReadAll()
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: "CSV Parse Error: " + err.Error()}
|
|
||||||
}
|
|
||||||
if len(records) < 2 {
|
|
||||||
return QueryResult{Success: false, Message: "CSV empty or missing header"}
|
|
||||||
}
|
|
||||||
headers := records[0]
|
|
||||||
for _, record := range records[1:] {
|
|
||||||
row := make(map[string]interface{})
|
|
||||||
for i, val := range record {
|
|
||||||
if i < len(headers) {
|
|
||||||
if val == "NULL" {
|
|
||||||
row[headers[i]] = nil
|
|
||||||
} else {
|
|
||||||
row[headers[i]] = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows = append(rows, row)
|
|
||||||
} } else {
|
|
||||||
return QueryResult{Success: false, Message: "Unsupported file format"}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return QueryResult{Success: true, Message: "No data to import"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to DB (Using cached connection)
|
|
||||||
runConfig := config
|
|
||||||
if dbName != "" {
|
|
||||||
runConfig.Database = dbName
|
|
||||||
}
|
|
||||||
db, err := a.getDatabase(runConfig)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
// No defer close
|
|
||||||
|
|
||||||
successCount := 0
|
|
||||||
errCount := 0
|
|
||||||
firstRow := rows[0]
|
|
||||||
var cols []string
|
|
||||||
for k := range firstRow {
|
|
||||||
cols = append(cols, k)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, row := range rows {
|
|
||||||
var values []string
|
|
||||||
for _, col := range cols {
|
|
||||||
val := row[col]
|
|
||||||
if val == nil {
|
|
||||||
values = append(values, "NULL")
|
|
||||||
} else {
|
|
||||||
vStr := fmt.Sprintf("%v", val)
|
|
||||||
vStr = strings.ReplaceAll(vStr, "'", "''")
|
|
||||||
values = append(values, fmt.Sprintf("'%s'", vStr))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s)",
|
|
||||||
tableName,
|
|
||||||
strings.Join(cols, ", "),
|
|
||||||
strings.Join(values, ", "))
|
|
||||||
|
|
||||||
if runConfig.Type == "postgres" {
|
|
||||||
pgCols := make([]string, len(cols))
|
|
||||||
for i, c := range cols { pgCols[i] = fmt.Sprintf(`"%s"`, c) }
|
|
||||||
query = fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s)`,
|
|
||||||
tableName,
|
|
||||||
strings.Join(pgCols, ", "),
|
|
||||||
strings.Join(values, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := db.Exec(query)
|
|
||||||
if err != nil {
|
|
||||||
errCount++
|
|
||||||
fmt.Println("Import Error:", err)
|
|
||||||
} else {
|
|
||||||
successCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return QueryResult{Success: true, Message: fmt.Sprintf("Imported: %d, Failed: %d", successCount, errCount)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyChanges executes a batch of Insert/Update/Delete operations
|
|
||||||
func (a *App) ApplyChanges(config ConnectionConfig, dbName, tableName string, changes ChangeSet) QueryResult {
|
|
||||||
runConfig := config
|
|
||||||
if dbName != "" {
|
|
||||||
runConfig.Database = dbName
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := a.getDatabase(runConfig)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
if applier, ok := db.(BatchApplier); ok {
|
|
||||||
err := applier.ApplyChanges(tableName, changes)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
return QueryResult{Success: true, Message: "Changes applied successfully"}
|
|
||||||
}
|
|
||||||
|
|
||||||
return QueryResult{Success: false, Message: "Batch updates not supported for this database type"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExportTable
|
|
||||||
func (a *App) ExportTable(config ConnectionConfig, dbName string, tableName string, format string) QueryResult {
|
|
||||||
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
|
||||||
Title: fmt.Sprintf("Export %s", tableName),
|
|
||||||
DefaultFilename: fmt.Sprintf("%s.%s", tableName, format),
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil || filename == "" {
|
|
||||||
return QueryResult{Success: false, Message: "Cancelled"}
|
|
||||||
}
|
|
||||||
|
|
||||||
runConfig := config
|
|
||||||
if dbName != "" {
|
|
||||||
runConfig.Database = dbName
|
|
||||||
}
|
|
||||||
|
|
||||||
dbObj, err := a.getDatabase(runConfig)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf("SELECT * FROM `%s`", tableName)
|
|
||||||
if runConfig.Type == "postgres" {
|
|
||||||
query = fmt.Sprintf("SELECT * FROM \"%s\"", tableName)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, columns, err := dbObj.Query(query)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.Create(filename)
|
|
||||||
if err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
format = strings.ToLower(format)
|
|
||||||
var csvWriter *csv.Writer
|
|
||||||
var jsonEncoder *json.Encoder
|
|
||||||
var isJsonFirstRow = true
|
|
||||||
|
|
||||||
switch format {
|
|
||||||
case "csv", "xlsx":
|
|
||||||
f.Write([]byte{0xEF, 0xBB, 0xBF})
|
|
||||||
csvWriter = csv.NewWriter(f)
|
|
||||||
defer csvWriter.Flush()
|
|
||||||
if err := csvWriter.Write(columns); err != nil {
|
|
||||||
return QueryResult{Success: false, Message: err.Error()}
|
|
||||||
}
|
|
||||||
case "json":
|
|
||||||
f.WriteString("[\n")
|
|
||||||
jsonEncoder = json.NewEncoder(f)
|
|
||||||
jsonEncoder.SetIndent(" ", " ")
|
|
||||||
case "md":
|
|
||||||
fmt.Fprintf(f, "| %s |\n", strings.Join(columns, " | "))
|
|
||||||
seps := make([]string, len(columns))
|
|
||||||
for i := range seps {
|
|
||||||
seps[i] = "---"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(f, "| %s |\n", strings.Join(seps, " | "))
|
|
||||||
default:
|
|
||||||
return QueryResult{Success: false, Message: "Unsupported format: " + format}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rowMap := range data {
|
|
||||||
record := make([]string, len(columns))
|
|
||||||
for i, col := range columns {
|
|
||||||
val := rowMap[col]
|
|
||||||
if val == nil {
|
|
||||||
record[i] = "NULL"
|
|
||||||
} else {
|
|
||||||
s := fmt.Sprintf("%v", val)
|
|
||||||
if format == "md" {
|
|
||||||
s = strings.ReplaceAll(s, "|", "\\|")
|
|
||||||
s = strings.ReplaceAll(s, "\n", "<br>")
|
|
||||||
}
|
|
||||||
record[i] = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch format {
|
|
||||||
case "csv", "xlsx":
|
|
||||||
if err := csvWriter.Write(record); err != nil {
|
|
||||||
return QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
|
||||||
}
|
|
||||||
case "json":
|
|
||||||
if !isJsonFirstRow {
|
|
||||||
f.WriteString(",\n")
|
|
||||||
}
|
|
||||||
if err := jsonEncoder.Encode(rowMap); err != nil {
|
|
||||||
return QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
|
||||||
}
|
|
||||||
isJsonFirstRow = false
|
|
||||||
case "md":
|
|
||||||
fmt.Fprintf(f, "| %s |\n", strings.Join(record, " | "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if format == "json" {
|
|
||||||
f.WriteString("\n]")
|
|
||||||
}
|
|
||||||
|
|
||||||
return QueryResult{Success: true, Message: "Export successful"}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
APP_NAME="GoNavi"
|
APP_NAME="GoNavi"
|
||||||
DIST_DIR="dist"
|
DIST_DIR="dist"
|
||||||
BUILD_BIN_DIR="build/bin"
|
BUILD_BIN_DIR="build/bin"
|
||||||
DEFAULT_BINARY_NAME="gonavi" # 对应 wails.json 中的 outputfilename
|
DEFAULT_BINARY_NAME="GoNavi" # 对应 wails.json 中的 outputfilename
|
||||||
|
|
||||||
# 提取版本号
|
# 提取版本号
|
||||||
VERSION=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[[:space:]]')
|
VERSION=$(grep '"version":' frontend/package.json | head -1 | awk -F: '{ print $2 }' | sed 's/[",]//g' | tr -d '[[:space:]]')
|
||||||
|
|||||||
12
changeset.go
12
changeset.go
@@ -1,12 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
type UpdateRow struct {
|
|
||||||
Keys map[string]interface{} `json:"keys"`
|
|
||||||
Values map[string]interface{} `json:"values"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChangeSet struct {
|
|
||||||
Inserts []map[string]interface{} `json:"inserts"`
|
|
||||||
Updates []UpdateRow `json:"updates"`
|
|
||||||
Deletes []map[string]interface{} `json:"deletes"`
|
|
||||||
}
|
|
||||||
80
database.go
80
database.go
@@ -1,80 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
type ColumnDefinition struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Nullable string `json:"nullable"` // YES/NO
|
|
||||||
Key string `json:"key"` // PRI, UNI, MUL
|
|
||||||
Default *string `json:"default"`
|
|
||||||
Extra string `json:"extra"` // auto_increment
|
|
||||||
Comment string `json:"comment"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type IndexDefinition struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
ColumnName string `json:"columnName"`
|
|
||||||
NonUnique int `json:"nonUnique"`
|
|
||||||
SeqInIndex int `json:"seqInIndex"`
|
|
||||||
IndexType string `json:"indexType"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ForeignKeyDefinition struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
ColumnName string `json:"columnName"`
|
|
||||||
RefTableName string `json:"refTableName"`
|
|
||||||
RefColumnName string `json:"refColumnName"`
|
|
||||||
ConstraintName string `json:"constraintName"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TriggerDefinition struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Timing string `json:"timing"` // BEFORE/AFTER
|
|
||||||
Event string `json:"event"` // INSERT/UPDATE/DELETE
|
|
||||||
Statement string `json:"statement"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ColumnDefinitionWithTable struct {
|
|
||||||
TableName string `json:"tableName"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Database interface {
|
|
||||||
Connect(config ConnectionConfig) error
|
|
||||||
Close() error
|
|
||||||
Ping() error
|
|
||||||
Query(query string) ([]map[string]interface{}, []string, error)
|
|
||||||
Exec(query string) (int64, error)
|
|
||||||
GetDatabases() ([]string, error)
|
|
||||||
GetTables(dbName string) ([]string, error)
|
|
||||||
GetCreateStatement(dbName, tableName string) (string, error)
|
|
||||||
GetColumns(dbName, tableName string) ([]ColumnDefinition, error)
|
|
||||||
GetAllColumns(dbName string) ([]ColumnDefinitionWithTable, error)
|
|
||||||
GetIndexes(dbName, tableName string) ([]IndexDefinition, error)
|
|
||||||
GetForeignKeys(dbName, tableName string) ([]ForeignKeyDefinition, error)
|
|
||||||
GetTriggers(dbName, tableName string) ([]TriggerDefinition, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type BatchApplier interface {
|
|
||||||
ApplyChanges(tableName string, changes ChangeSet) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Factory
|
|
||||||
func NewDatabase(dbType string) (Database, error) {
|
|
||||||
switch dbType {
|
|
||||||
case "mysql":
|
|
||||||
return &MySQLDB{}, nil
|
|
||||||
case "postgres":
|
|
||||||
return &PostgresDB{}, nil
|
|
||||||
case "sqlite":
|
|
||||||
return &SQLiteDB{}, nil
|
|
||||||
default:
|
|
||||||
// Default to MySQL for backward compatibility if empty
|
|
||||||
if dbType == "" {
|
|
||||||
return &MySQLDB{}, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("unsupported database type: %s", dbType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ html, body, #root {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow: hidden; /* Disable global scrollbar */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 侧边栏 Tree 样式优化 */
|
/* 侧边栏 Tree 样式优化 */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Layout, Button, ConfigProvider, theme } from 'antd';
|
import { Layout, Button, ConfigProvider, theme } from 'antd';
|
||||||
import { PlusOutlined, BulbOutlined, BulbFilled } from '@ant-design/icons';
|
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined } from '@ant-design/icons';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import TabManager from './components/TabManager';
|
import TabManager from './components/TabManager';
|
||||||
import ConnectionModal from './components/ConnectionModal';
|
import ConnectionModal from './components/ConnectionModal';
|
||||||
@@ -11,27 +11,63 @@ const { Sider, Content } = Layout;
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const { darkMode, toggleDarkMode } = useStore();
|
const { darkMode, toggleDarkMode, addTab, activeContext } = useStore();
|
||||||
|
|
||||||
// Sidebar Resizing
|
// Sidebar Resizing
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(300);
|
const [sidebarWidth, setSidebarWidth] = useState(300);
|
||||||
const sidebarDragRef = React.useRef<{ startX: number, startWidth: number } | null>(null);
|
const sidebarDragRef = React.useRef<{ startX: number, startWidth: number } | null>(null);
|
||||||
|
const rafRef = React.useRef<number | null>(null);
|
||||||
|
const ghostRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const latestMouseX = React.useRef<number>(0); // Store latest mouse position
|
||||||
|
|
||||||
const handleSidebarMouseDown = (e: React.MouseEvent) => {
|
const handleSidebarMouseDown = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (ghostRef.current) {
|
||||||
|
ghostRef.current.style.left = `${sidebarWidth}px`;
|
||||||
|
ghostRef.current.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
sidebarDragRef.current = { startX: e.clientX, startWidth: sidebarWidth };
|
sidebarDragRef.current = { startX: e.clientX, startWidth: sidebarWidth };
|
||||||
|
latestMouseX.current = e.clientX; // Init
|
||||||
document.addEventListener('mousemove', handleSidebarMouseMove);
|
document.addEventListener('mousemove', handleSidebarMouseMove);
|
||||||
document.addEventListener('mouseup', handleSidebarMouseUp);
|
document.addEventListener('mouseup', handleSidebarMouseUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSidebarMouseMove = (e: MouseEvent) => {
|
const handleSidebarMouseMove = (e: MouseEvent) => {
|
||||||
if (!sidebarDragRef.current) return;
|
if (!sidebarDragRef.current) return;
|
||||||
const delta = e.clientX - sidebarDragRef.current.startX;
|
|
||||||
const newWidth = Math.max(200, Math.min(600, sidebarDragRef.current.startWidth + delta));
|
latestMouseX.current = e.clientX; // Always update latest pos
|
||||||
setSidebarWidth(newWidth);
|
|
||||||
|
if (rafRef.current) return; // Schedule once per frame
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
|
if (!sidebarDragRef.current || !ghostRef.current) return;
|
||||||
|
// Use latestMouseX.current instead of stale closure 'e.clientX'
|
||||||
|
const delta = latestMouseX.current - sidebarDragRef.current.startX;
|
||||||
|
const newWidth = Math.max(200, Math.min(600, sidebarDragRef.current.startWidth + delta));
|
||||||
|
ghostRef.current.style.left = `${newWidth}px`;
|
||||||
|
rafRef.current = null;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSidebarMouseUp = () => {
|
const handleSidebarMouseUp = (e: MouseEvent) => {
|
||||||
|
if (rafRef.current) {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sidebarDragRef.current) {
|
||||||
|
// Use latest position for final commit too
|
||||||
|
const delta = e.clientX - sidebarDragRef.current.startX;
|
||||||
|
const newWidth = Math.max(200, Math.min(600, sidebarDragRef.current.startWidth + delta));
|
||||||
|
setSidebarWidth(newWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ghostRef.current) {
|
||||||
|
ghostRef.current.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
sidebarDragRef.current = null;
|
sidebarDragRef.current = null;
|
||||||
document.removeEventListener('mousemove', handleSidebarMouseMove);
|
document.removeEventListener('mousemove', handleSidebarMouseMove);
|
||||||
document.removeEventListener('mouseup', handleSidebarMouseUp);
|
document.removeEventListener('mouseup', handleSidebarMouseUp);
|
||||||
@@ -53,12 +89,26 @@ function App() {
|
|||||||
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Layout style={{ height: '100vh' }}>
|
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
<Sider theme={darkMode ? "dark" : "light"} width={sidebarWidth} style={{ borderRight: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', position: 'relative' }}>
|
<Sider
|
||||||
|
theme={darkMode ? "dark" : "light"}
|
||||||
|
width={sidebarWidth}
|
||||||
|
style={{
|
||||||
|
borderRight: darkMode ? '1px solid #303030' : '1px solid #f0f0f0',
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ padding: '10px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ padding: '10px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<span style={{ fontWeight: 'bold', paddingLeft: 8 }}>GoNavi</span>
|
<span style={{ fontWeight: 'bold', paddingLeft: 8 }}>GoNavi</span>
|
||||||
<div>
|
<div>
|
||||||
<Button type="text" icon={darkMode ? <BulbFilled /> : <BulbOutlined />} onClick={toggleDarkMode} title="切换主题" />
|
<Button type="text" icon={darkMode ? <BulbFilled /> : <BulbOutlined />} onClick={toggleDarkMode} title="切换主题" />
|
||||||
|
<Button type="text" icon={<ConsoleSqlOutlined />} onClick={() => addTab({
|
||||||
|
id: `query-${Date.now()}`,
|
||||||
|
title: '新建查询',
|
||||||
|
type: 'query',
|
||||||
|
connectionId: activeContext?.connectionId || '',
|
||||||
|
dbName: activeContext?.dbName || ''
|
||||||
|
})} title="新建查询" />
|
||||||
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" />
|
<Button type="text" icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} title="新建连接" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,10 +130,26 @@ function App() {
|
|||||||
title="拖动调整宽度"
|
title="拖动调整宽度"
|
||||||
/>
|
/>
|
||||||
</Sider>
|
</Sider>
|
||||||
<Content style={{ background: darkMode ? '#141414' : '#fff' }}>
|
<Content style={{ background: darkMode ? '#141414' : '#fff', overflow: 'hidden' }}>
|
||||||
<TabManager />
|
<TabManager />
|
||||||
</Content>
|
</Content>
|
||||||
<ConnectionModal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
<ConnectionModal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||||||
|
|
||||||
|
{/* Ghost Resize Line */}
|
||||||
|
<div
|
||||||
|
ref={ghostRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '4px',
|
||||||
|
background: 'rgba(24, 144, 255, 0.5)',
|
||||||
|
zIndex: 9999,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
display: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Collapse, Select } from 'antd';
|
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Collapse, Select } from 'antd';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { MySQLConnect } from '../../wailsjs/go/main/App';
|
import { MySQLConnect } from '../../wailsjs/go/app/App';
|
||||||
|
|
||||||
const ConnectionModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
const ConnectionModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|||||||
726
frontend/src/components/DataGrid.tsx
Normal file
726
frontend/src/components/DataGrid.tsx
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
|
||||||
|
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select } from 'antd';
|
||||||
|
import type { SortOrder } from 'antd/es/table/interface';
|
||||||
|
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined } from '@ant-design/icons';
|
||||||
|
import { Resizable } from 'react-resizable';
|
||||||
|
import { ImportData, ExportTable, ApplyChanges } from '../../wailsjs/go/app/App';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import 'react-resizable/css/styles.css';
|
||||||
|
|
||||||
|
// --- Helper: Format Value ---
|
||||||
|
const formatCellValue = (val: any) => {
|
||||||
|
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
|
||||||
|
if (typeof val === 'object') return JSON.stringify(val);
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(val)) {
|
||||||
|
return val.replace('T', ' ').replace(/\+.*$/, '').replace(/Z$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Resizable Header (Native Implementation) ---
|
||||||
|
const ResizableTitle = (props: any) => {
|
||||||
|
const { onResizeStart, width, ...restProps } = props;
|
||||||
|
|
||||||
|
if (!width) {
|
||||||
|
return <th {...restProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th {...restProps} style={{ ...restProps.style, position: 'relative' }}>
|
||||||
|
{restProps.children}
|
||||||
|
<span
|
||||||
|
className="react-resizable-handle"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Pass the header element reference implicitly via event target
|
||||||
|
onResizeStart(e);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0, // Align to right edge
|
||||||
|
bottom: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
zIndex: 10,
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Contexts ---
|
||||||
|
const EditableContext = React.createContext<any>(null);
|
||||||
|
const DataContext = React.createContext<{
|
||||||
|
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
|
||||||
|
displayDataRef: React.MutableRefObject<any[]>;
|
||||||
|
handleCopyInsert: (r: any) => void;
|
||||||
|
handleCopyJson: (r: any) => void;
|
||||||
|
handleCopyCsv: (r: any) => void;
|
||||||
|
copyToClipboard: (t: string) => void;
|
||||||
|
tableName?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
key: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditableCellProps {
|
||||||
|
title: React.ReactNode;
|
||||||
|
editable: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
dataIndex: string;
|
||||||
|
record: Item;
|
||||||
|
handleSave: (record: Item) => void;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||||
|
title,
|
||||||
|
editable,
|
||||||
|
children,
|
||||||
|
dataIndex,
|
||||||
|
record,
|
||||||
|
handleSave,
|
||||||
|
...restProps
|
||||||
|
}) => {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const inputRef = useRef<any>(null);
|
||||||
|
const form = useContext(EditableContext);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
|
const toggleEdit = () => {
|
||||||
|
setEditing(!editing);
|
||||||
|
form.setFieldsValue({ [dataIndex]: record[dataIndex] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
try {
|
||||||
|
if (!form) return;
|
||||||
|
const values = await form.validateFields();
|
||||||
|
toggleEdit();
|
||||||
|
handleSave({ ...record, ...values });
|
||||||
|
} catch (errInfo) {
|
||||||
|
console.log('Save failed:', errInfo);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let childNode = children;
|
||||||
|
|
||||||
|
if (editable) {
|
||||||
|
childNode = editing ? (
|
||||||
|
<Form.Item style={{ margin: 0 }} name={dataIndex}>
|
||||||
|
<Input ref={inputRef} onPressEnter={save} onBlur={save} />
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<div className="editable-cell-value-wrap" style={{ paddingRight: 24, minHeight: 20 }} onClick={toggleEdit}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <td {...restProps}>{childNode}</td>;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ContextMenuRow = React.memo(({ children, ...props }: any) => {
|
||||||
|
const record = props.record;
|
||||||
|
const context = useContext(DataContext);
|
||||||
|
|
||||||
|
if (!record || !context) return <tr {...props}>{children}</tr>;
|
||||||
|
|
||||||
|
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard } = context;
|
||||||
|
|
||||||
|
const getTargets = () => {
|
||||||
|
const keys = selectedRowKeysRef.current;
|
||||||
|
if (keys.includes(record.key)) {
|
||||||
|
return displayDataRef.current.filter(d => keys.includes(d.key));
|
||||||
|
}
|
||||||
|
return [record];
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuItems: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'insert',
|
||||||
|
label: `复制为 INSERT`,
|
||||||
|
icon: <ConsoleSqlOutlined />,
|
||||||
|
onClick: () => handleCopyInsert(record)
|
||||||
|
},
|
||||||
|
{ key: 'json', label: '复制为 JSON', icon: <FileTextOutlined />, onClick: () => handleCopyJson(record) },
|
||||||
|
{ key: 'csv', label: '复制为 CSV', icon: <FileTextOutlined />, onClick: () => handleCopyCsv(record) },
|
||||||
|
{ key: 'copy', label: '复制为 Markdown', icon: <CopyOutlined />, onClick: () => {
|
||||||
|
const records = getTargets();
|
||||||
|
const lines = records.map((r: any) => {
|
||||||
|
const { key, ...vals } = r;
|
||||||
|
return `| ${Object.values(vals).join(' | ')} |`;
|
||||||
|
});
|
||||||
|
copyToClipboard(lines.join('\n'));
|
||||||
|
} },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||||
|
<tr {...props}>{children}</tr>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface DataGridProps {
|
||||||
|
data: any[];
|
||||||
|
columnNames: string[];
|
||||||
|
loading: boolean;
|
||||||
|
tableName?: string;
|
||||||
|
dbName?: string;
|
||||||
|
connectionId?: string;
|
||||||
|
pkColumns?: string[];
|
||||||
|
readOnly?: boolean;
|
||||||
|
onReload?: () => void;
|
||||||
|
onSort?: (field: string, order: string) => void;
|
||||||
|
onPageChange?: (page: number, size: number) => void;
|
||||||
|
pagination?: { current: number, pageSize: number, total: number };
|
||||||
|
// Filtering
|
||||||
|
showFilter?: boolean;
|
||||||
|
onToggleFilter?: () => void;
|
||||||
|
onApplyFilter?: (conditions: any[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataGrid: React.FC<DataGridProps> = ({
|
||||||
|
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
|
||||||
|
onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter
|
||||||
|
}) => {
|
||||||
|
const connections = useStore(state => state.connections);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
|
||||||
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
// Dynamic Height
|
||||||
|
const [tableHeight, setTableHeight] = useState(500);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const resizeObserver = new ResizeObserver(entries => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
// Subtract header height (~40px)
|
||||||
|
// Ensure minimum height to prevent collapse loop
|
||||||
|
const h = Math.max(100, entry.contentRect.height - 42);
|
||||||
|
setTableHeight(h);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
|
const [addedRows, setAddedRows] = useState<any[]>([]);
|
||||||
|
const [modifiedRows, setModifiedRows] = useState<Record<string, any>>({});
|
||||||
|
const [deletedRowKeys, setDeletedRowKeys] = useState<Set<React.Key>>(new Set());
|
||||||
|
|
||||||
|
// Filter State
|
||||||
|
const [filterConditions, setFilterConditions] = useState<{ id: number, column: string, op: string, value: string }[]>([]);
|
||||||
|
const [nextFilterId, setNextFilterId] = useState(1);
|
||||||
|
|
||||||
|
const selectedRowKeysRef = useRef(selectedRowKeys);
|
||||||
|
const displayDataRef = useRef<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => { selectedRowKeysRef.current = selectedRowKeys; }, [selectedRowKeys]);
|
||||||
|
|
||||||
|
// Reset local state when data source likely changes (e.g. tableName change)
|
||||||
|
useEffect(() => {
|
||||||
|
setAddedRows([]);
|
||||||
|
setModifiedRows({});
|
||||||
|
setDeletedRowKeys(new Set());
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
}, [tableName, dbName, connectionId]); // Reset on context change
|
||||||
|
|
||||||
|
const displayData = useMemo(() => {
|
||||||
|
return [...data, ...addedRows].filter(item => !deletedRowKeys.has(item.key));
|
||||||
|
}, [data, addedRows, deletedRowKeys]);
|
||||||
|
|
||||||
|
useEffect(() => { displayDataRef.current = displayData; }, [displayData]);
|
||||||
|
|
||||||
|
const hasChanges = addedRows.length > 0 || Object.keys(modifiedRows).length > 0 || deletedRowKeys.size > 0;
|
||||||
|
|
||||||
|
const handleTableChange = (pag: any, filtersArg: any, sorter: any) => {
|
||||||
|
if (isResizingRef.current) return; // Block sort if resizing
|
||||||
|
if (sorter.field) {
|
||||||
|
const order = sorter.order as string;
|
||||||
|
setSortInfo({ columnKey: sorter.field as string, order });
|
||||||
|
if (onSort) onSort(sorter.field, order);
|
||||||
|
} else {
|
||||||
|
setSortInfo(null);
|
||||||
|
if (onSort) onSort('', '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Native Drag State
|
||||||
|
const draggingRef = useRef<{
|
||||||
|
startX: number,
|
||||||
|
startWidth: number,
|
||||||
|
key: string
|
||||||
|
} | null>(null);
|
||||||
|
const ghostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isResizingRef = useRef(false); // Lock for sorting
|
||||||
|
|
||||||
|
// 1. Drag Start
|
||||||
|
|
||||||
|
const handleResizeStart = useCallback((key: string) => (e: React.MouseEvent) => {
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
isResizingRef.current = true; // Engage lock
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const startX = e.clientX;
|
||||||
|
|
||||||
|
const currentWidth = columnWidths[key] || 200;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
draggingRef.current = { startX, startWidth: currentWidth, key };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Show Ghost Line at initial position
|
||||||
|
|
||||||
|
if (ghostRef.current && containerRef.current) {
|
||||||
|
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
const relativeLeft = startX - containerRect.left;
|
||||||
|
|
||||||
|
ghostRef.current.style.left = `${relativeLeft}px`;
|
||||||
|
|
||||||
|
ghostRef.current.style.display = 'block';
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Add global listeners
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleResizeMove);
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', handleResizeStop);
|
||||||
|
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
|
}, [columnWidths]);
|
||||||
|
|
||||||
|
// 2. Drag Move (Global)
|
||||||
|
const handleResizeMove = useCallback((e: MouseEvent) => {
|
||||||
|
if (!draggingRef.current || !ghostRef.current || !containerRef.current) return;
|
||||||
|
|
||||||
|
// Update Ghost Line Position directly
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
const relativeLeft = e.clientX - containerRect.left;
|
||||||
|
ghostRef.current.style.left = `${relativeLeft}px`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 3. Drag Stop (Global)
|
||||||
|
const handleResizeStop = useCallback((e: MouseEvent) => {
|
||||||
|
if (!draggingRef.current) return;
|
||||||
|
|
||||||
|
const { startX, startWidth, key } = draggingRef.current;
|
||||||
|
const deltaX = e.clientX - startX;
|
||||||
|
const newWidth = Math.max(50, startWidth + deltaX);
|
||||||
|
|
||||||
|
// Commit State
|
||||||
|
setColumnWidths(prev => ({ ...prev, [key]: newWidth }));
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
if (ghostRef.current) ghostRef.current.style.display = 'none';
|
||||||
|
document.removeEventListener('mousemove', handleResizeMove);
|
||||||
|
document.removeEventListener('mouseup', handleResizeStop);
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
draggingRef.current = null;
|
||||||
|
|
||||||
|
// Release lock after a short delay to block subsequent click events (sorting)
|
||||||
|
setTimeout(() => {
|
||||||
|
isResizingRef.current = false;
|
||||||
|
}, 100);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCellSave = useCallback((row: any) => {
|
||||||
|
// Optimistic update for display
|
||||||
|
// In parent-controlled data, we might need parent to update 'data',
|
||||||
|
// but here we manage 'modifiedRows' locally and overlay it.
|
||||||
|
// Since 'displayData' is derived from 'data' + 'modifiedRows', we need to update the source if it's in 'data'.
|
||||||
|
// But 'data' prop is immutable.
|
||||||
|
// So we update 'modifiedRows'.
|
||||||
|
|
||||||
|
// Check if it's an added row
|
||||||
|
const isAdded = addedRows.some(r => r.key === row.key);
|
||||||
|
if (isAdded) {
|
||||||
|
setAddedRows(prev => prev.map(r => r.key === row.key ? { ...r, ...row } : r));
|
||||||
|
} else {
|
||||||
|
setModifiedRows(prev => ({ ...prev, [row.key]: row }));
|
||||||
|
}
|
||||||
|
}, [addedRows]);
|
||||||
|
|
||||||
|
// Merge Data for Display
|
||||||
|
// 'displayData' already merges addedRows.
|
||||||
|
// We need to merge modifiedRows into it for rendering.
|
||||||
|
const mergedDisplayData = useMemo(() => {
|
||||||
|
return displayData.map(row => {
|
||||||
|
if (modifiedRows[row.key]) {
|
||||||
|
return { ...row, ...modifiedRows[row.key] };
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}, [displayData, modifiedRows]);
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return columnNames.map(key => ({
|
||||||
|
title: key,
|
||||||
|
dataIndex: key,
|
||||||
|
key: key,
|
||||||
|
ellipsis: true,
|
||||||
|
width: columnWidths[key] || 200,
|
||||||
|
sorter: !!onSort,
|
||||||
|
sortOrder: (sortInfo?.columnKey === key ? sortInfo.order : null) as SortOrder | undefined,
|
||||||
|
editable: !readOnly && !!tableName, // Only editable if table name known
|
||||||
|
render: (text: any) => formatCellValue(text),
|
||||||
|
onHeaderCell: (column: any) => ({
|
||||||
|
width: column.width,
|
||||||
|
onResizeStart: handleResizeStart(key), // Only need start
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}, [columnNames, columnWidths, sortInfo, handleResizeStart, readOnly, tableName, onSort]);
|
||||||
|
|
||||||
|
const mergedColumns = useMemo(() => columns.map(col => {
|
||||||
|
if (!col.editable) return col;
|
||||||
|
return {
|
||||||
|
...col,
|
||||||
|
onCell: (record: Item) => ({
|
||||||
|
record,
|
||||||
|
editable: col.editable,
|
||||||
|
dataIndex: col.dataIndex,
|
||||||
|
title: col.title,
|
||||||
|
handleSave: handleCellSave,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}), [columns, handleCellSave]);
|
||||||
|
|
||||||
|
const handleAddRow = () => {
|
||||||
|
const newKey = `new-${Date.now()}`;
|
||||||
|
const newRow: any = { key: newKey };
|
||||||
|
columnNames.forEach(col => newRow[col] = '');
|
||||||
|
setAddedRows(prev => [...prev, newRow]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSelected = () => {
|
||||||
|
setDeletedRowKeys(prev => {
|
||||||
|
const newDeleted = new Set(prev);
|
||||||
|
selectedRowKeys.forEach(key => newDeleted.add(key));
|
||||||
|
return newDeleted;
|
||||||
|
});
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommit = async () => {
|
||||||
|
if (!connectionId || !tableName) return;
|
||||||
|
const conn = connections.find(c => c.id === connectionId);
|
||||||
|
if (!conn) return;
|
||||||
|
|
||||||
|
const inserts: any[] = [];
|
||||||
|
const updates: any[] = [];
|
||||||
|
const deletes: any[] = [];
|
||||||
|
|
||||||
|
addedRows.forEach(row => { const { key, ...vals } = row; inserts.push(vals); });
|
||||||
|
deletedRowKeys.forEach(key => {
|
||||||
|
// Find original data
|
||||||
|
const originalRow = data.find(d => d.key === key) || addedRows.find(d => d.key === key);
|
||||||
|
if (originalRow) {
|
||||||
|
const pkData: any = {};
|
||||||
|
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
|
||||||
|
else { const { key: _, ...rest } = originalRow; Object.assign(pkData, rest); }
|
||||||
|
deletes.push(pkData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.entries(modifiedRows).forEach(([key, newRow]) => {
|
||||||
|
if (deletedRowKeys.has(key)) return;
|
||||||
|
const originalRow = data.find(d => d.key === key);
|
||||||
|
if (!originalRow) return; // Should not happen for modified rows unless deleted
|
||||||
|
|
||||||
|
const pkData: any = {};
|
||||||
|
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
|
||||||
|
else { const { key: _, ...rest } = originalRow; Object.assign(pkData, rest); }
|
||||||
|
|
||||||
|
const { key: _, ...vals } = newRow;
|
||||||
|
updates.push({ keys: pkData, values: vals });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
|
||||||
|
message.info("No changes to commit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
...conn.config,
|
||||||
|
port: Number(conn.config.port),
|
||||||
|
password: conn.config.password || "",
|
||||||
|
database: conn.config.database || "",
|
||||||
|
useSSH: conn.config.useSSH || false,
|
||||||
|
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await ApplyChanges(config as any, dbName || '', tableName, { inserts, updates, deletes } as any);
|
||||||
|
if (res.success) {
|
||||||
|
message.success("Changes committed successfully!");
|
||||||
|
setAddedRows([]);
|
||||||
|
setModifiedRows({});
|
||||||
|
setDeletedRowKeys(new Set());
|
||||||
|
if (onReload) onReload();
|
||||||
|
} else {
|
||||||
|
message.error("Commit failed: " + res.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = useCallback((text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
message.success("Copied to clipboard");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getTargets = useCallback((clickedRecord: any) => {
|
||||||
|
const selKeys = selectedRowKeysRef.current;
|
||||||
|
const currentData = displayDataRef.current;
|
||||||
|
if (selKeys.includes(clickedRecord.key)) {
|
||||||
|
return currentData.filter(d => selKeys.includes(d.key));
|
||||||
|
}
|
||||||
|
return [clickedRecord];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopyInsert = useCallback((record: any) => {
|
||||||
|
const records = getTargets(record);
|
||||||
|
const sqls = records.map((r: any) => {
|
||||||
|
const { key, ...vals } = r;
|
||||||
|
const cols = Object.keys(vals);
|
||||||
|
const values = Object.values(vals).map(v => v === null ? 'NULL' : `'${v}'`);
|
||||||
|
const targetTable = tableName || 'table';
|
||||||
|
return `INSERT INTO \`${targetTable}\` (${cols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`;
|
||||||
|
});
|
||||||
|
copyToClipboard(sqls.join('\n'));
|
||||||
|
}, [tableName, getTargets, copyToClipboard]);
|
||||||
|
|
||||||
|
const handleCopyJson = useCallback((record: any) => {
|
||||||
|
const records = getTargets(record);
|
||||||
|
const cleanRecords = records.map((r: any) => {
|
||||||
|
const { key, ...rest } = r;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
copyToClipboard(JSON.stringify(cleanRecords, null, 2));
|
||||||
|
}, [getTargets, copyToClipboard]);
|
||||||
|
|
||||||
|
const handleCopyCsv = useCallback((record: any) => {
|
||||||
|
const records = getTargets(record);
|
||||||
|
const lines = records.map((r: any) => {
|
||||||
|
const { key, ...vals } = r;
|
||||||
|
const values = Object.values(vals).map(v => v === null ? 'NULL' : `"${v}"`);
|
||||||
|
return values.join(',');
|
||||||
|
});
|
||||||
|
copyToClipboard(lines.join('\n'));
|
||||||
|
}, [getTargets, copyToClipboard]);
|
||||||
|
|
||||||
|
// Export
|
||||||
|
const handleExport = async (format: string) => {
|
||||||
|
if (!connectionId || !tableName) return;
|
||||||
|
const conn = connections.find(c => c.id === connectionId);
|
||||||
|
if (!conn) return;
|
||||||
|
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
|
||||||
|
|
||||||
|
const hide = message.loading(`Exporting as ${format.toUpperCase()}...`, 0);
|
||||||
|
const res = await ExportTable(config as any, dbName || '', tableName, format);
|
||||||
|
hide();
|
||||||
|
if (res.success) { message.success("Export Successful"); } else if (res.message !== "Cancelled") { message.error("Export Failed: " + res.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!connectionId || !tableName) return;
|
||||||
|
const conn = connections.find(c => c.id === connectionId);
|
||||||
|
if (!conn) return;
|
||||||
|
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
|
||||||
|
|
||||||
|
const res = await ImportData(config as any, dbName || '', tableName);
|
||||||
|
if (res.success) { message.success(res.message); if (onReload) onReload(); } else if (res.message !== "Cancelled") { message.error("Import Failed: " + res.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const addFilter = () => {
|
||||||
|
setFilterConditions([...filterConditions, { id: nextFilterId, column: columnNames[0] || '', op: '=', value: '' }]);
|
||||||
|
setNextFilterId(nextFilterId + 1);
|
||||||
|
};
|
||||||
|
const updateFilter = (id: number, field: string, val: string) => {
|
||||||
|
setFilterConditions(prev => prev.map(c => c.id === id ? { ...c, [field]: val } : c));
|
||||||
|
};
|
||||||
|
const removeFilter = (id: number) => {
|
||||||
|
setFilterConditions(prev => prev.filter(c => c.id !== id));
|
||||||
|
};
|
||||||
|
const applyFilters = () => {
|
||||||
|
if (onApplyFilter) onApplyFilter(filterConditions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportMenu: MenuProps['items'] = [
|
||||||
|
{ key: 'csv', label: 'CSV', onClick: () => handleExport('csv') },
|
||||||
|
{ key: 'xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') },
|
||||||
|
{ key: 'json', label: 'JSON', onClick: () => handleExport('json') },
|
||||||
|
{ key: 'md', label: 'Markdown', onClick: () => handleExport('md') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tableComponents = useMemo(() => ({
|
||||||
|
body: { cell: EditableCell, row: ContextMenuRow },
|
||||||
|
header: { cell: ResizableTitle }
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const totalWidth = columns.reduce((sum, col) => sum + (col.width as number || 200), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
{onReload && <Button icon={<ReloadOutlined />} onClick={() => {
|
||||||
|
setAddedRows([]);
|
||||||
|
setModifiedRows({});
|
||||||
|
setDeletedRowKeys(new Set());
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
onReload();
|
||||||
|
}}>刷新</Button>}
|
||||||
|
{tableName && <Button icon={<ImportOutlined />} onClick={handleImport}>导入</Button>}
|
||||||
|
{tableName && <Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}>导出 <DownOutlined /></Button></Dropdown>}
|
||||||
|
|
||||||
|
{!readOnly && tableName && (
|
||||||
|
<>
|
||||||
|
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
||||||
|
<Button icon={<PlusOutlined />} onClick={handleAddRow}>添加行</Button>
|
||||||
|
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}>删除选中</Button>
|
||||||
|
{selectedRowKeys.length > 0 && <span style={{ fontSize: '12px', color: '#888' }}>已选 {selectedRowKeys.length}</span>}
|
||||||
|
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
||||||
|
<Button icon={<SaveOutlined />} type="primary" disabled={!hasChanges} onClick={handleCommit}>提交事务 ({addedRows.length + Object.keys(modifiedRows).length + deletedRowKeys.size})</Button>
|
||||||
|
{hasChanges && (<Button icon={<UndoOutlined />} onClick={() => {
|
||||||
|
setAddedRows([]);
|
||||||
|
setModifiedRows({});
|
||||||
|
setDeletedRowKeys(new Set());
|
||||||
|
}}>回滚</Button>)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onToggleFilter && (
|
||||||
|
<>
|
||||||
|
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
||||||
|
<Button icon={<FilterOutlined />} type={showFilter ? 'primary' : 'default'} onClick={() => {
|
||||||
|
onToggleFilter();
|
||||||
|
if (filterConditions.length === 0 && !showFilter) addFilter();
|
||||||
|
}}>筛选</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Panel */}
|
||||||
|
{showFilter && (
|
||||||
|
<div style={{ padding: '8px', background: '#f5f5f5', borderBottom: '1px solid #eee' }}>
|
||||||
|
{filterConditions.map(cond => (
|
||||||
|
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||||
|
<Select style={{ width: 150 }} value={cond.column} onChange={v => updateFilter(cond.id, 'column', v)} options={columnNames.map(c => ({ value: c, label: c }))} />
|
||||||
|
<Select style={{ width: 100 }} value={cond.op} onChange={v => updateFilter(cond.id, 'op', v)} options={[{ value: '=', label: '=' }, { value: 'LIKE', label: '包含' }]} />
|
||||||
|
<Input style={{ width: 200 }} value={cond.value} onChange={e => updateFilter(cond.id, 'value', e.target.value)} />
|
||||||
|
<Button icon={<CloseOutlined />} onClick={() => removeFilter(cond.id)} type="text" danger />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button type="dashed" onClick={addFilter} size="small" icon={<FilterOutlined />}>Add Condition</Button>
|
||||||
|
<Button type="primary" onClick={applyFilters} size="small">Apply</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={containerRef} style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
|
||||||
|
<Form component={false} form={form}>
|
||||||
|
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard, tableName }}>
|
||||||
|
<EditableContext.Provider value={form}>
|
||||||
|
<Table
|
||||||
|
components={tableComponents}
|
||||||
|
dataSource={mergedDisplayData}
|
||||||
|
columns={mergedColumns}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: Math.max(totalWidth, 1000), y: tableHeight }}
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
bordered
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: setSelectedRowKeys,
|
||||||
|
}}
|
||||||
|
rowClassName={(record) => {
|
||||||
|
if (addedRows.some(r => r.key === record.key)) return 'row-added';
|
||||||
|
if (modifiedRows[record.key] || deletedRowKeys.has(record.key)) return 'row-modified'; // deleted won't show
|
||||||
|
return '';
|
||||||
|
}}
|
||||||
|
onRow={(record) => ({ record } as any)}
|
||||||
|
/>
|
||||||
|
</EditableContext.Provider>
|
||||||
|
</DataContext.Provider>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pagination && (
|
||||||
|
<div style={{ padding: '8px', borderTop: '1px solid #eee', display: 'flex', justifyContent: 'flex-end', background: '#fff' }}>
|
||||||
|
<Pagination
|
||||||
|
current={pagination.current}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
total={pagination.total}
|
||||||
|
showTotal={(total, range) => `当前 ${range[1] - range[0] + 1} 条 / 共 ${total} 条`}
|
||||||
|
showSizeChanger
|
||||||
|
pageSizeOptions={['100', '200', '500', '1000']}
|
||||||
|
onChange={onPageChange}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.row-added td { background-color: #f6ffed !important; }
|
||||||
|
.row-modified td { background-color: #e6f7ff !important; }
|
||||||
|
.ant-table-body {
|
||||||
|
height: ${tableHeight}px !important;
|
||||||
|
max-height: ${tableHeight}px !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Ghost Resize Line for Columns */}
|
||||||
|
<div
|
||||||
|
ref={ghostRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0, // Fits container height
|
||||||
|
width: '2px',
|
||||||
|
background: '#1890ff',
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'none',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(DataGrid);
|
||||||
@@ -1,200 +1,9 @@
|
|||||||
import React, { useEffect, useState, useRef, useContext, useMemo, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { Table, message, Spin, Input, Button, Space, Select, Tag, Dropdown, MenuProps, Form, Popconfirm, Pagination } from 'antd';
|
import { message } from 'antd';
|
||||||
import type { SortOrder } from 'antd/es/table/interface';
|
|
||||||
import { SearchOutlined, FilterOutlined, CloseOutlined, ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, CheckOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined } from '@ant-design/icons';
|
|
||||||
import { Resizable } from 'react-resizable';
|
|
||||||
import { TabData, ColumnDefinition } from '../types';
|
import { TabData, ColumnDefinition } from '../types';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { MySQLQuery, ImportData, ExportTable, ApplyChanges, DBGetColumns } from '../../wailsjs/go/main/App';
|
import { MySQLQuery, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||||
import 'react-resizable/css/styles.css';
|
import DataGrid from './DataGrid';
|
||||||
|
|
||||||
// --- Helper: Format Value ---
|
|
||||||
const formatCellValue = (val: any) => {
|
|
||||||
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
|
|
||||||
if (typeof val === 'object') return JSON.stringify(val);
|
|
||||||
if (typeof val === 'string') {
|
|
||||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(val)) {
|
|
||||||
return val.replace('T', ' ').replace(/\+.*$/, '').replace(/Z$/, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return String(val);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Resizable Header ---
|
|
||||||
const ResizableTitle = (props: any) => {
|
|
||||||
const { onResize, width, ...restProps } = props;
|
|
||||||
|
|
||||||
if (!width) {
|
|
||||||
return <th {...restProps} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Resizable
|
|
||||||
width={width}
|
|
||||||
height={0}
|
|
||||||
handle={
|
|
||||||
<span
|
|
||||||
className="react-resizable-handle"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: -5,
|
|
||||||
bottom: 0,
|
|
||||||
top: 0,
|
|
||||||
width: 10,
|
|
||||||
cursor: 'col-resize',
|
|
||||||
zIndex: 100,
|
|
||||||
touchAction: 'none'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onResize={onResize}
|
|
||||||
draggableOpts={{ enableUserSelectHack: false }}
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
{...restProps}
|
|
||||||
style={{
|
|
||||||
...restProps.style,
|
|
||||||
position: 'relative',
|
|
||||||
userSelect: 'none'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Resizable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Contexts ---
|
|
||||||
const EditableContext = React.createContext<any>(null);
|
|
||||||
|
|
||||||
// Use Ref for selection to prevent Context updates on every selection change
|
|
||||||
const DataContext = React.createContext<{
|
|
||||||
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
|
|
||||||
displayDataRef: React.MutableRefObject<any[]>;
|
|
||||||
handleCopyInsert: (r: any) => void;
|
|
||||||
handleCopyJson: (r: any) => void;
|
|
||||||
handleCopyCsv: (r: any) => void;
|
|
||||||
copyToClipboard: (t: string) => void;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
interface Item {
|
|
||||||
key: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditableCellProps {
|
|
||||||
title: React.ReactNode;
|
|
||||||
editable: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
dataIndex: string;
|
|
||||||
record: Item;
|
|
||||||
handleSave: (record: Item) => void;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optimization: Memoize EditableCell
|
|
||||||
const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
|
||||||
title,
|
|
||||||
editable,
|
|
||||||
children,
|
|
||||||
dataIndex,
|
|
||||||
record,
|
|
||||||
handleSave,
|
|
||||||
...restProps
|
|
||||||
}) => {
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const inputRef = useRef<any>(null);
|
|
||||||
const form = useContext(EditableContext);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editing) {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}
|
|
||||||
}, [editing]);
|
|
||||||
|
|
||||||
const toggleEdit = () => {
|
|
||||||
setEditing(!editing);
|
|
||||||
form.setFieldsValue({ [dataIndex]: record[dataIndex] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
try {
|
|
||||||
if (!form) return;
|
|
||||||
const values = await form.validateFields();
|
|
||||||
toggleEdit();
|
|
||||||
handleSave({ ...record, ...values });
|
|
||||||
} catch (errInfo) {
|
|
||||||
console.log('Save failed:', errInfo);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let childNode = children;
|
|
||||||
|
|
||||||
if (editable) {
|
|
||||||
childNode = editing ? (
|
|
||||||
<Form.Item
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
name={dataIndex}
|
|
||||||
>
|
|
||||||
<Input ref={inputRef} onPressEnter={save} onBlur={save} />
|
|
||||||
</Form.Item>
|
|
||||||
) : (
|
|
||||||
<div className="editable-cell-value-wrap" style={{ paddingRight: 24, minHeight: 20 }} onClick={toggleEdit}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <td {...restProps}>{childNode}</td>;
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Context Menu Row Wrapper (External & Memoized) ---
|
|
||||||
const ContextMenuRow = React.memo(({ children, ...props }: any) => {
|
|
||||||
const record = props.record;
|
|
||||||
const context = useContext(DataContext);
|
|
||||||
|
|
||||||
if (!record || !context) {
|
|
||||||
return <tr {...props}>{children}</tr>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard } = context;
|
|
||||||
|
|
||||||
const getTargets = () => {
|
|
||||||
const keys = selectedRowKeysRef.current;
|
|
||||||
if (keys.includes(record.key)) {
|
|
||||||
return displayDataRef.current.filter(d => keys.includes(d.key));
|
|
||||||
}
|
|
||||||
return [record];
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuItems: MenuProps['items'] = [
|
|
||||||
{
|
|
||||||
key: 'insert',
|
|
||||||
label: `复制为 INSERT`,
|
|
||||||
icon: <ConsoleSqlOutlined />,
|
|
||||||
onClick: () => handleCopyInsert(record)
|
|
||||||
},
|
|
||||||
{ key: 'json', label: '复制为 JSON', icon: <FileTextOutlined />, onClick: () => handleCopyJson(record) },
|
|
||||||
{ key: 'csv', label: '复制为 CSV', icon: <FileTextOutlined />, onClick: () => handleCopyCsv(record) },
|
|
||||||
{ key: 'copy', label: '复制为 Markdown', icon: <CopyOutlined />, onClick: () => {
|
|
||||||
const records = getTargets();
|
|
||||||
const lines = records.map((r: any) => {
|
|
||||||
const { key, ...vals } = r;
|
|
||||||
return `| ${Object.values(vals).join(' | ')} |`;
|
|
||||||
});
|
|
||||||
copyToClipboard(lines.join('\n'));
|
|
||||||
} },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
|
||||||
<tr {...props}>{children}</tr>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
@@ -209,39 +18,12 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
total: 0
|
total: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
|
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
|
||||||
|
|
||||||
const [showFilter, setShowFilter] = useState(false);
|
const [showFilter, setShowFilter] = useState(false);
|
||||||
const [filterConditions, setFilterConditions] = useState<{ id: number, column: string, op: string, value: string }[]>([]);
|
const [filterConditions, setFilterConditions] = useState<any[]>([]);
|
||||||
const [nextFilterId, setNextFilterId] = useState(1);
|
|
||||||
|
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize) => {
|
||||||
|
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
|
||||||
const [addedRows, setAddedRows] = useState<any[]>([]);
|
|
||||||
const [modifiedRows, setModifiedRows] = useState<Record<string, any>>({});
|
|
||||||
const [deletedRowKeys, setDeletedRowKeys] = useState<Set<React.Key>>(new Set());
|
|
||||||
|
|
||||||
// Refs
|
|
||||||
const selectedRowKeysRef = useRef(selectedRowKeys);
|
|
||||||
const displayDataRef = useRef<any[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedRowKeysRef.current = selectedRowKeys;
|
|
||||||
}, [selectedRowKeys]);
|
|
||||||
|
|
||||||
const displayData = useMemo(() => {
|
|
||||||
return [...data, ...addedRows].filter(item => !deletedRowKeys.has(item.key));
|
|
||||||
}, [data, addedRows, deletedRowKeys]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
displayDataRef.current = displayData;
|
|
||||||
}, [displayData]);
|
|
||||||
|
|
||||||
const hasChanges = addedRows.length > 0 || Object.keys(modifiedRows).length > 0 || deletedRowKeys.size > 0;
|
|
||||||
|
|
||||||
const fetchData = async (page = pagination.current, size = pagination.pageSize) => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const conn = connections.find(c => c.id === tab.connectionId);
|
const conn = connections.find(c => c.id === tab.connectionId);
|
||||||
if (!conn) {
|
if (!conn) {
|
||||||
@@ -320,11 +102,6 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
setData(resultData.map((row: any, i: number) => ({ ...row, key: `row-${i}` })));
|
setData(resultData.map((row: any, i: number) => ({ ...row, key: `row-${i}` })));
|
||||||
|
|
||||||
setPagination(prev => ({ ...prev, current: page, pageSize: size, total: totalRecords }));
|
setPagination(prev => ({ ...prev, current: page, pageSize: size, total: totalRecords }));
|
||||||
|
|
||||||
setAddedRows([]);
|
|
||||||
setModifiedRows({});
|
|
||||||
setDeletedRowKeys(new Set());
|
|
||||||
setSelectedRowKeys([]);
|
|
||||||
} else {
|
} else {
|
||||||
message.error(resData.message);
|
message.error(resData.message);
|
||||||
}
|
}
|
||||||
@@ -332,323 +109,42 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
message.error("Error fetching data: " + e.message);
|
message.error("Error fetching data: " + e.message);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
}, [connections, tab, sortInfo, filterConditions, pkColumns.length]);
|
||||||
|
// Depend on pkColumns.length to avoid loop? No, pkColumns is updated inside.
|
||||||
|
// Actually, 'pkColumns' state shouldn't trigger re-fetch.
|
||||||
|
// The 'if (pkColumns.length === 0)' check is inside.
|
||||||
|
// So adding pkColumns to dependency is safer but might trigger double fetch if not careful?
|
||||||
|
// Only if pkColumns changes. It changes once from [] to [...].
|
||||||
|
// So it's fine.
|
||||||
|
|
||||||
|
// Handlers memoized
|
||||||
|
const handleReload = useCallback(() => fetchData(), [fetchData]);
|
||||||
|
const handleSort = useCallback((field: string, order: string) => setSortInfo({ columnKey: field, order }), []);
|
||||||
|
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
|
||||||
|
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
|
||||||
|
const handleApplyFilter = useCallback((conditions: any[]) => setFilterConditions(conditions), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(1, pagination.pageSize);
|
fetchData(1, pagination.pageSize);
|
||||||
}, [tab, sortInfo]);
|
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
|
||||||
|
|
||||||
const handlePaginationChange = (page: number, pageSize: number) => {
|
|
||||||
fetchData(page, pageSize);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTableChange = (pag: any, filtersArg: any, sorter: any) => {
|
|
||||||
if (sorter.field) {
|
|
||||||
setSortInfo({ columnKey: sorter.field as string, order: sorter.order as string });
|
|
||||||
} else {
|
|
||||||
setSortInfo(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResize = useCallback((key: string) => (_: React.SyntheticEvent, { size }: { size: { width: number } }) => {
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
setColumnWidths(prev => ({ ...prev, [key]: size.width }));
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
return columnNames.map(key => ({
|
|
||||||
title: key,
|
|
||||||
dataIndex: key,
|
|
||||||
key: key,
|
|
||||||
ellipsis: true,
|
|
||||||
width: columnWidths[key] || 200,
|
|
||||||
sorter: true,
|
|
||||||
sortOrder: (sortInfo?.columnKey === key ? sortInfo.order : null) as SortOrder | undefined,
|
|
||||||
editable: true,
|
|
||||||
render: (text: any) => formatCellValue(text),
|
|
||||||
onHeaderCell: (column: any) => ({
|
|
||||||
width: column.width,
|
|
||||||
onResize: handleResize(key),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
}, [columnNames, columnWidths, sortInfo, handleResize]);
|
|
||||||
|
|
||||||
// Calculate total width
|
|
||||||
const totalWidth = columns.reduce((sum, col) => sum + (col.width as number || 200), 0);
|
|
||||||
|
|
||||||
const handleCellSave = useCallback((row: any) => {
|
|
||||||
setData(prevData => {
|
|
||||||
const newData = [...prevData];
|
|
||||||
const index = newData.findIndex(item => item.key === row.key);
|
|
||||||
if (index > -1) {
|
|
||||||
const item = newData[index];
|
|
||||||
newData.splice(index, 1, { ...item, ...row });
|
|
||||||
setModifiedRows(prev => ({ ...prev, [row.key]: row }));
|
|
||||||
return newData;
|
|
||||||
}
|
|
||||||
return prevData;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Compute merged columns for editable
|
|
||||||
const mergedColumns = useMemo(() => columns.map(col => {
|
|
||||||
if (!col.editable) return col;
|
|
||||||
return {
|
|
||||||
...col,
|
|
||||||
onCell: (record: Item) => ({
|
|
||||||
record,
|
|
||||||
editable: col.editable,
|
|
||||||
dataIndex: col.dataIndex,
|
|
||||||
title: col.title,
|
|
||||||
handleSave: handleCellSave,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}), [columns, handleCellSave]);
|
|
||||||
|
|
||||||
const handleAddRow = () => {
|
|
||||||
const newKey = `new-${Date.now()}`;
|
|
||||||
const newRow: any = { key: newKey };
|
|
||||||
columnNames.forEach(col => newRow[col] = '');
|
|
||||||
setAddedRows(prev => [...prev, newRow]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteSelected = () => {
|
|
||||||
setDeletedRowKeys(prev => {
|
|
||||||
const newDeleted = new Set(prev);
|
|
||||||
selectedRowKeys.forEach(key => {
|
|
||||||
newDeleted.add(key);
|
|
||||||
});
|
|
||||||
return newDeleted;
|
|
||||||
});
|
|
||||||
setSelectedRowKeys([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCommit = async () => {
|
|
||||||
const conn = connections.find(c => c.id === tab.connectionId);
|
|
||||||
if (!conn) return;
|
|
||||||
|
|
||||||
const inserts: any[] = [];
|
|
||||||
const updates: any[] = [];
|
|
||||||
const deletes: any[] = [];
|
|
||||||
|
|
||||||
addedRows.forEach(row => { const { key, ...vals } = row; inserts.push(vals); });
|
|
||||||
deletedRowKeys.forEach(key => {
|
|
||||||
const originalRow = data.find(d => d.key === key);
|
|
||||||
if (originalRow) {
|
|
||||||
const pkData: any = {};
|
|
||||||
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
|
|
||||||
else { const { key: _, ...rest } = originalRow; Object.assign(pkData, rest); }
|
|
||||||
deletes.push(pkData);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Object.entries(modifiedRows).forEach(([key, newRow]) => {
|
|
||||||
if (deletedRowKeys.has(key)) return;
|
|
||||||
const originalRow = data.find(d => d.key === key);
|
|
||||||
if (!originalRow) return;
|
|
||||||
const pkData: any = {};
|
|
||||||
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
|
|
||||||
else { const { key: _, ...rest } = originalRow; Object.assign(pkData, rest); }
|
|
||||||
const { key: _, ...vals } = newRow;
|
|
||||||
updates.push({ keys: pkData, values: vals });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
|
|
||||||
message.info("No changes to commit");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
|
|
||||||
const res = await ApplyChanges(config as any, tab.dbName || '', tab.tableName || '', { inserts, updates, deletes } as any);
|
|
||||||
if (res.success) {
|
|
||||||
message.success("Changes committed successfully!");
|
|
||||||
fetchData();
|
|
||||||
} else {
|
|
||||||
message.error("Commit failed: " + res.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToClipboard = useCallback((text: string) => {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
message.success("Copied to clipboard");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getTargets = useCallback((clickedRecord: any) => {
|
|
||||||
const selKeys = selectedRowKeysRef.current;
|
|
||||||
const currentData = displayDataRef.current;
|
|
||||||
if (selKeys.includes(clickedRecord.key)) {
|
|
||||||
return currentData.filter(d => selKeys.includes(d.key));
|
|
||||||
}
|
|
||||||
return [clickedRecord];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCopyInsert = useCallback((record: any) => {
|
|
||||||
const records = getTargets(record);
|
|
||||||
const sqls = records.map((r: any) => {
|
|
||||||
const { key, ...vals } = r;
|
|
||||||
const cols = Object.keys(vals);
|
|
||||||
const values = Object.values(vals).map(v => v === null ? 'NULL' : `'${v}'`);
|
|
||||||
return `INSERT INTO \`${tab.tableName}\` (${cols.map(c => `\`${c}\``).join(', ')}) VALUES (${values.join(', ')});`;
|
|
||||||
});
|
|
||||||
copyToClipboard(sqls.join('\n'));
|
|
||||||
}, [tab.tableName, getTargets, copyToClipboard]);
|
|
||||||
|
|
||||||
const handleCopyJson = useCallback((record: any) => {
|
|
||||||
const records = getTargets(record);
|
|
||||||
const cleanRecords = records.map((r: any) => {
|
|
||||||
const { key, ...rest } = r;
|
|
||||||
return rest;
|
|
||||||
});
|
|
||||||
copyToClipboard(JSON.stringify(cleanRecords, null, 2));
|
|
||||||
}, [getTargets, copyToClipboard]);
|
|
||||||
|
|
||||||
const handleCopyCsv = useCallback((record: any) => {
|
|
||||||
const records = getTargets(record);
|
|
||||||
const lines = records.map((r: any) => {
|
|
||||||
const { key, ...vals } = r;
|
|
||||||
const values = Object.values(vals).map(v => v === null ? 'NULL' : `"${v}"`);
|
|
||||||
return values.join(',');
|
|
||||||
});
|
|
||||||
copyToClipboard(lines.join('\n'));
|
|
||||||
}, [getTargets, copyToClipboard]);
|
|
||||||
|
|
||||||
// ... (Filter Handlers)
|
|
||||||
const addFilter = () => {
|
|
||||||
setFilterConditions([...filterConditions, { id: nextFilterId, column: columnNames[0] || '', op: '=', value: '' }]);
|
|
||||||
setNextFilterId(nextFilterId + 1);
|
|
||||||
setShowFilter(true);
|
|
||||||
};
|
|
||||||
const updateFilter = (id: number, field: string, val: string) => {
|
|
||||||
setFilterConditions(prev => prev.map(c => c.id === id ? { ...c, [field]: val } : c));
|
|
||||||
};
|
|
||||||
const removeFilter = (id: number) => {
|
|
||||||
setFilterConditions(prev => prev.filter(c => c.id !== id));
|
|
||||||
};
|
|
||||||
const applyFilters = () => fetchData(1, pagination.pageSize);
|
|
||||||
|
|
||||||
const handleImport = async () => {
|
|
||||||
const conn = connections.find(c => c.id === tab.connectionId);
|
|
||||||
if (!conn) return;
|
|
||||||
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
|
|
||||||
const res = await ImportData(config as any, tab.dbName || '', tab.tableName || '');
|
|
||||||
if (res.success) { message.success(res.message); fetchData(); } else if (res.message !== "Cancelled") { message.error("Import Failed: " + res.message); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = async (format: string) => {
|
|
||||||
const conn = connections.find(c => c.id === tab.connectionId);
|
|
||||||
if (!conn) return;
|
|
||||||
const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } };
|
|
||||||
const hide = message.loading(`Exporting as ${format.toUpperCase()}...`, 0);
|
|
||||||
const res = await ExportTable(config as any, tab.dbName || '', tab.tableName || '', format);
|
|
||||||
hide();
|
|
||||||
if (res.success) { message.success("Export Successful"); } else if (res.message !== "Cancelled") { message.error("Export Failed: " + res.message); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportMenu: MenuProps['items'] = [
|
|
||||||
{ key: 'csv', label: 'CSV', onClick: () => handleExport('csv') },
|
|
||||||
{ key: 'xlsx', label: 'Excel (XLSX)', onClick: () => handleExport('xlsx') },
|
|
||||||
{ key: 'json', label: 'JSON', onClick: () => handleExport('json') },
|
|
||||||
{ key: 'md', label: 'Markdown', onClick: () => handleExport('md') },
|
|
||||||
];
|
|
||||||
|
|
||||||
const contextValue = useMemo(() => ({
|
|
||||||
selectedRowKeysRef,
|
|
||||||
displayDataRef,
|
|
||||||
handleCopyInsert,
|
|
||||||
handleCopyJson,
|
|
||||||
handleCopyCsv,
|
|
||||||
copyToClipboard
|
|
||||||
}), [handleCopyInsert, handleCopyJson, handleCopyCsv, copyToClipboard]);
|
|
||||||
|
|
||||||
const tableComponents = useMemo(() => ({
|
|
||||||
body: { cell: EditableCell, row: ContextMenuRow },
|
|
||||||
header: { cell: ResizableTitle }
|
|
||||||
}), []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
|
<DataGrid
|
||||||
{/* Toolbar */}
|
data={data}
|
||||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
|
columnNames={columnNames}
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>刷新</Button>
|
loading={loading}
|
||||||
<Button icon={<ImportOutlined />} onClick={handleImport}>导入</Button>
|
tableName={tab.tableName}
|
||||||
<Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}>导出 <DownOutlined /></Button></Dropdown>
|
dbName={tab.dbName}
|
||||||
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
connectionId={tab.connectionId}
|
||||||
<Button icon={<PlusOutlined />} onClick={handleAddRow}>添加行</Button>
|
pkColumns={pkColumns}
|
||||||
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}>删除选中</Button>
|
onReload={handleReload}
|
||||||
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
onSort={handleSort}
|
||||||
<Button icon={<SaveOutlined />} type="primary" disabled={!hasChanges} onClick={handleCommit}>提交事务 ({addedRows.length + Object.keys(modifiedRows).length + deletedRowKeys.size})</Button>
|
onPageChange={handlePageChange}
|
||||||
{hasChanges && (<Button icon={<UndoOutlined />} onClick={() => fetchData()}>回滚</Button>)}
|
pagination={pagination}
|
||||||
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
|
showFilter={showFilter}
|
||||||
<Button icon={<FilterOutlined />} type={showFilter ? 'primary' : 'default'} onClick={() => { setShowFilter(!showFilter); if (filterConditions.length === 0 && !showFilter) addFilter(); }}>筛选</Button>
|
onToggleFilter={handleToggleFilter}
|
||||||
</div>
|
onApplyFilter={handleApplyFilter}
|
||||||
|
/>
|
||||||
{/* Filter Panel */}
|
|
||||||
{showFilter && (
|
|
||||||
<div style={{ padding: '8px', background: '#f5f5f5', borderBottom: '1px solid #eee' }}>
|
|
||||||
{filterConditions.map(cond => (
|
|
||||||
<div key={cond.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
|
||||||
<Select style={{ width: 150 }} value={cond.column} onChange={v => updateFilter(cond.id, 'column', v)} options={columnNames.map(c => ({ value: c, label: c }))} />
|
|
||||||
<Select style={{ width: 100 }} value={cond.op} onChange={v => updateFilter(cond.id, 'op', v)} options={[{ value: '=', label: '=' }, { value: 'LIKE', label: '包含' }]} />
|
|
||||||
<Input style={{ width: 200 }} value={cond.value} onChange={e => updateFilter(cond.id, 'value', e.target.value)} />
|
|
||||||
<Button icon={<CloseOutlined />} onClick={() => removeFilter(cond.id)} type="text" danger />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<Button type="dashed" onClick={addFilter} size="small" icon={<FilterOutlined />}>Add Condition</Button>
|
|
||||||
<Button type="primary" onClick={applyFilters} size="small">Apply</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
|
||||||
<Form component={false} form={form}>
|
|
||||||
<DataContext.Provider value={contextValue}>
|
|
||||||
<EditableContext.Provider value={form}>
|
|
||||||
<Table
|
|
||||||
components={tableComponents}
|
|
||||||
dataSource={displayData}
|
|
||||||
columns={mergedColumns}
|
|
||||||
size="small"
|
|
||||||
scroll={{ x: Math.max(totalWidth, 1000), y: 'calc(100vh - 200px - 40px)' }}
|
|
||||||
loading={loading}
|
|
||||||
pagination={false}
|
|
||||||
onChange={handleTableChange}
|
|
||||||
bordered
|
|
||||||
rowSelection={{
|
|
||||||
selectedRowKeys,
|
|
||||||
onChange: setSelectedRowKeys,
|
|
||||||
}}
|
|
||||||
rowClassName={(record) => {
|
|
||||||
if (addedRows.includes(record)) return 'row-added';
|
|
||||||
if (modifiedRows[record.key]) return 'row-modified';
|
|
||||||
return '';
|
|
||||||
}}
|
|
||||||
onRow={(record) => ({ record } as any)}
|
|
||||||
/>
|
|
||||||
</EditableContext.Provider>
|
|
||||||
</DataContext.Provider>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination Bar */}
|
|
||||||
<div style={{ padding: '8px', borderTop: '1px solid #eee', display: 'flex', justifyContent: 'flex-end', background: '#fff' }}>
|
|
||||||
<Pagination
|
|
||||||
current={pagination.current}
|
|
||||||
pageSize={pagination.pageSize}
|
|
||||||
total={pagination.total}
|
|
||||||
showTotal={(total, range) => `当前 ${range[1] - range[0] + 1} 条 / 共 ${total} 条`}
|
|
||||||
showSizeChanger
|
|
||||||
pageSizeOptions={['100', '200', '500', '1000']}
|
|
||||||
onChange={handlePaginationChange}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>{`
|
|
||||||
.row-added td { background-color: #f6ffed !important; }
|
|
||||||
.row-modified td { background-color: #e6f7ff !important; }
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import Editor, { OnMount } from '@monaco-editor/react';
|
import Editor, { OnMount } from '@monaco-editor/react';
|
||||||
import { Button, Table, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip } from 'antd';
|
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select } from 'antd';
|
||||||
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined } from '@ant-design/icons';
|
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
import { format } from 'sql-formatter';
|
import { format } from 'sql-formatter';
|
||||||
import { TabData } from '../types';
|
import { TabData, ColumnDefinition } from '../types';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { MySQLQuery, DBGetTables, DBGetAllColumns } from '../../wailsjs/go/main/App';
|
import { MySQLQuery, DBGetTables, DBGetAllColumns, MySQLGetDatabases, DBGetColumns } from '../../wailsjs/go/app/App';
|
||||||
|
import DataGrid from './DataGrid';
|
||||||
|
|
||||||
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||||
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
|
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
|
||||||
|
|
||||||
|
// DataGrid State
|
||||||
const [results, setResults] = useState<any[]>([]);
|
const [results, setResults] = useState<any[]>([]);
|
||||||
const [columns, setColumns] = useState<any[]>([]);
|
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||||
|
const [pkColumns, setPkColumns] = useState<string[]>([]);
|
||||||
|
const [targetTableName, setTargetTableName] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
|
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
|
||||||
const [saveForm] = Form.useForm();
|
const [saveForm] = Form.useForm();
|
||||||
|
|
||||||
|
// Database Selection
|
||||||
|
const [currentConnectionId, setCurrentConnectionId] = useState<string>(tab.connectionId);
|
||||||
|
const [currentDb, setCurrentDb] = useState<string>(tab.dbName || '');
|
||||||
|
const [dbList, setDbList] = useState<string[]>([]);
|
||||||
|
|
||||||
// Resizing state
|
// Resizing state
|
||||||
const [editorHeight, setEditorHeight] = useState(300);
|
const [editorHeight, setEditorHeight] = useState(300);
|
||||||
const editorRef = useRef<any>(null);
|
const editorRef = useRef<any>(null);
|
||||||
@@ -31,16 +42,44 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
|
|
||||||
// If opening a saved query, load its SQL
|
// If opening a saved query, load its SQL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab.query) {
|
if (tab.query) setQuery(tab.query);
|
||||||
setQuery(tab.query);
|
|
||||||
}
|
|
||||||
}, [tab.query]);
|
}, [tab.query]);
|
||||||
|
|
||||||
|
// Fetch Database List
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDbs = async () => {
|
||||||
|
const conn = connections.find(c => c.id === currentConnectionId);
|
||||||
|
if (!conn) return;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
...conn.config,
|
||||||
|
port: Number(conn.config.port),
|
||||||
|
password: conn.config.password || "",
|
||||||
|
database: conn.config.database || "",
|
||||||
|
useSSH: conn.config.useSSH || false,
|
||||||
|
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await MySQLGetDatabases(config as any);
|
||||||
|
if (res.success && Array.isArray(res.data)) {
|
||||||
|
const dbs = res.data.map((row: any) => row.Database || row.database);
|
||||||
|
setDbList(dbs);
|
||||||
|
if (!currentDb) {
|
||||||
|
if (conn.config.database) setCurrentDb(conn.config.database);
|
||||||
|
else if (dbs.length > 0 && dbs[0] !== 'information_schema') setCurrentDb(dbs[0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDbList([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchDbs();
|
||||||
|
}, [currentConnectionId, connections, currentDb]);
|
||||||
|
|
||||||
// Fetch Metadata for Autocomplete
|
// Fetch Metadata for Autocomplete
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchMetadata = async () => {
|
const fetchMetadata = async () => {
|
||||||
const conn = connections.find(c => c.id === tab.connectionId);
|
const conn = connections.find(c => c.id === currentConnectionId);
|
||||||
if (!conn) return;
|
if (!conn || !currentDb) return;
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
...conn.config,
|
...conn.config,
|
||||||
@@ -51,26 +90,25 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||||
};
|
};
|
||||||
|
|
||||||
const dbName = tab.dbName || conn.config.database || "";
|
const resTables = await DBGetTables(config as any, currentDb);
|
||||||
|
|
||||||
// Fetch Tables
|
|
||||||
const resTables = await DBGetTables(config as any, dbName);
|
|
||||||
if (resTables.success && Array.isArray(resTables.data)) {
|
if (resTables.success && Array.isArray(resTables.data)) {
|
||||||
// res.data is [{Table: "name"}, ...]
|
|
||||||
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
|
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
|
||||||
tablesRef.current = tableNames;
|
tablesRef.current = tableNames;
|
||||||
|
} else {
|
||||||
|
tablesRef.current = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch All Columns (Optimized for autocomplete)
|
|
||||||
if (config.type === 'mysql' || !config.type) {
|
if (config.type === 'mysql' || !config.type) {
|
||||||
const resCols = await DBGetAllColumns(config as any, dbName);
|
const resCols = await DBGetAllColumns(config as any, currentDb);
|
||||||
if (resCols.success && Array.isArray(resCols.data)) {
|
if (resCols.success && Array.isArray(resCols.data)) {
|
||||||
allColumnsRef.current = resCols.data;
|
allColumnsRef.current = resCols.data;
|
||||||
|
} else {
|
||||||
|
allColumnsRef.current = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchMetadata();
|
fetchMetadata();
|
||||||
}, [tab.connectionId, tab.dbName, connections]);
|
}, [currentConnectionId, currentDb, connections]);
|
||||||
|
|
||||||
// Handle Resizing
|
// Handle Resizing
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
@@ -98,7 +136,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
monacoRef.current = monaco;
|
monacoRef.current = monaco;
|
||||||
|
|
||||||
// SQL Autocomplete
|
|
||||||
monaco.languages.registerCompletionItemProvider('sql', {
|
monaco.languages.registerCompletionItemProvider('sql', {
|
||||||
provideCompletionItems: (model: any, position: any) => {
|
provideCompletionItems: (model: any, position: any) => {
|
||||||
const word = model.getWordUntilPosition(position);
|
const word = model.getWordUntilPosition(position);
|
||||||
@@ -109,7 +146,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
endColumn: word.endColumn,
|
endColumn: word.endColumn,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Simple Heuristic: Find tables mentioned in the query
|
|
||||||
const tableRegex = /(?:FROM|JOIN|UPDATE|INTO)\s+[`"]?(\w+)[`"]?/gi;
|
const tableRegex = /(?:FROM|JOIN|UPDATE|INTO)\s+[`"]?(\w+)[`"]?/gi;
|
||||||
const foundTables = new Set<string>();
|
const foundTables = new Set<string>();
|
||||||
let match;
|
let match;
|
||||||
@@ -118,7 +154,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
foundTables.add(match[1]);
|
foundTables.add(match[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Columns suggestion
|
|
||||||
const relevantColumns = allColumnsRef.current
|
const relevantColumns = allColumnsRef.current
|
||||||
.filter(c => foundTables.has(c.tableName))
|
.filter(c => foundTables.has(c.tableName))
|
||||||
.map(c => ({
|
.map(c => ({
|
||||||
@@ -131,14 +166,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const suggestions = [
|
const suggestions = [
|
||||||
// Keywords
|
|
||||||
...['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({
|
...['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({
|
||||||
label: k,
|
label: k,
|
||||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||||
insertText: k,
|
insertText: k,
|
||||||
range
|
range
|
||||||
})),
|
})),
|
||||||
// Tables
|
|
||||||
...tablesRef.current.map(t => ({
|
...tablesRef.current.map(t => ({
|
||||||
label: t,
|
label: t,
|
||||||
kind: monaco.languages.CompletionItemKind.Class,
|
kind: monaco.languages.CompletionItemKind.Class,
|
||||||
@@ -146,7 +179,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
detail: 'Table',
|
detail: 'Table',
|
||||||
range
|
range
|
||||||
})),
|
})),
|
||||||
// Columns
|
|
||||||
...relevantColumns
|
...relevantColumns
|
||||||
];
|
];
|
||||||
return { suggestions };
|
return { suggestions };
|
||||||
@@ -180,8 +212,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
|
|
||||||
const handleRun = async () => {
|
const handleRun = async () => {
|
||||||
if (!query.trim()) return;
|
if (!query.trim()) return;
|
||||||
|
if (!currentDb) {
|
||||||
|
message.error("请先选择数据库");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const conn = connections.find(c => c.id === tab.connectionId);
|
const conn = connections.find(c => c.id === currentConnectionId);
|
||||||
if (!conn) {
|
if (!conn) {
|
||||||
message.error("Connection not found");
|
message.error("Connection not found");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -196,30 +232,42 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
useSSH: conn.config.useSSH || false,
|
useSSH: conn.config.useSSH || false,
|
||||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||||
};
|
};
|
||||||
const res = await MySQLQuery(config as any, tab.dbName || conn.config.database || '', query);
|
|
||||||
|
// Detect Simple Table Query
|
||||||
|
let simpleTableName: string | undefined = undefined;
|
||||||
|
let primaryKeys: string[] = [];
|
||||||
|
|
||||||
|
// Naive regex to detect SELECT * FROM table
|
||||||
|
const tableMatch = query.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i);
|
||||||
|
if (tableMatch) {
|
||||||
|
simpleTableName = tableMatch[1];
|
||||||
|
// Fetch PKs for editing
|
||||||
|
const resCols = await DBGetColumns(config as any, currentDb, simpleTableName);
|
||||||
|
if (resCols.success) {
|
||||||
|
primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTargetTableName(simpleTableName);
|
||||||
|
setPkColumns(primaryKeys);
|
||||||
|
|
||||||
|
const res = await MySQLQuery(config as any, currentDb, query);
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
if (Array.isArray(res.data)) {
|
if (Array.isArray(res.data)) {
|
||||||
if (res.data.length > 0) {
|
if (res.data.length > 0) {
|
||||||
const cols = Object.keys(res.data[0]).map(key => ({
|
const cols = Object.keys(res.data[0]);
|
||||||
title: key,
|
setColumnNames(cols);
|
||||||
dataIndex: key,
|
|
||||||
key: key,
|
|
||||||
ellipsis: true,
|
|
||||||
render: (text: any) => typeof text === 'object' ? JSON.stringify(text) : String(text),
|
|
||||||
}));
|
|
||||||
setColumns(cols);
|
|
||||||
setResults(res.data.map((row: any, i: number) => ({ ...row, key: i })));
|
setResults(res.data.map((row: any, i: number) => ({ ...row, key: i })));
|
||||||
} else {
|
} else {
|
||||||
message.info('查询执行成功,但没有返回结果。');
|
message.info('查询执行成功,但没有返回结果。');
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setColumns([]);
|
setColumnNames([]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Handle update/insert results
|
|
||||||
const affected = (res.data as any).affectedRows;
|
const affected = (res.data as any).affectedRows;
|
||||||
message.success(`受影响行数: ${affected}`);
|
message.success(`受影响行数: ${affected}`);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setColumnNames([]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
message.error(res.message);
|
message.error(res.message);
|
||||||
@@ -234,20 +282,38 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
id: tab.id.startsWith('saved-') ? tab.id : `saved-${Date.now()}`,
|
id: tab.id.startsWith('saved-') ? tab.id : `saved-${Date.now()}`,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
sql: query,
|
sql: query,
|
||||||
connectionId: tab.connectionId,
|
connectionId: currentConnectionId,
|
||||||
dbName: tab.dbName || '',
|
dbName: currentDb || tab.dbName || '',
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
});
|
});
|
||||||
message.success('查询已保存!');
|
message.success('查询已保存!');
|
||||||
setIsSaveModalOpen(false);
|
setIsSaveModalOpen(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// validation failed
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: '8px', flexShrink: 0 }}>
|
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
|
||||||
|
<Select
|
||||||
|
style={{ width: 150 }}
|
||||||
|
placeholder="选择连接"
|
||||||
|
value={currentConnectionId}
|
||||||
|
onChange={(val) => {
|
||||||
|
setCurrentConnectionId(val);
|
||||||
|
setCurrentDb('');
|
||||||
|
}}
|
||||||
|
options={connections.map(c => ({ label: c.name, value: c.id }))}
|
||||||
|
showSearch
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
style={{ width: 200 }}
|
||||||
|
placeholder="选择数据库"
|
||||||
|
value={currentDb}
|
||||||
|
onChange={setCurrentDb}
|
||||||
|
options={dbList.map(db => ({ label: db, value: db }))}
|
||||||
|
showSearch
|
||||||
|
/>
|
||||||
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleRun} loading={loading}>
|
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleRun} loading={loading}>
|
||||||
运行
|
运行
|
||||||
</Button>
|
</Button>
|
||||||
@@ -268,7 +334,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
</Button.Group>
|
</Button.Group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor Area - Resizable */}
|
|
||||||
<div style={{ height: editorHeight, minHeight: '100px', borderBottom: '1px solid #eee' }}>
|
<div style={{ height: editorHeight, minHeight: '100px', borderBottom: '1px solid #eee' }}>
|
||||||
<Editor
|
<Editor
|
||||||
height="100%"
|
height="100%"
|
||||||
@@ -286,7 +351,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resize Handle */}
|
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
style={{
|
style={{
|
||||||
@@ -299,16 +363,17 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
title="拖动调整高度"
|
title="拖动调整高度"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Results Area - Fills remaining space */}
|
<div style={{ flex: 1, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{ flex: 1, overflow: 'hidden', padding: 10, display: 'flex', flexDirection: 'column' }}>
|
<DataGrid
|
||||||
<Table
|
data={results}
|
||||||
dataSource={results}
|
columnNames={columnNames}
|
||||||
columns={columns}
|
|
||||||
size="small"
|
|
||||||
scroll={{ x: 'max-content', y: 'calc(100% - 40px)' }}
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
tableName={targetTableName} // Pass table name only if detection succeeded
|
||||||
style={{ flex: 1, overflow: 'hidden' }}
|
dbName={currentDb}
|
||||||
|
connectionId={currentConnectionId}
|
||||||
|
pkColumns={pkColumns}
|
||||||
|
onReload={handleRun}
|
||||||
|
readOnly={!targetTableName} // Read-only if not a simple table query
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -330,4 +395,4 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default QueryEditor;
|
export default QueryEditor;
|
||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { SavedConnection } from '../types';
|
import { SavedConnection } from '../types';
|
||||||
import { MySQLGetDatabases, MySQLGetTables, MySQLShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase } from '../../wailsjs/go/main/App';
|
import { MySQLGetDatabases, MySQLGetTables, MySQLShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase } from '../../wailsjs/go/app/App';
|
||||||
|
|
||||||
const { Search } = Input;
|
const { Search } = Input;
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ interface TreeNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Sidebar: React.FC = () => {
|
const Sidebar: React.FC = () => {
|
||||||
const { connections, savedQueries, addTab } = useStore();
|
const { connections, savedQueries, addTab, setActiveContext } = useStore();
|
||||||
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState('');
|
||||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||||
@@ -50,6 +50,27 @@ const Sidebar: React.FC = () => {
|
|||||||
const [createDbForm] = Form.useForm();
|
const [createDbForm] = Form.useForm();
|
||||||
const [targetConnection, setTargetConnection] = useState<any>(null);
|
const [targetConnection, setTargetConnection] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Refresh queries for expanded databases
|
||||||
|
const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.key === k) return node;
|
||||||
|
if (node.children) {
|
||||||
|
const res = findNode(node.children, k);
|
||||||
|
if (res) return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
expandedKeys.forEach(key => {
|
||||||
|
const node = findNode(treeData, key);
|
||||||
|
if (node && node.type === 'database') {
|
||||||
|
loadTables(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [savedQueries]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTreeData(connections.map(conn => ({
|
setTreeData(connections.map(conn => ({
|
||||||
title: conn.name,
|
title: conn.name,
|
||||||
@@ -230,9 +251,24 @@ const Sidebar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSelect = (keys: React.Key[], info: any) => {
|
const onSelect = (keys: React.Key[], info: any) => {
|
||||||
if (!info.node.selected) return;
|
if (!info.node.selected) {
|
||||||
|
setActiveContext(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { type, dataRef } = info.node;
|
const { type, dataRef, key, title } = info.node;
|
||||||
|
|
||||||
|
// Update active context
|
||||||
|
if (type === 'connection') {
|
||||||
|
setActiveContext({ connectionId: key, dbName: '' });
|
||||||
|
} else if (type === 'database') {
|
||||||
|
setActiveContext({ connectionId: dataRef.id, dbName: title });
|
||||||
|
} else if (type === 'table') {
|
||||||
|
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
|
||||||
|
} else if (type === 'saved-query') {
|
||||||
|
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'folder-columns') openDesign(info.node, 'columns', true);
|
if (type === 'folder-columns') openDesign(info.node, 'columns', true);
|
||||||
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', true);
|
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', true);
|
||||||
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', true);
|
else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', true);
|
||||||
@@ -315,7 +351,7 @@ const Sidebar: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRunSQLFile = async (node: any) => {
|
const handleRunSQLFile = async (node: any) => {
|
||||||
const res = await (window as any).go.main.App.OpenSQLFile();
|
const res = await (window as any).go.app.App.OpenSQLFile();
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
const sqlContent = res.data;
|
const sqlContent = res.data;
|
||||||
const { dbName, id } = node.dataRef;
|
const { dbName, id } = node.dataRef;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Tabs, Button } from 'antd';
|
import { Tabs, Button } from 'antd';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import DataViewer from './DataViewer';
|
import DataViewer from './DataViewer';
|
||||||
@@ -18,7 +18,7 @@ const TabManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = tabs.map(tab => {
|
const items = useMemo(() => tabs.map(tab => {
|
||||||
let content;
|
let content;
|
||||||
if (tab.type === 'query') {
|
if (tab.type === 'query') {
|
||||||
content = <QueryEditor tab={tab} />;
|
content = <QueryEditor tab={tab} />;
|
||||||
@@ -33,18 +33,24 @@ const TabManager: React.FC = () => {
|
|||||||
key: tab.id,
|
key: tab.id,
|
||||||
children: content,
|
children: content,
|
||||||
};
|
};
|
||||||
});
|
}), [tabs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<>
|
||||||
type="editable-card"
|
<style>{`
|
||||||
onChange={onChange}
|
.ant-tabs-content { height: 100%; }
|
||||||
activeKey={activeTabId || undefined}
|
.ant-tabs-tabpane { height: 100%; }
|
||||||
onEdit={onEdit}
|
`}</style>
|
||||||
items={items}
|
<Tabs
|
||||||
style={{ height: '100%' }}
|
type="editable-card"
|
||||||
hideAdd
|
onChange={onChange}
|
||||||
/>
|
activeKey={activeTabId || undefined}
|
||||||
|
onEdit={onEdit}
|
||||||
|
items={items}
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
hideAdd
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { CSS } from '@dnd-kit/utilities';
|
|||||||
import { Resizable } from 'react-resizable';
|
import { Resizable } from 'react-resizable';
|
||||||
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
|
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { DBGetColumns, DBGetIndexes, MySQLQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/main/App';
|
import { DBGetColumns, DBGetIndexes, MySQLQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
|
||||||
|
|
||||||
// Need styles for react-resizable
|
// Need styles for react-resizable
|
||||||
import 'react-resizable/css/styles.css';
|
import 'react-resizable/css/styles.css';
|
||||||
@@ -74,6 +74,11 @@ const ResizableTitle = (props: any) => {
|
|||||||
className="react-resizable-handle"
|
className="react-resizable-handle"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault(); // Prevent text selection and focus hijacking
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -87,7 +92,7 @@ const ResizableTitle = (props: any) => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onResize={onResize}
|
onResize={onResize}
|
||||||
draggableOpts={{ enableUserSelectHack: false }}
|
draggableOpts={{ enableUserSelectHack: true }}
|
||||||
>
|
>
|
||||||
<th {...restProps} style={{ ...restProps.style, position: 'relative' }} />
|
<th {...restProps} style={{ ...restProps.style, position: 'relative' }} />
|
||||||
</Resizable>
|
</Resizable>
|
||||||
@@ -263,16 +268,24 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
setTableColumns(initialCols);
|
setTableColumns(initialCols);
|
||||||
}, [readOnly]); // Re-create if readOnly changes
|
}, [readOnly]); // Re-create if readOnly changes
|
||||||
|
|
||||||
|
const rafRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
// Resize Handler
|
// Resize Handler
|
||||||
const handleResize = (index: number) => (_: React.SyntheticEvent, { size }: { size: { width: number } }) => {
|
const handleResize = (index: number) => (_: React.SyntheticEvent, { size }: { size: { width: number } }) => {
|
||||||
setTableColumns((columns) => {
|
if (rafRef.current) {
|
||||||
const nextColumns = [...columns];
|
cancelAnimationFrame(rafRef.current);
|
||||||
nextColumns[index] = {
|
}
|
||||||
...nextColumns[index],
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
width: size.width,
|
setTableColumns((columns) => {
|
||||||
};
|
const nextColumns = [...columns];
|
||||||
return nextColumns;
|
nextColumns[index] = {
|
||||||
});
|
...nextColumns[index],
|
||||||
|
width: size.width,
|
||||||
|
};
|
||||||
|
return nextColumns;
|
||||||
|
});
|
||||||
|
rafRef.current = null;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -587,8 +600,8 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
|||||||
)}
|
)}
|
||||||
{!readOnly && <Button icon={<SaveOutlined />} type="primary" onClick={generateDDL}>保存</Button>}
|
{!readOnly && <Button icon={<SaveOutlined />} type="primary" onClick={generateDDL}>保存</Button>}
|
||||||
{!isNewTable && <Button icon={<ReloadOutlined />} onClick={fetchData}>刷新</Button>}
|
{!isNewTable && <Button icon={<ReloadOutlined />} onClick={fetchData}>刷新</Button>}
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
{!readOnly && <Button icon={<PlusOutlined />} onClick={handleAddColumn}>添加字段</Button>}
|
{!readOnly && <Button icon={<PlusOutlined />} onClick={handleAddColumn}>添加字段</Button>}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
</div>
|
</div>
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface AppState {
|
|||||||
connections: SavedConnection[];
|
connections: SavedConnection[];
|
||||||
tabs: TabData[];
|
tabs: TabData[];
|
||||||
activeTabId: string | null;
|
activeTabId: string | null;
|
||||||
|
activeContext: { connectionId: string; dbName: string } | null;
|
||||||
savedQueries: SavedQuery[];
|
savedQueries: SavedQuery[];
|
||||||
darkMode: boolean;
|
darkMode: boolean;
|
||||||
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
||||||
@@ -16,6 +17,7 @@ interface AppState {
|
|||||||
addTab: (tab: TabData) => void;
|
addTab: (tab: TabData) => void;
|
||||||
closeTab: (id: string) => void;
|
closeTab: (id: string) => void;
|
||||||
setActiveTab: (id: string) => void;
|
setActiveTab: (id: string) => void;
|
||||||
|
setActiveContext: (context: { connectionId: string; dbName: string } | null) => void;
|
||||||
|
|
||||||
saveQuery: (query: SavedQuery) => void;
|
saveQuery: (query: SavedQuery) => void;
|
||||||
deleteQuery: (id: string) => void;
|
deleteQuery: (id: string) => void;
|
||||||
@@ -30,6 +32,7 @@ export const useStore = create<AppState>()(
|
|||||||
connections: [],
|
connections: [],
|
||||||
tabs: [],
|
tabs: [],
|
||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
|
activeContext: null,
|
||||||
savedQueries: [],
|
savedQueries: [],
|
||||||
darkMode: false,
|
darkMode: false,
|
||||||
sqlFormatOptions: { keywordCase: 'upper' },
|
sqlFormatOptions: { keywordCase: 'upper' },
|
||||||
@@ -58,6 +61,7 @@ export const useStore = create<AppState>()(
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
setActiveTab: (id) => set({ activeTabId: id }),
|
setActiveTab: (id) => set({ activeTabId: id }),
|
||||||
|
setActiveContext: (context) => set({ activeContext: context }),
|
||||||
|
|
||||||
saveQuery: (query) => set((state) => {
|
saveQuery: (query) => set((state) => {
|
||||||
// If query with same ID exists, update it
|
// If query with same ID exists, update it
|
||||||
|
|||||||
43
frontend/wailsjs/go/app/App.d.ts
vendored
Executable file
43
frontend/wailsjs/go/app/App.d.ts
vendored
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
import {connection} from '../models';
|
||||||
|
|
||||||
|
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function DBConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function DBGetAllColumns(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function DBGetColumns(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function DBGetDatabases(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function DBGetForeignKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function DBGetIndexes(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function DBGetTables(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function DBGetTriggers(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function DBQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function MySQLConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function MySQLGetDatabases(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function MySQLGetTables(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function MySQLQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||||
|
|
||||||
|
export function OpenSQLFile():Promise<connection.QueryResult>;
|
||||||
83
frontend/wailsjs/go/app/App.js
Executable file
83
frontend/wailsjs/go/app/App.js
Executable file
@@ -0,0 +1,83 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function ApplyChanges(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateDatabase(arg1, arg2) {
|
||||||
|
return window['go']['app']['App']['CreateDatabase'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DBConnect(arg1) {
|
||||||
|
return window['go']['app']['App']['DBConnect'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DBGetAllColumns(arg1, arg2) {
|
||||||
|
return window['go']['app']['App']['DBGetAllColumns'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DBGetColumns(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['DBGetColumns'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DBGetDatabases(arg1) {
|
||||||
|
return window['go']['app']['App']['DBGetDatabases'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DBGetForeignKeys(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['DBGetForeignKeys'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DBGetIndexes(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['DBGetIndexes'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DBGetTables(arg1, arg2) {
|
||||||
|
return window['go']['app']['App']['DBGetTables'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DBGetTriggers(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['DBGetTriggers'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DBQuery(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['DBQuery'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DBShowCreateTable(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['DBShowCreateTable'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportTable(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['app']['App']['ExportTable'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportData(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['ImportData'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MySQLConnect(arg1) {
|
||||||
|
return window['go']['app']['App']['MySQLConnect'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MySQLGetDatabases(arg1) {
|
||||||
|
return window['go']['app']['App']['MySQLGetDatabases'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MySQLGetTables(arg1, arg2) {
|
||||||
|
return window['go']['app']['App']['MySQLGetTables'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MySQLQuery(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['MySQLQuery'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MySQLShowCreateTable(arg1, arg2, arg3) {
|
||||||
|
return window['go']['app']['App']['MySQLShowCreateTable'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenSQLFile() {
|
||||||
|
return window['go']['app']['App']['OpenSQLFile']();
|
||||||
|
}
|
||||||
43
frontend/wailsjs/go/main/App.d.ts
vendored
43
frontend/wailsjs/go/main/App.d.ts
vendored
@@ -1,43 +0,0 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
|
||||||
// This file is automatically generated. DO NOT EDIT
|
|
||||||
import {main} from '../models';
|
|
||||||
|
|
||||||
export function ApplyChanges(arg1:main.ConnectionConfig,arg2:string,arg3:string,arg4:main.ChangeSet):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function CreateDatabase(arg1:main.ConnectionConfig,arg2:string):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function DBConnect(arg1:main.ConnectionConfig):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function DBGetAllColumns(arg1:main.ConnectionConfig,arg2:string):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function DBGetColumns(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function DBGetDatabases(arg1:main.ConnectionConfig):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function DBGetForeignKeys(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function DBGetIndexes(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function DBGetTables(arg1:main.ConnectionConfig,arg2:string):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function DBGetTriggers(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function DBQuery(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function DBShowCreateTable(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function ExportTable(arg1:main.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function ImportData(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function MySQLConnect(arg1:main.ConnectionConfig):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function MySQLGetDatabases(arg1:main.ConnectionConfig):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function MySQLGetTables(arg1:main.ConnectionConfig,arg2:string):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function MySQLQuery(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function MySQLShowCreateTable(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise<main.QueryResult>;
|
|
||||||
|
|
||||||
export function OpenSQLFile():Promise<main.QueryResult>;
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
|
||||||
// This file is automatically generated. DO NOT EDIT
|
|
||||||
|
|
||||||
export function ApplyChanges(arg1, arg2, arg3, arg4) {
|
|
||||||
return window['go']['main']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateDatabase(arg1, arg2) {
|
|
||||||
return window['go']['main']['App']['CreateDatabase'](arg1, arg2);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DBConnect(arg1) {
|
|
||||||
return window['go']['main']['App']['DBConnect'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DBGetAllColumns(arg1, arg2) {
|
|
||||||
return window['go']['main']['App']['DBGetAllColumns'](arg1, arg2);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DBGetColumns(arg1, arg2, arg3) {
|
|
||||||
return window['go']['main']['App']['DBGetColumns'](arg1, arg2, arg3);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DBGetDatabases(arg1) {
|
|
||||||
return window['go']['main']['App']['DBGetDatabases'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DBGetForeignKeys(arg1, arg2, arg3) {
|
|
||||||
return window['go']['main']['App']['DBGetForeignKeys'](arg1, arg2, arg3);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DBGetIndexes(arg1, arg2, arg3) {
|
|
||||||
return window['go']['main']['App']['DBGetIndexes'](arg1, arg2, arg3);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DBGetTables(arg1, arg2) {
|
|
||||||
return window['go']['main']['App']['DBGetTables'](arg1, arg2);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DBGetTriggers(arg1, arg2, arg3) {
|
|
||||||
return window['go']['main']['App']['DBGetTriggers'](arg1, arg2, arg3);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DBQuery(arg1, arg2, arg3) {
|
|
||||||
return window['go']['main']['App']['DBQuery'](arg1, arg2, arg3);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DBShowCreateTable(arg1, arg2, arg3) {
|
|
||||||
return window['go']['main']['App']['DBShowCreateTable'](arg1, arg2, arg3);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExportTable(arg1, arg2, arg3, arg4) {
|
|
||||||
return window['go']['main']['App']['ExportTable'](arg1, arg2, arg3, arg4);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImportData(arg1, arg2, arg3) {
|
|
||||||
return window['go']['main']['App']['ImportData'](arg1, arg2, arg3);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MySQLConnect(arg1) {
|
|
||||||
return window['go']['main']['App']['MySQLConnect'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MySQLGetDatabases(arg1) {
|
|
||||||
return window['go']['main']['App']['MySQLGetDatabases'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MySQLGetTables(arg1, arg2) {
|
|
||||||
return window['go']['main']['App']['MySQLGetTables'](arg1, arg2);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MySQLQuery(arg1, arg2, arg3) {
|
|
||||||
return window['go']['main']['App']['MySQLQuery'](arg1, arg2, arg3);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MySQLShowCreateTable(arg1, arg2, arg3) {
|
|
||||||
return window['go']['main']['App']['MySQLShowCreateTable'](arg1, arg2, arg3);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpenSQLFile() {
|
|
||||||
return window['go']['main']['App']['OpenSQLFile']();
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export namespace main {
|
export namespace connection {
|
||||||
|
|
||||||
export class UpdateRow {
|
export class UpdateRow {
|
||||||
keys: Record<string, any>;
|
keys: Record<string, any>;
|
||||||
|
|||||||
72
internal/app/app.go
Normal file
72
internal/app/app.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// App struct
|
||||||
|
type App struct {
|
||||||
|
ctx context.Context
|
||||||
|
dbCache map[string]db.Database // Cache for DB connections
|
||||||
|
mu sync.Mutex // Mutex for cache access
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewApp creates a new App application struct
|
||||||
|
func NewApp() *App {
|
||||||
|
return &App{
|
||||||
|
dbCache: make(map[string]db.Database),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Startup is called when the app starts. The context is saved
|
||||||
|
// so we can call the runtime methods
|
||||||
|
func (a *App) Startup(ctx context.Context) {
|
||||||
|
a.ctx = ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown is called when the app terminates
|
||||||
|
func (a *App) Shutdown(ctx context.Context) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
for _, dbInst := range a.dbCache {
|
||||||
|
dbInst.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Generate a unique key for the connection config
|
||||||
|
func getCacheKey(config connection.ConnectionConfig) string {
|
||||||
|
return fmt.Sprintf("%s|%s|%s:%d|%s|%s|%v", config.Type, config.User, config.Host, config.Port, config.Database, config.SSH.Host, config.UseSSH)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Get or create a database connection
|
||||||
|
func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, error) {
|
||||||
|
key := getCacheKey(config)
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
if dbInst, ok := a.dbCache[key]; ok {
|
||||||
|
if err := dbInst.Ping(); err == nil {
|
||||||
|
return dbInst, nil
|
||||||
|
}
|
||||||
|
dbInst.Close()
|
||||||
|
delete(a.dbCache, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbInst, err := db.NewDatabase(config.Type)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dbInst.Connect(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
a.dbCache[key] = dbInst
|
||||||
|
return dbInst, nil
|
||||||
|
}
|
||||||
263
internal/app/methods_db.go
Normal file
263
internal/app/methods_db.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generic DB Methods
|
||||||
|
|
||||||
|
func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResult {
|
||||||
|
key := getCacheKey(config)
|
||||||
|
|
||||||
|
// Use an anonymous function to scope the lock
|
||||||
|
func() {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
if oldDB, ok := a.dbCache[key]; ok {
|
||||||
|
oldDB.Close()
|
||||||
|
delete(a.dbCache, key)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// getDatabase acquires the lock internally, so we must be unlocked here
|
||||||
|
_, err := a.getDatabase(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "Connected successfully"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string) connection.QueryResult {
|
||||||
|
runConfig := config
|
||||||
|
runConfig.Database = ""
|
||||||
|
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("CREATE DATABASE `%%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", dbName)
|
||||||
|
if runConfig.Type == "postgres" {
|
||||||
|
query = fmt.Sprintf("CREATE DATABASE \"%%s\"", dbName)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dbInst.Exec(query)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "Database created successfully"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) MySQLConnect(config connection.ConnectionConfig) connection.QueryResult {
|
||||||
|
config.Type = "mysql"
|
||||||
|
return a.DBConnect(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) MySQLQuery(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult {
|
||||||
|
config.Type = "mysql"
|
||||||
|
return a.DBQuery(config, dbName, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) MySQLGetDatabases(config connection.ConnectionConfig) connection.QueryResult {
|
||||||
|
config.Type = "mysql"
|
||||||
|
return a.DBGetDatabases(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) MySQLGetTables(config connection.ConnectionConfig, dbName string) connection.QueryResult {
|
||||||
|
config.Type = "mysql"
|
||||||
|
return a.DBGetTables(config, dbName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) MySQLShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||||
|
config.Type = "mysql"
|
||||||
|
return a.DBShowCreateTable(config, dbName, tableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DBQuery(config connection.ConnectionConfig, dbName string, query string) connection.QueryResult {
|
||||||
|
runConfig := config
|
||||||
|
if dbName != "" {
|
||||||
|
runConfig.Database = dbName
|
||||||
|
}
|
||||||
|
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerQuery := strings.TrimSpace(strings.ToLower(query))
|
||||||
|
if strings.HasPrefix(lowerQuery, "select") || strings.HasPrefix(lowerQuery, "show") || strings.HasPrefix(lowerQuery, "describe") || strings.HasPrefix(lowerQuery, "explain") {
|
||||||
|
data, columns, err := dbInst.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
return connection.QueryResult{Success: true, Data: data, Fields: columns}
|
||||||
|
} else {
|
||||||
|
affected, err := dbInst.Exec(query)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
return connection.QueryResult{Success: true, Data: map[string]int64{"affectedRows": affected}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DBGetDatabases(config connection.ConnectionConfig) connection.QueryResult {
|
||||||
|
dbInst, err := a.getDatabase(config)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbs, err := dbInst.GetDatabases()
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resData []map[string]string
|
||||||
|
for _, name := range dbs {
|
||||||
|
resData = append(resData, map[string]string{"Database": name})
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: resData}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DBGetTables(config connection.ConnectionConfig, dbName string) connection.QueryResult {
|
||||||
|
runConfig := config
|
||||||
|
if dbName != "" {
|
||||||
|
runConfig.Database = dbName
|
||||||
|
}
|
||||||
|
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
tables, err := dbInst.GetTables(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resData []map[string]string
|
||||||
|
for _, name := range tables {
|
||||||
|
resData = append(resData, map[string]string{"Table": name})
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: resData}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DBShowCreateTable(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||||
|
runConfig := config
|
||||||
|
if dbName != "" {
|
||||||
|
runConfig.Database = dbName
|
||||||
|
}
|
||||||
|
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlStr, err := dbInst.GetCreateStatement(dbName, tableName)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: sqlStr}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DBGetColumns(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||||
|
runConfig := config
|
||||||
|
if dbName != "" {
|
||||||
|
runConfig.Database = dbName
|
||||||
|
}
|
||||||
|
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
columns, err := dbInst.GetColumns(dbName, tableName)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: columns}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DBGetIndexes(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||||
|
runConfig := config
|
||||||
|
if dbName != "" {
|
||||||
|
runConfig.Database = dbName
|
||||||
|
}
|
||||||
|
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
indexes, err := dbInst.GetIndexes(dbName, tableName)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: indexes}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DBGetForeignKeys(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||||
|
runConfig := config
|
||||||
|
if dbName != "" {
|
||||||
|
runConfig.Database = dbName
|
||||||
|
}
|
||||||
|
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
fks, err := dbInst.GetForeignKeys(dbName, tableName)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: fks}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DBGetTriggers(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult {
|
||||||
|
runConfig := config
|
||||||
|
if dbName != "" {
|
||||||
|
runConfig.Database = dbName
|
||||||
|
}
|
||||||
|
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
triggers, err := dbInst.GetTriggers(dbName, tableName)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: triggers}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DBGetAllColumns(config connection.ConnectionConfig, dbName string) connection.QueryResult {
|
||||||
|
runConfig := config
|
||||||
|
if dbName != "" {
|
||||||
|
runConfig.Database = dbName
|
||||||
|
}
|
||||||
|
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
cols, err := dbInst.GetAllColumns(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: cols}
|
||||||
|
}
|
||||||
291
internal/app/methods_file.go
Normal file
291
internal/app/methods_file.go
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/db"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) OpenSQLFile() connection.QueryResult {
|
||||||
|
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||||
|
Title: "Select SQL File",
|
||||||
|
Filters: []runtime.FileFilter{
|
||||||
|
{
|
||||||
|
DisplayName: "SQL Files (*.sql)",
|
||||||
|
Pattern: "*.sql",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "All Files (*.*)",
|
||||||
|
Pattern: "*.*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selection == "" {
|
||||||
|
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(selection)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Data: string(content)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ImportData(config connection.ConnectionConfig, dbName, tableName string) connection.QueryResult {
|
||||||
|
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||||
|
Title: fmt.Sprintf("Import into %s", tableName),
|
||||||
|
Filters: []runtime.FileFilter{
|
||||||
|
{
|
||||||
|
DisplayName: "Data Files",
|
||||||
|
Pattern: "*.csv;*.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selection == "" {
|
||||||
|
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(selection)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var rows []map[string]interface{ }
|
||||||
|
|
||||||
|
if strings.HasSuffix(strings.ToLower(selection), ".json") {
|
||||||
|
decoder := json.NewDecoder(f)
|
||||||
|
if err := decoder.Decode(&rows); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: "JSON Parse Error: " + err.Error()}
|
||||||
|
}
|
||||||
|
} else if strings.HasSuffix(strings.ToLower(selection), ".csv") {
|
||||||
|
reader := csv.NewReader(f)
|
||||||
|
records, err := reader.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: "CSV Parse Error: " + err.Error()}
|
||||||
|
}
|
||||||
|
if len(records) < 2 {
|
||||||
|
return connection.QueryResult{Success: false, Message: "CSV empty or missing header"}
|
||||||
|
}
|
||||||
|
headers := records[0]
|
||||||
|
for _, record := range records[1:] {
|
||||||
|
row := make(map[string]interface{ })
|
||||||
|
for i, val := range record {
|
||||||
|
if i < len(headers) {
|
||||||
|
if val == "NULL" {
|
||||||
|
row[headers[i]] = nil
|
||||||
|
} else {
|
||||||
|
row[headers[i]] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return connection.QueryResult{Success: false, Message: "Unsupported file format"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return connection.QueryResult{Success: true, Message: "No data to import"}
|
||||||
|
}
|
||||||
|
|
||||||
|
runConfig := config
|
||||||
|
if dbName != "" {
|
||||||
|
runConfig.Database = dbName
|
||||||
|
}
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount := 0
|
||||||
|
errCount := 0
|
||||||
|
firstRow := rows[0]
|
||||||
|
var cols []string
|
||||||
|
for k := range firstRow {
|
||||||
|
cols = append(cols, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
var values []string
|
||||||
|
for _, col := range cols {
|
||||||
|
val := row[col]
|
||||||
|
if val == nil {
|
||||||
|
values = append(values, "NULL")
|
||||||
|
} else {
|
||||||
|
vStr := fmt.Sprintf("%v", val)
|
||||||
|
vStr = strings.ReplaceAll(vStr, "'", "''")
|
||||||
|
values = append(values, fmt.Sprintf("'%s'", vStr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s)",
|
||||||
|
tableName,
|
||||||
|
strings.Join(cols, ", "),
|
||||||
|
strings.Join(values, ", "))
|
||||||
|
|
||||||
|
if runConfig.Type == "postgres" {
|
||||||
|
pgCols := make([]string, len(cols))
|
||||||
|
for i, c := range cols { pgCols[i] = fmt.Sprintf("\"%s\"", c) }
|
||||||
|
query = fmt.Sprintf("INSERT INTO \"%s\" (%s) VALUES (%s)",
|
||||||
|
tableName,
|
||||||
|
strings.Join(pgCols, ", "),
|
||||||
|
strings.Join(values, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := dbInst.Exec(query)
|
||||||
|
if err != nil {
|
||||||
|
errCount++
|
||||||
|
fmt.Println("Import Error:", err)
|
||||||
|
} else {
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: fmt.Sprintf("Imported: %d, Failed: %d", successCount, errCount)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName string, changes connection.ChangeSet) connection.QueryResult {
|
||||||
|
runConfig := config
|
||||||
|
if dbName != "" {
|
||||||
|
runConfig.Database = dbName
|
||||||
|
}
|
||||||
|
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if applier, ok := dbInst.(db.BatchApplier); ok {
|
||||||
|
err := applier.ApplyChanges(tableName, changes)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
return connection.QueryResult{Success: true, Message: "Changes applied successfully"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: false, Message: "Batch updates not supported for this database type"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tableName string, format string) connection.QueryResult {
|
||||||
|
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||||
|
Title: fmt.Sprintf("Export %s", tableName),
|
||||||
|
DefaultFilename: fmt.Sprintf("%s.%s", tableName, format),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || filename == "" {
|
||||||
|
return connection.QueryResult{Success: false, Message: "Cancelled"}
|
||||||
|
}
|
||||||
|
|
||||||
|
runConfig := config
|
||||||
|
if dbName != "" {
|
||||||
|
runConfig.Database = dbName
|
||||||
|
}
|
||||||
|
|
||||||
|
dbInst, err := a.getDatabase(runConfig)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("SELECT * FROM `%s`", tableName)
|
||||||
|
if runConfig.Type == "postgres" {
|
||||||
|
query = fmt.Sprintf("SELECT * FROM \"%s\"", tableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, columns, err := dbInst.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
format = strings.ToLower(format)
|
||||||
|
var csvWriter *csv.Writer
|
||||||
|
var jsonEncoder *json.Encoder
|
||||||
|
var isJsonFirstRow = true
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case "csv", "xlsx":
|
||||||
|
f.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||||
|
csvWriter = csv.NewWriter(f)
|
||||||
|
defer csvWriter.Flush()
|
||||||
|
if err := csvWriter.Write(columns); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||||
|
}
|
||||||
|
case "json":
|
||||||
|
f.WriteString("[\n")
|
||||||
|
jsonEncoder = json.NewEncoder(f)
|
||||||
|
jsonEncoder.SetIndent(" ", " ")
|
||||||
|
case "md":
|
||||||
|
fmt.Fprintf(f, "| %s |\n", strings.Join(columns, " | "))
|
||||||
|
seps := make([]string, len(columns))
|
||||||
|
for i := range seps {
|
||||||
|
seps[i] = "---"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(f, "| %s |\n", strings.Join(seps, " | "))
|
||||||
|
default:
|
||||||
|
return connection.QueryResult{Success: false, Message: "Unsupported format: " + format}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rowMap := range data {
|
||||||
|
record := make([]string, len(columns))
|
||||||
|
for i, col := range columns {
|
||||||
|
val := rowMap[col]
|
||||||
|
if val == nil {
|
||||||
|
record[i] = "NULL"
|
||||||
|
} else {
|
||||||
|
s := fmt.Sprintf("%v", val)
|
||||||
|
if format == "md" {
|
||||||
|
s = strings.ReplaceAll(s, "|", "\\|")
|
||||||
|
s = strings.ReplaceAll(s, "\n", "<br>")
|
||||||
|
}
|
||||||
|
record[i] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case "csv", "xlsx":
|
||||||
|
if err := csvWriter.Write(record); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
||||||
|
}
|
||||||
|
case "json":
|
||||||
|
if !isJsonFirstRow {
|
||||||
|
f.WriteString(",\n")
|
||||||
|
}
|
||||||
|
if err := jsonEncoder.Encode(rowMap); err != nil {
|
||||||
|
return connection.QueryResult{Success: false, Message: "Write error: " + err.Error()}
|
||||||
|
}
|
||||||
|
isJsonFirstRow = false
|
||||||
|
case "md":
|
||||||
|
fmt.Fprintf(f, "| %s |\n", strings.Join(record, " | "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if format == "json" {
|
||||||
|
f.WriteString("\n]")
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection.QueryResult{Success: true, Message: "Export successful"}
|
||||||
|
}
|
||||||
87
internal/connection/types.go
Normal file
87
internal/connection/types.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package connection
|
||||||
|
|
||||||
|
// SSHConfig holds SSH connection details
|
||||||
|
type SSHConfig struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
KeyPath string `json:"keyPath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionConfig holds database connection details including SSH
|
||||||
|
type ConnectionConfig struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
UseSSH bool `json:"useSSH"`
|
||||||
|
SSH SSHConfig `json:"ssh"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryResult is the standard response format for Wails methods
|
||||||
|
type QueryResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
Fields []string `json:"fields,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColumnDefinition represents a table column
|
||||||
|
type ColumnDefinition struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Nullable string `json:"nullable"` // YES/NO
|
||||||
|
Key string `json:"key"` // PRI, UNI, MUL
|
||||||
|
Default *string `json:"default"`
|
||||||
|
Extra string `json:"extra"` // auto_increment
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexDefinition represents a table index
|
||||||
|
type IndexDefinition struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ColumnName string `json:"columnName"`
|
||||||
|
NonUnique int `json:"nonUnique"`
|
||||||
|
SeqInIndex int `json:"seqInIndex"`
|
||||||
|
IndexType string `json:"indexType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForeignKeyDefinition represents a foreign key
|
||||||
|
type ForeignKeyDefinition struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ColumnName string `json:"columnName"`
|
||||||
|
RefTableName string `json:"refTableName"`
|
||||||
|
RefColumnName string `json:"refColumnName"`
|
||||||
|
ConstraintName string `json:"constraintName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerDefinition represents a trigger
|
||||||
|
type TriggerDefinition struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Timing string `json:"timing"` // BEFORE/AFTER
|
||||||
|
Event string `json:"event"` // INSERT/UPDATE/DELETE
|
||||||
|
Statement string `json:"statement"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColumnDefinitionWithTable represents a column with its table name (for search/autocomplete)
|
||||||
|
type ColumnDefinitionWithTable struct {
|
||||||
|
TableName string `json:"tableName"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRow represents a row update with keys (WHERE) and values (SET)
|
||||||
|
type UpdateRow struct {
|
||||||
|
Keys map[string]interface{} `json:"keys"`
|
||||||
|
Values map[string]interface{} `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeSet represents a batch of changes
|
||||||
|
type ChangeSet struct {
|
||||||
|
Inserts []map[string]interface{} `json:"inserts"`
|
||||||
|
Updates []UpdateRow `json:"updates"`
|
||||||
|
Deletes []map[string]interface{} `json:"deletes"`
|
||||||
|
}
|
||||||
44
internal/db/database.go
Normal file
44
internal/db/database.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Database interface {
|
||||||
|
Connect(config connection.ConnectionConfig) error
|
||||||
|
Close() error
|
||||||
|
Ping() error
|
||||||
|
Query(query string) ([]map[string]interface{}, []string, error)
|
||||||
|
Exec(query string) (int64, error)
|
||||||
|
GetDatabases() ([]string, error)
|
||||||
|
GetTables(dbName string) ([]string, error)
|
||||||
|
GetCreateStatement(dbName, tableName string) (string, error)
|
||||||
|
GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error)
|
||||||
|
GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error)
|
||||||
|
GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error)
|
||||||
|
GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error)
|
||||||
|
GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type BatchApplier interface {
|
||||||
|
ApplyChanges(tableName string, changes connection.ChangeSet) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factory
|
||||||
|
func NewDatabase(dbType string) (Database, error) {
|
||||||
|
switch dbType {
|
||||||
|
case "mysql":
|
||||||
|
return &MySQLDB{}, nil
|
||||||
|
case "postgres":
|
||||||
|
return &PostgresDB{}, nil
|
||||||
|
case "sqlite":
|
||||||
|
return &SQLiteDB{}, nil
|
||||||
|
default:
|
||||||
|
// Default to MySQL for backward compatibility if empty
|
||||||
|
if dbType == "" {
|
||||||
|
return &MySQLDB{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unsupported database type: %s", dbType)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@@ -6,6 +6,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/ssh"
|
||||||
|
"GoNavi-Wails/internal/utils"
|
||||||
|
|
||||||
_ "github.com/go-sql-driver/mysql"
|
_ "github.com/go-sql-driver/mysql"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,15 +17,13 @@ type MySQLDB struct {
|
|||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MySQLDB) getDSN(config ConnectionConfig) string {
|
func (m *MySQLDB) getDSN(config connection.ConnectionConfig) string {
|
||||||
database := config.Database
|
database := config.Database
|
||||||
protocol := "tcp"
|
protocol := "tcp"
|
||||||
address := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
address := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||||
|
|
||||||
// Reuse SSH logic from app.go/ssh.go if available globally or duplicate logic
|
|
||||||
// For now assuming RegisterSSHNetwork is global
|
|
||||||
if config.UseSSH {
|
if config.UseSSH {
|
||||||
netName, err := RegisterSSHNetwork(config.SSH)
|
netName, err := ssh.RegisterSSHNetwork(config.SSH)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
protocol = netName
|
protocol = netName
|
||||||
address = fmt.Sprintf("%s:%d", config.Host, config.Port)
|
address = fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||||
@@ -32,7 +34,7 @@ func (m *MySQLDB) getDSN(config ConnectionConfig) string {
|
|||||||
config.User, config.Password, protocol, address, database)
|
config.User, config.Password, protocol, address, database)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MySQLDB) Connect(config ConnectionConfig) error {
|
func (m *MySQLDB) Connect(config connection.ConnectionConfig) error {
|
||||||
dsn := m.getDSN(config)
|
dsn := m.getDSN(config)
|
||||||
db, err := sql.Open("mysql", dsn)
|
db, err := sql.Open("mysql", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -53,7 +55,7 @@ func (m *MySQLDB) Ping() error {
|
|||||||
if m.conn == nil {
|
if m.conn == nil {
|
||||||
return fmt.Errorf("connection not open")
|
return fmt.Errorf("connection not open")
|
||||||
}
|
}
|
||||||
ctx, cancel := contextWithTimeout(5 * time.Second)
|
ctx, cancel := utils.ContextWithTimeout(5 * time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return m.conn.PingContext(ctx)
|
return m.conn.PingContext(ctx)
|
||||||
}
|
}
|
||||||
@@ -133,8 +135,6 @@ func (m *MySQLDB) GetDatabases() ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MySQLDB) GetTables(dbName string) ([]string, error) {
|
func (m *MySQLDB) GetTables(dbName string) ([]string, error) {
|
||||||
// MySQL connection is usually bound to a DB, but we might need to query another DB or just SHOW TABLES
|
|
||||||
// If current conn is bound to dbName, fine. If not, SHOW TABLES FROM dbName
|
|
||||||
query := "SHOW TABLES"
|
query := "SHOW TABLES"
|
||||||
if dbName != "" {
|
if dbName != "" {
|
||||||
query = fmt.Sprintf("SHOW TABLES FROM `%s`", dbName)
|
query = fmt.Sprintf("SHOW TABLES FROM `%s`", dbName)
|
||||||
@@ -147,10 +147,9 @@ func (m *MySQLDB) GetTables(dbName string) ([]string, error) {
|
|||||||
|
|
||||||
var tables []string
|
var tables []string
|
||||||
for _, row := range data {
|
for _, row := range data {
|
||||||
// The column name is usually "Tables_in_dbname"
|
|
||||||
for _, v := range row {
|
for _, v := range row {
|
||||||
tables = append(tables, fmt.Sprintf("%v", v))
|
tables = append(tables, fmt.Sprintf("%v", v))
|
||||||
break // Only first column
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tables, nil
|
return tables, nil
|
||||||
@@ -158,7 +157,6 @@ func (m *MySQLDB) GetTables(dbName string) ([]string, error) {
|
|||||||
|
|
||||||
func (m *MySQLDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
func (m *MySQLDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||||
query := fmt.Sprintf("SHOW CREATE TABLE `%s`.`%s`", dbName, tableName)
|
query := fmt.Sprintf("SHOW CREATE TABLE `%s`.`%s`", dbName, tableName)
|
||||||
// If dbName is already selected or empty, just table name
|
|
||||||
if dbName == "" {
|
if dbName == "" {
|
||||||
query = fmt.Sprintf("SHOW CREATE TABLE `%s`", tableName)
|
query = fmt.Sprintf("SHOW CREATE TABLE `%s`", tableName)
|
||||||
}
|
}
|
||||||
@@ -176,7 +174,7 @@ func (m *MySQLDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
|||||||
return "", fmt.Errorf("create statement not found")
|
return "", fmt.Errorf("create statement not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MySQLDB) GetColumns(dbName, tableName string) ([]ColumnDefinition, error) {
|
func (m *MySQLDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||||
query := fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`.`%s`", dbName, tableName)
|
query := fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`.`%s`", dbName, tableName)
|
||||||
if dbName == "" {
|
if dbName == "" {
|
||||||
query = fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`", tableName)
|
query = fmt.Sprintf("SHOW FULL COLUMNS FROM `%s`", tableName)
|
||||||
@@ -187,9 +185,9 @@ func (m *MySQLDB) GetColumns(dbName, tableName string) ([]ColumnDefinition, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var columns []ColumnDefinition
|
var columns []connection.ColumnDefinition
|
||||||
for _, row := range data {
|
for _, row := range data {
|
||||||
col := ColumnDefinition{
|
col := connection.ColumnDefinition{
|
||||||
Name: fmt.Sprintf("%v", row["Field"]),
|
Name: fmt.Sprintf("%v", row["Field"]),
|
||||||
Type: fmt.Sprintf("%v", row["Type"]),
|
Type: fmt.Sprintf("%v", row["Type"]),
|
||||||
Nullable: fmt.Sprintf("%v", row["Null"]),
|
Nullable: fmt.Sprintf("%v", row["Null"]),
|
||||||
@@ -208,7 +206,7 @@ func (m *MySQLDB) GetColumns(dbName, tableName string) ([]ColumnDefinition, erro
|
|||||||
return columns, nil
|
return columns, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MySQLDB) GetIndexes(dbName, tableName string) ([]IndexDefinition, error) {
|
func (m *MySQLDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||||
query := fmt.Sprintf("SHOW INDEX FROM `%s`.`%s`", dbName, tableName)
|
query := fmt.Sprintf("SHOW INDEX FROM `%s`.`%s`", dbName, tableName)
|
||||||
if dbName == "" {
|
if dbName == "" {
|
||||||
query = fmt.Sprintf("SHOW INDEX FROM `%s`", tableName)
|
query = fmt.Sprintf("SHOW INDEX FROM `%s`", tableName)
|
||||||
@@ -219,12 +217,10 @@ func (m *MySQLDB) GetIndexes(dbName, tableName string) ([]IndexDefinition, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var indexes []IndexDefinition
|
var indexes []connection.IndexDefinition
|
||||||
for _, row := range data {
|
for _, row := range data {
|
||||||
// Need to handle types carefully. Non_unique is int usually.
|
|
||||||
nonUnique := 0
|
nonUnique := 0
|
||||||
if val, ok := row["Non_unique"]; ok {
|
if val, ok := row["Non_unique"]; ok {
|
||||||
// Handle various number types (json decoding might be float64)
|
|
||||||
if f, ok := val.(float64); ok {
|
if f, ok := val.(float64); ok {
|
||||||
nonUnique = int(f)
|
nonUnique = int(f)
|
||||||
} else if i, ok := val.(int64); ok {
|
} else if i, ok := val.(int64); ok {
|
||||||
@@ -241,7 +237,7 @@ func (m *MySQLDB) GetIndexes(dbName, tableName string) ([]IndexDefinition, error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
idx := IndexDefinition{
|
idx := connection.IndexDefinition{
|
||||||
Name: fmt.Sprintf("%v", row["Key_name"]),
|
Name: fmt.Sprintf("%v", row["Key_name"]),
|
||||||
ColumnName: fmt.Sprintf("%v", row["Column_name"]),
|
ColumnName: fmt.Sprintf("%v", row["Column_name"]),
|
||||||
NonUnique: nonUnique,
|
NonUnique: nonUnique,
|
||||||
@@ -253,7 +249,7 @@ func (m *MySQLDB) GetIndexes(dbName, tableName string) ([]IndexDefinition, error
|
|||||||
return indexes, nil
|
return indexes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MySQLDB) GetForeignKeys(dbName, tableName string) ([]ForeignKeyDefinition, error) {
|
func (m *MySQLDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||||
query := fmt.Sprintf(`SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
|
query := fmt.Sprintf(`SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
|
||||||
FROM information_schema.KEY_COLUMN_USAGE
|
FROM information_schema.KEY_COLUMN_USAGE
|
||||||
WHERE TABLE_SCHEMA = '%s' AND TABLE_NAME = '%s' AND REFERENCED_TABLE_NAME IS NOT NULL`, dbName, tableName)
|
WHERE TABLE_SCHEMA = '%s' AND TABLE_NAME = '%s' AND REFERENCED_TABLE_NAME IS NOT NULL`, dbName, tableName)
|
||||||
@@ -263,9 +259,9 @@ func (m *MySQLDB) GetForeignKeys(dbName, tableName string) ([]ForeignKeyDefiniti
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var fks []ForeignKeyDefinition
|
var fks []connection.ForeignKeyDefinition
|
||||||
for _, row := range data {
|
for _, row := range data {
|
||||||
fk := ForeignKeyDefinition{
|
fk := connection.ForeignKeyDefinition{
|
||||||
Name: fmt.Sprintf("%v", row["CONSTRAINT_NAME"]),
|
Name: fmt.Sprintf("%v", row["CONSTRAINT_NAME"]),
|
||||||
ColumnName: fmt.Sprintf("%v", row["COLUMN_NAME"]),
|
ColumnName: fmt.Sprintf("%v", row["COLUMN_NAME"]),
|
||||||
RefTableName: fmt.Sprintf("%v", row["REFERENCED_TABLE_NAME"]),
|
RefTableName: fmt.Sprintf("%v", row["REFERENCED_TABLE_NAME"]),
|
||||||
@@ -277,16 +273,16 @@ func (m *MySQLDB) GetForeignKeys(dbName, tableName string) ([]ForeignKeyDefiniti
|
|||||||
return fks, nil
|
return fks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MySQLDB) GetTriggers(dbName, tableName string) ([]TriggerDefinition, error) {
|
func (m *MySQLDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||||
query := fmt.Sprintf("SHOW TRIGGERS FROM `%s` WHERE `Table` = '%s'", dbName, tableName)
|
query := fmt.Sprintf("SHOW TRIGGERS FROM `%s` WHERE `Table` = '%s'", dbName, tableName)
|
||||||
data, _, err := m.Query(query)
|
data, _, err := m.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var triggers []TriggerDefinition
|
var triggers []connection.TriggerDefinition
|
||||||
for _, row := range data {
|
for _, row := range data {
|
||||||
trig := TriggerDefinition{
|
trig := connection.TriggerDefinition{
|
||||||
Name: fmt.Sprintf("%v", row["Trigger"]),
|
Name: fmt.Sprintf("%v", row["Trigger"]),
|
||||||
Timing: fmt.Sprintf("%v", row["Timing"]),
|
Timing: fmt.Sprintf("%v", row["Timing"]),
|
||||||
Event: fmt.Sprintf("%v", row["Event"]),
|
Event: fmt.Sprintf("%v", row["Event"]),
|
||||||
@@ -297,7 +293,7 @@ func (m *MySQLDB) GetTriggers(dbName, tableName string) ([]TriggerDefinition, er
|
|||||||
return triggers, nil
|
return triggers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MySQLDB) ApplyChanges(tableName string, changes ChangeSet) error {
|
func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||||
if m.conn == nil {
|
if m.conn == nil {
|
||||||
return fmt.Errorf("connection not open")
|
return fmt.Errorf("connection not open")
|
||||||
}
|
}
|
||||||
@@ -306,11 +302,10 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes ChangeSet) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback() // Rollback if not committed
|
defer tx.Rollback()
|
||||||
|
|
||||||
// 1. Deletes
|
// 1. Deletes
|
||||||
for _, pk := range changes.Deletes {
|
for _, pk := range changes.Deletes {
|
||||||
// Build WHERE clause from PK
|
|
||||||
var wheres []string
|
var wheres []string
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
for k, v := range pk {
|
for k, v := range pk {
|
||||||
@@ -318,7 +313,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes ChangeSet) error {
|
|||||||
args = append(args, v)
|
args = append(args, v)
|
||||||
}
|
}
|
||||||
if len(wheres) == 0 {
|
if len(wheres) == 0 {
|
||||||
continue // Safety
|
continue
|
||||||
}
|
}
|
||||||
query := fmt.Sprintf("DELETE FROM `%s` WHERE %s", tableName, strings.Join(wheres, " AND "))
|
query := fmt.Sprintf("DELETE FROM `%s` WHERE %s", tableName, strings.Join(wheres, " AND "))
|
||||||
if _, err := tx.Exec(query, args...); err != nil {
|
if _, err := tx.Exec(query, args...); err != nil {
|
||||||
@@ -347,7 +342,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes ChangeSet) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(wheres) == 0 {
|
if len(wheres) == 0 {
|
||||||
return fmt.Errorf("update requires keys") // Safety
|
return fmt.Errorf("update requires keys")
|
||||||
}
|
}
|
||||||
|
|
||||||
query := fmt.Sprintf("UPDATE `%s` SET %s WHERE %s", tableName, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
query := fmt.Sprintf("UPDATE `%s` SET %s WHERE %s", tableName, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||||
@@ -381,13 +376,9 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes ChangeSet) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MySQLDB) GetAllColumns(dbName string) ([]ColumnDefinitionWithTable, error) {
|
func (m *MySQLDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", dbName)
|
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", dbName)
|
||||||
if dbName == "" {
|
if dbName == "" {
|
||||||
// If dbName is empty, we might need to use the current DB from connection?
|
|
||||||
// But information_schema requires a schema filter usually or it returns all.
|
|
||||||
// Let's assume dbName is provided or we try to get it.
|
|
||||||
// For MVP, if empty, we return empty or try "SELECT DATABASE()".
|
|
||||||
return nil, fmt.Errorf("database name required for GetAllColumns")
|
return nil, fmt.Errorf("database name required for GetAllColumns")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,9 +387,9 @@ func (m *MySQLDB) GetAllColumns(dbName string) ([]ColumnDefinitionWithTable, err
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var cols []ColumnDefinitionWithTable
|
var cols []connection.ColumnDefinitionWithTable
|
||||||
for _, row := range data {
|
for _, row := range data {
|
||||||
col := ColumnDefinitionWithTable{
|
col := connection.ColumnDefinitionWithTable{
|
||||||
TableName: fmt.Sprintf("%v", row["TABLE_NAME"]),
|
TableName: fmt.Sprintf("%v", row["TABLE_NAME"]),
|
||||||
Name: fmt.Sprintf("%v", row["COLUMN_NAME"]),
|
Name: fmt.Sprintf("%v", row["COLUMN_NAME"]),
|
||||||
Type: fmt.Sprintf("%v", row["COLUMN_TYPE"]),
|
Type: fmt.Sprintf("%v", row["COLUMN_TYPE"]),
|
||||||
@@ -406,4 +397,4 @@ func (m *MySQLDB) GetAllColumns(dbName string) ([]ColumnDefinitionWithTable, err
|
|||||||
cols = append(cols, col)
|
cols = append(cols, col)
|
||||||
}
|
}
|
||||||
return cols, nil
|
return cols, nil
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package main
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/utils"
|
||||||
|
|
||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,22 +15,13 @@ type PostgresDB struct {
|
|||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) getDSN(config ConnectionConfig) string {
|
func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string {
|
||||||
// postgres://user:password@host:port/dbname?sslmode=disable
|
// postgres://user:password@host:port/dbname?sslmode=disable
|
||||||
// If SSH is used, host/port will be local tunnel, similar to MySQL
|
|
||||||
host := config.Host
|
host := config.Host
|
||||||
port := config.Port
|
port := config.Port
|
||||||
|
// SSH placeholder kept from original
|
||||||
if config.UseSSH {
|
if config.UseSSH {
|
||||||
// Assuming generic SSH tunnel registered for PG as well
|
// Logic to be implemented
|
||||||
// But lib/pq registerDialer is different or harder to hook.
|
|
||||||
// For MVP, if we use the same RegisterSSHNetwork, we need to see if lib/pq supports custom dialer easily.
|
|
||||||
// lib/pq uses 'postgres' driver. hooking dialer is not standard in DSN.
|
|
||||||
// Standard SSH tunneling: Listen on local port -> Forward to remote.
|
|
||||||
// Our implementation in ssh.go does RegisterDialContext which works for drivers that support it (mysql does).
|
|
||||||
// lib/pq *does not* support DialContext in sql.Open directly via DSN easily without wrapping connector.
|
|
||||||
//
|
|
||||||
// FOR NOW: Disable SSH for Postgres in MVP or use basic local forwarding manually if we had time.
|
|
||||||
// Let's assume direct connection for PG MVP.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dbname := config.Database
|
dbname := config.Database
|
||||||
@@ -39,7 +33,7 @@ func (p *PostgresDB) getDSN(config ConnectionConfig) string {
|
|||||||
config.User, config.Password, host, port, dbname)
|
config.User, config.Password, host, port, dbname)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) Connect(config ConnectionConfig) error {
|
func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
|
||||||
dsn := p.getDSN(config)
|
dsn := p.getDSN(config)
|
||||||
db, err := sql.Open("postgres", dsn)
|
db, err := sql.Open("postgres", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -60,7 +54,7 @@ func (p *PostgresDB) Ping() error {
|
|||||||
if p.conn == nil {
|
if p.conn == nil {
|
||||||
return fmt.Errorf("connection not open")
|
return fmt.Errorf("connection not open")
|
||||||
}
|
}
|
||||||
ctx, cancel := contextWithTimeout(5 * time.Second)
|
ctx, cancel := utils.ContextWithTimeout(5 * time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return p.conn.PingContext(ctx)
|
return p.conn.PingContext(ctx)
|
||||||
}
|
}
|
||||||
@@ -139,11 +133,6 @@ func (p *PostgresDB) GetDatabases() ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) GetTables(dbName string) ([]string, error) {
|
func (p *PostgresDB) GetTables(dbName string) ([]string, error) {
|
||||||
// In PG, dbName usually implies a separate connection.
|
|
||||||
// If we are already connected to 'postgres' db, we can't easily query tables of another DB without reconnecting.
|
|
||||||
// For MVP simplicity: we assume the user connects to the specific DB, or we list tables of current DB.
|
|
||||||
// If dbName is provided and different from current, we might need to error or reconnect (logic in App layer).
|
|
||||||
// Here we query current connection's tables.
|
|
||||||
query := "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname != 'pg_catalog' AND schemaname != 'information_schema'"
|
query := "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname != 'pg_catalog' AND schemaname != 'information_schema'"
|
||||||
data, _, err := p.Query(query)
|
data, _, err := p.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -160,31 +149,25 @@ func (p *PostgresDB) GetTables(dbName string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
func (p *PostgresDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||||
// PG doesn't have SHOW CREATE TABLE. We need a complex query or use pg_dump logic.
|
|
||||||
// MVP: return placeholder or simple definition.
|
|
||||||
// Or use a query to reconstruct it (simplified).
|
|
||||||
return fmt.Sprintf("-- SHOW CREATE TABLE not fully supported for PostgreSQL in this MVP.\n-- Table: %s", tableName), nil
|
return fmt.Sprintf("-- SHOW CREATE TABLE not fully supported for PostgreSQL in this MVP.\n-- Table: %s", tableName), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) GetColumns(dbName, tableName string) ([]ColumnDefinition, error) {
|
func (p *PostgresDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||||
// TODO: Implement query against information_schema.columns
|
return []connection.ColumnDefinition{}, nil
|
||||||
return []ColumnDefinition{}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) GetIndexes(dbName, tableName string) ([]IndexDefinition, error) {
|
func (p *PostgresDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||||
// TODO: Implement query against pg_indexes
|
return []connection.IndexDefinition{}, nil
|
||||||
return []IndexDefinition{}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) GetForeignKeys(dbName, tableName string) ([]ForeignKeyDefinition, error) {
|
func (p *PostgresDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||||
return []ForeignKeyDefinition{}, nil
|
return []connection.ForeignKeyDefinition{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) GetTriggers(dbName, tableName string) ([]TriggerDefinition, error) {
|
func (p *PostgresDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||||
return []TriggerDefinition{}, nil
|
return []connection.TriggerDefinition{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostgresDB) GetAllColumns(dbName string) ([]ColumnDefinitionWithTable, error) {
|
func (p *PostgresDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
// TODO: Implement using information_schema.columns
|
return []connection.ColumnDefinitionWithTable{}, nil
|
||||||
return []ColumnDefinitionWithTable{}, nil
|
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package main
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
"GoNavi-Wails/internal/utils"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,8 +15,7 @@ type SQLiteDB struct {
|
|||||||
conn *sql.DB
|
conn *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) Connect(config ConnectionConfig) error {
|
func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error {
|
||||||
// Host field will be used as File Path for SQLite
|
|
||||||
dsn := config.Host
|
dsn := config.Host
|
||||||
db, err := sql.Open("sqlite", dsn)
|
db, err := sql.Open("sqlite", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -34,7 +36,7 @@ func (s *SQLiteDB) Ping() error {
|
|||||||
if s.conn == nil {
|
if s.conn == nil {
|
||||||
return fmt.Errorf("connection not open")
|
return fmt.Errorf("connection not open")
|
||||||
}
|
}
|
||||||
ctx, cancel := contextWithTimeout(5 * time.Second)
|
ctx, cancel := utils.ContextWithTimeout(5 * time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return s.conn.PingContext(ctx)
|
return s.conn.PingContext(ctx)
|
||||||
}
|
}
|
||||||
@@ -98,7 +100,6 @@ func (s *SQLiteDB) Exec(query string) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) GetDatabases() ([]string, error) {
|
func (s *SQLiteDB) GetDatabases() ([]string, error) {
|
||||||
// SQLite only has one DB (the file).
|
|
||||||
return []string{"main"}, nil
|
return []string{"main"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,24 +133,22 @@ func (s *SQLiteDB) GetCreateStatement(dbName, tableName string) (string, error)
|
|||||||
return "", fmt.Errorf("create statement not found")
|
return "", fmt.Errorf("create statement not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) GetColumns(dbName, tableName string) ([]ColumnDefinition, error) {
|
func (s *SQLiteDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||||
// SQLite has PRAGMA table_info(tableName)
|
return []connection.ColumnDefinition{}, nil
|
||||||
// For MVP Stub:
|
|
||||||
return []ColumnDefinition{}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) GetIndexes(dbName, tableName string) ([]IndexDefinition, error) {
|
func (s *SQLiteDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||||
return []IndexDefinition{}, nil
|
return []connection.IndexDefinition{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) GetForeignKeys(dbName, tableName string) ([]ForeignKeyDefinition, error) {
|
func (s *SQLiteDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||||
return []ForeignKeyDefinition{}, nil
|
return []connection.ForeignKeyDefinition{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]TriggerDefinition, error) {
|
func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||||
return []TriggerDefinition{}, nil
|
return []connection.TriggerDefinition{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) GetAllColumns(dbName string) ([]ColumnDefinitionWithTable, error) {
|
func (s *SQLiteDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||||
return []ColumnDefinitionWithTable{}, nil
|
return []connection.ColumnDefinitionWithTable{}, nil
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package ssh
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -7,18 +7,12 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/connection"
|
||||||
|
|
||||||
"github.com/go-sql-driver/mysql"
|
"github.com/go-sql-driver/mysql"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SSHConfig struct {
|
|
||||||
Host string `json:"host"`
|
|
||||||
Port int `json:"port"`
|
|
||||||
User string `json:"user"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
KeyPath string `json:"keyPath"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ViaSSHDialer registers a custom network for MySQL that proxies through SSH
|
// ViaSSHDialer registers a custom network for MySQL that proxies through SSH
|
||||||
type ViaSSHDialer struct {
|
type ViaSSHDialer struct {
|
||||||
sshClient *ssh.Client
|
sshClient *ssh.Client
|
||||||
@@ -29,7 +23,7 @@ func (d *ViaSSHDialer) Dial(ctx context.Context, addr string) (net.Conn, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// connectSSH establishes an SSH connection and returns a Dialer
|
// connectSSH establishes an SSH connection and returns a Dialer
|
||||||
func connectSSH(config SSHConfig) (*ssh.Client, error) {
|
func connectSSH(config connection.SSHConfig) (*ssh.Client, error) {
|
||||||
authMethods := []ssh.AuthMethod{}
|
authMethods := []ssh.AuthMethod{}
|
||||||
|
|
||||||
if config.KeyPath != "" {
|
if config.KeyPath != "" {
|
||||||
@@ -59,7 +53,7 @@ func connectSSH(config SSHConfig) (*ssh.Client, error) {
|
|||||||
|
|
||||||
// RegisterSSHNetwork registers a unique network name for a specific SSH tunnel
|
// RegisterSSHNetwork registers a unique network name for a specific SSH tunnel
|
||||||
// Returns the network name to use in DSN
|
// Returns the network name to use in DSN
|
||||||
func RegisterSSHNetwork(sshConfig SSHConfig) (string, error) {
|
func RegisterSSHNetwork(sshConfig connection.SSHConfig) (string, error) {
|
||||||
client, err := connectSSH(sshConfig)
|
client, err := connectSSH(sshConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
11
internal/utils/utils.go
Normal file
11
internal/utils/utils.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContextWithTimeout returns a context with a timeout
|
||||||
|
func ContextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(context.Background(), d)
|
||||||
|
}
|
||||||
12
main.go
12
main.go
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
|
||||||
|
"GoNavi-Wails/internal/app"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
@@ -13,7 +15,7 @@ var assets embed.FS
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Create an instance of the app structure
|
// Create an instance of the app structure
|
||||||
app := NewApp()
|
application := app.NewApp()
|
||||||
|
|
||||||
// Create application with options
|
// Create application with options
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
@@ -24,14 +26,14 @@ func main() {
|
|||||||
Assets: assets,
|
Assets: assets,
|
||||||
},
|
},
|
||||||
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
||||||
OnStartup: app.startup,
|
OnStartup: application.Startup,
|
||||||
OnShutdown: app.shutdown, // Bind shutdown method
|
OnShutdown: application.Shutdown,
|
||||||
Bind: []interface{}{
|
Bind: []interface{}{
|
||||||
app,
|
application,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
println("Error:", err.Error())
|
println("Error:", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
utils.go
10
utils.go
@@ -1,10 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
|
|
||||||
return context.WithTimeout(context.Background(), d)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "GoNavi-Wails",
|
"name": "GoNavi",
|
||||||
"outputfilename": "gonavi",
|
"outputfilename": "GoNavi",
|
||||||
"frontend:install": "npm install",
|
"frontend:install": "npm install",
|
||||||
"frontend:build": "npm run build",
|
"frontend:build": "npm run build",
|
||||||
"frontend:dev:watcher": "npm run dev",
|
"frontend:dev:watcher": "npm run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user