diff --git a/.gitignore b/.gitignore index 33468da..77af73e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ node_modules/ dist/ .DS_Store -.gemini-clipboard \ No newline at end of file +.gemini-clipboard +GoNavi-Wails \ No newline at end of file diff --git a/app.go b/app.go deleted file mode 100644 index 3db60f0..0000000 --- a/app.go +++ /dev/null @@ -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", "
") - } - 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"} -} diff --git a/build-release.sh b/build-release.sh index 2ea84f0..7866a59 100755 --- a/build-release.sh +++ b/build-release.sh @@ -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:]]') diff --git a/changeset.go b/changeset.go deleted file mode 100644 index bc5ff76..0000000 --- a/changeset.go +++ /dev/null @@ -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"` -} diff --git a/database.go b/database.go deleted file mode 100644 index 572eaec..0000000 --- a/database.go +++ /dev/null @@ -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) - } -} diff --git a/frontend/src/App.css b/frontend/src/App.css index 7ec0e27..4eb522e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2,6 +2,7 @@ html, body, #root { height: 100%; margin: 0; padding: 0; + overflow: hidden; /* Disable global scrollbar */ } /* 侧边栏 Tree 样式优化 */ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5700f2c..0921cff 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); + const ghostRef = React.useRef(null); + const latestMouseX = React.useRef(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, }} > - - + +
GoNavi
@@ -80,10 +130,26 @@ function App() { title="拖动调整宽度" />
- + setIsModalOpen(false)} /> + + {/* Ghost Resize Line */} +
); diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 264263b..ed1a767 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -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(); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx new file mode 100644 index 0000000..8b152a3 --- /dev/null +++ b/frontend/src/components/DataGrid.tsx @@ -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 NULL; + 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 ; + } + + return ( + + {restProps.children} + { + 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' + }} + /> + + ); +}; + +// --- Contexts --- +const EditableContext = React.createContext(null); +const DataContext = React.createContext<{ + selectedRowKeysRef: React.MutableRefObject; + displayDataRef: React.MutableRefObject; + 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 = React.memo(({ + title, + editable, + children, + dataIndex, + record, + handleSave, + ...restProps +}) => { + const [editing, setEditing] = useState(false); + const inputRef = useRef(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 ? ( + + + + ) : ( +
+ {children} +
+ ); + } + + return {childNode}; +}); + +const ContextMenuRow = React.memo(({ children, ...props }: any) => { + const record = props.record; + const context = useContext(DataContext); + + if (!record || !context) return {children}; + + 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: , + onClick: () => handleCopyInsert(record) + }, + { key: 'json', label: '复制为 JSON', icon: , onClick: () => handleCopyJson(record) }, + { key: 'csv', label: '复制为 CSV', icon: , onClick: () => handleCopyCsv(record) }, + { key: 'copy', label: '复制为 Markdown', icon: , onClick: () => { + const records = getTargets(); + const lines = records.map((r: any) => { + const { key, ...vals } = r; + return `| ${Object.values(vals).join(' | ')} |`; + }); + copyToClipboard(lines.join('\n')); + } }, + ]; + + return ( + + {children} + + ); +}); + +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 = ({ + 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>({}); + + // Dynamic Height + const [tableHeight, setTableHeight] = useState(500); + const containerRef = useRef(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([]); + const [addedRows, setAddedRows] = useState([]); + const [modifiedRows, setModifiedRows] = useState>({}); + const [deletedRowKeys, setDeletedRowKeys] = useState>(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([]); + + 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(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 ( +
+ {/* Toolbar */} +
+ {onReload && } + {tableName && } + {tableName && } + + {!readOnly && tableName && ( + <> +
+ + + {selectedRowKeys.length > 0 && 已选 {selectedRowKeys.length}} +
+ + {hasChanges && ()} + + )} + + {onToggleFilter && ( + <> +
+ + + )} +
+ + {/* Filter Panel */} + {showFilter && ( +
+ {filterConditions.map(cond => ( +
+ updateFilter(cond.id, 'op', v)} options={[{ value: '=', label: '=' }, { value: 'LIKE', label: '包含' }]} /> + updateFilter(cond.id, 'value', e.target.value)} /> +
+ ))} +
+ + +
+
+ )} + +
+
+ + + { + 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)} + /> + + + + + + {pagination && ( +
+ `当前 ${range[1] - range[0] + 1} 条 / 共 ${total} 条`} + showSizeChanger + pageSizeOptions={['100', '200', '500', '1000']} + onChange={onPageChange} + size="small" + /> +
+ )} + + + + {/* Ghost Resize Line for Columns */} +
+
+ ); +}; + +export default React.memo(DataGrid); diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index 4cbaf72..2e144eb 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -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 NULL; - 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 ; -}); - -// --- 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 {children}; - } - - 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: , - onClick: () => handleCopyInsert(record) - }, - { key: 'json', label: '复制为 JSON', icon: , onClick: () => handleCopyJson(record) }, - { key: 'csv', label: '复制为 CSV', icon: , onClick: () => handleCopyCsv(record) }, - { key: 'copy', label: '复制为 Markdown', icon: , onClick: () => { - const records = getTargets(); - const lines = records.map((r: any) => { - const { key, ...vals } = r; - return `| ${Object.values(vals).join(' | ')} |`; - }); - copyToClipboard(lines.join('\n')); - } }, - ]; - - return ( - - {children} - - ); -}); +import { MySQLQuery, DBGetColumns } from '../../wailsjs/go/app/App'; +import DataGrid from './DataGrid'; const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const [data, setData] = useState([]); @@ -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([]); - const [columnWidths, setColumnWidths] = useState>({}); - - const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const [addedRows, setAddedRows] = useState([]); - const [modifiedRows, setModifiedRows] = useState>({}); - const [deletedRowKeys, setDeletedRowKeys] = useState>(new Set()); - - // Refs - const selectedRowKeysRef = useRef(selectedRowKeys); - const displayDataRef = useRef([]); - - 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 ( -
- {/* Toolbar */} -
- - - -
- - -
- - {hasChanges && ()} -
- -
- - {/* Filter Panel */} - {showFilter && ( -
- {filterConditions.map(cond => ( -
- updateFilter(cond.id, 'op', v)} options={[{ value: '=', label: '=' }, { value: 'LIKE', label: '包含' }]} /> - updateFilter(cond.id, 'value', e.target.value)} /> -
- ))} -
- - -
-
- )} - -
-
- - -
; - } - - return ( - { - 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 }} - > - - - ); -}; - -// --- Contexts --- -const EditableContext = React.createContext(null); - -// Use Ref for selection to prevent Context updates on every selection change -const DataContext = React.createContext<{ - selectedRowKeysRef: React.MutableRefObject; - displayDataRef: React.MutableRefObject; - 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 = React.memo(({ - title, - editable, - children, - dataIndex, - record, - handleSave, - ...restProps -}) => { - const [editing, setEditing] = useState(false); - const inputRef = useRef(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 ? ( - - - - ) : ( -
- {children} -
- ); - } - - return
{childNode}
{ - if (addedRows.includes(record)) return 'row-added'; - if (modifiedRows[record.key]) return 'row-modified'; - return ''; - }} - onRow={(record) => ({ record } as any)} - /> - - - - - - {/* Pagination Bar */} -
- `当前 ${range[1] - range[0] + 1} 条 / 共 ${total} 条`} - showSizeChanger - pageSizeOptions={['100', '200', '500', '1000']} - onChange={handlePaginationChange} - size="small" - /> -
- - - + ); }; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index bb992a0..eb8bddb 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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([]); - const [columns, setColumns] = useState([]); + const [columnNames, setColumnNames] = useState([]); + const [pkColumns, setPkColumns] = useState([]); + const [targetTableName, setTargetTableName] = useState(undefined); + const [loading, setLoading] = useState(false); const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); const [saveForm] = Form.useForm(); + // Database Selection + const [currentConnectionId, setCurrentConnectionId] = useState(tab.connectionId); + const [currentDb, setCurrentDb] = useState(tab.dbName || ''); + const [dbList, setDbList] = useState([]); + // Resizing state const [editorHeight, setEditorHeight] = useState(300); const editorRef = useRef(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(); 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 (
-
+
+ ({ label: db, value: db }))} + showSearch + /> @@ -268,7 +334,6 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
- {/* Editor Area - Resizable */}
= ({ tab }) => { />
- {/* Resize Handle */}
= ({ tab }) => { title="拖动调整高度" /> - {/* Results Area - Fills remaining space */} -
-
+ @@ -330,4 +395,4 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { ); }; -export default QueryEditor; +export default QueryEditor; \ No newline at end of file diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 75051e7..132ac21 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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([]); const [searchValue, setSearchValue] = useState(''); const [expandedKeys, setExpandedKeys] = useState([]); @@ -50,6 +50,27 @@ const Sidebar: React.FC = () => { const [createDbForm] = Form.useForm(); const [targetConnection, setTargetConnection] = useState(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; diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 1e386ae..f356e18 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -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 = ; @@ -33,18 +33,24 @@ const TabManager: React.FC = () => { key: tab.id, children: content, }; - }); + }), [tabs]); return ( - + <> + + + ); }; diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 8b47ee9..72205ff 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -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 }} >
@@ -263,16 +268,24 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { setTableColumns(initialCols); }, [readOnly]); // Re-create if readOnly changes + const rafRef = React.useRef(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 && } {!isNewTable && } -
{!readOnly && } +
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()( connections: [], tabs: [], activeTabId: null, + activeContext: null, savedQueries: [], darkMode: false, sqlFormatOptions: { keywordCase: 'upper' }, @@ -58,6 +61,7 @@ export const useStore = create()( }), setActiveTab: (id) => set({ activeTabId: id }), + setActiveContext: (context) => set({ activeContext: context }), saveQuery: (query) => set((state) => { // If query with same ID exists, update it diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts new file mode 100755 index 0000000..1e27fdf --- /dev/null +++ b/frontend/wailsjs/go/app/App.d.ts @@ -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; + +export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise; + +export function DBConnect(arg1:connection.ConnectionConfig):Promise; + +export function DBGetAllColumns(arg1:connection.ConnectionConfig,arg2:string):Promise; + +export function DBGetColumns(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + +export function DBGetDatabases(arg1:connection.ConnectionConfig):Promise; + +export function DBGetForeignKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + +export function DBGetIndexes(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + +export function DBGetTables(arg1:connection.ConnectionConfig,arg2:string):Promise; + +export function DBGetTriggers(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + +export function DBQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + +export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + +export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; + +export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + +export function MySQLConnect(arg1:connection.ConnectionConfig):Promise; + +export function MySQLGetDatabases(arg1:connection.ConnectionConfig):Promise; + +export function MySQLGetTables(arg1:connection.ConnectionConfig,arg2:string):Promise; + +export function MySQLQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + +export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + +export function OpenSQLFile():Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js new file mode 100755 index 0000000..250b49a --- /dev/null +++ b/frontend/wailsjs/go/app/App.js @@ -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'](); +} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts deleted file mode 100755 index 6e40389..0000000 --- a/frontend/wailsjs/go/main/App.d.ts +++ /dev/null @@ -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; - -export function CreateDatabase(arg1:main.ConnectionConfig,arg2:string):Promise; - -export function DBConnect(arg1:main.ConnectionConfig):Promise; - -export function DBGetAllColumns(arg1:main.ConnectionConfig,arg2:string):Promise; - -export function DBGetColumns(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise; - -export function DBGetDatabases(arg1:main.ConnectionConfig):Promise; - -export function DBGetForeignKeys(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise; - -export function DBGetIndexes(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise; - -export function DBGetTables(arg1:main.ConnectionConfig,arg2:string):Promise; - -export function DBGetTriggers(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise; - -export function DBQuery(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise; - -export function DBShowCreateTable(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise; - -export function ExportTable(arg1:main.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; - -export function ImportData(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise; - -export function MySQLConnect(arg1:main.ConnectionConfig):Promise; - -export function MySQLGetDatabases(arg1:main.ConnectionConfig):Promise; - -export function MySQLGetTables(arg1:main.ConnectionConfig,arg2:string):Promise; - -export function MySQLQuery(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise; - -export function MySQLShowCreateTable(arg1:main.ConnectionConfig,arg2:string,arg3:string):Promise; - -export function OpenSQLFile():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js deleted file mode 100755 index b32200a..0000000 --- a/frontend/wailsjs/go/main/App.js +++ /dev/null @@ -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'](); -} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 3745b30..e2c4ae3 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,4 +1,4 @@ -export namespace main { +export namespace connection { export class UpdateRow { keys: Record; diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..fc0e117 --- /dev/null +++ b/internal/app/app.go @@ -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 +} diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go new file mode 100644 index 0000000..347031f --- /dev/null +++ b/internal/app/methods_db.go @@ -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} +} \ No newline at end of file diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go new file mode 100644 index 0000000..ffea478 --- /dev/null +++ b/internal/app/methods_file.go @@ -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", "
") + } + 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"} +} \ No newline at end of file diff --git a/internal/connection/types.go b/internal/connection/types.go new file mode 100644 index 0000000..d7b1956 --- /dev/null +++ b/internal/connection/types.go @@ -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"` +} diff --git a/internal/db/database.go b/internal/db/database.go new file mode 100644 index 0000000..1e4a0e1 --- /dev/null +++ b/internal/db/database.go @@ -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) + } +} diff --git a/mysql_impl.go b/internal/db/mysql_impl.go similarity index 80% rename from mysql_impl.go rename to internal/db/mysql_impl.go index 70a0a36..b40a898 100644 --- a/mysql_impl.go +++ b/internal/db/mysql_impl.go @@ -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 -} \ No newline at end of file +} diff --git a/postgres_impl.go b/internal/db/postgres_impl.go similarity index 56% rename from postgres_impl.go rename to internal/db/postgres_impl.go index e7da396..44bfc9c 100644 --- a/postgres_impl.go +++ b/internal/db/postgres_impl.go @@ -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 } diff --git a/sqlite_impl.go b/internal/db/sqlite_impl.go similarity index 72% rename from sqlite_impl.go rename to internal/db/sqlite_impl.go index cc67388..e338ad3 100644 --- a/sqlite_impl.go +++ b/internal/db/sqlite_impl.go @@ -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 } diff --git a/ssh.go b/internal/ssh/ssh.go similarity index 83% rename from ssh.go rename to internal/ssh/ssh.go index 1e182a1..177c5fb 100644 --- a/ssh.go +++ b/internal/ssh/ssh.go @@ -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 diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..efc63f5 --- /dev/null +++ b/internal/utils/utils.go @@ -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) +} diff --git a/main.go b/main.go index 5823175..35febcc 100644 --- a/main.go +++ b/main.go @@ -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()) } -} \ No newline at end of file +} diff --git a/utils.go b/utils.go deleted file mode 100644 index 4fb2120..0000000 --- a/utils.go +++ /dev/null @@ -1,10 +0,0 @@ -package main - -import ( - "context" - "time" -) - -func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), d) -} diff --git a/wails.json b/wails.json index 53387c1..ad42f25 100644 --- a/wails.json +++ b/wails.json @@ -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",