feat(frontend): 升级 DataGrid 组件并引入高性能拖拽交互

- 实现基于原生 DOM 事件的零渲染列宽拖拽,彻底解决卡顿与误触排序问题
- 查询编辑器集成 DataGrid,支持 SQL 结果直接编辑与事务提交
- 侧边栏新增上下文感知的 "新建查询" 快捷入口
- 优化 TabManager 渲染逻辑与全局布局,消除不必要的滚动条
This commit is contained in:
杨国锋
2026-02-02 11:32:49 +08:00
parent e0181cc7ac
commit af91c916c3
33 changed files with 2020 additions and 1618 deletions

3
.gitignore vendored
View File

@@ -13,4 +13,5 @@ node_modules/
dist/
.DS_Store
.gemini-clipboard
.gemini-clipboard
GoNavi-Wails

647
app.go
View File

@@ -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"}
}

View File

@@ -4,7 +4,7 @@
APP_NAME="GoNavi"
DIST_DIR="dist"
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:]]')

View File

@@ -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"`
}

View File

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

View File

@@ -2,6 +2,7 @@ html, body, #root {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden; /* Disable global scrollbar */
}
/* 侧边栏 Tree 样式优化 */

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
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 TabManager from './components/TabManager';
import ConnectionModal from './components/ConnectionModal';
@@ -11,27 +11,63 @@ const { Sider, Content } = Layout;
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
const { darkMode, toggleDarkMode } = useStore();
const { darkMode, toggleDarkMode, addTab, activeContext } = useStore();
// Sidebar Resizing
const [sidebarWidth, setSidebarWidth] = useState(300);
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) => {
e.preventDefault();
if (ghostRef.current) {
ghostRef.current.style.left = `${sidebarWidth}px`;
ghostRef.current.style.display = 'block';
}
sidebarDragRef.current = { startX: e.clientX, startWidth: sidebarWidth };
latestMouseX.current = e.clientX; // Init
document.addEventListener('mousemove', handleSidebarMouseMove);
document.addEventListener('mouseup', handleSidebarMouseUp);
};
const handleSidebarMouseMove = (e: MouseEvent) => {
if (!sidebarDragRef.current) return;
const delta = e.clientX - sidebarDragRef.current.startX;
const newWidth = Math.max(200, Math.min(600, sidebarDragRef.current.startWidth + delta));
setSidebarWidth(newWidth);
latestMouseX.current = e.clientX; // Always update latest pos
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;
document.removeEventListener('mousemove', handleSidebarMouseMove);
document.removeEventListener('mouseup', handleSidebarMouseUp);
@@ -53,12 +89,26 @@ function App() {
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<Layout style={{ height: '100vh' }}>
<Sider theme={darkMode ? "dark" : "light"} width={sidebarWidth} style={{ borderRight: darkMode ? '1px solid #303030' : '1px solid #f0f0f0', position: 'relative' }}>
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
<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' }}>
<span style={{ fontWeight: 'bold', paddingLeft: 8 }}>GoNavi</span>
<div>
<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="新建连接" />
</div>
</div>
@@ -80,10 +130,26 @@ function App() {
title="拖动调整宽度"
/>
</Sider>
<Content style={{ background: darkMode ? '#141414' : '#fff' }}>
<Content style={{ background: darkMode ? '#141414' : '#fff', overflow: 'hidden' }}>
<TabManager />
</Content>
<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>
</ConfigProvider>
);

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Collapse, Select } from 'antd';
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 [form] = Form.useForm();

View 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);

View File

@@ -1,200 +1,9 @@
import React, { useEffect, useState, useRef, useContext, useMemo, useCallback } from 'react';
import { Table, message, Spin, Input, Button, Space, Select, Tag, Dropdown, MenuProps, Form, Popconfirm, Pagination } 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 React, { useEffect, useState, useCallback } from 'react';
import { message } from 'antd';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { MySQLQuery, ImportData, ExportTable, ApplyChanges, DBGetColumns } from '../../wailsjs/go/main/App';
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 ---
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>
);
});
import { MySQLQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid from './DataGrid';
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const [data, setData] = useState<any[]>([]);
@@ -209,39 +18,12 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
total: 0
});
const [form] = Form.useForm();
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
const [showFilter, setShowFilter] = useState(false);
const [filterConditions, setFilterConditions] = useState<{ id: number, column: string, op: string, value: string }[]>([]);
const [nextFilterId, setNextFilterId] = useState(1);
const [filterConditions, setFilterConditions] = useState<any[]>([]);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
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) => {
const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize) => {
setLoading(true);
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) {
@@ -320,11 +102,6 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
setData(resultData.map((row: any, i: number) => ({ ...row, key: `row-${i}` })));
setPagination(prev => ({ ...prev, current: page, pageSize: size, total: totalRecords }));
setAddedRows([]);
setModifiedRows({});
setDeletedRowKeys(new Set());
setSelectedRowKeys([]);
} else {
message.error(resData.message);
}
@@ -332,323 +109,42 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
message.error("Error fetching data: " + e.message);
}
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(() => {
fetchData(1, pagination.pageSize);
}, [tab, sortInfo]);
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 }
}), []);
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
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' }}>
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}></Button>
<Button icon={<ImportOutlined />} onClick={handleImport}></Button>
<Dropdown menu={{ items: exportMenu }}><Button icon={<ExportOutlined />}> <DownOutlined /></Button></Dropdown>
<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>
<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={() => fetchData()}></Button>)}
<div style={{ width: 1, background: '#eee', height: 20, margin: '0 8px' }} />
<Button icon={<FilterOutlined />} type={showFilter ? 'primary' : 'default'} onClick={() => { setShowFilter(!showFilter); 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 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>
<DataGrid
data={data}
columnNames={columnNames}
loading={loading}
tableName={tab.tableName}
dbName={tab.dbName}
connectionId={tab.connectionId}
pkColumns={pkColumns}
onReload={handleReload}
onSort={handleSort}
onPageChange={handlePageChange}
pagination={pagination}
showFilter={showFilter}
onToggleFilter={handleToggleFilter}
onApplyFilter={handleApplyFilter}
/>
);
};

View File

@@ -1,20 +1,31 @@
import React, { useState, useEffect, useRef } from '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 { format } from 'sql-formatter';
import { TabData } from '../types';
import { TabData, ColumnDefinition } from '../types';
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 [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
// DataGrid State
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 [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
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
const [editorHeight, setEditorHeight] = useState(300);
const editorRef = useRef<any>(null);
@@ -31,16 +42,44 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
// If opening a saved query, load its SQL
useEffect(() => {
if (tab.query) {
setQuery(tab.query);
}
if (tab.query) setQuery(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
useEffect(() => {
const fetchMetadata = async () => {
const conn = connections.find(c => c.id === tab.connectionId);
if (!conn) return;
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn || !currentDb) return;
const config = {
...conn.config,
@@ -51,26 +90,25 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const dbName = tab.dbName || conn.config.database || "";
// Fetch Tables
const resTables = await DBGetTables(config as any, dbName);
const resTables = await DBGetTables(config as any, currentDb);
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);
tablesRef.current = tableNames;
} else {
tablesRef.current = [];
}
// Fetch All Columns (Optimized for autocomplete)
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)) {
allColumnsRef.current = resCols.data;
} else {
allColumnsRef.current = [];
}
}
};
fetchMetadata();
}, [tab.connectionId, tab.dbName, connections]);
}, [currentConnectionId, currentDb, connections]);
// Handle Resizing
const handleMouseDown = (e: React.MouseEvent) => {
@@ -98,7 +136,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
editorRef.current = editor;
monacoRef.current = monaco;
// SQL Autocomplete
monaco.languages.registerCompletionItemProvider('sql', {
provideCompletionItems: (model: any, position: any) => {
const word = model.getWordUntilPosition(position);
@@ -109,7 +146,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
endColumn: word.endColumn,
};
// Simple Heuristic: Find tables mentioned in the query
const tableRegex = /(?:FROM|JOIN|UPDATE|INTO)\s+[`"]?(\w+)[`"]?/gi;
const foundTables = new Set<string>();
let match;
@@ -118,7 +154,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
foundTables.add(match[1]);
}
// Columns suggestion
const relevantColumns = allColumnsRef.current
.filter(c => foundTables.has(c.tableName))
.map(c => ({
@@ -131,14 +166,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
}));
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 => ({
label: k,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: k,
range
})),
// Tables
...tablesRef.current.map(t => ({
label: t,
kind: monaco.languages.CompletionItemKind.Class,
@@ -146,7 +179,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
detail: 'Table',
range
})),
// Columns
...relevantColumns
];
return { suggestions };
@@ -180,8 +212,12 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const handleRun = async () => {
if (!query.trim()) return;
if (!currentDb) {
message.error("请先选择数据库");
return;
}
setLoading(true);
const conn = connections.find(c => c.id === tab.connectionId);
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) {
message.error("Connection not found");
setLoading(false);
@@ -196,30 +232,42 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
useSSH: conn.config.useSSH || false,
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 (Array.isArray(res.data)) {
if (res.data.length > 0) {
const cols = Object.keys(res.data[0]).map(key => ({
title: key,
dataIndex: key,
key: key,
ellipsis: true,
render: (text: any) => typeof text === 'object' ? JSON.stringify(text) : String(text),
}));
setColumns(cols);
const cols = Object.keys(res.data[0]);
setColumnNames(cols);
setResults(res.data.map((row: any, i: number) => ({ ...row, key: i })));
} else {
message.info('查询执行成功,但没有返回结果。');
setResults([]);
setColumns([]);
setColumnNames([]);
}
} else {
// Handle update/insert results
const affected = (res.data as any).affectedRows;
message.success(`受影响行数: ${affected}`);
setResults([]);
setColumnNames([]);
}
} else {
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()}`,
name: values.name,
sql: query,
connectionId: tab.connectionId,
dbName: tab.dbName || '',
connectionId: currentConnectionId,
dbName: currentDb || tab.dbName || '',
createdAt: Date.now()
});
message.success('查询已保存!');
setIsSaveModalOpen(false);
} catch (e) {
// validation failed
}
};
return (
<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>
@@ -268,7 +334,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
</Button.Group>
</div>
{/* Editor Area - Resizable */}
<div style={{ height: editorHeight, minHeight: '100px', borderBottom: '1px solid #eee' }}>
<Editor
height="100%"
@@ -286,7 +351,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
/>
</div>
{/* Resize Handle */}
<div
onMouseDown={handleMouseDown}
style={{
@@ -299,16 +363,17 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
title="拖动调整高度"
/>
{/* Results Area - Fills remaining space */}
<div style={{ flex: 1, overflow: 'hidden', padding: 10, display: 'flex', flexDirection: 'column' }}>
<Table
dataSource={results}
columns={columns}
size="small"
scroll={{ x: 'max-content', y: 'calc(100% - 40px)' }}
<div style={{ flex: 1, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={results}
columnNames={columnNames}
loading={loading}
pagination={false}
style={{ flex: 1, overflow: 'hidden' }}
tableName={targetTableName} // Pass table name only if detection succeeded
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={pkColumns}
onReload={handleRun}
readOnly={!targetTableName} // Read-only if not a simple table query
/>
</div>
@@ -330,4 +395,4 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
);
};
export default QueryEditor;
export default QueryEditor;

View File

@@ -24,7 +24,7 @@ import {
} from '@ant-design/icons';
import { useStore } from '../store';
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;
@@ -39,7 +39,7 @@ interface TreeNode {
}
const Sidebar: React.FC = () => {
const { connections, savedQueries, addTab } = useStore();
const { connections, savedQueries, addTab, setActiveContext } = useStore();
const [treeData, setTreeData] = useState<TreeNode[]>([]);
const [searchValue, setSearchValue] = useState('');
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
@@ -50,6 +50,27 @@ const Sidebar: React.FC = () => {
const [createDbForm] = Form.useForm();
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(() => {
setTreeData(connections.map(conn => ({
title: conn.name,
@@ -230,9 +251,24 @@ const Sidebar: React.FC = () => {
};
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);
else if (type === 'folder-indexes') openDesign(info.node, 'indexes', 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 res = await (window as any).go.main.App.OpenSQLFile();
const res = await (window as any).go.app.App.OpenSQLFile();
if (res.success) {
const sqlContent = res.data;
const { dbName, id } = node.dataRef;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Tabs, Button } from 'antd';
import { useStore } from '../store';
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;
if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
@@ -33,18 +33,24 @@ const TabManager: React.FC = () => {
key: tab.id,
children: content,
};
});
}), [tabs]);
return (
<Tabs
type="editable-card"
onChange={onChange}
activeKey={activeTabId || undefined}
onEdit={onEdit}
items={items}
style={{ height: '100%' }}
hideAdd
/>
<>
<style>{`
.ant-tabs-content { height: 100%; }
.ant-tabs-tabpane { height: 100%; }
`}</style>
<Tabs
type="editable-card"
onChange={onChange}
activeKey={activeTabId || undefined}
onEdit={onEdit}
items={items}
style={{ height: '100%' }}
hideAdd
/>
</>
);
};

View File

@@ -7,7 +7,7 @@ import { CSS } from '@dnd-kit/utilities';
import { Resizable } from 'react-resizable';
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
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
import 'react-resizable/css/styles.css';
@@ -74,6 +74,11 @@ const ResizableTitle = (props: any) => {
className="react-resizable-handle"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault(); // Prevent text selection and focus hijacking
}}
style={{
position: 'absolute',
@@ -87,7 +92,7 @@ const ResizableTitle = (props: any) => {
/>
}
onResize={onResize}
draggableOpts={{ enableUserSelectHack: false }}
draggableOpts={{ enableUserSelectHack: true }}
>
<th {...restProps} style={{ ...restProps.style, position: 'relative' }} />
</Resizable>
@@ -263,16 +268,24 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
setTableColumns(initialCols);
}, [readOnly]); // Re-create if readOnly changes
const rafRef = React.useRef<number | null>(null);
// Resize Handler
const handleResize = (index: number) => (_: React.SyntheticEvent, { size }: { size: { width: number } }) => {
setTableColumns((columns) => {
const nextColumns = [...columns];
nextColumns[index] = {
...nextColumns[index],
width: size.width,
};
return nextColumns;
});
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
setTableColumns((columns) => {
const nextColumns = [...columns];
nextColumns[index] = {
...nextColumns[index],
width: size.width,
};
return nextColumns;
});
rafRef.current = null;
});
};
const fetchData = async () => {
@@ -587,8 +600,8 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
)}
{!readOnly && <Button icon={<SaveOutlined />} type="primary" onClick={generateDDL}></Button>}
{!isNewTable && <Button icon={<ReloadOutlined />} onClick={fetchData}></Button>}
<div style={{ flex: 1 }} />
{!readOnly && <Button icon={<PlusOutlined />} onClick={handleAddColumn}></Button>}
<div style={{ flex: 1 }} />
</div>
<Tabs
activeKey={activeKey}

View File

@@ -6,6 +6,7 @@ interface AppState {
connections: SavedConnection[];
tabs: TabData[];
activeTabId: string | null;
activeContext: { connectionId: string; dbName: string } | null;
savedQueries: SavedQuery[];
darkMode: boolean;
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
@@ -16,6 +17,7 @@ interface AppState {
addTab: (tab: TabData) => void;
closeTab: (id: string) => void;
setActiveTab: (id: string) => void;
setActiveContext: (context: { connectionId: string; dbName: string } | null) => void;
saveQuery: (query: SavedQuery) => void;
deleteQuery: (id: string) => void;
@@ -30,6 +32,7 @@ export const useStore = create<AppState>()(
connections: [],
tabs: [],
activeTabId: null,
activeContext: null,
savedQueries: [],
darkMode: false,
sqlFormatOptions: { keywordCase: 'upper' },
@@ -58,6 +61,7 @@ export const useStore = create<AppState>()(
}),
setActiveTab: (id) => set({ activeTabId: id }),
setActiveContext: (context) => set({ activeContext: context }),
saveQuery: (query) => set((state) => {
// If query with same ID exists, update it

43
frontend/wailsjs/go/app/App.d.ts vendored Executable file
View 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
View 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']();
}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
export namespace main {
export namespace connection {
export class UpdateRow {
keys: Record<string, any>;

72
internal/app/app.go Normal file
View 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
View 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}
}

View 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"}
}

View 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
View 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)
}
}

View File

@@ -1,4 +1,4 @@
package main
package db
import (
"database/sql"
@@ -6,6 +6,10 @@ import (
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/ssh"
"GoNavi-Wails/internal/utils"
_ "github.com/go-sql-driver/mysql"
)
@@ -13,15 +17,13 @@ type MySQLDB struct {
conn *sql.DB
}
func (m *MySQLDB) getDSN(config ConnectionConfig) string {
func (m *MySQLDB) getDSN(config connection.ConnectionConfig) string {
database := config.Database
protocol := "tcp"
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 {
netName, err := RegisterSSHNetwork(config.SSH)
netName, err := ssh.RegisterSSHNetwork(config.SSH)
if err == nil {
protocol = netName
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)
}
func (m *MySQLDB) Connect(config ConnectionConfig) error {
func (m *MySQLDB) Connect(config connection.ConnectionConfig) error {
dsn := m.getDSN(config)
db, err := sql.Open("mysql", dsn)
if err != nil {
@@ -53,7 +55,7 @@ func (m *MySQLDB) Ping() error {
if m.conn == nil {
return fmt.Errorf("connection not open")
}
ctx, cancel := contextWithTimeout(5 * time.Second)
ctx, cancel := utils.ContextWithTimeout(5 * time.Second)
defer cancel()
return m.conn.PingContext(ctx)
}
@@ -133,8 +135,6 @@ func (m *MySQLDB) GetDatabases() ([]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"
if dbName != "" {
query = fmt.Sprintf("SHOW TABLES FROM `%s`", dbName)
@@ -147,10 +147,9 @@ func (m *MySQLDB) GetTables(dbName string) ([]string, error) {
var tables []string
for _, row := range data {
// The column name is usually "Tables_in_dbname"
for _, v := range row {
tables = append(tables, fmt.Sprintf("%v", v))
break // Only first column
break
}
}
return tables, nil
@@ -158,7 +157,6 @@ func (m *MySQLDB) GetTables(dbName string) ([]string, error) {
func (m *MySQLDB) GetCreateStatement(dbName, tableName string) (string, error) {
query := fmt.Sprintf("SHOW CREATE TABLE `%s`.`%s`", dbName, tableName)
// If dbName is already selected or empty, just table name
if dbName == "" {
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")
}
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)
if dbName == "" {
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
}
var columns []ColumnDefinition
var columns []connection.ColumnDefinition
for _, row := range data {
col := ColumnDefinition{
col := connection.ColumnDefinition{
Name: fmt.Sprintf("%v", row["Field"]),
Type: fmt.Sprintf("%v", row["Type"]),
Nullable: fmt.Sprintf("%v", row["Null"]),
@@ -208,7 +206,7 @@ func (m *MySQLDB) GetColumns(dbName, tableName string) ([]ColumnDefinition, erro
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)
if dbName == "" {
query = fmt.Sprintf("SHOW INDEX FROM `%s`", tableName)
@@ -219,12 +217,10 @@ func (m *MySQLDB) GetIndexes(dbName, tableName string) ([]IndexDefinition, error
return nil, err
}
var indexes []IndexDefinition
var indexes []connection.IndexDefinition
for _, row := range data {
// Need to handle types carefully. Non_unique is int usually.
nonUnique := 0
if val, ok := row["Non_unique"]; ok {
// Handle various number types (json decoding might be float64)
if f, ok := val.(float64); ok {
nonUnique = int(f)
} 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"]),
ColumnName: fmt.Sprintf("%v", row["Column_name"]),
NonUnique: nonUnique,
@@ -253,7 +249,7 @@ func (m *MySQLDB) GetIndexes(dbName, tableName string) ([]IndexDefinition, error
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
FROM information_schema.KEY_COLUMN_USAGE
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
}
var fks []ForeignKeyDefinition
var fks []connection.ForeignKeyDefinition
for _, row := range data {
fk := ForeignKeyDefinition{
fk := connection.ForeignKeyDefinition{
Name: fmt.Sprintf("%v", row["CONSTRAINT_NAME"]),
ColumnName: fmt.Sprintf("%v", row["COLUMN_NAME"]),
RefTableName: fmt.Sprintf("%v", row["REFERENCED_TABLE_NAME"]),
@@ -277,16 +273,16 @@ func (m *MySQLDB) GetForeignKeys(dbName, tableName string) ([]ForeignKeyDefiniti
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)
data, _, err := m.Query(query)
if err != nil {
return nil, err
}
var triggers []TriggerDefinition
var triggers []connection.TriggerDefinition
for _, row := range data {
trig := TriggerDefinition{
trig := connection.TriggerDefinition{
Name: fmt.Sprintf("%v", row["Trigger"]),
Timing: fmt.Sprintf("%v", row["Timing"]),
Event: fmt.Sprintf("%v", row["Event"]),
@@ -297,7 +293,7 @@ func (m *MySQLDB) GetTriggers(dbName, tableName string) ([]TriggerDefinition, er
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 {
return fmt.Errorf("connection not open")
}
@@ -306,11 +302,10 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes ChangeSet) error {
if err != nil {
return err
}
defer tx.Rollback() // Rollback if not committed
defer tx.Rollback()
// 1. Deletes
for _, pk := range changes.Deletes {
// Build WHERE clause from PK
var wheres []string
var args []interface{}
for k, v := range pk {
@@ -318,7 +313,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes ChangeSet) error {
args = append(args, v)
}
if len(wheres) == 0 {
continue // Safety
continue
}
query := fmt.Sprintf("DELETE FROM `%s` WHERE %s", tableName, strings.Join(wheres, " AND "))
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 {
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 "))
@@ -381,13 +376,9 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes ChangeSet) error {
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)
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")
}
@@ -396,9 +387,9 @@ func (m *MySQLDB) GetAllColumns(dbName string) ([]ColumnDefinitionWithTable, err
return nil, err
}
var cols []ColumnDefinitionWithTable
var cols []connection.ColumnDefinitionWithTable
for _, row := range data {
col := ColumnDefinitionWithTable{
col := connection.ColumnDefinitionWithTable{
TableName: fmt.Sprintf("%v", row["TABLE_NAME"]),
Name: fmt.Sprintf("%v", row["COLUMN_NAME"]),
Type: fmt.Sprintf("%v", row["COLUMN_TYPE"]),
@@ -406,4 +397,4 @@ func (m *MySQLDB) GetAllColumns(dbName string) ([]ColumnDefinitionWithTable, err
cols = append(cols, col)
}
return cols, nil
}
}

View File

@@ -1,10 +1,13 @@
package main
package db
import (
"database/sql"
"fmt"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/utils"
_ "github.com/lib/pq"
)
@@ -12,22 +15,13 @@ type PostgresDB struct {
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
// If SSH is used, host/port will be local tunnel, similar to MySQL
host := config.Host
port := config.Port
// SSH placeholder kept from original
if config.UseSSH {
// Assuming generic SSH tunnel registered for PG as well
// 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.
// Logic to be implemented
}
dbname := config.Database
@@ -39,7 +33,7 @@ func (p *PostgresDB) getDSN(config ConnectionConfig) string {
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)
db, err := sql.Open("postgres", dsn)
if err != nil {
@@ -60,7 +54,7 @@ func (p *PostgresDB) Ping() error {
if p.conn == nil {
return fmt.Errorf("connection not open")
}
ctx, cancel := contextWithTimeout(5 * time.Second)
ctx, cancel := utils.ContextWithTimeout(5 * time.Second)
defer cancel()
return p.conn.PingContext(ctx)
}
@@ -139,11 +133,6 @@ func (p *PostgresDB) GetDatabases() ([]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'"
data, _, err := p.Query(query)
if err != nil {
@@ -160,31 +149,25 @@ func (p *PostgresDB) GetTables(dbName 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
}
func (p *PostgresDB) GetColumns(dbName, tableName string) ([]ColumnDefinition, error) {
// TODO: Implement query against information_schema.columns
return []ColumnDefinition{}, nil
func (p *PostgresDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
return []connection.ColumnDefinition{}, nil
}
func (p *PostgresDB) GetIndexes(dbName, tableName string) ([]IndexDefinition, error) {
// TODO: Implement query against pg_indexes
return []IndexDefinition{}, nil
func (p *PostgresDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return []connection.IndexDefinition{}, nil
}
func (p *PostgresDB) GetForeignKeys(dbName, tableName string) ([]ForeignKeyDefinition, error) {
return []ForeignKeyDefinition{}, nil
func (p *PostgresDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return []connection.ForeignKeyDefinition{}, nil
}
func (p *PostgresDB) GetTriggers(dbName, tableName string) ([]TriggerDefinition, error) {
return []TriggerDefinition{}, nil
func (p *PostgresDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return []connection.TriggerDefinition{}, nil
}
func (p *PostgresDB) GetAllColumns(dbName string) ([]ColumnDefinitionWithTable, error) {
// TODO: Implement using information_schema.columns
return []ColumnDefinitionWithTable{}, nil
func (p *PostgresDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return []connection.ColumnDefinitionWithTable{}, nil
}

View File

@@ -1,10 +1,13 @@
package main
package db
import (
"database/sql"
"fmt"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/utils"
_ "modernc.org/sqlite"
)
@@ -12,8 +15,7 @@ type SQLiteDB struct {
conn *sql.DB
}
func (s *SQLiteDB) Connect(config ConnectionConfig) error {
// Host field will be used as File Path for SQLite
func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error {
dsn := config.Host
db, err := sql.Open("sqlite", dsn)
if err != nil {
@@ -34,7 +36,7 @@ func (s *SQLiteDB) Ping() error {
if s.conn == nil {
return fmt.Errorf("connection not open")
}
ctx, cancel := contextWithTimeout(5 * time.Second)
ctx, cancel := utils.ContextWithTimeout(5 * time.Second)
defer cancel()
return s.conn.PingContext(ctx)
}
@@ -98,7 +100,6 @@ func (s *SQLiteDB) Exec(query string) (int64, error) {
}
func (s *SQLiteDB) GetDatabases() ([]string, error) {
// SQLite only has one DB (the file).
return []string{"main"}, nil
}
@@ -132,24 +133,22 @@ func (s *SQLiteDB) GetCreateStatement(dbName, tableName string) (string, error)
return "", fmt.Errorf("create statement not found")
}
func (s *SQLiteDB) GetColumns(dbName, tableName string) ([]ColumnDefinition, error) {
// SQLite has PRAGMA table_info(tableName)
// For MVP Stub:
return []ColumnDefinition{}, nil
func (s *SQLiteDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
return []connection.ColumnDefinition{}, nil
}
func (s *SQLiteDB) GetIndexes(dbName, tableName string) ([]IndexDefinition, error) {
return []IndexDefinition{}, nil
func (s *SQLiteDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return []connection.IndexDefinition{}, nil
}
func (s *SQLiteDB) GetForeignKeys(dbName, tableName string) ([]ForeignKeyDefinition, error) {
return []ForeignKeyDefinition{}, nil
func (s *SQLiteDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return []connection.ForeignKeyDefinition{}, nil
}
func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]TriggerDefinition, error) {
return []TriggerDefinition{}, nil
func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return []connection.TriggerDefinition{}, nil
}
func (s *SQLiteDB) GetAllColumns(dbName string) ([]ColumnDefinitionWithTable, error) {
return []ColumnDefinitionWithTable{}, nil
func (s *SQLiteDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return []connection.ColumnDefinitionWithTable{}, nil
}

View File

@@ -1,4 +1,4 @@
package main
package ssh
import (
"context"
@@ -7,18 +7,12 @@ import (
"os"
"time"
"GoNavi-Wails/internal/connection"
"github.com/go-sql-driver/mysql"
"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
type ViaSSHDialer struct {
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
func connectSSH(config SSHConfig) (*ssh.Client, error) {
func connectSSH(config connection.SSHConfig) (*ssh.Client, error) {
authMethods := []ssh.AuthMethod{}
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
// 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)
if err != nil {
return "", err

11
internal/utils/utils.go Normal file
View 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
View File

@@ -3,6 +3,8 @@ package main
import (
"embed"
"GoNavi-Wails/internal/app"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
@@ -13,7 +15,7 @@ var assets embed.FS
func main() {
// Create an instance of the app structure
app := NewApp()
application := app.NewApp()
// Create application with options
err := wails.Run(&options.App{
@@ -24,14 +26,14 @@ func main() {
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
OnShutdown: app.shutdown, // Bind shutdown method
OnStartup: application.Startup,
OnShutdown: application.Shutdown,
Bind: []interface{}{
app,
application,
},
})
if err != nil {
println("Error:", err.Error())
}
}
}

View File

@@ -1,10 +0,0 @@
package main
import (
"context"
"time"
)
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}

View File

@@ -1,6 +1,6 @@
{
"name": "GoNavi-Wails",
"outputfilename": "gonavi",
"name": "GoNavi",
"outputfilename": "GoNavi",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",